Compare commits

..

18 Commits

Author SHA1 Message Date
Claude (owner)
b53f51a0b4 v0.4.34: purge() cancels in-flight download (per-URL cancellation)
Some checks failed
build / build (push) Failing after 1m16s
videoCache remove(=purge) 가 다운로드 중인 항목도 취소하도록 per-URL 세대
(DOWNLOAD_GEN) 채널 추가. 기존엔 READY/디스크 최종 파일만 지워서, 느린/큰
영상이 다운로드 중에 evict 되면 완료 후 다시 디스크+READY 에 되살아남.
이제 purge 가 세대를 제거 → download 의 cancelled() 검사(읽기 루프 + 모든
publish 지점)가 실패 → .part/승격 파일 self-clean. clearAll/wipe/clearIndex
도 DOWNLOAD_GEN 정리. 데이터팩 keep(FIFO) 메모리 관리 보장에 필요.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-04 22:40:20 +09:00
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
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
tkrmagid
d11289309b v0.4.19: fix post-move publish race in VideoCache.download
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: v0.4.18 only checked epoch BEFORE Files.move. The window
between the move completing and READY.put / "완료" chat was still racy —
if /videoCache clear landed in that window, clearAll would epoch++ +
clear READY + delete files on disk, then the download thread would do
READY.put(url, finalPath) anyway, resurrecting a cleared entry and emitting
a stale "완료" message.

Add a second epoch check immediately AFTER Files.move(): if the epoch
changed, delete finalPath and return without publishing. The pre-move
check is kept too — it lets the common cancel-during-read case skip the
wasted move/delete round-trip.
2026-05-16 22:51:32 +09:00
tkrmagid
de723fd0b4 v0.4.18: fix /videoCache clear vs in-flight download race
Some checks failed
build / build (push) Has been cancelled
clearAll() previously only wiped finalized files + the IN_FLIGHT set; any
download that was already running on the single-thread executor kept
writing past the clear, then atomically moved its .part to final and
re-published into READY — resurrecting one cache entry post-clear.

Fix: add an AtomicLong CACHE_EPOCH. preload() captures the epoch at submit
time; clearAll() bumps the epoch BEFORE wiping disk; download(url, epoch)
checks at three points (pre-start, in read loop, pre-publish) and bails if
the epoch moved, deleting its own .part only AFTER closing the output
stream (Windows can't delete an open file).

Phase ordering in clearAll() also matters: bump epoch first → drop
indexes → delete files. That way the in-flight download sees the
cancellation flag before the index wipe / file delete races can interact
with it.
2026-05-16 22:48:16 +09:00
tkrmagid
e3c25fc845 v0.4.17: music_quiz datapack handshake (server + per-player presence)
Some checks failed
build / build (push) Has been cancelled
Implements docs/mc_video_player_mod_integration.md.

Server (MusicQuizPresence):
- every server tick: #server mq_video_mod = 1
- on MqHelloPayload receive: <player> mq_video_mod = 1
- both silently skip if objective absent (datapack not yet loaded)

Client (MusicQuizClient):
- send MqHelloPayload(1) on JOIN
- resend every 100 client ticks (~5 s) — mandatory because the datapack's
  mq:players/login resets per-player score to 0 at spawn-dialog passage

Payload: video_player:mq_hello, single VAR_INT body (version=1).
2026-05-16 22:40:40 +09:00
18 changed files with 689 additions and 77 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.16 mod_version=0.4.34
maven_group=com.ejclaw.videoplayer maven_group=com.ejclaw.videoplayer
archives_base_name=video_player archives_base_name=video_player

View File

@@ -1,7 +1,9 @@
package com.ejclaw.videoplayer; package com.ejclaw.videoplayer;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.MusicQuizClient;
import com.ejclaw.videoplayer.client.net.ClientNetworking; 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.playback.VideoPlayback;
import com.ejclaw.videoplayer.client.render.VideoAnchorRenderer; import com.ejclaw.videoplayer.client.render.VideoAnchorRenderer;
import com.ejclaw.videoplayer.item.VideoStickItem; import com.ejclaw.videoplayer.item.VideoStickItem;
@@ -11,6 +13,7 @@ import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientBlockEntityEvents; 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.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry; import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
@@ -19,6 +22,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;
@@ -28,6 +32,7 @@ public class VideoPlayerClient implements ClientModInitializer {
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
ClientNetworking.register(); ClientNetworking.register();
MusicQuizClient.register();
BlockEntityRendererRegistry.register( BlockEntityRendererRegistry.register(
VideoPlayerBlockEntities.VIDEO_ANCHOR, VideoPlayerBlockEntities.VIDEO_ANCHOR,
@@ -79,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); VideoPlayerMod.LOG.info("[{}] client initialized", VideoPlayerMod.MOD_ID);
} }
@@ -87,17 +97,31 @@ 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. 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) { 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);
SoundSource category = VideoPlayerConfig.soundCategory();
float categoryScale = (category == SoundSource.MASTER)
? masterVol
: masterVol * client.options.getSoundSourceVolume(category);
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

@@ -7,6 +7,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.sounds.SoundSource;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -62,10 +63,17 @@ public final class VideoPlayerConfig {
private static final int DEFAULT_MAX_CACHE_MB = 750; private static final int DEFAULT_MAX_CACHE_MB = 750;
/** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */ /** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */
private static final int DEFAULT_RENDER_DISTANCE = 128; 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 maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
private static volatile int maxCacheMb = DEFAULT_MAX_CACHE_MB; private static volatile int maxCacheMb = DEFAULT_MAX_CACHE_MB;
private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE; private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
private static volatile SoundSource soundCategory = DEFAULT_SOUND_CATEGORY;
private static volatile List<String> preloadUrls = Collections.emptyList(); private static volatile List<String> preloadUrls = Collections.emptyList();
/** Insertion-ordered name → url. Mutated only under the class monitor. */ /** Insertion-ordered name → url. Mutated only under the class monitor. */
private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>(); private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>();
@@ -80,6 +88,8 @@ public final class VideoPlayerConfig {
VideoPlayerMod.MOD_ID, path); VideoPlayerMod.MOD_ID, path);
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB; maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB; maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList(); preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear(); CACHE_ENTRIES.clear();
return; return;
@@ -127,6 +137,23 @@ public final class VideoPlayerConfig {
if (rd > 2048) rd = 2048; if (rd > 2048) rd = 2048;
renderDistanceBlocks = rd; 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) // preload_urls (legacy)
List<String> urls = new ArrayList<>(); List<String> urls = new ArrayList<>();
if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) { if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) {
@@ -162,9 +189,9 @@ public final class VideoPlayerConfig {
VideoPlayerMod.LOG.info( VideoPlayerMod.LOG.info(
"[{}] config loaded: per-video={} MB, total-cache={} MB, render={} blocks, " "[{}] 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, 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. // Auto-augment: rewrite the file once so missing keys appear after a mod update.
if (augmented) { if (augmented) {
@@ -179,11 +206,23 @@ public final class VideoPlayerConfig {
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB; maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB; maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE; renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList(); preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear(); 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 --------------------------------------------------------------------------- // -- accessors ---------------------------------------------------------------------------
/** Hard cap on a single client-side video download, in MB. */ /** 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. */ /** Anchor BE view-distance cap, in blocks. */
public static int renderDistanceBlocks() { return renderDistanceBlocks; } 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. */ /** Legacy un-named preload list (still pushed at join). Never null. */
public static List<String> preloadUrls() { return preloadUrls; } 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_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). " + "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. " + "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). " + "preload_urls: HTTP(S) videos auto-pushed to every player on join (no name). "
+ "cache_entries: named entries managed by /videoCache add|list|remove."); + "cache_entries: named entries managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB); root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB);
root.addProperty("max_cache_mb", DEFAULT_MAX_CACHE_MB); root.addProperty("max_cache_mb", DEFAULT_MAX_CACHE_MB);
root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE); root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE);
root.addProperty("sound_category", DEFAULT_SOUND_CATEGORY.getName());
root.add("preload_urls", new JsonArray()); root.add("preload_urls", new JsonArray());
root.add("cache_entries", new JsonArray()); root.add("cache_entries", new JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); 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_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). " + "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. " + "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. " + "preload_urls: legacy un-named auto-preload list. "
+ "cache_entries: managed by /videoCache add|list|remove."); + "cache_entries: managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", maxPreloadMb); root.addProperty("max_preload_mb", maxPreloadMb);
root.addProperty("max_cache_mb", maxCacheMb); root.addProperty("max_cache_mb", maxCacheMb);
root.addProperty("render_distance_blocks", renderDistanceBlocks); root.addProperty("render_distance_blocks", renderDistanceBlocks);
root.addProperty("sound_category", soundCategory.getName());
JsonArray legacyArr = new JsonArray(); JsonArray legacyArr = new JsonArray();
for (String u : preloadUrls) legacyArr.add(u); for (String u : preloadUrls) legacyArr.add(u);
root.add("preload_urls", legacyArr); root.add("preload_urls", legacyArr);

View File

@@ -8,6 +8,7 @@ import com.ejclaw.videoplayer.command.VideoStickCommand;
import com.ejclaw.videoplayer.net.CachePolicyPayload; import com.ejclaw.videoplayer.net.CachePolicyPayload;
import com.ejclaw.videoplayer.net.PreloadPayload; import com.ejclaw.videoplayer.net.PreloadPayload;
import com.ejclaw.videoplayer.net.VideoPlayerNetwork; import com.ejclaw.videoplayer.net.VideoPlayerNetwork;
import com.ejclaw.videoplayer.server.MusicQuizPresence;
import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities; import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import com.ejclaw.videoplayer.registry.VideoPlayerItems; import com.ejclaw.videoplayer.registry.VideoPlayerItems;
@@ -30,6 +31,7 @@ public class VideoPlayerMod implements ModInitializer {
VideoPlayerNetwork.registerPayloadTypes(); VideoPlayerNetwork.registerPayloadTypes();
VideoPlayerNetwork.registerServerReceivers(); VideoPlayerNetwork.registerServerReceivers();
MusicQuizPresence.register();
VideoPlayerConfig.load(); VideoPlayerConfig.load();

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

@@ -0,0 +1,56 @@
package com.ejclaw.videoplayer.client;
import com.ejclaw.videoplayer.net.MqHelloPayload;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
/**
* Client side of the {@code music_quiz} datapack handshake
* ({@code docs/mc_video_player_mod_integration.md}). Sends one {@link MqHelloPayload}
* on JOIN, then repeats every 100 client ticks (~5 s at 20 tps).
*
* <p>The periodic resend is <em>mandatory</em>: the datapack's
* {@code mq:players/login} resets the per-player score to 0 the moment the player
* passes the spawn dialog. The single JOIN-time send can land before that reset,
* leaving the score at 0 and breaking the {@code mq:commands/start} guard. With a
* 5-second resend, the score is restored to 1 within at most one cycle.
*/
@Environment(EnvType.CLIENT)
public final class MusicQuizClient {
private MusicQuizClient() {}
/** ~5 s at 20 tps. Spec recommends ≤5 s; tighter is fine but unnecessary. */
private static final int RESEND_INTERVAL_TICKS = 100;
private static int tickCounter = 0;
public static void register() {
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) ->
send());
ClientTickEvents.END_CLIENT_TICK.register(client -> {
// Gate on world presence — when the player isn't in-game, level is null and the
// counter resets so the next session starts fresh instead of firing immediately.
if (client.level == null) {
tickCounter = 0;
return;
}
tickCounter++;
if (tickCounter >= RESEND_INTERVAL_TICKS) {
tickCounter = 0;
send();
}
});
}
private static void send() {
// ClientPlayNetworking.send no-ops when the server hasn't registered the payload
// type (e.g. vanilla server or server without this mod) — safe to call blindly.
if (ClientPlayNetworking.canSend(MqHelloPayload.TYPE)) {
ClientPlayNetworking.send(new MqHelloPayload(MqHelloPayload.CURRENT_VERSION));
}
}
}

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;
} }
@@ -279,6 +276,14 @@ public class JavaCvBackend implements VideoBackend {
Class<?> frameCls = Class.forName(FRAME_CLASS); Class<?> frameCls = Class.forName(FRAME_CLASS);
Field imageField = frameCls.getField("image"); Field imageField = frameCls.getField("image");
Field samplesField = frameCls.getField("samples"); 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, // 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 // 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. // 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 // 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 // top to absorb the burst-then-stall caused by SourceDataLine backpressure
// pacing only at audio-buffer granularity. // pacing only at audio-buffer granularity.
int need = src.remaining(); // The destination texture is tightly packed: one frame = width*height*4
// Reviewer-mandated sanity bounds: memCopy is a raw native copy with no // bytes with no row padding (consumeFrame copies ringBytes[idx] straight
// fence against overrun. Validate against (a) the source buffer's own // into a w*h*4 GPU texture and rejects anything larger). swscale, however,
// capacity (so a corrupt plane can't read past it) and (b) the expected // hands us an RGBA plane whose linesize is SIMD-aligned, so for any width
// RGBA frame size (width*height*4) (so an unexpectedly oversized plane // where width*4 isn't a multiple of the alignment the source rows carry
// can't smash the dst slot we'll allocate). If either fails, skip this // trailing padding (e.g. 1674-wide → 6720 B/row vs the 6696 B we want).
// frame and continue — the next grab() will give us a fresh one. // Copying the padded buffer verbatim both overruns the tight dst slot and
int expected = width * height * 4; // shears the image; we must strip the pad row-by-row.
if (need > src.capacity()) { int rowBytes = width * 4; // bytes of real pixels per row
VideoPlayerMod.LOG.warn("[{}] frame overruns source capacity (need={}, cap={}); skipping", int dstBytes = rowBytes * height; // tightly-packed frame size
VideoPlayerMod.MOD_ID, need, src.capacity()); // Source row stride: prefer Frame.imageStride (authoritative), else derive
need = 0; // it from the buffer size. Guard against a stride smaller than a full row
} else if (need > expected) { // (bogus field) by falling back to the tight rowBytes.
VideoPlayerMod.LOG.warn("[{}] frame larger than expected RGBA size (need={}, expected={}); skipping", int strideBytes = rowBytes;
VideoPlayerMod.MOD_ID, need, expected); if (strideField != null) {
need = 0; 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(); int srcPos = src.position();
long srcAddr = MemoryUtil.memAddress(src) + srcPos; long srcBase = MemoryUtil.memAddress(src) + srcPos;
synchronized (frameLock) { synchronized (frameLock) {
// Recheck shutdown inside the lock: stopWorker() flipped running=false // Recheck shutdown inside the lock: stopWorker() flipped running=false
// before signaling, so worker is the only writer here and grabber.close() // 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. // the contract obvious to future readers.
if (!running.get() || closed) break; if (!running.get() || closed) break;
int idx = ringTail; int idx = ringTail;
if (ringBufs[idx] == null || ringBufs[idx].capacity() < need) { if (ringBufs[idx] == null || ringBufs[idx].capacity() < dstBytes) {
ringBufs[idx] = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder()); ringBufs[idx] = ByteBuffer.allocateDirect(dstBytes).order(ByteOrder.nativeOrder());
} }
long dstAddr = MemoryUtil.memAddress(ringBufs[idx]); long dstBase = MemoryUtil.memAddress(ringBufs[idx]);
MemoryUtil.memCopy(srcAddr, dstAddr, need); if (strideBytes == rowBytes) {
ringBytes[idx] = need; 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; ringTail = (idx + 1) % FRAME_RING_SLOTS;
if (ringCount < FRAME_RING_SLOTS) { if (ringCount < FRAME_RING_SLOTS) {
ringCount++; ringCount++;

View File

@@ -22,6 +22,7 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
/** /**
* Client-side disk cache for preloaded HTTP video URLs. Driven by the {@code /videopreload} * Client-side disk cache for preloaded HTTP video URLs. Driven by the {@code /videopreload}
@@ -42,6 +43,26 @@ public final class VideoCache {
private static final Map<String, Path> READY = new ConcurrentHashMap<>(); private static final Map<String, Path> READY = new ConcurrentHashMap<>();
/** urls whose download is currently queued or in flight. */ /** urls whose download is currently queued or in flight. */
private static final Set<String> IN_FLIGHT = ConcurrentHashMap.newKeySet(); private static final Set<String> IN_FLIGHT = ConcurrentHashMap.newKeySet();
/**
* Monotonic cache-generation counter. {@link #clearAll()} bumps this; every download
* captures the value at submit time and bails (deletes its {@code .part} without
* publishing) if the epoch has moved since. This is the cancellation channel for the
* in-flight download — without it, {@code clearAll} only wipes already-finalized files
* while the running download keeps writing and re-publishes one entry post-clear.
*/
private static final AtomicLong CACHE_EPOCH = new AtomicLong(0);
/**
* Per-URL cancellation channel for {@link #purge(String)}. The global {@link #CACHE_EPOCH}
* can only cancel <em>all</em> in-flight downloads at once; a targeted purge needs to stop
* exactly one. Each submitted download captures a unique generation here at submit time;
* {@link #purge(String)} removes the URL's entry, which makes the running download's
* {@link #cancelled(String, long, long)} checks (read loop + every publish point) fail and
* self-clean its {@code .part}/promoted file instead of re-publishing post-purge.
*/
private static final Map<String, Long> DOWNLOAD_GEN = new ConcurrentHashMap<>();
/** Monotonic source for per-download generations stored in {@link #DOWNLOAD_GEN}. */
private static final AtomicLong GEN_SEQ = new AtomicLong(0);
/** /**
* Single-thread executor that serializes all downloads. Cap enforcement (the total * Single-thread executor that serializes all downloads. Cap enforcement (the total
@@ -94,6 +115,12 @@ public final class VideoCache {
/** Server-driven delete of a cached URL. Removes from READY and from disk. */ /** Server-driven delete of a cached URL. Removes from READY and from disk. */
public static void purge(String url) { public static void purge(String url) {
if (url == null || url.isEmpty()) return; if (url == null || url.isEmpty()) return;
// Cancel any in-flight download of this exact URL first. Dropping its generation
// makes the download thread's cancelled() checks fail, so it deletes its own .part
// (or rolls back a just-promoted file) instead of finishing and re-publishing after
// we've purged. Without this, a slow/large download evicted mid-flight would silently
// reappear on disk + in READY once it completed.
DOWNLOAD_GEN.remove(url);
Path p = READY.remove(url); Path p = READY.remove(url);
if (p == null) { if (p == null) {
// Not in this session's index, but the file may still be on disk from a prior run. // Not in this session's index, but the file may still be on disk from a prior run.
@@ -121,11 +148,37 @@ public final class VideoCache {
/** /**
* Wipe the entire cache directory and drop both indexes. Sent in response to * Wipe the entire cache directory and drop both indexes. Sent in response to
* {@code /videoCache clear}. Best-effort per-file deletion; logs failures but doesn't * {@code /videoCache clear}.
* abort on the first one. In-flight downloads aren't cancelled — they'll fail at the *
* atomic-move step (target dir gone) and log a warning, which is fine. * <p>Three phases that need to stay in this order:
* <ol>
* <li>Bump the epoch first — that flips the cancellation flag for any in-flight
* download. The download thread checks the epoch in the read loop and at the
* move step, so after this point it will refuse to publish a new entry and
* will delete its own {@code .part}.</li>
* <li>Clear the in-memory indexes. (READY clears whatever was already finalized
* pre-clear; IN_FLIGHT is wiped so a subsequent same-URL preload re-enters
* the queue instead of getting deduped by stale state.)</li>
* <li>Best-effort delete every regular file in the cache directory. We do this
* last so that if the in-flight download was momentarily holding a {@code .part}
* open, the listing here also catches its post-close residue (though normally
* phase 1 → the download deletes its own part).</li>
* </ol>
*
* <p>Best-effort per-file deletion; logs failures but doesn't abort on the first one.
* On Windows the open {@code .part} may not be deletable here — phase 1's cancellation
* + the download's own cleanup-on-bail handles that case.
*/ */
public static void clearAll() { public static void clearAll() {
// Phase 1: signal cancellation to any in-flight download BEFORE wiping disk.
CACHE_EPOCH.incrementAndGet();
// Phase 2: drop indexes.
READY.clear();
IN_FLIGHT.clear();
DOWNLOAD_GEN.clear();
// Phase 3: delete files on disk.
int deleted = 0; int deleted = 0;
int failed = 0; int failed = 0;
try { try {
@@ -147,8 +200,6 @@ public final class VideoCache {
VideoPlayerMod.LOG.warn("[{}] clearAll failed: {}", VideoPlayerMod.LOG.warn("[{}] clearAll failed: {}",
VideoPlayerMod.MOD_ID, t.toString()); VideoPlayerMod.MOD_ID, t.toString());
} }
READY.clear();
IN_FLIGHT.clear();
VideoPlayerMod.LOG.info("[{}] clearAll: deleted={} failed={}", VideoPlayerMod.LOG.info("[{}] clearAll: deleted={} failed={}",
VideoPlayerMod.MOD_ID, deleted, failed); VideoPlayerMod.MOD_ID, deleted, failed);
notifyChat("[videocache] 전체 캐시 삭제: " + deleted + "개 파일", ChatFormatting.YELLOW); notifyChat("[videocache] 전체 캐시 삭제: " + deleted + "개 파일", ChatFormatting.YELLOW);
@@ -158,7 +209,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;
@@ -169,7 +224,15 @@ public final class VideoCache {
return; return;
} }
notifyChat("[videopreload] 다운로드 대기열 추가: " + url, ChatFormatting.YELLOW); notifyChat("[videopreload] 다운로드 대기열 추가: " + url, ChatFormatting.YELLOW);
DOWNLOAD_POOL.submit(() -> download(url)); // Capture the current epoch at submit time. The download thread checks against
// CACHE_EPOCH later — any mismatch means clearAll() ran in between and this
// download must abort without publishing.
long epoch = CACHE_EPOCH.get();
// Register a unique generation for this download so a targeted purge() can cancel
// exactly this URL (see DOWNLOAD_GEN / cancelled()).
long gen = GEN_SEQ.incrementAndGet();
DOWNLOAD_GEN.put(url, gen);
DOWNLOAD_POOL.submit(() -> download(url, epoch, gen));
} }
/** Return the local cached file path for this URL, or {@code null} if not yet ready. */ /** Return the local cached file path for this URL, or {@code null} if not yet ready. */
@@ -184,6 +247,42 @@ public final class VideoCache {
public static void clearIndex() { public static void clearIndex() {
READY.clear(); READY.clear();
IN_FLIGHT.clear(); IN_FLIGHT.clear();
DOWNLOAD_GEN.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();
DOWNLOAD_GEN.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. */ /** Caller-supplied: current set of URLs that are fully cached, for diagnostics. */
@@ -193,25 +292,82 @@ public final class VideoCache {
// -- internals ----------------------------------------------------------------------- // -- internals -----------------------------------------------------------------------
private static void download(String url) { /**
* 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, long startGen) {
// If a wipe (clearAll) or a targeted purge ran between download's pre-move 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.
if (cancelled(url, startEpoch, startGen)) {
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
READY.put(url, path);
if (cancelled(url, startEpoch, startGen)) {
READY.remove(url, path);
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
return true;
}
/**
* True if the in-flight download for {@code url} should abort and self-clean. Two channels:
* a global wipe ({@link #clearAll()}/{@link #wipeOnShutdown()} bumped {@link #CACHE_EPOCH}),
* or a targeted {@link #purge(String)} that removed/replaced this URL's generation in
* {@link #DOWNLOAD_GEN}.
*/
private static boolean cancelled(String url, long startEpoch, long startGen) {
if (CACHE_EPOCH.get() != startEpoch) return true;
Long cur = DOWNLOAD_GEN.get(url);
return cur == null || cur.longValue() != startGen;
}
private static void download(String url, long startEpoch, long startGen) {
Path cacheDir = cacheDir(); Path cacheDir = cacheDir();
Path partPath = null;
try { try {
if (cacheDir == null) { if (cacheDir == null) {
VideoPlayerMod.LOG.warn("[{}] preload: no game dir, skipping {}", VideoPlayerMod.LOG.warn("[{}] preload: no game dir, skipping {}",
VideoPlayerMod.MOD_ID, url); VideoPlayerMod.MOD_ID, url);
return; return;
} }
// Pre-flight cancellation check — if clearAll already ran between submit and now,
// skip the whole thing. Avoids creating directories / .part files post-clear.
if (cancelled(url, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before start (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
Files.createDirectories(cacheDir); Files.createDirectories(cacheDir);
String hash = sha256(url); String hash = sha256(url);
String ext = extensionFromUrl(url); String ext = extensionFromUrl(url);
Path finalPath = cacheDir.resolve(hash + ext); Path finalPath = cacheDir.resolve(hash + ext);
Path partPath = cacheDir.resolve(hash + ext + ".part"); partPath = cacheDir.resolve(hash + ext + ".part");
// 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) {
READY.put(url, finalPath); if (!publishIfNotCancelled(url, finalPath, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll/purge 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);
@@ -250,21 +406,30 @@ public final class VideoCache {
} }
long total = 0; long total = 0;
boolean cancelled = false;
try (InputStream in = raw.getInputStream(); try (InputStream in = raw.getInputStream();
OutputStream out = Files.newOutputStream(partPath)) { OutputStream out = Files.newOutputStream(partPath)) {
byte[] buf = new byte[64 * 1024]; byte[] buf = new byte[64 * 1024];
int n; int n;
while ((n = in.read(buf)) >= 0) { while ((n = in.read(buf)) >= 0) {
// Cancellation check inside the loop. Break (not return) so the try-with
// closes the output stream first — on Windows, deleting an open .part can
// fail with AccessDeniedException, so we always close before deleting.
if (cancelled(url, startEpoch, startGen)) {
cancelled = true;
break;
}
total += n; total += n;
if (total > MAX_BYTES) { if (total > MAX_BYTES) {
long capMb = MAX_BYTES / (1024 * 1024); long capMb = MAX_BYTES / (1024 * 1024);
VideoPlayerMod.LOG.warn( VideoPlayerMod.LOG.warn(
"[{}] preload: {} exceeded per-video {} MB cap; aborting", "[{}] preload: {} exceeded per-video {} MB cap; aborting",
VideoPlayerMod.MOD_ID, url, capMb); VideoPlayerMod.MOD_ID, url, capMb);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {} // Same close-before-delete dance for Windows.
cancelled = true;
notifyChat("[videopreload] 실패 (단일 영상 " + capMb + "MB 초과): " + url, notifyChat("[videopreload] 실패 (단일 영상 " + capMb + "MB 초과): " + url,
ChatFormatting.RED); ChatFormatting.RED);
return; break;
} }
if (existingCacheBytes + total > MAX_CACHE_BYTES) { if (existingCacheBytes + total > MAX_CACHE_BYTES) {
long capMb = MAX_CACHE_BYTES / (1024 * 1024); long capMb = MAX_CACHE_BYTES / (1024 * 1024);
@@ -272,18 +437,42 @@ public final class VideoCache {
VideoPlayerMod.LOG.warn( VideoPlayerMod.LOG.warn(
"[{}] preload: total-cache cap exceeded ({}>{} MB); aborting {}", "[{}] preload: total-cache cap exceeded ({}>{} MB); aborting {}",
VideoPlayerMod.MOD_ID, usedMb, capMb, url); VideoPlayerMod.MOD_ID, usedMb, capMb, url);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {} cancelled = true;
notifyChat("[videopreload] 실패 (전체 캐시 " + capMb + "MB 초과): " + url, notifyChat("[videopreload] 실패 (전체 캐시 " + capMb + "MB 초과): " + url,
ChatFormatting.RED); ChatFormatting.RED);
return; break;
} }
out.write(buf, 0, n); out.write(buf, 0, n);
} }
} }
// Now the .part file is closed — safe to delete on Windows.
if (cancelled) {
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
return;
}
// Pre-move cancellation check. If clearAll ran during the read loop, abort
// before promoting .part to final — saves a wasted move + delete.
if (cancelled(url, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
return;
}
Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING, Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE); StandardCopyOption.ATOMIC_MOVE);
READY.put(url, finalPath);
// Race-safe publish: this covers the window between Files.move and READY.put
// (clearAll deletes the freshly-moved file then we resurrect it in READY) AND
// the window between READY.put and the "완료" notification (clearAll wipes
// READY then we re-publish + emit stale completion chat). publishIfNotCancelled
// rolls back both READY and the on-disk file if the epoch moved at any time.
if (!publishIfNotCancelled(url, finalPath, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
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));
@@ -293,8 +482,15 @@ public final class VideoCache {
VideoPlayerMod.MOD_ID, url, t.toString()); VideoPlayerMod.MOD_ID, url, t.toString());
notifyChat("[videopreload] 실패 (" + t.getClass().getSimpleName() + "): " + url, notifyChat("[videopreload] 실패 (" + t.getClass().getSimpleName() + "): " + url,
ChatFormatting.RED); ChatFormatting.RED);
// Best-effort cleanup of any leftover .part on the error path.
if (partPath != null) {
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
}
} finally { } finally {
IN_FLIGHT.remove(url); IN_FLIGHT.remove(url);
// Only clear our own generation — a re-preload after a purge may have installed a
// newer gen for this URL that must survive.
DOWNLOAD_GEN.remove(url, startGen);
} }
} }

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

@@ -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.network.chat.Style;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import java.net.URI; import java.net.URI;
import java.util.Map; 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 * <br>{@code /videocache remove <name>} — drop the entry from server config and tell every client
* to delete the matching cache file. * to delete the matching cache file.
* *
* <p>Replaces the old {@code /videopreload}. Same permission gate * <p>Replaces the old {@code /videopreload}. Permission gate via
* ({@link Permissions#COMMANDS_GAMEMASTER}) so command blocks can drive it. * {@link CommandPermissions#opOrServer(CommandSourceStack)} so command blocks and datapack
* functions can drive it without touching {@code functionPermissionLevel}.
*/ */
public final class VideoCacheCommand { public final class VideoCacheCommand {
private VideoCacheCommand() {} private VideoCacheCommand() {}
@@ -44,7 +44,7 @@ public final class VideoCacheCommand {
private static LiteralArgumentBuilder<CommandSourceStack> build(String root) { private static LiteralArgumentBuilder<CommandSourceStack> build(String root) {
return Commands.literal(root) return Commands.literal(root)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .requires(CommandPermissions::opOrServer)
.then(Commands.literal("add") .then(Commands.literal("add")
.then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("url", StringArgumentType.greedyString()) .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.core.BlockPos;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel; 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.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
@@ -24,7 +23,7 @@ public final class VideoDeleteCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) { build(String name) {
return Commands.literal(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("pos", BlockPosArgument.blockPos())
.executes(VideoDeleteCommand::run)); .executes(VideoDeleteCommand::run));
} }

View File

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

View File

@@ -19,13 +19,32 @@ import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
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"));
} }
@@ -33,17 +52,38 @@ public final class VideoPlaceCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) { build(String name) {
return Commands.literal(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("pos", BlockPosArgument.blockPos())
.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 +94,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 +120,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)) {

View File

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

View File

@@ -0,0 +1,35 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/**
* C2S — client-presence handshake for the {@code music_quiz} datapack
* ({@code docs/mc_video_player_mod_integration.md}). Each modded client sends one on JOIN
* and one every ~5 s thereafter. The server side flips the player's {@code mq_video_mod}
* scoreboard score to {@code 1}, which the datapack reads in its {@code mq:commands/start}
* guard.
*
* <p>Payload body is a single int (protocol version) so future schema bumps don't require
* a new packet id. v1 is the only version that exists today.
*/
public record MqHelloPayload(int version) implements CustomPacketPayload {
public static final int CURRENT_VERSION = 1;
public static final CustomPacketPayload.Type<MqHelloPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "mq_hello"));
public static final StreamCodec<RegistryFriendlyByteBuf, MqHelloPayload> CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, MqHelloPayload::version,
MqHelloPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -32,6 +32,7 @@ public final class VideoPlayerNetwork {
// C2S // C2S
PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(MqHelloPayload.TYPE, MqHelloPayload.CODEC);
} }
public static void registerServerReceivers() { public static void registerServerReceivers() {

View File

@@ -0,0 +1,74 @@
package com.ejclaw.videoplayer.server;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.net.MqHelloPayload;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.scores.Objective;
import net.minecraft.world.scores.ScoreHolder;
import net.minecraft.world.scores.Scoreboard;
/**
* Implements the server side of the {@code music_quiz} datapack handshake described in
* {@code docs/mc_video_player_mod_integration.md}.
*
* <p>Two scoreboard writes against objective {@code mq_video_mod} (dummy):
* <ul>
* <li>Every server tick — {@code #server mq_video_mod = 1}. Proves the mod jar is on the
* server side. The datapack's {@code mq:commands/start} guard checks this first.</li>
* <li>Every received {@link MqHelloPayload} — {@code <player> mq_video_mod = 1}. Proves
* the sending player's client has the mod installed. Per-player flag is needed
* because rendering is client-side; the server can't tell from join state alone.</li>
* </ul>
*
* <p>The objective itself ({@code scoreboard objectives add mq_video_mod dummy}) is
* created by the datapack's {@code mq:load} function. If it isn't present yet (datapack
* not applied, or load hasn't run), the writes are silently skipped — when the datapack
* appears later, the next tick / next payload arrival populates it.
*/
public final class MusicQuizPresence {
private MusicQuizPresence() {}
/** Scoreboard objective name expected by the {@code music_quiz} datapack. */
private static final String OBJECTIVE = "mq_video_mod";
/** Fake holder used to mark "the server has the mod" (datapack reads {@code #server}). */
private static final String SERVER_HOLDER = "#server";
/** Call from {@code VideoPlayerMod.onInitialize} after payload types are registered. */
public static void register() {
// (a) per-tick server presence
ServerTickEvents.END_SERVER_TICK.register(MusicQuizPresence::onServerTick);
// (b) per-player client presence — flipped on every received Hello
ServerPlayNetworking.registerGlobalReceiver(MqHelloPayload.TYPE, (payload, context) -> {
ServerPlayer player = context.player();
MinecraftServer server = context.server();
// hop back onto the server thread before touching the scoreboard
server.execute(() -> markPlayerPresent(server, player));
});
}
private static void onServerTick(MinecraftServer server) {
Scoreboard sb = server.getScoreboard();
Objective obj = sb.getObjective(OBJECTIVE);
// Datapack not loaded yet — silently skip. The score holder doesn't exist until
// the objective does, and trying to write blind would just blow up.
if (obj == null) return;
sb.getOrCreatePlayerScore(ScoreHolder.forNameOnly(SERVER_HOLDER), obj).set(1);
}
private static void markPlayerPresent(MinecraftServer server, ServerPlayer player) {
Scoreboard sb = server.getScoreboard();
Objective obj = sb.getObjective(OBJECTIVE);
if (obj == null) {
VideoPlayerMod.LOG.debug("[{}] mq hello from {} but objective '{}' not present",
VideoPlayerMod.MOD_ID, player.getName().getString(), OBJECTIVE);
return;
}
// ServerPlayer itself implements ScoreHolder, so this matches selector @s on the
// datapack side without name-formatting quirks (uuid vs profile name).
sb.getOrCreatePlayerScore(player, obj).set(1);
}
}