41 Commits

Author SHA1 Message Date
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
tkrmagid
2fc09deb4f v0.4.16: fix v0.4.15 reviewer-flagged regressions
Some checks failed
build / build (push) Has been cancelled
1. VideoPlayerConfig.save() now persists max_cache_mb. Previously the
   auto-augment path in load() called save(), but save() didn't emit the
   key — so the rewrite kept losing it. Now updated comment + property.

2. /videoCache clear sends a new ClearCachePayload (no body) that wipes
   the entire client video_player_cache/ directory. Previously it only
   broadcast a DeleteCachePayload per *registered* URL, so leftover files
   from legacy preload_urls or prior sessions were never cleaned; now
   we always send clearAll regardless of whether the named index was
   non-empty.

3. Downloads are now serialized through a single-thread executor instead
   of `new Thread(...).start()`. With parallel downloads, every worker
   was snapshotting cacheDirSize() before any .part was renamed to its
   final name, so 50 simultaneous joins could collectively bust the
   max_cache_mb cap. With one in-flight at a time, each download's
   directory scan reflects every preceding completion.

Network: new S2C ClearCachePayload(unit codec), registered in
VideoPlayerNetwork + ClientNetworking. VideoCache.clearAll() iterates
the dir, deletes regular files, resets READY/IN_FLIGHT, reports
deleted/failed counts in chat + log. /videoCache clear command always
broadcasts to all clients now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 20:08:25 +09:00
tkrmagid
6e242bb675 v0.4.15: split per-video vs total cache cap; auto-augment legacy config
Some checks failed
build / build (push) Has been cancelled
- max_preload_mb is now strictly the per-video download cap (default raised
  to 2048 MB so a single 4K short clip fits without hitting the wall).
- New max_cache_mb knob caps total client-side cache directory size
  (default 750 MB, sized for ~50 short FHD clips). Enforced cooperatively
  at start of each /videoPreload download and during the read loop so a
  late-arriving large clip can't blow past the cap.
- VideoPlayerConfig.load() now detects missing keys (max_preload_mb,
  max_cache_mb, render_distance_blocks) and rewrites the file once with
  defaults filled in, so existing servers pick up new options without
  having to delete config/video_player.json.
- CachePolicyPayload now carries (maxPerVideoBytes, maxCacheBytes,
  renderDistanceBlocks); StreamCodec order: VAR_LONG, VAR_LONG, VAR_INT.
- Client receiver wires both caps into VideoCache; chat errors distinguish
  "단일 영상 NMB 초과" vs "전체 캐시 NMB 초과".
- Add dist/ to .gitignore (release artifacts uploaded to Gitea, never
  committed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 20:01:41 +09:00
tkrmagid
849e53096c v0.4.14: /videoCache clear + tighten default cap to 750 MB
Some checks failed
build / build (push) Has been cancelled
- New /videoCache clear subcommand: drops every named entry from server config
  and broadcasts DeleteCachePayload per URL so each client purges its disk cache
  in one shot.
- Default max_preload_mb lowered from 1024 → 750. Sized to fit ~50 short FHD
  clips (FHD H.264 ~5 Mbps × 20 s ≈ 12.5 MB → 50 × 15 MB ≈ 750 MB with headroom).
  Config file's max_preload_mb still wins when set; the new default only
  affects fresh installs and the client-side bootstrap value before the JOIN
  policy packet arrives.
2026-05-16 19:52:40 +09:00
tkrmagid
c53ebcc30d v0.4.13: fix delete-while-playing race, /videoCache, config additions, name resolution
Some checks failed
build / build (push) Has been cancelled
Crash fix (4K delete EXCEPTION_ACCESS_VIOLATION):
- JavaCvBackend.stopWorker() no longer calls grabber.close() from caller thread.
  Only flips running=false, stops/flushes audio line, then interrupt+join(2s). The
  worker's own finally still closes grabber from the decoder thread, so the av_frame
  native plane is never freed mid-memCopy.
- Validate memCopy length against ByteBuffer.capacity() AND width*height*4 before
  copying, and re-check running/closed inside the frameLock.

Config:
- max_preload_mb (default 1024) — replaces the hard-coded 512 MB cap in VideoCache.
  Pushed to clients at join via CachePolicyPayload.
- render_distance_blocks (default 128) — replaces the hard-coded 128 in
  VideoAnchorRenderer.getViewDistance(). Mirrored client-side via ClientPolicy.

Command rename: /videopreload → /videoCache add|list|remove
- Persistent named index in cache_entries (server config).
- /videoCache list prints clickable URLs (ClickEvent.OpenUrl).
- /videoCache remove broadcasts DeleteCachePayload so each client purges its disk
  cache file.

Name resolution:
- /videoPlace ... <urlOrName> and the GUI save path both accept a /videoCache name
  in place of an http(s) URL; VideoPlayerConfig.resolveUrlOrName() does the lookup
  server-side before persisting to the anchor BE.

Cleanup:
- Drop the lowercase Brigadier aliases (videoplace, videostick, videodelete,
  videomute) — keep camelCase only.
2026-05-16 04:03:43 +09:00
tkrmagid
6896870186 v0.4.12: single-sided anchor quad (drop back face)
Some checks failed
build / build (push) Has been cancelled
The anchor sits flush against a wall by design, so the back face never
gets a viewer. Drawing it cost an extra 4 vertices and doubled the
fragment shader work for the video texture per anchor for no visible
benefit. Removing it also fixes the mirrored-texture artifact a player
would see if they clipped behind the wall (e.g. spectator mode).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:29:03 +09:00
tkrmagid
9b99283b70 v0.4.11: video frame ring buffer + decoder stats + 0.1s audio buffer
Some checks failed
build / build (push) Has been cancelled
0.4.10 still played at ~2-5 fps even though the decoder buffer was
preallocated. Root cause: the single-slot staging buffer was paced by
SourceDataLine backpressure at the audio buffer's granularity (~0.5 s),
so the decoder burst-produced ~12 video frames into the slot while audio
drained, the consumer saw only the last frame of each burst, then the
decoder stalled until audio drained again. Net visible rate ~ source_fps
/ frames_per_burst.

Fix:
- Replace single staging slot with a 4-slot ring (preallocated, FIFO).
  Decoder writes to ringTail; if full, overwrites oldest and bumps
  droppedFrames so we can see overflow in the log. Render thread drains
  oldest under the same lock — no allocation, no race.
- Shrink audio driver buffer 0.5 s → 0.1 s so the decoder is paced more
  tightly. Burst size collapses from ~12 frames to 2-3, which fits
  inside the ring.
- Log decoder spec on start (WxH @ fps, audio Hz x ch, ring depth) and
  produced/consumed/dropped counters every ~10 s. Lets the user log
  confirm whether the decoder is keeping real-time pace and whether the
  ring is overflowing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:10:47 +09:00
tkrmagid
cee01bd448 v0.4.10: preallocate decoder direct buffer, fix 5fps video
Some checks failed
build / build (push) Has been cancelled
0.4.9 allocated a fresh w*h*4 direct ByteBuffer on every grab() — at
1080p × 24fps that's ~192 MB/s of direct memory churn (page zero-fill +
Cleaner enqueue). The decoder thread spent most of its frame budget on
memory bookkeeping instead of decoding, fell behind real time, and the
single-slot AtomicReference saw bursty refills that the render thread
could only sample at ~5fps. Game thread was fine, only the video looked
like 5fps.

Replace it with one preallocated direct buffer per backend instance,
filled under a short-held lock on the decoder side. Swap the pollFrame()
ByteBuffer-returning API for consumeFrame(dstAddr, maxBytes) so the
render thread memcpys straight from staging buffer → GPU texture
pointer under the same lock — no allocation, no race window between
"got buffer" and "decoder overwrote it".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:55:04 +09:00
tkrmagid
3d4843dd0d v0.4.9: kill BufferedImage path, release texture on close
Some checks failed
build / build (push) Has been cancelled
Stutter fix (root cause):
- 0.4.7 made the GPU upload a memcpy, but toRgba() in JavaCvBackend was
  still doing BufferedImage.getRGB() + a per-pixel ARGB->RGBA loop. That
  loop ran 20-50ms per 1080p frame on the decode thread. When it slipped
  behind real-time, the audio buffer drained, backpressure vanished,
  the decoder burst-fired catch-up frames into the single-slot
  AtomicReference (dropping 11 of 12 for ~0.5s of buffer), then blocked
  again on the next audio refill -- exactly the periodic stutter the
  user reported.
- Force the grabber to output AV_PIX_FMT_RGBA (=26) via setPixelFormat.
  Now frame.image[0] is already a ByteBuffer of RGBA bytes; we just
  copy it into a fresh direct buffer and hand it to the upload path.
  The colorspace conversion happens inside swscale (native SIMD) at
  <1ms per frame, so the decoder consistently keeps real-time pace and
  the audio backpressure stays smooth.
- Removed Java2DFrameConverter / BufferedImage usage entirely.

Defensive delete fix (potential crash on anchor delete):
- Entry.close() now calls TextureManager.release(id) before closing the
  texture itself. Without this, a RenderType cached by Identifier could
  still try to bind the dead GL handle on the next frame and crash the
  render thread. The crash report the user reported couldn't be located
  (no crash-reports/ folder) so this is the most plausible suspect from
  reading the code; full diagnosis still pending the tail of latest.log.
2026-05-15 22:32:32 +09:00
tkrmagid
dbc76e0083 v0.4.8: bundle JavaCV per-platform via Fabric jarJar (no separate install)
Some checks failed
build / build (push) Has been cancelled
- build.gradle: optional -Pplatform=<id> property switches the build into
  a fat-jar mode where javacv 1.5.13 + javacpp + ffmpeg 8.0.1 (java + the
  picked platform's native jar) are all nested into the mod jar via
  Fabric loom's `include` directive. Fabric loader unpacks them at
  runtime, so users no longer need -Xbootclasspath/a:... or 5 separate
  jars in .minecraft/libraries.
- Without -Pplatform, the build produces the same small ~85KB vanilla
  jar as before, so devs/server-side and bring-your-own-JavaCV setups
  still work.
- Per-platform artifacts: video_player-<platform>-0.4.8.jar where
  <platform> ∈ windows-x86_64 / linux-x86_64 / macosx-x86_64 /
  macosx-arm64. Sizes 21-32MB.
- README: STEP 5 (the long JavaCV manual-install + -Xbootclasspath
  section) is gone. New STEP 4 just says 'pick the jar for your OS'.
  Also added a warning about removing the old -Xbootclasspath JVM arg
  when upgrading, since duplicate JavaCV on the boot classpath can
  silently break decoding.
2026-05-15 22:15:34 +09:00
tkrmagid
7b7fd7f320 v0.4.7: smoother playback via memcpy upload + render-rate pump
Some checks failed
build / build (push) Has been cancelled
- Replace per-pixel RGBA->ABGR loop in Entry.upload() with a single
  MemoryUtil.memCopy() into NativeImage's native buffer. The two layouts
  are identical when viewed as little-endian bytes, so no swap is needed.
  Cuts 1080p upload time from a ~2M-iter Java loop to one native memcpy.
- Move the frame-pump tick from 20Hz client tick (END_CLIENT_TICK) to
  per-render-frame (LevelRenderEvents.START_MAIN). At 60+fps display vs
  24fps source, this removes the worst stutter window where a decoded
  frame waited up to 50ms for the next tick. Distance-gain math stays on
  20Hz where it's plenty.
- Bump version 0.4.6 -> 0.4.7 in gradle.properties and README.
2026-05-15 22:06:15 +09:00
tkrmagid
d34dc97671 v0.4.6: server config for auto-preload on join
Some checks failed
build / build (push) Has been cancelled
- new: config/video_player.json on first server start. Field preload_urls
  is a list of HTTP(S) URLs (≤256 chars each) that the server broadcasts
  via PreloadPayload to every player when they finish joining, so common
  videos are warmed into each client's video_player_cache/ before they
  ever play. Reuses the same PreloadPayload + VideoCache path as
  /videopreload, so chat feedback ("[videopreload] 완료") still applies.
- config is loaded once at mod init; invalid entries are dropped with a
  WARN line. Edit + restart server to apply changes.
2026-05-15 21:58:26 +09:00
tkrmagid
e6faae3f39 v0.4.5: panel-center audio, preload chat feedback, defensive BE check
Some checks failed
build / build (push) Has been cancelled
- audio: distance attenuation now uses the panel center (width/2, height/2
  offset from the anchor along the renderer's right/up axes) instead of the
  anchor block corner, so a 4x4 panel sounds like it's coming from the
  middle of the screen and not the bottom-left.
- preload: each client now posts a chat line on start / completion / failure
  / cache-hit, so a command-block sequence like /videopreload -> /videoplace
  can be timed against the visible "[videopreload] 완료" message.
- safety: VideoPlayback.tick() verifies the anchor BE still exists at each
  active position and forcibly stops playback if it doesn't — covers any
  edge case where BLOCK_ENTITY_UNLOAD doesn't fire.
- /videopreload feedback now explicitly states "완료 알림 후 재생하세요".
2026-05-15 21:53:21 +09:00
tkrmagid
4fc7cf46b7 v0.4.4: fix audio-on-delete, reduce stutter, add /videopreload
Some checks failed
build / build (push) Has been cancelled
- fix: stop playback when anchor block entity unloads (BLOCK_ENTITY_UNLOAD)
  so deleting a video while audio is playing actually silences it.
- fix: force-stop SourceDataLine and grabber from outside the worker thread
  so a blocked line.write() / grab() unblocks immediately on close.
- perf: tune FFmpeg streaming options (buffer_size, probesize, analyzeduration,
  max_delay, fflags=+genpts, reconnect_delay_max) and pre-size audio line buffer
  to ~0.5s to smooth out mid-stream stutter.
- feat: /videopreload <url> broadcasts a S2C PreloadPayload to all clients;
  each client downloads the URL to <gameDir>/video_player_cache/<sha256> and
  subsequent playback reads from the local file instead of streaming.
  Gated by COMMANDS_GAMEMASTER (op level 2), so command blocks can invoke it.
2026-05-15 21:42:11 +09:00
tkrmagid
0d1f802555 docs: add missing javacpp native jar to install guide (v0.4.3)
Some checks failed
build / build (push) Has been cancelled
JavaCPP Loader (javacpp.jar) ships pure-Java code that needs its own
JNI bridge DLL (jnijavacpp) to extract & link other native libraries.
That bridge lives in the platform-specific javacpp-<os>-<arch>.jar
which the old install guide silently omitted — users following it
ended up with a black panel and an UnsatisfiedLinkError for jnijavacpp
because FFmpeg natives could never be loaded.

This bumps the required JavaCV jar list from 4 to 5, updates the
-Xbootclasspath/a: examples on all three OSes, and adds a diagnostic
note for the matching log line.

Docs-only change; mod code is unchanged from v0.4.2.
2026-05-15 21:22:02 +09:00
tkrmagid
8f11fe24bf docs: bump README to v0.4.2, note 0.4.1 stick-icon defect
Some checks failed
build / build (push) Has been cancelled
Reviewer caught that the README still pointed users at video_player-0.4.1.jar
even though 0.4.2 fixes the stick missing-model issue. Updated:
- "current version" header → 0.4.2
- STEP 4 download filename → video_player-0.4.2.jar
- build output path → build/libs/video_player-0.4.2.jar
- STEP 6 verification: explicitly call out that 0.4.1 and below have a known
  stick-icon defect (unprefixed item/generated parent rejected by the 26.1.2
  model loader) so users on those versions need to upgrade, not just dedupe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:04:49 +09:00
tkrmagid
4b14fb479b fix(item): namespace item/generated parent for 26.1.2 model loader (v0.4.2)
Some checks failed
build / build (push) Has been cancelled
The held video_stick item rendered as the default missing-model cube even
with v0.4.1 jar loaded (lang strings resolved, so the mod itself was active).
Root cause confirmed against Fabric 26.1.2 docs: the new model loader no
longer auto-resolves unprefixed parent paths. `item/generated` needs to be
written as `minecraft:item/generated`.

models/item/video_stick.json — parent → minecraft:item/generated.
gradle.properties — 0.4.1 → 0.4.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:02:03 +09:00
tkrmagid
693c1f2cd1 docs: require absolute paths in JVM args, drop %APPDATA% expansion myth
Some checks failed
build / build (push) Has been cancelled
User followed README using %APPDATA% in -Xbootclasspath/a: and the official
Mojang launcher passed the literal string through to Java without expanding
it, so boot classpath ended up empty and video stayed black despite all 4
JavaCV jars being present.

Replaced the %APPDATA% example with an absolute C:\Users\<name>\AppData\
Roaming\... template, added a callout warning that the launcher does not
expand env vars in JVM args, and pointed at `echo %APPDATA%` from cmd as the
way to discover the right path.

Also corrected the ffmpeg jar filenames: the bundle zip uses the short form
(e.g. ffmpeg-windows-x86_64.jar), not the Maven-style
ffmpeg-8.0.1-1.5.13-windows-x86_64.jar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:58:46 +09:00
tkrmagid
137767e75c docs: pin direct Fabric API 26.1.2 download link, warn about version suffix
Some checks failed
build / build (push) Has been cancelled
User reported Incompatible-mods crash because they downloaded
fabric-api-0.140.2+1.21.11.jar from Modrinth (Modrinth's version-filter URL
param does not always restrict the listing to the requested game version).

Replaced the generic search-page link with the direct CDN URL of
fabric-api-0.149.0+26.1.2.jar and added a callout telling readers to verify
the filename suffix ends in +26.1.2.jar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:55:32 +09:00
tkrmagid
6e2ef661ea docs: rewrite README for first-time users on official launcher + Fabric
Some checks failed
build / build (push) Has been cancelled
Previous README used "방법 A/B/C" terminology that confused readers and
recommended Prism over the official launcher. Per user feedback, restructured
into a step-by-step guide assuming the official Mojang launcher:

1. boot 26.1.2 vanilla once to create .minecraft
2. run fabric-installer-1.x for client / 26.1.2 / loader 0.19.2
3. open .minecraft/mods (per-OS instructions)
4. drop fabric-api + video_player-0.4.1.jar, remove old versions
5. install JavaCV — two routes:
   5-A. Prism Launcher (easiest)
   5-B. official launcher via -Xbootclasspath/a: with Windows/macOS/Linux examples
6. verify with /videostick

Moved Maven coords to a developer footnote. Added install verification step
to disambiguate "missing texture" symptom from leftover old-version jars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:44:47 +09:00
tkrmagid
d382babfbd docs: client-side install guide for v0.4.1 + JavaCV 1.5.13
Some checks failed
build / build (push) Has been cancelled
README rewritten for MC 26.1.2 / Java 25 / Fabric Loader 0.19.2 target.
Added JavaCV install section (Prism/MultiMC preferred, JVM args fallback,
Maven coords for builders) pointing at the 2026-02-22 1.5.13 binaries
bundling FFmpeg 8.0.1. Usage section reflects the v0.4.1 invisible-anchor
flow: clicked block = bottom-left, sneak+left-click deletes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:39:02 +09:00
tkrmagid
459b3249a4 fix(render): anchor video to clicked block's bottom-left, EAST/WEST flush
Some checks failed
build / build (push) Has been cancelled
Three fixes for v0.4.1:

1. Video stick item rendered as missing-texture because 26.1.2 requires the
   new client_item descriptor at assets/<mod>/items/<name>.json. Add it; the
   existing models/item/video_stick.json is kept as the underlying model.

2. Quad placement now anchors the local (0,0) corner at the bottom-left of
   the wall face the player clicked, so the clicked block is the BL and the
   video grows up & right. Previously it was centered on the anchor.

3. EAST/WEST face rotations were swapped, which placed the quad on the far
   side of the air block (~1 block away from the wall) instead of flush.
   Derived the correct rotations from first principles:
     EAST = Axis.YP +90°  (local +Z → world +X, +X → -Z = north)
     WEST = Axis.YP -90°  (local +Z → world -X, +X → +Z = south)
   NORTH/SOUTH/UP/DOWN math re-verified — those were already correct.
2026-05-15 20:21:19 +09:00
tkrmagid
2b50f56980 render: paint video on the clicked wall face (no visible anchor block)
Some checks failed
build / build (push) Has been cancelled
The anchor block becomes invisible and non-collidable; it exists only as a
BlockEntity host in the air block adjacent to the clicked wall. The renderer
now translates and rotates the textured quad so it sits flush against the
surface of the wall the user actually clicked, on any of the six faces.

Stick interaction:
  right-click face → place anchor at hit.relative(face), facing=face, open GUI
  right-click face with anchor already there → reopen the GUI
  sneak + left-click face with stick → delete the anchor on that face
The anchor's selection outline / collision / occlusion are all empty, so the
player can target the wall block behind it without interference.

JavaCV / streaming polish:
- Bump missing-JavaCV log to WARN so users notice when the runtime jar is
  not installed (previously buried at INFO).
- Add HTTP resilience options: `timeout`, `reconnect`, `reconnect_streamed`,
  `reconnect_at_eof`, and a `user_agent` so picky servers don't 403 us.
2026-05-15 20:08:33 +09:00
tkrmagid
429244d820 audio: route JavaCV samples through SourceDataLine with live gain
Some checks failed
build / build (push) Has been cancelled
setVolume/Mute previously stored gain without affecting audible output: the
backend only called grabImage() and never opened an audio sink. Switch to
grab() (interleaved video+audio frames), force AV_SAMPLE_FMT_S16 on the
grabber so samples are always interleaved signed 16-bit PCM, open a matching
JavaSound SourceDataLine and write scaled samples per-frame. gain is read
on every block so /videoMute, GUI Mute and the per-tick distance attenuation
now take effect immediately. SourceDataLine.write blocking provides natural
A/V pacing, so the legacy 15ms sleep is dropped when an audio line is open;
sleep is retained as a 60fps cap when there is no audio device.

bump version to 0.3.1.
2026-05-15 19:45:42 +09:00
tkrmagid
52fbcd1861 render: restore textured quad on new 26.1.2 BlockEntityRenderer pipeline
Some checks failed
build / build (push) Has been cancelled
VideoPlayback now allocates a DynamicTexture per active anchor under a unique
Identifier (registered on Minecraft.getTextureManager()) and pumps RGBA frames
into it via NativeImage.setPixelABGR + DynamicTexture.upload() during the
client tick. Until the backend (JavaCV) produces a first frame, the texture
shows a dark gray placeholder with a thin border so the anchor screen is
visibly present.

VideoAnchorRenderer.submit() now uses SubmitNodeCollector.submitCustomGeometry
with RenderTypes.entityCutout(textureId), drawing a two-sided width×height
quad oriented by Direction.toYRot() + Axis.YP.rotationDegrees. Vertex
attributes use the new VertexConsumer fluent API (addVertex(Matrix4f, ...)
.setColor.setUv.setOverlay(NO_OVERLAY).setLight.setNormal).

JavaCvBackend / WatermediaBackend / WatermediaProbe / VideoBackend are
unchanged — JavaCV is referenced entirely via reflection so the mod jar
remains loadable when the bytedeco classifier jars aren't on the runtime
classpath, in which case the anchor renders its placeholder surface.
2026-05-15 19:38:23 +09:00
tkrmagid
27a3f34bfa port: migrate all sources from Yarn 1.21.x to Mojmap 26.1.2
Some checks failed
build / build (push) Has been cancelled
- Block/BE/Item: BaseEntityBlock + useItemOn(InteractionResult), useOn(UseOnContext),
  setChanged(), loadAdditional(ValueInput) / saveAdditional(ValueOutput) with
  getStringOr/getIntOr/getBooleanOr/getFloatOr defaults
- Registries: BuiltInRegistries + ResourceKey + Properties.setId(ResourceKey)
- Networking: CustomPacketPayload.Type + StreamCodec.composite + RegistryFriendlyByteBuf
  (note: clientboundPlay/serverboundPlay names in fabric-networking-api-v1 6.3.1)
- Commands: Commands.literal/argument, CommandSourceStack.sendSuccess/sendFailure,
  PermissionSet.hasPermission(Permissions.COMMANDS_GAMEMASTER) (level-2 equivalent)
- Client GUI: EditBox / Button / Checkbox / AbstractSliderButton + addRenderableWidget
  (no render override; widgets render themselves under the new pipeline)
- Renderer: rewritten as stub against new BlockEntityRenderer<T, S extends BlockEntityRenderState>
  pattern (createRenderState / extractRenderState / submit). Stub does not draw a quad yet
  — frame upload and dynamic texture surface deferred until Watermedia/JavaCV are
  re-audited for Java 25
- Playback: stripped to bookkeeping-only stub (tracks active anchors, no frame pump)
- Client entrypoint: ClientTickEvents.END_LEVEL_TICK (was END_WORLD_TICK), Minecraft.level,
  LocalPlayer, Vec3, InteractionResult

./gradlew build passes against MC 26.1.2 + Fabric Loader 0.19.2 + fabric-api 0.149.0+26.1.2.
Block placement, anchor BE, payloads, commands, and GUI are functional; the anchor renders
as the plain block until the new render-state pipeline is wired with a texture.
2026-05-15 19:27:12 +09:00
tkrmagid
8f69814cb2 build: switch toolchain to MC 26.1.2 (intermediary retired)
Some checks failed
build / build (push) Has been cancelled
- net.fabricmc.fabric-loom 1.16-SNAPSHOT (no remap; MC 26.1+ ships unobfuscated)
- gradle.properties: minecraft_version=26.1.2, loader=0.19.2, fabric-api=0.149.0+26.1.2
- Java 25 toolchain
- fabric.mod.json: fabricloader>=0.19.0, java>=25
- Drop multi-version build script + matrix CI (single-target now)
- Backup of 1.21.6/7/8 working tree preserved on mc-1.21.x branch

Source migration to Mojmap names is in progress on follow-up commits;
this commit alone will not build until source files are ported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:09:59 +09:00
46 changed files with 3044 additions and 743 deletions

View File

@@ -1,4 +1,4 @@
name: build-matrix name: build
on: on:
push: push:
@@ -9,33 +9,15 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- mc: "1.21.6"
yarn: "1.21.6+build.1"
fabric: "0.120.1+1.21.6"
- mc: "1.21.7"
yarn: "1.21.7+build.8"
fabric: "0.129.0+1.21.7"
- mc: "1.21.8"
yarn: "1.21.8+build.1"
fabric: "0.136.1+1.21.8"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 25
- name: Build (MC ${{ matrix.mc }}) - name: Build (MC 26.1.2)
run: | run: ./gradlew --no-daemon build
./gradlew --no-daemon \
-Pminecraft_version=${{ matrix.mc }} \
-Pyarn_mappings=${{ matrix.yarn }} \
-Pfabric_version=${{ matrix.fabric }} \
build
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: video_player-mc${{ matrix.mc }} name: video_player-mc26.1.2
path: build/libs/*.jar path: build/libs/*.jar

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.gradle/ .gradle/
build/ build/
dist/
out/ out/
.idea/ .idea/
.vscode/ .vscode/

196
README.md
View File

@@ -1,29 +1,189 @@
# video_player (영상재생모드) # video_player (영상재생모드)
마인크래프트(Fabric, MC 1.21.6+) 안에서 임의의 mp4 URL을 블록 표면에 재생하는 모드. 마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드.
- 모드 ID: `video_player` - 모드 ID: `video_player`
- 한글명: 영상재생모드 - 현재 버전: **0.4.12**
- 로더: Fabric (싱글플레이어 / 전용 서버 양쪽 지원) - 마인크래프트 버전: **26.1.2**
- 명세: 별도 SPEC 문서 참조 - 필요 Java: **25** (마인크래프트 26.x 가 요구함)
## 빌드 ---
```sh ## 처음 사용하는 분을 위한 설치 가이드
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew build
이 모드는 마인크래프트 **공식 런처**에 **Fabric**을 설치해서 쓰는 것을 기준으로 합니다. 차근차근 따라오시면 됩니다.
### STEP 1. 마인크래프트 공식 런처를 켜고 최소 1회 26.1.2 바닐라로 접속
런처 메뉴에서 마인크래프트 버전을 **26.1.2** 로 한 번 실행해 두면, 게임 폴더(`.minecraft`)와 `versions/26.1.2/` 가 자동으로 만들어집니다. 월드를 만들 필요는 없고 메인 화면까지만 들어가면 됩니다.
### STEP 2. Fabric 설치하기
Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니다.
1. https://fabricmc.net/use/installer/ 에 접속해서 "Download for Windows" (또는 macOS / Linux)를 누릅니다. `fabric-installer-1.x.x.exe` (또는 `.jar`) 파일이 다운로드 됩니다.
2. 받은 파일을 **더블 클릭** 으로 실행합니다.
3. 창이 뜨면:
- **클라이언트** 탭이 선택되어 있는지 확인
- 마인크래프트 버전: **26.1.2**
- 로더 버전: **0.19.2** (또는 그보다 높은 숫자)
- 설치 위치는 그대로 두세요
4. **설치** 버튼을 누르고 완료 메시지가 뜨면 닫습니다.
5. 다시 공식 마인크래프트 런처를 열면 좌측 하단 프로필 선택 칸에 **`fabric-loader-0.19.2-26.1.2`** (이름 비슷한 항목) 이 새로 생겨 있습니다. 이 프로필을 선택합니다.
### STEP 3. 모드 폴더 열기
선택한 fabric 프로필 옆에 톱니바퀴 ⚙ 모양 아이콘이나 "편집" 버튼이 있습니다. (없으면 그냥 한 번 플레이를 눌러서 게임을 띄웠다가 닫으면 폴더가 만들어집니다.)
`.minecraft/mods` 폴더가 모드를 넣는 곳입니다. 운영체제별 경로:
- **Windows**: 키보드에서 `윈도우키+R``%appdata%\.minecraft\mods` 입력 → 엔터
- **macOS**: Finder → `Go` 메뉴 → `Go to Folder``~/Library/Application Support/minecraft/mods`
- **Linux**: `~/.minecraft/mods`
폴더가 없으면 `mods` 라는 이름으로 직접 만드세요.
### STEP 4. 모드 jar 파일 두 개를 mods 폴더에 넣기
1. **Fabric API** (Fabric 모드들이 공통으로 쓰는 라이브러리. 거의 모든 Fabric 모드에 필요)
- **반드시 26.1.2 용으로 받아야 합니다.** 파일명 끝에 `+26.1.2.jar` 가 붙어있는지 꼭 확인하세요. `+1.21.11.jar` 같은 다른 버전을 받으면 게임이 "Incompatible mods found / requires Minecraft 1.21.x" 에러로 안 켜집니다.
- 직접 다운로드 (2026-05-14 빌드, MC 26.1.2 전용):
https://cdn.modrinth.com/data/P7dR8mSH/versions/Sy2Bq7Xc/fabric-api-0.149.0%2B26.1.2.jar
- 더 최신 빌드를 찾을 땐: https://modrinth.com/mod/fabric-api/versions → 페이지에서 게임 버전 필터 `26.1.2` 를 직접 선택. (URL 파라미터 필터가 듣지 않는 경우가 있어서 페이지 안에서 한 번 더 확인하는 게 안전합니다.)
- 받은 `fabric-api-0.149.0+26.1.2.jar``mods` 폴더에 넣습니다.
2. **video_player** (이 모드, 0.4.12 부터 JavaCV 가 jar 안에 포함됨)
- 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases
- 자신의 OS·CPU 에 맞는 jar **한 개** 만 받아서 `mods` 폴더에 넣으면 됩니다 (별도 JavaCV 설치 불필요):
- Windows 64bit: `video_player-windows-x86_64-0.4.12.jar` (~32MB)
- macOS Intel: `video_player-macosx-x86_64-0.4.12.jar` (~24MB)
- macOS Apple Silicon (M1/M2/M3/M4): `video_player-macosx-arm64-0.4.12.jar` (~21MB)
- Linux 64bit: `video_player-linux-x86_64-0.4.12.jar` (~27MB)
- 자기 OS 가 헷갈리면: Windows 는 거의 다 `windows-x86_64`, 인텔맥은 `macosx-x86_64`, 애플 실리콘 맥은 `macosx-arm64`, 리눅스는 `linux-x86_64`.
이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다. 0.4.7 이하에서 쓰던 JVM 인수(`-Xbootclasspath/a:...javacv...`) 도 0.4.12 부터는 **빼주세요** — 모드 jar 안에 같은 JavaCV 가 들어있어서 부트클래스패스의 것과 충돌해 검은 화면이 날 수 있습니다.
### STEP 5. 잘 설치됐는지 확인
게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면:
- 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘).
- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.12` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.)
---
## 사용법
### 영상 배치
1. 비디오 스틱을 손에 들고, 영상을 띄우고 싶은 벽/바닥/천장 블록을 **우클릭**.
2. 열린 GUI 에 영상 URL, 가로(W), 세로(H), 반복 여부, 자동재생 여부를 입력.
3. **클릭한 그 블록의 면** 이 영상의 왼쪽 아래 모서리가 되고, 오른쪽으로 W블록, 위로 H블록 만큼 영상이 펼쳐집니다.
### 영상 수정 / 삭제
- 이미 영상이 걸린 면을 다시 **우클릭** → GUI 재오픈, 내용 수정 가능
- 영상 삭제: **쉬프트(Shift)** 누른 상태로 그 면을 **좌클릭**
### URL 조건
- `http://` 또는 `https://` URL만 됩니다 (`file://`, 로컬 파일 X)
- 길이 256자 이하
- FFmpeg 가 디코드 가능한 형식이면 됩니다 — mp4, webm, mkv, mov 등
- 인증 토큰이 URL 에 들어 있으면 그 상태로 저장되니 공유 주의
### 명령어
| 명령 | 설명 |
| --- | --- |
| `/videostick` | 비디오 스틱 아이템을 인벤토리에 지급 |
| `/videoplace <pos> <facing> <w> <h> <url>` | 좌표에 영상 앵커를 즉시 배치 |
| `/videodelete <pos>` | 좌표의 영상 앵커 제거 |
| `/videomute <pos> <on\|off>` | 영상의 음소거 켜고/끄기 |
| `/videopreload <url>` | URL 을 미리 받아 디스크 캐시에 저장 (자세한 내용은 아래) |
`/videoplace`, `/videodelete`, `/videomute`, `/videopreload` 는 기본적으로 OP(권한 레벨 2) 가 필요하므로 **커맨드 블럭에서도 그대로 호출 가능합니다**. 커맨드 블럭은 기본이 권한 레벨 2 라 별도 설정 없이 동작합니다.
### `/videopreload` — 영상 미리 로딩 (스터터 제거)
스트리밍 URL 을 라이브로 받으면서 재생하면 네트워크가 잠깐 느려질 때 끊김이 생깁니다. `/videopreload <url>` 을 미리 한번 실행하면 클라이언트가 백그라운드에서 URL 을 통째로 다운로드해서 `.minecraft/video_player_cache/` 폴더에 저장해 두고, 같은 URL 로 영상이 재생될 때 인터넷이 아니라 그 로컬 파일을 사용합니다 (= 끊김 없음).
```
/videopreload https://video.example.com/foo.mp4
``` ```
산출물: `build/libs/video_player-<version>.jar` 특징:
## 현재 진행도 - **커맨드 블럭에서 사용 가능** — 예: 압력판 → 커맨드 블럭 `/videopreload <url>` 으로 트리거하면 플레이어가 영상 영역에 다가가기 전에 미리 다운로드 시작
- 명령은 서버에서 실행되지만, 다운로드는 각 **클라이언트**(접속해 있는 모든 플레이어)가 자기 PC 에 받습니다
- 이미 받아둔 URL 은 재요청해도 다시 다운로드 안 함 (URL 의 SHA-256 으로 캐싱)
- 한 파일당 상한 512 MB
- 캐시 폴더가 너무 커지면 직접 `.minecraft/video_player_cache/` 안의 파일을 삭제해도 됩니다 (그러면 다음 사용 시 다시 받아옴)
이 저장소는 SPEC §10 마일스톤을 순차적으로 채우는 중입니다. > 영상 삭제 시 소리가 안 멎던 문제는 0.4.4 에서 수정되었습니다 (앵커 블록이 사라지면 디코더 / 오디오 라인을 즉시 강제 종료). 0.4.5 에서는 `BLOCK_ENTITY_UNLOAD` 이벤트가 누락되는 엣지케이스를 대비해 매 틱마다 BE 존재를 한 번 더 검증합니다.
- **M1 — 스캐폴드** (현재): Fabric 모드 골격, 비디오 앵커 블록/엔티티 등록, 비디오 스틱 아이템, `/videoStick` 명령. > 0.4.5 부터 다운로드 시작 / 완료 / 실패가 채팅창에 표시됩니다. 커맨드블럭으로 `/videopreload` 후 `/videoplace` 를 이어 실행할 때는 `[videopreload] 완료` 메시지를 본 뒤에 재생해야 로컬 파일에서 재생됩니다 (그 전에 재생하면 일반 스트리밍으로 떨어집니다).
- M2 — 정적 렌더
- M3 — JavaCV mp4 재생 > 0.4.5 부터 오디오 거리 감쇠가 **판때기 중앙**을 기준으로 계산됩니다. 예전엔 앵커 블록(보통 화면 모서리)을 기준으로 측정해서 큰 화면일수록 소리가 한쪽에서 들리는 느낌이었습니다.
- M4 — GUI / 네트워크
- M5 — 좌클릭 삭제 + `/videoPlace`, `/videoDelete` ### 서버 config 로 자동 프리로드 (0.4.6+)
- M6 — 오디오 + 거리감 + Mute(소리 on/off)
- M7 — WaterMedia 백엔드 + 자동 선택 서버에 모드를 넣고 한 번 실행하면 `<서버폴더>/config/video_player.json` 가 자동 생성됩니다. 이 파일에 자주 쓰는 영상 URL 을 적어두면, 플레이어가 접속할 때마다 서버가 자동으로 그 URL 들의 프리로드 요청을 보냅니다 (= `/videopreload` 를 사람마다 친 것과 같음).
- M8 — Stonecutter 멀티버전
기본 생성된 파일 예시:
```json
{
"_comment": "preload_urls: HTTP(S) video URLs broadcast to every player on join. ...",
"preload_urls": []
}
```
사용 예시 — 인트로 영상과 BGM 영상을 모든 접속자가 미리 받도록:
```json
{
"preload_urls": [
"https://video.example.com/intro.mp4",
"https://video.example.com/bgm-loop.webm"
]
}
```
규칙:
- 각 URL 은 `http://` 또는 `https://` 시작, 256자 이하 (그 외는 무시되고 서버 로그에 WARN)
- 파일 수정 후 적용하려면 **서버 재시작** 이 필요합니다 (config 는 시작 시 1회만 로딩)
- 접속한 플레이어 화면에는 `/videopreload` 와 동일한 `[videopreload] 다운로드 시작 / 완료` 채팅 메시지가 보입니다
- 이미 캐시된 URL 은 다시 다운로드하지 않습니다 (SHA-256 캐시 키)
- 싱글플레이는 자체적으로 통합 서버를 띄우므로, 통합 서버의 config 폴더(`<게임폴더>/config/video_player.json`)에도 같은 효과로 동작합니다
---
## 알려진 이슈
- 영상 자리만 잡히고 검게 보이는 경우: 자신의 OS·CPU 와 다른 플랫폼의 jar 를 받았거나, 이전 버전(0.4.7 이하)의 `-Xbootclasspath/a:...javacv...` JVM 인수가 그대로 남아 있는 경우가 가장 흔합니다. 로그 파일(`.minecraft/logs/latest.log`)에서 `JavaCV not on classpath` WARN 또는 `UnsatisfiedLinkError... jnijavacpp` 메시지로 확인 가능합니다.
- 0.3.x 이하 버전에서 만든 영상은 새 버전(0.4.x) 에서 보이지 않으니 다시 배치해야 합니다.
---
## 개발자용 빌드
바닐라(JavaCV 미포함, 별도 설치 가정) 빌드:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build
```
산출물: `build/libs/video_player-0.4.12.jar` (~85KB)
플랫폼별 fat jar (JavaCV 1.5.13 + ffmpeg 8.0.1 네이티브 nested):
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=windows-x86_64
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=linux-x86_64
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=macosx-x86_64
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=macosx-arm64
```
산출물: `build/libs/video_player-<platform>-0.4.12.jar` (~21-32MB, jar 내부에 nested 로 javacv/javacpp/ffmpeg jar 5개 포함, Fabric loader 가 런타임에 classpath 로 풀어서 로딩)
JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표:
```
org.bytedeco:javacv-platform:1.5.13
```

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id 'fabric-loom' version '1.16.2' id 'net.fabricmc.fabric-loom' version "${loom_version}"
id 'maven-publish' id 'maven-publish'
id 'java' id 'java'
} }
@@ -7,8 +7,25 @@ plugins {
version = project.mod_version version = project.mod_version
group = project.maven_group group = project.maven_group
// Optional platform bundle target: `-Pplatform=windows-x86_64` etc. produces a fat jar
// with that platform's JavaCV + FFmpeg natives nested inside via Fabric's `include`
// directive. Without `-Pplatform`, the build matches pre-0.4.8 behavior (small jar,
// user supplies JavaCV separately).
def javacvPlatform = (project.findProperty('platform') ?: '').toString()
def bundleJavaCv = !javacvPlatform.isEmpty()
def javacvVersion = '1.5.13'
def ffmpegVersion = '8.0.1-1.5.13'
def supportedPlatforms = ['windows-x86_64', 'linux-x86_64', 'macosx-x86_64', 'macosx-arm64']
if (bundleJavaCv && !supportedPlatforms.contains(javacvPlatform)) {
throw new GradleException(
"Unknown -Pplatform=${javacvPlatform}. Allowed: ${supportedPlatforms.join(', ')}")
}
base { base {
archivesName = project.archives_base_name archivesName = bundleJavaCv
? "${project.archives_base_name}-${javacvPlatform}"
: project.archives_base_name
} }
repositories { repositories {
@@ -16,11 +33,29 @@ repositories {
maven { url = 'https://maven.fabricmc.net/' } maven { url = 'https://maven.fabricmc.net/' }
} }
loom {
// Intentionally empty — MC 26.1+ ships unobfuscated, so the new loom does not remap.
}
dependencies { dependencies {
// No mappings dep — Mojang ships official names since 26.1, intermediary is gone.
minecraft "com.mojang:minecraft:${project.minecraft_version}" minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" implementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
if (bundleJavaCv) {
// Nest the same 5 jars that the README's manual install step used to require, so
// users no longer need -Xbootclasspath/a. `include` adds them as jar-in-jar entries;
// Fabric loader unpacks and classloads them at runtime.
// `transitive = false` keeps us from dragging in opencv/openblas/etc — we only use
// FFmpegFrameGrabber / Frame / Java2DFrameConverter.
include(implementation("org.bytedeco:javacv:${javacvVersion}") { transitive = false })
include(implementation("org.bytedeco:javacpp:${javacvVersion}") { transitive = false })
include(implementation("org.bytedeco:ffmpeg:${ffmpegVersion}") { transitive = false })
include(implementation("org.bytedeco:javacpp:${javacvVersion}:${javacvPlatform}") { transitive = false })
include(implementation("org.bytedeco:ffmpeg:${ffmpegVersion}:${javacvPlatform}") { transitive = false })
}
} }
processResources { processResources {
@@ -28,27 +63,24 @@ processResources {
inputs.property "mod_id", project.mod_id inputs.property "mod_id", project.mod_id
inputs.property "minecraft_version", project.minecraft_version inputs.property "minecraft_version", project.minecraft_version
// Pin the fabric.mod.json's minecraft constraint to the build's exact target — keeps each
// multi-version jar from claiming compatibility it doesn't have.
def mc = project.minecraft_version
def target = "~${mc}"
filesMatching("fabric.mod.json") { filesMatching("fabric.mod.json") {
expand "version": project.version, "mod_id": project.mod_id, "target_minecraft": target expand "version": project.version,
"mod_id": project.mod_id,
"target_minecraft": "~${project.minecraft_version}"
} }
} }
tasks.withType(JavaCompile).configureEach { tasks.withType(JavaCompile).configureEach {
it.options.release = 21 it.options.release = 25
} }
java { java {
withSourcesJar() withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_25
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(21) languageVersion = JavaLanguageVersion.of(25)
} }
} }

View File

@@ -1,14 +1,16 @@
org.gradle.jvmargs=-Xmx2G org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true org.gradle.parallel=true
# Config cache disabled — new loom + IntelliJ aren't fully compatible yet
org.gradle.configuration-cache=false
# Mod # Mod
mod_id=video_player mod_id=video_player
mod_version=0.2.0 mod_version=0.4.31
maven_group=com.ejclaw.videoplayer maven_group=com.ejclaw.videoplayer
archives_base_name=video_player archives_base_name=video_player
# Minecraft / Fabric (1.21.6) # Minecraft / Fabric (26.1.2 — single target, intermediary/Yarn retired)
minecraft_version=1.21.6 minecraft_version=26.1.2
yarn_mappings=1.21.6+build.1
loader_version=0.19.2 loader_version=0.19.2
fabric_version=0.120.1+1.21.6 loom_version=1.16-SNAPSHOT
fabric_version=0.149.0+26.1.2

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Build the mod against all supported MC versions and collect jars under build/multiver/.
# Uses gradle property overrides so we don't have to maintain three settings.gradle copies.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=build/multiver
rm -rf "$OUT"
mkdir -p "$OUT"
build_one() {
local mc="$1" yarn="$2" fab="$3"
echo "==> Building for MC $mc (yarn=$yarn, fabric-api=$fab)"
./gradlew --no-daemon \
-Pminecraft_version="$mc" \
-Pyarn_mappings="$yarn" \
-Pfabric_version="$fab" \
build
# main jar = the one without "-sources"
local jar
jar=$(ls build/libs/video_player-*.jar | grep -v -- '-sources' | head -1)
cp "$jar" "$OUT/video_player-mc${mc}.jar"
}
build_one 1.21.6 1.21.6+build.1 0.120.1+1.21.6
build_one 1.21.7 1.21.7+build.8 0.129.0+1.21.7
build_one 1.21.8 1.21.8+build.1 0.136.1+1.21.8
echo
echo "All jars:"
ls -la "$OUT"

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;
@@ -10,66 +12,112 @@ import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import net.fabricmc.api.ClientModInitializer; 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.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;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback; import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.Minecraft;
import net.minecraft.client.network.ClientPlayerEntity; import net.minecraft.client.player.LocalPlayer;
import net.minecraft.entity.player.PlayerEntity; import net.minecraft.core.BlockPos;
import net.minecraft.util.ActionResult; import net.minecraft.sounds.SoundSource;
import net.minecraft.util.math.BlockPos; import net.minecraft.world.InteractionResult;
import net.minecraft.util.math.Vec3d; import net.minecraft.world.phys.Vec3;
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoPlayerClient implements ClientModInitializer { public class VideoPlayerClient implements ClientModInitializer {
@SuppressWarnings("deprecation")
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
ClientNetworking.register(); ClientNetworking.register();
MusicQuizClient.register();
BlockEntityRendererRegistry.register( BlockEntityRendererRegistry.register(
VideoPlayerBlockEntities.VIDEO_ANCHOR, VideoPlayerBlockEntities.VIDEO_ANCHOR,
VideoAnchorRenderer::new VideoAnchorRenderer::new
); );
AttackBlockCallback.EVENT.register((player, world, hand, pos, direction) -> { AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> {
if (world.isClient if (!level.isClientSide()) return InteractionResult.PASS;
&& player.getMainHandStack().getItem() instanceof VideoStickItem if (!(player.getMainHandItem().getItem() instanceof VideoStickItem)) return InteractionResult.PASS;
&& world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) { // The anchor itself is invisible / non-collidable so the player cannot left-click it
ClientPlayNetworking.send(new DeleteAnchorPayload(pos)); // directly. Sneak + left-click on the wall the video sits on → delete the anchor in
return ActionResult.SUCCESS; // the adjacent air block.
if (player.isShiftKeyDown()) {
BlockPos anchorPos = pos.relative(direction);
if (level.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(anchorPos));
return InteractionResult.SUCCESS;
} }
return ActionResult.PASS; }
// Legacy / safety: if the player somehow targets the anchor block directly.
if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
}); });
ClientTickEvents.END_CLIENT_TICK.register(client -> { // Pump frame uploads on every render frame (60+Hz) rather than every client tick
VideoPlayback.tick(); // (20Hz). At 24fps source, a 20Hz pump can skip a frame whenever a tick window happens
updateDistanceGains(client); // to miss the decode boundary, producing visible micro-stutter even when frames are
}); // ready. Polling at render rate lets the texture latch as soon as a frame is decoded.
LevelRenderEvents.START_MAIN.register(ctx -> VideoPlayback.tick());
ClientTickEvents.END_WORLD_TICK.register(world -> { // Distance-gain math is cheap and only audible state — 20Hz is plenty and avoids
// recomputing it on every render frame.
ClientTickEvents.END_CLIENT_TICK.register(VideoPlayerClient::updateDistanceGains);
ClientTickEvents.END_LEVEL_TICK.register(world -> {
// no-op for now // no-op for now
}); });
// When an anchor block is deleted (shift+left-click, /videodelete) or its chunk
// unloads (player walks away), Minecraft removes the block entity client-side. Hook
// that to stop the decode worker and audio line — otherwise VideoPlayback.ENTRIES
// keeps holding the backend and the sound keeps playing.
ClientBlockEntityEvents.BLOCK_ENTITY_UNLOAD.register((blockEntity, level) -> {
if (blockEntity instanceof VideoAnchorBlockEntity) {
VideoPlayback.stop(blockEntity.getBlockPos());
}
});
// 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);
} }
/** SPEC §6 — recompute per-anchor audio gain from player distance every tick. */ /**
private static void updateDistanceGains(MinecraftClient client) { * SPEC §6 — recompute per-anchor audio gain from player distance every tick.
ClientPlayerEntity p = client.player; * Distance is measured from the player's eye to the <em>panel center</em>, not the anchor
if (p == null || client.world == null) return; * block corner — for a 4×4 panel the corner is ~2 blocks off from where the screen visually
Vec3d eye = p.getEyePos(); * sits, which made the audio feel like it was off to the side.
for (BlockPos pos : com.ejclaw.videoplayer.client.playback.VideoPlayback.activePositions()) { *
if (!(client.world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue; * <p>Gain is also gated by the Minecraft sound options so the in-game sliders work as
double dx = (pos.getX() + 0.5) - eye.x; * expected: vanilla {@code SoundEngine.calculateVolume} multiplies by master × category, so
double dy = (pos.getY() + 0.5) - eye.y; * we do the same with {@link SoundSource#PLAYERS} as the category. Result: dragging the
double dz = (pos.getZ() + 0.5) - eye.z; * "Players" slider in Options → Music & Sounds attenuates video audio just like other
double d = Math.sqrt(dx * dx + dy * dy + dz * dz); * player sounds, 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;
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
Vec3 center = be.panelCenter();
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;
com.ejclaw.videoplayer.client.playback.VideoPlayback.setGain(pos, gain); VideoPlayback.setGain(pos, gain);
} }
} }
} }

View File

@@ -0,0 +1,327 @@
package com.ejclaw.videoplayer;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Server-side mod config, stored at {@code <gameDir>/config/video_player.json}.
*
* <p>Schema (auto-generated on first start):
* <pre>{@code
* {
* "max_preload_mb": 1024,
* "preload_urls": [
* "https://example.com/intro.mp4"
* ],
* "cache_entries": [
* { "name": "intro", "url": "https://example.com/intro.mp4" }
* ]
* }
* }</pre>
*
* <p>{@code max_preload_mb} is the hard ceiling each client enforces on a single video download
* (default 1024 MB ≈ 1 GB; enough headroom for ~50 short FHD clips). Pushed to every client on
* join via {@link com.ejclaw.videoplayer.net.CachePolicyPayload}.
*
* <p>{@code cache_entries} is the named cache index managed by {@code /videocache add|list|remove}.
* Each entry's URL is pushed to clients on join (and immediately on {@code add}). Names are unique.
*
* <p>{@code preload_urls} is the legacy un-named auto-preload list — same behavior as
* {@code cache_entries} but without a removable handle. Kept for backward-compat.
*/
public final class VideoPlayerConfig {
private VideoPlayerConfig() {}
private static final String FILE_NAME = "video_player.json";
/**
* Default per-video download cap in MB. Sized to allow a single 4K short clip
* (≈25 Mbps × 60 s ≈ 190 MB, with headroom for higher-bitrate sources). Per-video
* cap is intentionally separate from the total-cache cap below.
*/
private static final int DEFAULT_MAX_PRELOAD_MB = 2048;
/**
* Default total-cache cap in MB. Sized to comfortably fit ~50 short FHD clips:
* FHD H.264 at ~5 Mbps × 20 s ≈ 12.5 MB, so 50 × 15 MB ≈ 750 MB with headroom.
*/
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;
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 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<>();
/** Load (or create) the config file. Called once during mod initialization. */
public static synchronized void load() {
Path path = configPath();
try {
if (!Files.exists(path)) {
writeDefault(path);
VideoPlayerMod.LOG.info("[{}] created default config at {}",
VideoPlayerMod.MOD_ID, path);
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
return;
}
String raw = Files.readString(path, StandardCharsets.UTF_8);
JsonObject json = JsonParser.parseString(raw).getAsJsonObject();
// Track whether any expected key was missing; if so, we rewrite the file at the
// end so users don't have to delete their config to pick up new options.
boolean augmented = false;
// max_preload_mb (per-video cap, sanity-clamped to [16, 16384])
int cap = DEFAULT_MAX_PRELOAD_MB;
if (json.has("max_preload_mb") && json.get("max_preload_mb").isJsonPrimitive()
&& json.get("max_preload_mb").getAsJsonPrimitive().isNumber()) {
cap = json.get("max_preload_mb").getAsInt();
} else {
augmented = true;
}
if (cap < 16) cap = 16;
if (cap > 16384) cap = 16384;
maxPreloadMb = cap;
// max_cache_mb (total-cache cap, sanity-clamped to [16, 65536])
int total = DEFAULT_MAX_CACHE_MB;
if (json.has("max_cache_mb") && json.get("max_cache_mb").isJsonPrimitive()
&& json.get("max_cache_mb").getAsJsonPrimitive().isNumber()) {
total = json.get("max_cache_mb").getAsInt();
} else {
augmented = true;
}
if (total < 16) total = 16;
if (total > 65536) total = 65536;
maxCacheMb = total;
// render_distance_blocks (sanity-clamped to [16, 2048])
int rd = DEFAULT_RENDER_DISTANCE;
if (json.has("render_distance_blocks") && json.get("render_distance_blocks").isJsonPrimitive()
&& json.get("render_distance_blocks").getAsJsonPrimitive().isNumber()) {
rd = json.get("render_distance_blocks").getAsInt();
} else {
augmented = true;
}
if (rd < 16) rd = 16;
if (rd > 2048) rd = 2048;
renderDistanceBlocks = rd;
// preload_urls (legacy)
List<String> urls = new ArrayList<>();
if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) {
json.getAsJsonArray("preload_urls").forEach(el -> {
if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) {
String u = sanitizeUrl(el.getAsString());
if (u != null) urls.add(u);
}
});
}
preloadUrls = Collections.unmodifiableList(urls);
// cache_entries (named)
CACHE_ENTRIES.clear();
if (json.has("cache_entries") && json.get("cache_entries").isJsonArray()) {
for (JsonElement el : json.getAsJsonArray("cache_entries")) {
if (!el.isJsonObject()) continue;
JsonObject o = el.getAsJsonObject();
String name = o.has("name") && o.get("name").isJsonPrimitive()
? o.get("name").getAsString().trim() : null;
String url = o.has("url") && o.get("url").isJsonPrimitive()
? sanitizeUrl(o.get("url").getAsString()) : null;
if (name == null || name.isEmpty() || url == null) continue;
if (CACHE_ENTRIES.containsKey(name)) {
VideoPlayerMod.LOG.warn(
"[{}] config: duplicate cache entry name '{}' — keeping first",
VideoPlayerMod.MOD_ID, name);
continue;
}
CACHE_ENTRIES.put(name, url);
}
}
VideoPlayerMod.LOG.info(
"[{}] config loaded: per-video={} MB, total-cache={} MB, render={} blocks, "
+ "preload_urls={}, cache_entries={}",
VideoPlayerMod.MOD_ID, maxPreloadMb, maxCacheMb, renderDistanceBlocks,
urls.size(), CACHE_ENTRIES.size());
// Auto-augment: rewrite the file once so missing keys appear after a mod update.
if (augmented) {
VideoPlayerMod.LOG.info(
"[{}] config missing one or more keys — rewriting with defaults filled in",
VideoPlayerMod.MOD_ID);
save();
}
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] failed to read config {}: {} — using defaults",
VideoPlayerMod.MOD_ID, path, t.toString());
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
}
}
// -- accessors ---------------------------------------------------------------------------
/** Hard cap on a single client-side video download, in MB. */
public static int maxPreloadMb() { return maxPreloadMb; }
/** Same value in bytes. */
public static long maxPreloadBytes() { return (long) maxPreloadMb * 1024L * 1024L; }
/** Hard cap on the client-side total cache directory, in MB. */
public static int maxCacheMb() { return maxCacheMb; }
/** Same value in bytes. */
public static long maxCacheBytes() { return (long) maxCacheMb * 1024L * 1024L; }
/** Anchor BE view-distance cap, in blocks. */
public static int renderDistanceBlocks() { return renderDistanceBlocks; }
/** Legacy un-named preload list (still pushed at join). Never null. */
public static List<String> preloadUrls() { return preloadUrls; }
/** Snapshot of name → url, insertion-ordered. Never null. */
public static synchronized Map<String, String> cacheEntries() {
return new LinkedHashMap<>(CACHE_ENTRIES);
}
/** Lookup a single entry's URL by name. */
public static synchronized String cacheUrl(String name) {
return CACHE_ENTRIES.get(name);
}
/**
* Accept either a raw HTTP(S) URL or a previously-registered cache entry name and return
* the canonical URL to store on the anchor. Returns the trimmed input when it's already a
* URL, the looked-up URL when the input matches a cache entry name, or {@code null} if the
* input is neither (caller decides whether to fail or fall through).
*/
public static synchronized String resolveUrlOrName(String input) {
if (input == null) return null;
String t = input.trim();
if (t.isEmpty()) return "";
if (t.startsWith("http://") || t.startsWith("https://")) return t;
return CACHE_ENTRIES.get(t);
}
// -- mutations (driven by /videocache add|remove) -----------------------------------------
/** Returns true if added; false if the name already exists. Persists on success. */
public static synchronized boolean addCacheEntry(String name, String url) {
if (name == null || name.isEmpty() || url == null) return false;
if (CACHE_ENTRIES.containsKey(name)) return false;
CACHE_ENTRIES.put(name, url);
save();
return true;
}
/** Returns the removed URL, or null if no entry by that name. Persists on success. */
public static synchronized String removeCacheEntry(String name) {
if (name == null) return null;
String removed = CACHE_ENTRIES.remove(name);
if (removed != null) save();
return removed;
}
/**
* Drop every named cache entry and return the URLs that were registered so the caller
* can broadcast {@code DeleteCachePayload} for each. Persists on any non-empty removal.
*/
public static synchronized java.util.List<String> clearCacheEntries() {
if (CACHE_ENTRIES.isEmpty()) return java.util.Collections.emptyList();
java.util.List<String> urls = new java.util.ArrayList<>(CACHE_ENTRIES.values());
CACHE_ENTRIES.clear();
save();
return urls;
}
// -- io ----------------------------------------------------------------------------------
private static Path configPath() {
return FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME);
}
private static String sanitizeUrl(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty() || t.length() > 256) return null;
if (!(t.startsWith("http://") || t.startsWith("https://"))) return null;
return t;
}
private static void writeDefault(Path path) throws IOException {
Files.createDirectories(path.getParent());
JsonObject root = new JsonObject();
root.addProperty("_comment",
"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. "
+ "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.add("preload_urls", new JsonArray());
root.add("cache_entries", new JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
Files.writeString(path, gson.toJson(root), StandardCharsets.UTF_8);
}
/** Persist current in-memory state. Best-effort; logs on failure. */
private static void save() {
Path path = configPath();
try {
Files.createDirectories(path.getParent());
JsonObject root = new JsonObject();
root.addProperty("_comment",
"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. "
+ "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);
JsonArray legacyArr = new JsonArray();
for (String u : preloadUrls) legacyArr.add(u);
root.add("preload_urls", legacyArr);
JsonArray entriesArr = new JsonArray();
for (Map.Entry<String, String> e : CACHE_ENTRIES.entrySet()) {
JsonObject o = new JsonObject();
o.addProperty("name", e.getKey());
o.addProperty("url", e.getValue());
entriesArr.add(o);
}
root.add("cache_entries", entriesArr);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
Files.writeString(path, gson.toJson(root), StandardCharsets.UTF_8);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] failed to save config: {}",
VideoPlayerMod.MOD_ID, t.toString());
}
}
}

View File

@@ -1,15 +1,21 @@
package com.ejclaw.videoplayer; package com.ejclaw.videoplayer;
import com.ejclaw.videoplayer.command.VideoCacheCommand;
import com.ejclaw.videoplayer.command.VideoDeleteCommand; import com.ejclaw.videoplayer.command.VideoDeleteCommand;
import com.ejclaw.videoplayer.command.VideoMuteCommand; import com.ejclaw.videoplayer.command.VideoMuteCommand;
import com.ejclaw.videoplayer.command.VideoPlaceCommand; import com.ejclaw.videoplayer.command.VideoPlaceCommand;
import com.ejclaw.videoplayer.command.VideoStickCommand; import com.ejclaw.videoplayer.command.VideoStickCommand;
import com.ejclaw.videoplayer.net.CachePolicyPayload;
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;
import net.fabricmc.api.ModInitializer; import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -25,12 +31,43 @@ public class VideoPlayerMod implements ModInitializer {
VideoPlayerNetwork.registerPayloadTypes(); VideoPlayerNetwork.registerPayloadTypes();
VideoPlayerNetwork.registerServerReceivers(); VideoPlayerNetwork.registerServerReceivers();
MusicQuizPresence.register();
VideoPlayerConfig.load();
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> {
VideoStickCommand.register(dispatcher); VideoStickCommand.register(dispatcher);
VideoPlaceCommand.register(dispatcher); VideoPlaceCommand.register(dispatcher);
VideoDeleteCommand.register(dispatcher); VideoDeleteCommand.register(dispatcher);
VideoMuteCommand.register(dispatcher); VideoMuteCommand.register(dispatcher);
VideoCacheCommand.register(dispatcher);
});
// On join: (1) push the per-video download cap so the client knows whether to abort
// an over-cap stream, (2) replay every legacy preload_urls entry, (3) replay every
// named /videocache entry. Policy must go first so caps are honored before downloads
// start. Each PreloadPayload is fire-and-forget; clients post their own "[videopreload]"
// status lines when downloads finish.
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
var player = handler.getPlayer();
ServerPlayNetworking.send(player, new CachePolicyPayload(
VideoPlayerConfig.maxPreloadBytes(),
VideoPlayerConfig.maxCacheBytes(),
VideoPlayerConfig.renderDistanceBlocks()));
int sent = 0;
for (String url : VideoPlayerConfig.preloadUrls()) {
ServerPlayNetworking.send(player, new PreloadPayload(url));
sent++;
}
for (var e : VideoPlayerConfig.cacheEntries().entrySet()) {
ServerPlayNetworking.send(player, new PreloadPayload(e.getValue()));
sent++;
}
if (sent > 0) {
LOG.info("[{}] sent policy + {} preload(s) to {}",
MOD_ID, sent, player.getName().getString());
}
}); });
LOG.info("[{}] initialized", MOD_ID); LOG.info("[{}] initialized", MOD_ID);

View File

@@ -4,50 +4,87 @@ import com.ejclaw.videoplayer.item.VideoStickItem;
import com.ejclaw.videoplayer.net.OpenScreenPayload; import com.ejclaw.videoplayer.net.OpenScreenPayload;
import com.mojang.serialization.MapCodec; import com.mojang.serialization.MapCodec;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.AbstractBlock; import net.minecraft.core.BlockPos;
import net.minecraft.block.BlockEntityProvider; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.block.BlockState; import net.minecraft.world.InteractionHand;
import net.minecraft.block.BlockWithEntity; import net.minecraft.world.InteractionResult;
import net.minecraft.block.entity.BlockEntity; import net.minecraft.world.entity.player.Player;
import net.minecraft.entity.player.PlayerEntity; import net.minecraft.world.item.ItemStack;
import net.minecraft.item.ItemStack; import net.minecraft.world.level.BlockGetter;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.world.level.Level;
import net.minecraft.util.ActionResult; import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.util.Hand; import net.minecraft.world.level.block.RenderShape;
import net.minecraft.util.hit.BlockHitResult; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.util.math.BlockPos; import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.World; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
public class VideoAnchorBlock extends BlockWithEntity implements BlockEntityProvider { /**
public static final MapCodec<VideoAnchorBlock> CODEC = createCodec(VideoAnchorBlock::new); * Anchor block — invisible, non-collidable host for {@link VideoAnchorBlockEntity}.
*
* <p>The block exists only so a {@link BlockEntity} can be attached to a position; visually it is
* completely empty (no model, no selection outline, no collision). The video itself is drawn by
* {@link com.ejclaw.videoplayer.client.render.VideoAnchorRenderer} flush against the wall the
* player clicked, not as a textured surface on this block.
*/
public class VideoAnchorBlock extends BaseEntityBlock {
public static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new);
public VideoAnchorBlock(AbstractBlock.Settings settings) { public VideoAnchorBlock(BlockBehaviour.Properties properties) {
super(settings); super(properties);
} }
@Override @Override
protected MapCodec<? extends BlockWithEntity> getCodec() { protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC; return CODEC;
} }
@Override @Override
public BlockEntity createBlockEntity(BlockPos pos, BlockState state) { public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new VideoAnchorBlockEntity(pos, state); return new VideoAnchorBlockEntity(pos, state);
} }
@Override @Override
protected ActionResult onUseWithItem(ItemStack stack, BlockState state, World world, protected RenderShape getRenderShape(BlockState state) {
BlockPos pos, PlayerEntity player, Hand hand, return RenderShape.INVISIBLE;
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getCollisionShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getOcclusionShape(BlockState state) {
return Shapes.empty();
}
@Override
protected boolean propagatesSkylightDown(BlockState state) {
return true;
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level,
BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) { BlockHitResult hit) {
if (!(stack.getItem() instanceof VideoStickItem)) { if (!(stack.getItem() instanceof VideoStickItem)) {
return ActionResult.PASS; return InteractionResult.PASS;
} }
if (world.isClient) return ActionResult.SUCCESS; if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayerEntity sp)) return ActionResult.PASS; if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
if (world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be) { if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(pos, be.toNbt())); ServerPlayNetworking.send(sp, new OpenScreenPayload(pos, be.toNbt()));
return ActionResult.SUCCESS; return InteractionResult.SUCCESS;
} }
return ActionResult.PASS; return InteractionResult.PASS;
} }
} }

View File

@@ -1,17 +1,23 @@
package com.ejclaw.videoplayer.block; package com.ejclaw.videoplayer.block;
import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities; import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import net.minecraft.block.BlockState; import net.minecraft.core.BlockPos;
import net.minecraft.block.entity.BlockEntity; import net.minecraft.core.Direction;
import net.minecraft.nbt.NbtCompound; import net.minecraft.core.HolderLookup;
import net.minecraft.storage.ReadView; import net.minecraft.nbt.CompoundTag;
import net.minecraft.storage.WriteView; import net.minecraft.network.protocol.Packet;
import net.minecraft.util.math.BlockPos; import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.util.math.Direction; import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** /**
* Anchor BE — holds the per-block config that drives playback. NBT persistence uses * Anchor BE — holds per-block config that drives playback.
* 1.21.6's ReadView/WriteView. Network sync uses {@link #toNbt()} / {@link #fromNbt(NbtCompound)}. * NBT persistence uses 26.1's ValueInput/ValueOutput.
* Network sync uses {@link #toNbt()} / {@link #fromNbt(CompoundTag)}.
*/ */
public class VideoAnchorBlockEntity extends BlockEntity { public class VideoAnchorBlockEntity extends BlockEntity {
private String url = ""; private String url = "";
@@ -36,28 +42,59 @@ public class VideoAnchorBlockEntity extends BlockEntity {
public boolean isMuted() { return muted; } public boolean isMuted() { return muted; }
public boolean isAutoplay() { return autoplay; } public boolean isAutoplay() { return autoplay; }
public void setUrl(String url) { this.url = url == null ? "" : url; markDirty(); } public void setUrl(String url) { this.url = url == null ? "" : url; setChanged(); }
public void setWidth(int width) { this.width = clamp(width, 1, 32); markDirty(); } public void setWidth(int width) { this.width = clamp(width, 1, 32); setChanged(); }
public void setHeight(int height) { this.height = clamp(height, 1, 32); markDirty(); } public void setHeight(int height) { this.height = clamp(height, 1, 32); setChanged(); }
public void setFacing(Direction facing) { this.facing = facing == null ? Direction.NORTH : facing; markDirty(); } public void setFacing(Direction facing) { this.facing = facing == null ? Direction.NORTH : facing; setChanged(); }
public void setLoop(boolean loop) { this.loop = loop; markDirty(); } public void setLoop(boolean loop) { this.loop = loop; setChanged(); }
public void setVolume(float volume) { this.volume = Math.max(0F, Math.min(1F, volume)); markDirty(); } public void setVolume(float volume) { this.volume = Math.max(0F, Math.min(1F, volume)); setChanged(); }
public void setMuted(boolean muted) { this.muted = muted; markDirty(); } public void setMuted(boolean muted) { this.muted = muted; setChanged(); }
public void setAutoplay(boolean autoplay) { this.autoplay = autoplay; markDirty(); } public void setAutoplay(boolean autoplay) { this.autoplay = autoplay; setChanged(); }
/** Apply server-validated config from an NBT (used by network handler). */ /** Apply server-validated config from an NBT (used by network handler). */
public void applyFromNbt(NbtCompound nbt) { public void applyFromNbt(CompoundTag nbt) {
fromNbt(nbt); fromNbt(nbt);
markDirty(); setChanged();
}
/**
* World-space center of the rendered panel. Used for distance-based audio attenuation so
* the sound source feels like it's coming from the middle of the screen, not the corner
* (anchor block). Geometry mirrors {@code VideoAnchorRenderer#submit}:
* the panel's bottom-left corner sits on the wall surface at
* {@code anchorCenter + (-0.5)*facing + (-0.5)*right + (-0.5)*up}, and the panel extends
* {@code width × height} blocks along {@code right} and {@code up}. So the center is
* {@code anchorCenter + (w/2 - 0.5)*right + (h/2 - 0.5)*up + (-0.5)*facing}.
*/
public Vec3 panelCenter() {
Vec3 right, up;
switch (facing) {
case NORTH -> { right = new Vec3(-1, 0, 0); up = new Vec3(0, 1, 0); }
case SOUTH -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 1, 0); }
case EAST -> { right = new Vec3( 0, 0, -1); up = new Vec3(0, 1, 0); }
case WEST -> { right = new Vec3( 0, 0, 1); up = new Vec3(0, 1, 0); }
case UP -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 0, -1); }
case DOWN -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 0, 1); }
default -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 1, 0); }
}
Vec3 facingVec = new Vec3(facing.getStepX(), facing.getStepY(), facing.getStepZ());
BlockPos p = getBlockPos();
Vec3 anchorCenter = new Vec3(p.getX() + 0.5, p.getY() + 0.5, p.getZ() + 0.5);
double offR = width / 2.0 - 0.5;
double offU = height / 2.0 - 0.5;
return anchorCenter
.add(right.scale(offR))
.add(up.scale(offU))
.add(facingVec.scale(-0.5));
} }
/** Wire-format NBT used by SaveConfig/SyncAnchor payloads. */ /** Wire-format NBT used by SaveConfig/SyncAnchor payloads. */
public NbtCompound toNbt() { public CompoundTag toNbt() {
NbtCompound nbt = new NbtCompound(); CompoundTag nbt = new CompoundTag();
nbt.putString("url", url); nbt.putString("url", url);
nbt.putInt("width", width); nbt.putInt("width", width);
nbt.putInt("height", height); nbt.putInt("height", height);
nbt.putString("facing", facing.asString()); nbt.putString("facing", facing.getSerializedName());
nbt.putBoolean("loop", loop); nbt.putBoolean("loop", loop);
nbt.putFloat("volume", volume); nbt.putFloat("volume", volume);
nbt.putBoolean("muted", muted); nbt.putBoolean("muted", muted);
@@ -65,43 +102,80 @@ public class VideoAnchorBlockEntity extends BlockEntity {
return nbt; return nbt;
} }
public void fromNbt(NbtCompound nbt) { public void fromNbt(CompoundTag nbt) {
this.url = clampUrl(nbt.getString("url", "")); this.url = clampUrl(nbt.getStringOr("url", ""));
this.width = clamp(nbt.getInt("width", 1), 1, 32); this.width = clamp(nbt.getIntOr("width", 1), 1, 32);
this.height = clamp(nbt.getInt("height", 1), 1, 32); this.height = clamp(nbt.getIntOr("height", 1), 1, 32);
Direction d = Direction.byId(nbt.getString("facing", "north")); Direction d = directionFromName(nbt.getStringOr("facing", "north"));
this.facing = d == null ? Direction.NORTH : d; this.facing = d == null ? Direction.NORTH : d;
this.loop = nbt.getBoolean("loop", true); this.loop = nbt.getBooleanOr("loop", true);
this.volume = Math.max(0F, Math.min(1F, nbt.getFloat("volume", 0.5F))); this.volume = Math.max(0F, Math.min(1F, nbt.getFloatOr("volume", 0.5F)));
this.muted = nbt.getBoolean("muted", false); this.muted = nbt.getBooleanOr("muted", false);
this.autoplay = nbt.getBoolean("autoplay", true); this.autoplay = nbt.getBooleanOr("autoplay", true);
} }
@Override @Override
protected void writeData(WriteView view) { protected void saveAdditional(ValueOutput out) {
super.writeData(view); super.saveAdditional(out);
view.putString("url", url); out.putString("url", url);
view.putInt("width", width); out.putInt("width", width);
view.putInt("height", height); out.putInt("height", height);
view.putString("facing", facing.asString()); out.putString("facing", facing.getSerializedName());
view.putBoolean("loop", loop); out.putBoolean("loop", loop);
view.putFloat("volume", volume); out.putFloat("volume", volume);
view.putBoolean("muted", muted); out.putBoolean("muted", muted);
view.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 readData(ReadView view) { protected void loadAdditional(ValueInput in) {
super.readData(view); super.loadAdditional(in);
this.url = clampUrl(view.getString("url", "")); this.url = clampUrl(in.getStringOr("url", ""));
this.width = clamp(view.getInt("width", 1), 1, 32); this.width = clamp(in.getIntOr("width", 1), 1, 32);
this.height = clamp(view.getInt("height", 1), 1, 32); this.height = clamp(in.getIntOr("height", 1), 1, 32);
Direction d = Direction.byId(view.getString("facing", "north")); Direction d = directionFromName(in.getStringOr("facing", "north"));
this.facing = d == null ? Direction.NORTH : d; this.facing = d == null ? Direction.NORTH : d;
this.loop = view.getBoolean("loop", true); this.loop = in.getBooleanOr("loop", true);
this.volume = Math.max(0F, Math.min(1F, view.getFloat("volume", 0.5F))); this.volume = Math.max(0F, Math.min(1F, in.getFloatOr("volume", 0.5F)));
this.muted = view.getBoolean("muted", false); this.muted = in.getBooleanOr("muted", false);
this.autoplay = view.getBoolean("autoplay", true); this.autoplay = in.getBooleanOr("autoplay", true);
}
private static Direction directionFromName(String name) {
if (name == null) return null;
for (Direction d : Direction.values()) {
if (d.getSerializedName().equalsIgnoreCase(name)) return d;
}
return null;
} }
private static int clamp(int v, int lo, int hi) { private static int clamp(int v, int lo, int hi) {

View File

@@ -0,0 +1,30 @@
package com.ejclaw.videoplayer.client;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
/**
* Client-side mirror of the server's policy bundle (pushed via {@code CachePolicyPayload}
* on join). Currently just the anchor render-distance cap; the per-video download cap lives
* directly on {@link com.ejclaw.videoplayer.client.playback.VideoCache}.
*
* <p>Default 128 matches the legacy hard-coded value, so unpaired clients (e.g. dev tests
* with no JOIN packet, or older servers without the payload) render identically to before.
*/
@Environment(EnvType.CLIENT)
public final class ClientPolicy {
private ClientPolicy() {}
private static volatile int renderDistanceBlocks = 128;
public static int renderDistanceBlocks() { return renderDistanceBlocks; }
public static void setRenderDistanceBlocks(int blocks) {
if (blocks < 16) blocks = 16;
if (blocks > 2048) blocks = 2048;
renderDistanceBlocks = blocks;
VideoPlayerMod.LOG.info("[{}] anchor render distance set to {} blocks",
VideoPlayerMod.MOD_ID, blocks);
}
}

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

@@ -5,33 +5,32 @@ import com.ejclaw.videoplayer.net.SaveConfigPayload;
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.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.components.AbstractSliderButton;
import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.gui.components.Checkbox;
import net.minecraft.client.gui.widget.CheckboxWidget; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.widget.SliderWidget; import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.widget.TextFieldWidget; import net.minecraft.core.BlockPos;
import net.minecraft.nbt.NbtCompound; import net.minecraft.nbt.CompoundTag;
import net.minecraft.text.Text; import net.minecraft.network.chat.Component;
import net.minecraft.util.math.BlockPos;
/** SPEC §4.3 — anchor config GUI. Opened by S2C {@code OpenScreenPayload}. */ /** SPEC §4.3 — anchor config GUI. Opened by S2C {@code OpenScreenPayload}. */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoConfigScreen extends Screen { public class VideoConfigScreen extends Screen {
private final BlockPos pos; private final BlockPos pos;
private final NbtCompound initial; private final CompoundTag initial;
private TextFieldWidget urlField; private EditBox urlField;
private TextFieldWidget widthField; private EditBox widthField;
private TextFieldWidget heightField; private EditBox heightField;
private CheckboxWidget loopBox; private Checkbox loopBox;
private CheckboxWidget muteBox; private Checkbox muteBox;
private CheckboxWidget autoplayBox; private Checkbox autoplayBox;
private VolumeSlider volumeSlider; private VolumeSlider volumeSlider;
public VideoConfigScreen(BlockPos pos, NbtCompound data) { public VideoConfigScreen(BlockPos pos, CompoundTag data) {
super(Text.literal("Video Anchor")); super(Component.literal("Video Anchor"));
this.pos = pos; this.pos = pos;
this.initial = data; this.initial = data;
} }
@@ -41,81 +40,73 @@ public class VideoConfigScreen extends Screen {
int cx = this.width / 2; int cx = this.width / 2;
int y = 40; int y = 40;
urlField = new TextFieldWidget(this.textRenderer, cx - 150, y, 300, 20, Text.literal("URL")); urlField = new EditBox(this.font, cx - 150, y, 300, 20, Component.literal("URL"));
urlField.setMaxLength(256); urlField.setMaxLength(256);
urlField.setText(initial.getString("url", "")); urlField.setValue(initial.getStringOr("url", ""));
addDrawableChild(urlField); addRenderableWidget(urlField);
y += 30; y += 30;
widthField = new TextFieldWidget(this.textRenderer, cx - 150, y, 60, 20, Text.literal("W")); widthField = new EditBox(this.font, cx - 150, y, 60, 20, Component.literal("W"));
widthField.setMaxLength(2); widthField.setMaxLength(2);
widthField.setText(Integer.toString(initial.getInt("width", 1))); widthField.setValue(Integer.toString(initial.getIntOr("width", 1)));
widthField.setTextPredicate(VideoConfigScreen::isDigits); addRenderableWidget(widthField);
addDrawableChild(widthField);
heightField = new TextFieldWidget(this.textRenderer, cx - 80, y, 60, 20, Text.literal("H")); heightField = new EditBox(this.font, cx - 80, y, 60, 20, Component.literal("H"));
heightField.setMaxLength(2); heightField.setMaxLength(2);
heightField.setText(Integer.toString(initial.getInt("height", 1))); heightField.setValue(Integer.toString(initial.getIntOr("height", 1)));
heightField.setTextPredicate(VideoConfigScreen::isDigits); addRenderableWidget(heightField);
addDrawableChild(heightField);
volumeSlider = new VolumeSlider(cx - 10, y, 160, 20, volumeSlider = new VolumeSlider(cx - 10, y, 160, 20,
Math.max(0F, Math.min(1F, initial.getFloat("volume", 0.5F)))); Math.max(0F, Math.min(1F, initial.getFloatOr("volume", 0.5F))));
addDrawableChild(volumeSlider); addRenderableWidget(volumeSlider);
y += 30; y += 30;
loopBox = CheckboxWidget.builder(Text.literal("Loop"), this.textRenderer) loopBox = Checkbox.builder(Component.literal("Loop"), this.font)
.pos(cx - 150, y).checked(initial.getBoolean("loop", true)).build(); .pos(cx - 150, y).selected(initial.getBooleanOr("loop", true)).build();
addDrawableChild(loopBox); addRenderableWidget(loopBox);
muteBox = CheckboxWidget.builder(Text.literal("Mute"), this.textRenderer) muteBox = Checkbox.builder(Component.literal("Mute"), this.font)
.pos(cx - 60, y).checked(initial.getBoolean("muted", false)).build(); .pos(cx - 60, y).selected(initial.getBooleanOr("muted", false)).build();
addDrawableChild(muteBox); addRenderableWidget(muteBox);
autoplayBox = CheckboxWidget.builder(Text.literal("Autoplay"), this.textRenderer) autoplayBox = Checkbox.builder(Component.literal("Autoplay"), this.font)
.pos(cx + 30, y).checked(initial.getBoolean("autoplay", true)).build(); .pos(cx + 30, y).selected(initial.getBooleanOr("autoplay", true)).build();
addDrawableChild(autoplayBox); addRenderableWidget(autoplayBox);
y += 36; y += 36;
addDrawableChild(ButtonWidget.builder(Text.literal("Save"), b -> save()) addRenderableWidget(Button.builder(Component.literal("Save"), b -> save())
.dimensions(cx - 150, y, 90, 20).build()); .bounds(cx - 150, y, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Cancel"), b -> close()) addRenderableWidget(Button.builder(Component.literal("Cancel"), b -> onClose())
.dimensions(cx - 45, y, 90, 20).build()); .bounds(cx - 45, y, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Delete"), b -> delete()) addRenderableWidget(Button.builder(Component.literal("Delete"), b -> delete())
.dimensions(cx + 60, y, 90, 20).build()); .bounds(cx + 60, y, 90, 20).build());
} }
private void save() { private void save() {
NbtCompound out = new NbtCompound(); CompoundTag out = new CompoundTag();
out.putString("url", urlField.getText()); out.putString("url", urlField.getValue());
out.putInt("width", parseInt(widthField.getText(), 1)); out.putInt("width", parseInt(widthField.getValue(), 1));
out.putInt("height", parseInt(heightField.getText(), 1)); out.putInt("height", parseInt(heightField.getValue(), 1));
out.putString("facing", initial.getString("facing", "north")); out.putString("facing", initial.getStringOr("facing", "north"));
out.putBoolean("loop", loopBox.isChecked()); out.putBoolean("loop", loopBox.selected());
out.putFloat("volume", volumeSlider.getVolume()); out.putFloat("volume", volumeSlider.getVolume());
out.putBoolean("muted", muteBox.isChecked()); out.putBoolean("muted", muteBox.selected());
out.putBoolean("autoplay", autoplayBox.isChecked()); out.putBoolean("autoplay", autoplayBox.selected());
ClientPlayNetworking.send(new SaveConfigPayload(pos, out)); ClientPlayNetworking.send(new SaveConfigPayload(pos, out));
close(); onClose();
} }
private void delete() { private void delete() {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos)); ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
close(); onClose();
} }
@Override @Override
public void render(DrawContext ctx, int mouseX, int mouseY, float delta) { public boolean isPauseScreen() { return false; }
super.render(ctx, mouseX, mouseY, delta);
ctx.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, 0xFFFFFF);
}
@Override @Override
public boolean shouldPause() { return false; } public void onClose() {
Minecraft mc = this.minecraft != null ? this.minecraft : Minecraft.getInstance();
@Override
public void close() {
MinecraftClient mc = this.client != null ? this.client : MinecraftClient.getInstance();
if (mc != null) mc.setScreen(null); if (mc != null) mc.setScreen(null);
} }
@@ -123,15 +114,9 @@ public class VideoConfigScreen extends Screen {
try { return Integer.parseInt(s); } catch (Exception e) { return dflt; } try { return Integer.parseInt(s); } catch (Exception e) { return dflt; }
} }
private static boolean isDigits(String s) { private static final class VolumeSlider extends AbstractSliderButton {
if (s.isEmpty()) return true;
for (int i = 0; i < s.length(); i++) if (!Character.isDigit(s.charAt(i))) return false;
return true;
}
private static final class VolumeSlider extends SliderWidget {
VolumeSlider(int x, int y, int w, int h, float initial) { VolumeSlider(int x, int y, int w, int h, float initial) {
super(x, y, w, h, Text.literal("Volume: " + pct(initial)), initial); super(x, y, w, h, Component.literal("Volume: " + pct(initial)), initial);
updateMessage(); updateMessage();
} }
@@ -139,7 +124,7 @@ public class VideoConfigScreen extends Screen {
@Override @Override
protected void updateMessage() { protected void updateMessage() {
setMessage(Text.literal("Volume: " + pct((float) this.value))); setMessage(Component.literal("Volume: " + pct((float) this.value)));
} }
@Override @Override

View File

@@ -1,14 +1,20 @@
package com.ejclaw.videoplayer.client.net; package com.ejclaw.videoplayer.client.net;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.ClientPolicy;
import com.ejclaw.videoplayer.client.gui.VideoConfigScreen; import com.ejclaw.videoplayer.client.gui.VideoConfigScreen;
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.net.CachePolicyPayload;
import com.ejclaw.videoplayer.net.ClearCachePayload;
import com.ejclaw.videoplayer.net.DeleteCachePayload;
import com.ejclaw.videoplayer.net.OpenScreenPayload; import com.ejclaw.videoplayer.net.OpenScreenPayload;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.ejclaw.videoplayer.net.SyncAnchorPayload; import com.ejclaw.videoplayer.net.SyncAnchorPayload;
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.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.Minecraft;
/** Client-side S2C receivers for OpenScreen and SyncAnchor. */ /** Client-side S2C receivers for OpenScreen and SyncAnchor. */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
@@ -16,20 +22,46 @@ public final class ClientNetworking {
private ClientNetworking() {} private ClientNetworking() {}
public static void register() { public static void register() {
ClientPlayNetworking.registerGlobalReceiver(OpenScreenPayload.ID, (payload, context) -> { ClientPlayNetworking.registerGlobalReceiver(OpenScreenPayload.TYPE, (payload, context) -> {
MinecraftClient mc = context.client(); Minecraft mc = context.client();
mc.execute(() -> mc.setScreen(new VideoConfigScreen(payload.pos(), payload.data()))); mc.execute(() -> mc.setScreen(new VideoConfigScreen(payload.pos(), payload.data())));
}); });
ClientPlayNetworking.registerGlobalReceiver(SyncAnchorPayload.ID, (payload, context) -> { ClientPlayNetworking.registerGlobalReceiver(SyncAnchorPayload.TYPE, (payload, context) -> {
MinecraftClient mc = context.client(); Minecraft mc = context.client();
mc.execute(() -> { mc.execute(() -> {
if (mc.world == null) return; if (mc.level == null) return;
if (mc.world.getBlockEntity(payload.pos()) instanceof VideoAnchorBlockEntity be) { if (mc.level.getBlockEntity(payload.pos()) instanceof VideoAnchorBlockEntity be) {
be.applyFromNbt(payload.data()); be.applyFromNbt(payload.data());
VideoPlayback.onConfigChanged(be); VideoPlayback.onConfigChanged(be);
} }
}); });
}); });
// Server broadcasts a preload request to all clients; each client downloads to its
// own video_player_cache/ folder. Runs entirely off-thread, so we don't bounce through
// mc.execute() — VideoCache.preload() is itself non-blocking.
ClientPlayNetworking.registerGlobalReceiver(PreloadPayload.TYPE, (payload, context) -> {
VideoCache.preload(payload.url());
});
// Server tells us the per-video and total-cache download caps (bytes). Must arrive
// before PreloadPayload (server sends policy first on JOIN) so we don't use stale
// defaults.
ClientPlayNetworking.registerGlobalReceiver(CachePolicyPayload.TYPE, (payload, context) -> {
VideoCache.setMaxBytes(payload.maxPerVideoBytes());
VideoCache.setMaxCacheBytes(payload.maxCacheBytes());
ClientPolicy.setRenderDistanceBlocks(payload.renderDistanceBlocks());
});
// /videocache remove — drop the URL from this client's disk cache.
ClientPlayNetworking.registerGlobalReceiver(DeleteCachePayload.TYPE, (payload, context) -> {
VideoCache.purge(payload.url());
});
// /videoCache clear — wipe the entire client cache directory.
ClientPlayNetworking.registerGlobalReceiver(ClearCachePayload.TYPE, (payload, context) -> {
VideoCache.clearAll();
});
} }
} }

View File

@@ -4,36 +4,92 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import org.lwjgl.system.MemoryUtil;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.SourceDataLine;
import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicLong;
/** /**
* SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber. * SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber.
* *
* JavaCV is referenced entirely through reflection so that the mod jar stays loadable when the * <p>Video frames are decoded through {@code grab()}, audio samples are forced to interleaved
* (large) JavaCV dependency isn't bundled — the backend just reports {@code !isReady()} until the * signed 16-bit PCM ({@code AV_SAMPLE_FMT_S16}) and written to a {@link SourceDataLine} so that
* runtime classpath contains org.bytedeco.javacv.FFmpegFrameGrabber. * {@link #setVolume(float)} actually mutes / attenuates audible output. JavaCV is referenced
* entirely through reflection so that the mod jar stays loadable when the (large) JavaCV
* dependency isn't bundled — the backend just reports {@code !isReady()} until the runtime
* classpath contains {@code org.bytedeco.javacv.FFmpegFrameGrabber}.
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class JavaCvBackend implements VideoBackend { public class JavaCvBackend implements VideoBackend {
private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber"; private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber";
private static final String FRAME_CLASS = "org.bytedeco.javacv.Frame"; private static final String FRAME_CLASS = "org.bytedeco.javacv.Frame";
private static final String CONVERTER_CLASS = "org.bytedeco.javacv.Java2DFrameConverter"; /** {@code AV_SAMPLE_FMT_S16} from {@code org.bytedeco.ffmpeg.global.avutil}. */
private static final int AV_SAMPLE_FMT_S16 = 1;
/** {@code AV_PIX_FMT_RGBA} from {@code org.bytedeco.ffmpeg.global.avutil}. */
private static final int AV_PIX_FMT_RGBA = 26;
private final Object lock = new Object(); private final Object lock = new Object();
private Thread worker; private Thread worker;
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicBoolean paused = new AtomicBoolean(false); private final AtomicBoolean paused = new AtomicBoolean(false);
private final AtomicReference<ByteBuffer> latest = new AtomicReference<>(); /**
* Ring buffer of preallocated RGBA staging slots. Decoder thread writes to {@code ringTail}
* under {@link #frameLock}; render thread drains the oldest slot via
* {@link #consumeFrame(long, long)} under the same lock.
*
* <p>0.4.10 used a single staging slot and relied on {@link SourceDataLine#write}
* backpressure to pace the decoder. That paced only at audio-buffer granularity (~0.5 s):
* the decoder burst-produced ~12 video frames into the slot while the audio line drained,
* the consumer (60+ Hz polling) saw only the last frame of each burst, then the decoder
* stalled until audio drained again — net effect ~2 fps of visible video despite the
* decoder producing at the source's 24 fps. The ring absorbs the burst; combined with the
* smaller audio buffer (~0.1 s) below the burst collapses to 23 frames which fits in
* {@link #FRAME_RING_SLOTS}.
*
* <p>If the ring still fills, the decoder overwrites the oldest slot and increments
* {@link #droppedFrames}. Memory cost: {@code 4 × w × h × 4} bytes (32 MB at 1080p,
* ~130 MB at 4K).
*/
private static final int FRAME_RING_SLOTS = 4;
private final Object frameLock = new Object();
private final ByteBuffer[] ringBufs = new ByteBuffer[FRAME_RING_SLOTS];
private final int[] ringBytes = new int[FRAME_RING_SLOTS];
private int ringHead = 0; // next slot to consume
private int ringTail = 0; // next slot to produce into
private int ringCount = 0;
/** Decoder telemetry (cumulative). Logged ~every 10 s from the decode thread. */
private final AtomicLong producedFrames = new AtomicLong();
private final AtomicLong consumedFrames = new AtomicLong();
private final AtomicLong droppedFrames = new AtomicLong();
private volatile int width = 0; private volatile int width = 0;
private volatile int height = 0; private volatile int height = 0;
private volatile float gain = 1.0F; private volatile float gain = 1.0F;
private volatile boolean loop = true; private volatile boolean loop = true;
private volatile boolean ready = false; private volatile boolean ready = false;
private volatile boolean closed = false; private volatile boolean closed = false;
/**
* Held in a field (not just a local) so {@link #close()} can call
* {@link SourceDataLine#stop()} / {@link SourceDataLine#flush()} from outside the worker
* thread to unblock any in-flight {@link SourceDataLine#write(byte[], int, int)} call.
* Without this, deleting an anchor while audio is buffered would leave the worker stuck
* in {@code write()} until the in-driver PCM buffer drained naturally, so the deleted
* video would keep audible for ~0.5 s after the anchor disappeared.
*/
private volatile SourceDataLine audioLine;
/**
* Worker-side handle to the grabber. Held so {@link #close()} can yank it from another
* thread when {@code grab()} is blocked on a slow HTTP read.
*/
private volatile Object grabberHandle;
@Override @Override
public void play(String url, boolean loop) { public void play(String url, boolean loop) {
@@ -63,92 +119,369 @@ public class JavaCvBackend implements VideoBackend {
public int videoHeight() { return height; } public int videoHeight() { return height; }
@Override @Override
public ByteBuffer pollFrame() { public boolean consumeFrame(long dstAddr, long maxBytes) {
return latest.getAndSet(null); synchronized (frameLock) {
if (ringCount <= 0) return false;
int idx = ringHead;
int n = ringBytes[idx];
ByteBuffer buf = ringBufs[idx];
// Always advance head regardless of memcpy outcome — otherwise a single oversize
// frame (e.g. mid-resize) would jam the ring forever.
ringHead = (idx + 1) % FRAME_RING_SLOTS;
ringCount--;
if (buf == null || n <= 0 || n > maxBytes) {
// Texture not yet sized for this frame, or empty slot — skip. ensureTexture()
// runs in Entry.tryUpload() before consumeFrame, so n > maxBytes only happens
// on the exact tick of a resolution change.
return false;
}
MemoryUtil.memCopy(MemoryUtil.memAddress(buf), dstAddr, n);
consumedFrames.incrementAndGet();
return true;
}
} }
@Override @Override
public void close() { public void close() {
closed = true; closed = true;
stopWorker(); stopWorker();
synchronized (frameLock) {
for (int i = 0; i < FRAME_RING_SLOTS; i++) {
ringBufs[i] = null;
ringBytes[i] = 0;
}
ringHead = ringTail = ringCount = 0;
}
} }
private void stopWorker() { private void stopWorker() {
running.set(false); running.set(false);
// Unblock the audio sink so the worker's line.write() returns immediately instead of
// waiting for the in-driver PCM buffer to drain. Without this the audible tail of a
// deleted / unloaded anchor outlives the anchor by ~0.5 s.
SourceDataLine line = audioLine;
if (line != null) {
try { line.stop(); } catch (Throwable ignored) {}
try { line.flush(); } catch (Throwable ignored) {}
}
// CRITICAL: we do NOT call grabber.close() from this (caller) thread. The decoder's
// per-frame path is:
//
// frame = grab(grabber); // grabber-owned native memory
// src = frame.image[0]; // DirectByteBuffer over that memory
// need = src.remaining(); // (lock-free)
// srcAddr = MemoryUtil.memAddress(src); // (lock-free)
// synchronized (frameLock) {
// MemoryUtil.memCopy(srcAddr, ...); // reads from grabber-owned memory
// }
//
// Even if we held frameLock while closing the grabber, there's a window between
// grab() returning and entering the synchronized block where the decoder is holding a
// 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 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; Thread t = worker;
worker = null; worker = null;
if (t != null) t.interrupt(); if (t != null) {
t.interrupt();
}
ready = false; ready = false;
} }
/** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */ /** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */
private void runLoop(String url) { private void runLoop(String url) {
Object grabber = null; Object grabber = null;
SourceDataLine localAudioLine = null;
try { try {
Class<?> grabberCls = Class.forName(GRABBER_CLASS); Class<?> grabberCls = Class.forName(GRABBER_CLASS);
grabber = grabberCls.getConstructor(String.class).newInstance(url); grabber = grabberCls.getConstructor(String.class).newInstance(url);
this.grabberHandle = grabber;
Method start = grabberCls.getMethod("start"); Method start = grabberCls.getMethod("start");
Method stop = grabberCls.getMethod("stop"); Method stop = grabberCls.getMethod("stop");
Method grab = grabberCls.getMethod("grabImage"); Method grab = grabberCls.getMethod("grab");
Method getW = grabberCls.getMethod("getImageWidth"); Method getW = grabberCls.getMethod("getImageWidth");
Method getH = grabberCls.getMethod("getImageHeight"); Method getH = grabberCls.getMethod("getImageHeight");
Method getSampleRate = grabberCls.getMethod("getSampleRate");
Method getAudioChannels = grabberCls.getMethod("getAudioChannels");
Method setOpt = grabberCls.getMethod("setOption", String.class, String.class); Method setOpt = grabberCls.getMethod("setOption", String.class, String.class);
Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class);
Method setPixelFormat = grabberCls.getMethod("setPixelFormat", int.class);
// mp4/http(s) network tuning // HTTP(S) tuning for streaming URLs (webm via Range / chunked transfer).
try { setOpt.invoke(grabber, "rw_timeout", "5000000"); } catch (Throwable ignored) {} // Lower timeouts → close() snaps shut fast when an anchor is deleted mid-stream;
try { setOpt.invoke(grabber, "stimeout", "5000000"); } catch (Throwable ignored) {} // larger buffer/probe options → smoother playback when the upstream throttles.
try { setOpt.invoke(grabber, "rw_timeout", "3000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "timeout", "3000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_streamed", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_at_eof", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_delay_max", "2"); } catch (Throwable ignored) {}
// Bigger network read window so a transient stall doesn't starve the decoder.
try { setOpt.invoke(grabber, "buffer_size", "1048576"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "probesize", "8388608"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "analyzeduration", "2000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "max_delay", "500000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "fflags", "+genpts"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "user_agent",
"video_player/" + com.ejclaw.videoplayer.VideoPlayerMod.MOD_ID); } catch (Throwable ignored) {}
// Force interleaved signed 16-bit PCM so the audio sink path is single-shape.
try { setSampleFormat.invoke(grabber, AV_SAMPLE_FMT_S16); } catch (Throwable ignored) {}
// Force RGBA output so frame.image[0] is a ByteBuffer we can memcpy straight into
// the GPU texture. Without this, frame.image[0] is BGR24 and we'd have to round-trip
// through Java2DFrameConverter → BufferedImage.getRGB() → per-pixel ARGB→RGBA loop,
// which spends 20-50ms of Java work per 1080p frame and was the dominant stutter
// source in 0.4.7/0.4.8: when the decoder fell behind real time, the audio buffer
// drained, backpressure vanished, and the decoder burst-fired catch-up frames into
// the single-slot AtomicReference (dropping all but the last) before the buffer
// refilled and blocked it again. swscale's native SIMD does the same conversion in
// <1ms per frame, so the decoder consistently keeps real-time pace.
try { setPixelFormat.invoke(grabber, AV_PIX_FMT_RGBA); } catch (Throwable ignored) {}
start.invoke(grabber); start.invoke(grabber);
this.width = (int) getW.invoke(grabber); this.width = (int) getW.invoke(grabber);
this.height = (int) getH.invoke(grabber); this.height = (int) getH.invoke(grabber);
this.ready = (width > 0 && height > 0); this.ready = (width > 0 && height > 0);
Class<?> convCls = Class.forName(CONVERTER_CLASS); int sampleRate = safeInt(getSampleRate, grabber);
Object converter = convCls.getDeclaredConstructor().newInstance(); int audioChannels = safeInt(getAudioChannels, grabber);
Method toImage = convCls.getMethod("getBufferedImage", Class.forName(FRAME_CLASS)); localAudioLine = openLine(sampleRate, audioChannels);
this.audioLine = localAudioLine;
// Decoder spec — printed once per playback so the user log shows what the decoder
// actually sees (resolution / frame rate / sample rate). Used to verify our pacing
// assumptions (e.g. ring depth, audio buffer length) match the source.
double srcFrameRate = 0;
try { srcFrameRate = ((Number) grabberCls.getMethod("getFrameRate").invoke(grabber)).doubleValue(); }
catch (Throwable ignored) {}
VideoPlayerMod.LOG.info(
"[{}] decoder started: {}x{} @ {} fps, audio {} Hz x{}, ring={} slots",
VideoPlayerMod.MOD_ID, width, height,
String.format("%.2f", srcFrameRate),
sampleRate, audioChannels, FRAME_RING_SLOTS);
Class<?> frameCls = Class.forName(FRAME_CLASS);
Field imageField = frameCls.getField("image");
Field samplesField = frameCls.getField("samples");
// 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.
// Stats sampling: every 10 s of wall-clock we log produced/consumed/dropped deltas
// and the implied fps. Lets us tell from the log whether the decoder is keeping
// real-time pace (produced≈source fps) and whether the ring is overflowing
// (dropped>0). All counters are cumulative; we keep the previous sample to compute
// deltas.
long statsLastNs = System.nanoTime();
long lastProd = 0, lastCons = 0, lastDrop = 0;
while (running.get() && !closed) { while (running.get() && !closed) {
if (paused.get()) { Thread.sleep(20); continue; } if (paused.get()) { Thread.sleep(20); continue; }
Object frame = grab.invoke(grabber); Object frame;
try {
frame = grab.invoke(grabber);
} catch (Throwable t) {
// grabber.close() from another thread races our grab(); treat as a clean exit.
if (!running.get() || closed) break;
throw t;
}
if (frame == null) { if (frame == null) {
if (loop) { if (loop && running.get() && !closed) {
try { stop.invoke(grabber); } catch (Throwable ignored) {} try { stop.invoke(grabber); } catch (Throwable ignored) {}
try { start.invoke(grabber); } catch (Throwable ignored) {} try { start.invoke(grabber); } catch (Throwable ignored) {}
continue; continue;
} }
break; break;
} }
java.awt.image.BufferedImage img = (java.awt.image.BufferedImage) toImage.invoke(converter, frame);
if (img == null) continue; Object[] samples = (Object[]) samplesField.get(frame);
ByteBuffer buf = toRgba(img); if (samples != null && samples.length > 0 && localAudioLine != null) {
if (buf != null) latest.set(buf); writeAudio(localAudioLine, samples, this.gain);
Thread.sleep(15); // ~60fps cap }
Object[] images = (Object[]) imageField.get(frame);
if (images != null && images.length > 0 && images[0] instanceof ByteBuffer src) {
// frame.image[0] is the swscale-converted RGBA plane, reused by the grabber
// across grab() calls. Copy into the next ring slot under frameLock so the
// render thread's consumeFrame() sees coherent frames in FIFO order.
//
// Allocation is one-time per slot, lazily on first use (or on a resolution
// upgrade) — never per frame. 0.4.9's per-frame allocateDirect was the
// 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;
}
if (need > 0) {
int srcPos = src.position();
long srcAddr = 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()
// only runs from this thread's finally — but the explicit check keeps
// 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());
}
long dstAddr = MemoryUtil.memAddress(ringBufs[idx]);
MemoryUtil.memCopy(srcAddr, dstAddr, need);
ringBytes[idx] = need;
ringTail = (idx + 1) % FRAME_RING_SLOTS;
if (ringCount < FRAME_RING_SLOTS) {
ringCount++;
} else {
// Ring was full — we overwrote the oldest frame. Advance head
// to point at the next-oldest so consume order stays FIFO.
ringHead = (ringHead + 1) % FRAME_RING_SLOTS;
droppedFrames.incrementAndGet();
}
producedFrames.incrementAndGet();
}
src.position(srcPos); // restore — JavaCV reads it on subsequent grabs
}
}
// Periodic stats — once per ~10 s of wall-clock. Includes ring depth so we can
// see whether the consumer is keeping up.
long now = System.nanoTime();
if (now - statsLastNs > 10_000_000_000L) {
long prod = producedFrames.get();
long cons = consumedFrames.get();
long drop = droppedFrames.get();
double elapsedS = (now - statsLastNs) / 1e9;
int depth;
synchronized (frameLock) { depth = ringCount; }
VideoPlayerMod.LOG.info(
"[{}] decoder stats: produced={} ({} fps), consumed={} ({} fps), dropped={} (+{}) over {}s, ring={}/{}",
VideoPlayerMod.MOD_ID,
prod, String.format("%.1f", (prod - lastProd) / elapsedS),
cons, String.format("%.1f", (cons - lastCons) / elapsedS),
drop, (drop - lastDrop),
String.format("%.1f", elapsedS),
depth, FRAME_RING_SLOTS);
statsLastNs = now;
lastProd = prod; lastCons = cons; lastDrop = drop;
}
// If we have an open audio line, SourceDataLine.write() blocks for backpressure
// and provides natural A/V pacing; otherwise tick ~60fps so we don't busy-loop.
if (localAudioLine == null) Thread.sleep(15);
} }
} catch (ClassNotFoundException cnf) { } catch (ClassNotFoundException cnf) {
VideoPlayerMod.LOG.info("[{}] JavaCV not on classpath; backend inactive", VideoPlayerMod.MOD_ID); VideoPlayerMod.LOG.warn(
"[{}] JavaCV not on classpath — install org.bytedeco:javacv-platform (or javacv + ffmpeg natives)" +
" to enable video/audio playback. Anchor placeholder will remain visible.",
VideoPlayerMod.MOD_ID);
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (Throwable t) { } catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] JavaCV decode error: {}", VideoPlayerMod.MOD_ID, t.toString()); VideoPlayerMod.LOG.warn("[{}] JavaCV decode error: {}", VideoPlayerMod.MOD_ID, t.toString());
} finally { } finally {
ready = false; ready = false;
this.audioLine = null;
if (localAudioLine != null) {
try { localAudioLine.stop(); } catch (Throwable ignored) {}
try { localAudioLine.flush(); } catch (Throwable ignored) {}
try { localAudioLine.close(); } catch (Throwable ignored) {}
}
this.grabberHandle = null;
if (grabber != null) { if (grabber != null) {
try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {} try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {}
} }
} }
} }
private static ByteBuffer toRgba(java.awt.image.BufferedImage img) { /** Open a JavaSound output line for the stream's sample rate / channel count, or null. */
int w = img.getWidth(), h = img.getHeight(); private static SourceDataLine openLine(int sampleRate, int channels) {
int[] argb = img.getRGB(0, 0, w, h, null, 0, w); if (sampleRate <= 0 || channels <= 0) return null;
ByteBuffer buf = ByteBuffer.allocateDirect(w * h * 4).order(ByteOrder.nativeOrder()); try {
for (int p : argb) { AudioFormat fmt = new AudioFormat(sampleRate, 16, channels, true, false); // signed 16-bit LE
buf.put((byte) ((p >> 16) & 0xFF)); // R SourceDataLine line = AudioSystem.getSourceDataLine(fmt);
buf.put((byte) ((p >> 8) & 0xFF)); // G // ~0.1 s of audio buffered in the driver. 0.4.10 used 0.5 s, which let the decoder
buf.put((byte) ( p & 0xFF)); // B // burst ~12 video frames between backpressure stalls — way past the video ring's
buf.put((byte) ((p >> 24) & 0xFF)); // A // capacity and the visible cause of the "2-5 fps" stutter the user saw. With 0.1 s
} // the audio line refills more often, so the decoder is paced more tightly and
buf.flip(); // bursts collapse to 2-3 frames (well inside FRAME_RING_SLOTS).
return buf; //
// Floor at frameSizeBytes * 256 keeps the buffer above the typical OS / driver
// minimum so we don't get UnsupportedOperationException at line.open() on
// exotic sample rates.
int frameSizeBytes = 2 * channels;
int bufferBytes = Math.max(sampleRate * frameSizeBytes / 10, frameSizeBytes * 256);
line.open(fmt, bufferBytes);
line.start();
return line;
} catch (Throwable t) {
VideoPlayerMod.LOG.info("[{}] no audio sink ({} Hz x{}): {}",
VideoPlayerMod.MOD_ID, sampleRate, channels, t.toString());
return null;
} }
} }
/** Scale & write interleaved S16 PCM samples to the audio line. */
private static void writeAudio(SourceDataLine line, Object[] samples, float gain) {
if (!(samples[0] instanceof ShortBuffer sb)) return; // sample format forcing failed
int remaining = sb.remaining();
if (remaining <= 0) return;
byte[] pcm = new byte[remaining * 2];
int idx = 0;
if (gain >= 0.999F) {
while (sb.hasRemaining()) {
short s = sb.get();
pcm[idx++] = (byte) (s & 0xFF);
pcm[idx++] = (byte) ((s >> 8) & 0xFF);
}
} else if (gain <= 0F) {
// Mute: consume but emit silence so the line keeps cadence.
sb.position(sb.limit());
} else {
while (sb.hasRemaining()) {
int scaled = (int) (sb.get() * gain);
if (scaled > 32767) scaled = 32767;
if (scaled < -32768) scaled = -32768;
pcm[idx++] = (byte) (scaled & 0xFF);
pcm[idx++] = (byte) ((scaled >> 8) & 0xFF);
}
}
try {
line.write(pcm, 0, pcm.length);
} catch (Throwable ignored) {
// line may have been stop()'d from another thread mid-write — treat as a clean exit.
}
}
private static int safeInt(Method m, Object target) {
try { return (int) m.invoke(target); } catch (Throwable t) { return 0; }
}
}

View File

@@ -3,8 +3,6 @@ package com.ejclaw.videoplayer.client.playback;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import java.nio.ByteBuffer;
/** /**
* SPEC §5.3 — minimal playback backend abstraction. Implementations: WatermediaBackend (preferred, * SPEC §5.3 — minimal playback backend abstraction. Implementations: WatermediaBackend (preferred,
* when v2 supports the target MC version) and JavaCvBackend (fallback). * when v2 supports the target MC version) and JavaCvBackend (fallback).
@@ -21,10 +19,19 @@ public interface VideoBackend {
int videoHeight(); int videoHeight();
/** /**
* Poll a new decoded RGBA frame if one is ready. * If a new RGBA frame is ready, memcpy it directly into the GPU texture buffer at
* @return the frame buffer (capacity = w*h*4) or {@code null} if no new frame is ready. * {@code dstAddr} (must have room for at least {@code w*h*4} bytes) and clear the dirty
* flag. Returns {@code true} when a frame was written.
*
* <p>Replaces the prior {@code pollFrame()} which returned a {@link java.nio.ByteBuffer}.
* The old contract forced the decoder to either allocate a fresh direct buffer per frame
* (huge memory churn at 1080p — see 0.4.10 changelog) or expose a reused buffer whose
* memory the decoder could clobber while the renderer was still reading. Pushing the copy
* inside the backend lets the decoder hold a single preallocated buffer under its own
* lock and copy out to the GPU pointer in one synchronized block — zero allocation, no
* race window.
*/ */
ByteBuffer pollFrame(); boolean consumeFrame(long dstAddr, long maxBytes);
void close(); void close();
} }

View File

@@ -0,0 +1,533 @@
package com.ejclaw.videoplayer.client.playback;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
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}
* command (routed to clients via {@code PreloadPayload}). Once a URL is fully downloaded into
* {@code <gameDir>/video_player_cache/<sha256>.<ext>}, {@link #lookup(String)} returns its local
* path so {@link VideoPlayback} can hand FFmpeg a {@code file://} input that doesn't stall on
* network hiccups.
*
* <p>Concurrency model: {@link #preload(String)} returns immediately and downloads on a daemon
* background thread. {@link #lookup(String)} only returns once the download has finished
* (atomic rename from {@code .part}), so callers never see a half-written file.
*/
@Environment(EnvType.CLIENT)
public final class VideoCache {
private VideoCache() {}
/** url → local absolute path of the fully-downloaded cache file. */
private static final Map<String, Path> READY = new ConcurrentHashMap<>();
/** urls whose download is currently queued or in flight. */
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);
/**
* Single-thread executor that serializes all downloads. Cap enforcement (the total
* cache size check) needs an authoritative view of the cache directory at the moment a
* download starts, so we run one download at a time — that way the directory scan in
* {@link #cacheDirSize} reflects every finished download up to this point. Parallel
* downloads were racing the cap by each reading the directory before any of them had
* renamed their .part to final.
*/
private static final ExecutorService DOWNLOAD_POOL = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "video_player-preload");
t.setDaemon(true);
return t;
});
/**
* Hard ceiling on a single preload, in bytes. Default 2 GB sized to allow a single 4K
* short clip; overridden per-session by {@link com.ejclaw.videoplayer.net.CachePolicyPayload}
* on join, which carries the server's {@code max_preload_mb} config value.
*/
private static volatile long MAX_BYTES = 2048L * 1024 * 1024;
/**
* Hard ceiling on the total cache directory size, in bytes. Default 750 MB sized to fit
* ~50 short FHD clips; overridden per-session by {@link com.ejclaw.videoplayer.net.CachePolicyPayload}
* on join, which carries the server's {@code max_cache_mb} config value. Enforced
* cooperatively at the start of each download — running cache + new download must stay
* within this cap.
*/
private static volatile long MAX_CACHE_BYTES = 750L * 1024 * 1024;
/** Server-driven override of the per-video cap. */
public static void setMaxBytes(long bytes) {
if (bytes < 16L * 1024 * 1024) bytes = 16L * 1024 * 1024;
if (bytes > 16L * 1024 * 1024 * 1024) bytes = 16L * 1024 * 1024 * 1024;
MAX_BYTES = bytes;
VideoPlayerMod.LOG.info("[{}] per-video preload cap set to {} MB",
VideoPlayerMod.MOD_ID, bytes / (1024 * 1024));
}
/** Server-driven override of the total-cache cap. */
public static void setMaxCacheBytes(long bytes) {
if (bytes < 16L * 1024 * 1024) bytes = 16L * 1024 * 1024;
if (bytes > 64L * 1024 * 1024 * 1024) bytes = 64L * 1024 * 1024 * 1024;
MAX_CACHE_BYTES = bytes;
VideoPlayerMod.LOG.info("[{}] total-cache cap set to {} MB",
VideoPlayerMod.MOD_ID, bytes / (1024 * 1024));
}
/** Server-driven delete of a cached URL. Removes from READY and from disk. */
public static void purge(String url) {
if (url == null || url.isEmpty()) return;
Path p = READY.remove(url);
if (p == null) {
// Not in this session's index, but the file may still be on disk from a prior run.
// Reconstruct the path by hash + extension and try to delete it.
try {
Path dir = cacheDir();
if (dir != null) {
Path guess = dir.resolve(sha256(url) + extensionFromUrl(url));
if (Files.exists(guess)) p = guess;
}
} catch (Throwable ignored) {}
}
if (p != null) {
try {
boolean gone = Files.deleteIfExists(p);
VideoPlayerMod.LOG.info("[{}] purge: {} -> deleted={} ({})",
VideoPlayerMod.MOD_ID, url, gone, p.getFileName());
if (gone) notifyChat("[videocache] 캐시 삭제: " + url, ChatFormatting.YELLOW);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] purge failed for {}: {}",
VideoPlayerMod.MOD_ID, url, t.toString());
}
}
}
/**
* Wipe the entire cache directory and drop both indexes. Sent in response to
* {@code /videoCache clear}.
*
* <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() {
// Phase 1: signal cancellation to any in-flight download BEFORE wiping disk.
CACHE_EPOCH.incrementAndGet();
// Phase 2: drop indexes.
READY.clear();
IN_FLIGHT.clear();
// Phase 3: delete files on disk.
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++;
VideoPlayerMod.LOG.warn("[{}] clearAll: could not delete {}: {}",
VideoPlayerMod.MOD_ID, p.getFileName(), t.toString());
}
}
}
}
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] clearAll failed: {}",
VideoPlayerMod.MOD_ID, t.toString());
}
VideoPlayerMod.LOG.info("[{}] clearAll: deleted={} failed={}",
VideoPlayerMod.MOD_ID, deleted, failed);
notifyChat("[videocache] 전체 캐시 삭제: " + deleted + "개 파일", ChatFormatting.YELLOW);
}
/** Kick off a background download. No-op if already cached or in flight. */
public static void preload(String url) {
if (url == null || url.isEmpty()) return;
if (!(url.startsWith("http://") || url.startsWith("https://"))) return;
// 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);
notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY);
return;
}
if (!IN_FLIGHT.add(url)) {
VideoPlayerMod.LOG.info("[{}] preload: already downloading {}", VideoPlayerMod.MOD_ID, url);
notifyChat("[videopreload] 이미 다운로드 중: " + url, ChatFormatting.GRAY);
return;
}
notifyChat("[videopreload] 다운로드 대기열 추가: " + url, ChatFormatting.YELLOW);
// 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();
DOWNLOAD_POOL.submit(() -> download(url, epoch));
}
/** Return the local cached file path for this URL, or {@code null} if not yet ready. */
public static Path lookup(String url) {
if (url == null) return null;
Path p = READY.get(url);
if (p != null && Files.exists(p)) return p;
return null;
}
/** Drop the in-memory index (does not delete files). Called on disconnect. */
public static void clearIndex() {
READY.clear();
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());
}
// -- internals -----------------------------------------------------------------------
/**
* Publish {@code path} into {@link #READY} for {@code url}, but only if the cache
* epoch hasn't moved since {@code startEpoch} was captured. Wraps the
* pre-check / put / post-check / rollback dance so every publish site uses the
* same race-free pattern instead of re-implementing it.
*
* <p>Race coverage: if {@link #clearAll()} runs at any point between the
* pre-check and the post-check, the post-check sees the epoch bump and we
* roll back — {@link Map#remove(Object, Object)} is a compareAndRemove so we
* don't clobber a legitimate concurrent put under the same key, and we delete
* the on-disk file because clearAll's directory scan may have missed it (the
* file may not have existed yet when clearAll ran).
*
* @return {@code true} if the entry is now published; {@code false} if the
* caller's work was cancelled and {@code path} has been cleaned up.
*/
private static boolean publishIfNotCancelled(String url, Path path, long startEpoch) {
// If 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);
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
return true;
}
private static void download(String url, long startEpoch) {
Path cacheDir = cacheDir();
Path partPath = null;
try {
if (cacheDir == null) {
VideoPlayerMod.LOG.warn("[{}] preload: no game dir, skipping {}",
VideoPlayerMod.MOD_ID, url);
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 (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before start (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
Files.createDirectories(cacheDir);
String hash = sha256(url);
String ext = extensionFromUrl(url);
Path finalPath = cacheDir.resolve(hash + ext);
partPath = cacheDir.resolve(hash + ext + ".part");
// Resume-friendly: if the file's already on disk from an earlier session, just
// index it without re-downloading.
if (Files.exists(finalPath) && Files.size(finalPath) > 0) {
if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
return;
}
// Total-cache check: refuse to start if the cache directory is already at the cap.
// We re-check during the read loop too, since other downloads may be in flight in
// parallel. {@code existingCacheBytes} excludes our own .part (which we just wrote 0
// bytes to / haven't created yet).
long existingCacheBytes = cacheDirSize(cacheDir, partPath);
if (existingCacheBytes >= MAX_CACHE_BYTES) {
long capMb = MAX_CACHE_BYTES / (1024 * 1024);
long usedMb = existingCacheBytes / (1024 * 1024);
VideoPlayerMod.LOG.warn(
"[{}] preload: total-cache cap reached ({}/{} MB); aborting {}",
VideoPlayerMod.MOD_ID, usedMb, capMb, url);
notifyChat("[videopreload] 실패 (전체 캐시 " + capMb + "MB 초과 / 현재 "
+ usedMb + "MB): " + url, ChatFormatting.RED);
return;
}
URLConnection raw = URI.create(url).toURL().openConnection();
raw.setConnectTimeout(10_000);
raw.setReadTimeout(30_000);
raw.setRequestProperty("User-Agent", "video_player/" + VideoPlayerMod.MOD_ID);
if (raw instanceof HttpURLConnection http) {
http.setInstanceFollowRedirects(true);
int code = http.getResponseCode();
if (code >= 400) {
VideoPlayerMod.LOG.warn("[{}] preload: HTTP {} for {}",
VideoPlayerMod.MOD_ID, code, url);
notifyChat("[videopreload] 실패 (HTTP " + code + "): " + url, ChatFormatting.RED);
return;
}
}
long total = 0;
boolean cancelled = false;
try (InputStream in = raw.getInputStream();
OutputStream out = Files.newOutputStream(partPath)) {
byte[] buf = new byte[64 * 1024];
int n;
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 (CACHE_EPOCH.get() != startEpoch) {
cancelled = true;
break;
}
total += n;
if (total > MAX_BYTES) {
long capMb = MAX_BYTES / (1024 * 1024);
VideoPlayerMod.LOG.warn(
"[{}] preload: {} exceeded per-video {} MB cap; aborting",
VideoPlayerMod.MOD_ID, url, capMb);
// Same close-before-delete dance for Windows.
cancelled = true;
notifyChat("[videopreload] 실패 (단일 영상 " + capMb + "MB 초과): " + url,
ChatFormatting.RED);
break;
}
if (existingCacheBytes + total > MAX_CACHE_BYTES) {
long capMb = MAX_CACHE_BYTES / (1024 * 1024);
long usedMb = (existingCacheBytes + total) / (1024 * 1024);
VideoPlayerMod.LOG.warn(
"[{}] preload: total-cache cap exceeded ({}>{} MB); aborting {}",
VideoPlayerMod.MOD_ID, usedMb, capMb, url);
cancelled = true;
notifyChat("[videopreload] 실패 (전체 캐시 " + capMb + "MB 초과): " + url,
ChatFormatting.RED);
break;
}
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 (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
return;
}
Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
// 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)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}",
VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName());
long mb = Math.max(1, total / (1024 * 1024));
notifyChat("[videopreload] 완료 (" + mb + " MB): " + url, ChatFormatting.GREEN);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] preload failed for {}: {}",
VideoPlayerMod.MOD_ID, url, t.toString());
notifyChat("[videopreload] 실패 (" + t.getClass().getSimpleName() + "): " + url,
ChatFormatting.RED);
// Best-effort cleanup of any leftover .part on the error path.
if (partPath != null) {
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
}
} finally {
IN_FLIGHT.remove(url);
}
}
/** Post a status line to the local player's chat. No-op if there's no client/player yet. */
private static void notifyChat(String text, ChatFormatting color) {
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
mc.execute(() -> {
if (mc.player == null) return;
mc.player.sendSystemMessage(Component.literal(text).withStyle(color));
});
}
/**
* Sum of regular-file sizes under {@code dir}, skipping {@code excludePart} (the .part file
* for the in-flight download — we account for that via the running {@code total} counter).
* Best-effort: errors collapse to 0 so a transient FS hiccup doesn't strand a download.
*/
private static long cacheDirSize(Path dir, Path excludePart) {
if (dir == null) return 0L;
try {
if (!Files.isDirectory(dir)) return 0L;
final long[] sum = { 0L };
try (var stream = Files.newDirectoryStream(dir)) {
for (Path p : stream) {
if (excludePart != null && p.equals(excludePart)) continue;
try {
if (Files.isRegularFile(p)) sum[0] += Files.size(p);
} catch (Throwable ignored) {}
}
}
return sum[0];
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] cacheDirSize failed: {}",
VideoPlayerMod.MOD_ID, t.toString());
return 0L;
}
}
private static Path cacheDir() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.gameDirectory == null) return null;
return mc.gameDirectory.toPath().resolve("video_player_cache");
}
/** SHA-256 of the URL, hex. */
private static String sha256(String s) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] dig = md.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : dig) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Throwable t) {
// Fallback: tag with identity hash. Still unique within a session.
return "h" + Integer.toHexString(s.hashCode());
}
}
/**
* Best-effort filename extension from the URL path so FFmpeg's container probe gets a hint
* (e.g. {@code .webm} for a webm stream). Falls back to {@code .bin}.
*/
private static String extensionFromUrl(String url) {
try {
String path = URI.create(url).getPath();
if (path == null) return ".bin";
int slash = path.lastIndexOf('/');
String tail = slash < 0 ? path : path.substring(slash + 1);
int dot = tail.lastIndexOf('.');
if (dot <= 0 || dot == tail.length() - 1) return ".bin";
String ext = tail.substring(dot).toLowerCase();
// Sanity-clamp: only allow short, safe extensions.
if (ext.length() > 8 || !ext.matches("\\.[a-z0-9]+")) return ".bin";
return ext;
} catch (Throwable t) {
return ".bin";
}
}
}

View File

@@ -1,45 +1,71 @@
package com.ejclaw.videoplayer.client.playback; package com.ejclaw.videoplayer.client.playback;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.mojang.blaze3d.platform.NativeImage;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.Minecraft;
import net.minecraft.client.texture.NativeImage; import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.texture.NativeImageBackedTexture; import net.minecraft.core.BlockPos;
import net.minecraft.util.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.util.math.BlockPos;
import java.nio.ByteBuffer; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* SPEC §5 — per-anchor playback registry. Maps {@link BlockPos} → ({@link VideoBackend} + dynamic * SPEC §5 — per-anchor playback registry. Maps {@link BlockPos} → ({@link VideoBackend} +
* Identifier of an {@link NativeImageBackedTexture}). The renderer reads the texture id and binds * a {@link DynamicTexture} surface registered under a unique {@link Identifier}). The renderer
* it to the quad; this class drives the frame pump every client tick. * reads {@link #currentTexture(BlockPos)} and binds it to the quad. {@link #tick()} pumps
* decoded RGBA frames into the texture.
*
* <p>When no backend is available on the classpath (e.g. JavaCV jar not installed by the
* user), the texture stays at its initial placeholder pattern, so the anchor's screen quad
* still renders as a visible surface.
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public final class VideoPlayback { public final class VideoPlayback {
private VideoPlayback() {} private VideoPlayback() {}
private static final int PLACEHOLDER_SIZE = 32;
private static final Map<BlockPos, Entry> ENTRIES = new HashMap<>(); private static final Map<BlockPos, Entry> ENTRIES = new HashMap<>();
/**
* Ensure a playback entry exists for this anchor and return its texture identifier.
* Creates a backend + dynamic texture on first call. Returns {@code null} only if the
* URL is empty or autoplay is off.
*/
public static Identifier getOrStart(VideoAnchorBlockEntity be) { public static Identifier getOrStart(VideoAnchorBlockEntity be) {
BlockPos pos = be.getPos(); BlockPos pos = be.getBlockPos();
Entry e = ENTRIES.get(pos); Entry e = ENTRIES.get(pos);
if (be.getUrl().isEmpty() || !be.isAutoplay()) {
if (e != null) {
stop(pos);
}
return null;
}
if (e != null && e.url.equals(be.getUrl())) { if (e != null && e.url.equals(be.getUrl())) {
return e.id; return e.id;
} }
if (e != null) { if (e != null) {
stop(pos); stop(pos);
} }
if (be.getUrl().isEmpty() || !be.isAutoplay()) {
return null;
}
VideoBackend backend = WatermediaProbe.isAvailable() ? new WatermediaBackend() : new JavaCvBackend(); VideoBackend backend = WatermediaProbe.isAvailable() ? new WatermediaBackend() : new JavaCvBackend();
backend.play(be.getUrl(), be.isLoop()); // If /videopreload already cached the URL to disk, hand the local file path to FFmpeg
// instead of the HTTP URL — eliminates the network read entirely. Falls back to the
// live URL when the cache miss or the download hasn't finished yet.
Path cached = VideoCache.lookup(be.getUrl());
String source = cached != null ? cached.toAbsolutePath().toString() : be.getUrl();
backend.play(source, be.isLoop());
backend.setVolume(be.isMuted() ? 0F : be.getVolume()); backend.setVolume(be.isMuted() ? 0F : be.getVolume());
Entry created = new Entry(be.getUrl(), backend); Entry created = new Entry(be.getUrl(), backend);
ENTRIES.put(pos, created); ENTRIES.put(pos, created);
return created.id; return created.id;
@@ -56,10 +82,11 @@ public final class VideoPlayback {
} }
public static void onConfigChanged(VideoAnchorBlockEntity be) { public static void onConfigChanged(VideoAnchorBlockEntity be) {
Entry e = ENTRIES.get(be.getPos()); if (be == null) return;
Entry e = ENTRIES.get(be.getBlockPos());
if (e == null) return; if (e == null) return;
if (!e.url.equals(be.getUrl())) { if (!e.url.equals(be.getUrl())) {
stop(be.getPos()); stop(be.getBlockPos());
return; return;
} }
e.backend.setVolume(be.isMuted() ? 0F : be.getVolume()); e.backend.setVolume(be.isMuted() ? 0F : be.getVolume());
@@ -67,26 +94,35 @@ public final class VideoPlayback {
/** Called every client tick to upload new frames into the GPU texture. */ /** Called every client tick to upload new frames into the GPU texture. */
public static void tick() { public static void tick() {
if (MinecraftClient.getInstance() == null) return; Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
Iterator<Map.Entry<BlockPos, Entry>> it = ENTRIES.entrySet().iterator(); Iterator<Map.Entry<BlockPos, Entry>> it = ENTRIES.entrySet().iterator();
while (it.hasNext()) { while (it.hasNext()) {
Map.Entry<BlockPos, Entry> me = it.next(); Map.Entry<BlockPos, Entry> me = it.next();
BlockPos pos = me.getKey();
Entry e = me.getValue(); Entry e = me.getValue();
// Belt-and-suspenders for the audio-on-delete bug: if BLOCK_ENTITY_UNLOAD didn't
// fire for some edge case (dimension change, chunk torn down before event runs,
// etc.), the BE will be gone from the level but our Entry still holds an open
// audio line. Catch it here and stop next tick.
if (mc.level == null || !(mc.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) {
e.close();
it.remove();
continue;
}
if (!e.backend.isReady()) continue; if (!e.backend.isReady()) continue;
ByteBuffer buf = e.backend.pollFrame();
if (buf == null) continue;
try { try {
e.upload(buf); e.tryUpload();
} catch (Throwable t) { } catch (Throwable t) {
// texture upload errors shouldn't kill the client; drop this entry VideoPlayerMod.LOG.warn("[{}] texture upload failed: {}", VideoPlayerMod.MOD_ID, t.toString());
e.close(); e.close();
it.remove(); it.remove();
} }
} }
} }
public static java.util.Set<BlockPos> activePositions() { public static Set<BlockPos> activePositions() {
return new java.util.HashSet<>(ENTRIES.keySet()); return new HashSet<>(ENTRIES.keySet());
} }
public static void setGain(BlockPos pos, float gain) { public static void setGain(BlockPos pos, float gain) {
@@ -99,49 +135,92 @@ public final class VideoPlayback {
ENTRIES.clear(); ENTRIES.clear();
} }
/** Per-anchor playback state. */
private static final class Entry { private static final class Entry {
final String url; final String url;
final VideoBackend backend; final VideoBackend backend;
final Identifier id; final Identifier id;
NativeImageBackedTexture texture; DynamicTexture texture;
int texW = 0, texH = 0; int texW = 0, texH = 0;
boolean registered = false;
Entry(String url, VideoBackend backend) { Entry(String url, VideoBackend backend) {
this.url = url; this.url = url;
this.backend = backend; this.backend = backend;
this.id = Identifier.of("video_player", "dynamic/" + Integer.toHexString(System.identityHashCode(this))); String tag = Integer.toHexString(System.identityHashCode(this));
this.id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "dynamic/" + tag);
ensureTexture(PLACEHOLDER_SIZE, PLACEHOLDER_SIZE, true);
} }
void upload(ByteBuffer rgba) { /** Allocate or resize the dynamic texture, registering it on first allocation. */
int w = backend.videoWidth(); private void ensureTexture(int w, int h, boolean fillPlaceholder) {
int h = backend.videoHeight(); if (texture != null && w == texW && h == texH) return;
if (w <= 0 || h <= 0) return;
if (texture == null || w != texW || h != texH) {
if (texture != null) texture.close(); if (texture != null) texture.close();
NativeImage img = new NativeImage(NativeImage.Format.RGBA, w, h, false); NativeImage img = new NativeImage(w, h, false);
texture = new NativeImageBackedTexture(() -> "video_player_dyn", img); if (fillPlaceholder) {
MinecraftClient.getInstance().getTextureManager().registerTexture(id, texture); fillPlaceholder(img, w, h);
texW = w; texH = h;
} }
NativeImage img = texture.getImage(); texture = new DynamicTexture(() -> "video_player:" + id, img);
if (img == null) return; texW = w;
// copy buf → image pixels (RGBA bytes, native order) texH = h;
int pixels = w * h; Minecraft mc = Minecraft.getInstance();
for (int i = 0; i < pixels; i++) { if (mc != null) {
int r = rgba.get() & 0xFF; mc.getTextureManager().register(id, texture);
int g = rgba.get() & 0xFF; registered = true;
int b = rgba.get() & 0xFF;
int a = rgba.get() & 0xFF;
int argb = (a << 24) | (r << 16) | (g << 8) | b;
img.setColorArgb(i % w, i / w, argb);
} }
texture.upload(); texture.upload();
} }
/** Dark gray surface with a thin border — visible "screen" until first frame arrives. */
private static void fillPlaceholder(NativeImage img, int w, int h) {
// ABGR int. 0xAABBGGRR.
int body = 0xFF202020; // dark gray
int border = 0xFF505050;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
boolean isEdge = (x == 0 || y == 0 || x == w - 1 || y == h - 1);
img.setPixelABGR(x, y, isEdge ? border : body);
}
}
}
/**
* If the backend has a new RGBA frame, copy it straight into the texture's native
* pixel buffer and re-upload to GPU. The backend does the memcpy under its own lock
* so we never read a half-written frame. RGBA bytes already match NativeImage's
* ABGR-int layout in little-endian byte order (byte 0 = R = low byte of the int).
*/
void tryUpload() {
int w = backend.videoWidth();
int h = backend.videoHeight();
if (w <= 0 || h <= 0) return;
ensureTexture(w, h, false);
NativeImage img = texture.getPixels();
if (img == null) return;
long maxBytes = (long) w * h * 4L;
if (backend.consumeFrame(img.getPointer(), maxBytes)) {
texture.upload();
}
}
void close() { void close() {
backend.close(); try { backend.close(); } catch (Throwable ignored) {}
// Unregister from TextureManager BEFORE closing the texture itself, so any
// straggler binding by Identifier looks up "no such texture" instead of a closed
// GL handle (which crashes the renderer on the next frame). Renderer pipelines
// can cache RenderType objects keyed by Identifier across frames, and on delete
// the old anchor's frame can still be in flight in the submit buffer when its
// texture closes — without this release(), the bind would dereference a freed
// GL handle.
if (registered) {
Minecraft mc = Minecraft.getInstance();
if (mc != null) {
try { mc.getTextureManager().release(id); } catch (Throwable ignored) {}
}
registered = false;
}
if (texture != null) { if (texture != null) {
texture.close(); try { texture.close(); } catch (Throwable ignored) {}
texture = null; texture = null;
} }
} }

View File

@@ -4,8 +4,6 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import java.nio.ByteBuffer;
/** /**
* SPEC §5.3 / §5.4 — WaterMedia v2 backend. Reflection-only so the mod jar stays clean of * SPEC §5.3 / §5.4 — WaterMedia v2 backend. Reflection-only so the mod jar stays clean of
* compile-time WaterMedia dependencies. Until a v2 build supports 1.21.6+ this returns * compile-time WaterMedia dependencies. Until a v2 build supports 1.21.6+ this returns
@@ -38,8 +36,8 @@ public class WatermediaBackend implements VideoBackend {
@Override public int videoHeight() { return height; } @Override public int videoHeight() { return height; }
@Override @Override
public ByteBuffer pollFrame() { public boolean consumeFrame(long dstAddr, long maxBytes) {
return null; // no frames until v2 is wired up return false; // no frames until v2 is wired up
} }
@Override @Override

View File

@@ -1,91 +1,156 @@
package com.ejclaw.videoplayer.client.render; package com.ejclaw.videoplayer.client.render;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.ClientPolicy;
import com.ejclaw.videoplayer.client.playback.VideoPlayback; import com.ejclaw.videoplayer.client.playback.VideoPlayback;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import net.minecraft.client.render.RenderLayer; import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.render.VertexConsumer; import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.render.VertexConsumerProvider; import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.render.block.entity.BlockEntityRenderer; import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.render.block.entity.BlockEntityRendererFactory; import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.util.math.MatrixStack; import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.util.Identifier; import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.util.math.Direction; import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.util.math.RotationAxis; import net.minecraft.core.Direction;
import net.minecraft.util.math.Vec3d; import net.minecraft.resources.Identifier;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f; import org.joml.Matrix4f;
/** SPEC §5.2 — draws a width × height quad in front of the anchor, oriented by facing. */ /**
* Draws the video as a textured quad <em>on the surface of the block the user clicked</em>.
*
* <p>The anchor BE lives in the air block adjacent to the clicked wall. Its {@code facing}
* field is the surface normal of the wall (= the {@link Direction} the player clicked). The
* quad is rotated so its normal aligns with that direction and shifted so it sits flush against
* the wall surface, with a tiny outward offset to avoid z-fighting.
*/
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity> { public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
/** Placeholder texture used until a frame is uploaded. */ /**
private static final Identifier PLACEHOLDER = * Outward offset so the quad doesn't z-fight with the wall it sits on.
Identifier.of(VideoPlayerMod.MOD_ID, "block/video_anchor"); *
* <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(BlockEntityRendererFactory.Context ctx) { public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op — context kept for future symbol/lookup needs // no-op
} }
@Override @Override
public void render(VideoAnchorBlockEntity be, float tickDelta, MatrixStack matrices, public State createRenderState() {
VertexConsumerProvider vertices, int light, int overlay, Vec3d cam) { return new State();
Identifier tex = VideoPlayback.currentTexture(be.getPos());
Identifier bound = tex != null ? tex : PLACEHOLDER;
float w = be.getWidth();
float h = be.getHeight();
Direction facing = be.getFacing();
matrices.push();
// Center the quad above the anchor's top face, then rotate to facing.
matrices.translate(0.5, 1.01, 0.5);
float rot = facing.getAxis().isHorizontal()
? Direction.getHorizontalDegreesOrThrow(facing)
: 0F;
matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(-rot));
matrices.translate(-w / 2.0F, 0, 0);
VertexConsumer vc = vertices.getBuffer(RenderLayer.getEntityCutoutNoCull(bound));
Matrix4f mat = matrices.peek().getPositionMatrix();
// Two-sided quad in the XY plane at z=0
emit(vc, mat, 0, 0, 0, 0, 1, light, overlay);
emit(vc, mat, w, 0, 0, 1, 1, light, overlay);
emit(vc, mat, w, h, 0, 1, 0, light, overlay);
emit(vc, mat, 0, h, 0, 0, 0, light, overlay);
// back face (so the anchor is visible from behind too)
emit(vc, mat, 0, h, 0, 0, 0, light, overlay);
emit(vc, mat, w, h, 0, 1, 0, light, overlay);
emit(vc, mat, w, 0, 0, 1, 1, light, overlay);
emit(vc, mat, 0, 0, 0, 0, 1, light, overlay);
matrices.pop();
// Trigger playback startup lazily, on first frame the camera sees the BE.
VideoPlayback.getOrStart(be);
}
private static void emit(VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v,
int light, int overlay) {
vc.vertex(mat, x, y, z)
.color(255, 255, 255, 255)
.texture(u, v)
.overlay(overlay)
.light(light)
.normal(0F, 0F, 1F);
} }
@Override @Override
public boolean rendersOutsideBoundingBox() { public void extractRenderState(VideoAnchorBlockEntity be, State state, float partialTick,
Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumbling) {
BlockEntityRenderState.extractBase(be, state, crumbling);
state.width = be.getWidth();
state.height = be.getHeight();
state.facing = be.getFacing();
state.textureId = VideoPlayback.getOrStart(be);
}
@Override
public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
Identifier tex = state.textureId;
if (tex == null) return; // url empty or autoplay off — nothing to draw
final float w = state.width;
final float h = state.height;
final int light = state.lightCoords;
final Direction f = state.facing == null ? Direction.NORTH : state.facing;
pose.pushPose();
// 1) Move to the anchor block's center.
pose.translate(0.5F, 0.5F, 0.5F);
// 2) Rotate local +Z to align with the wall's outward normal.
applyFaceRotation(pose, f);
// 3) Place the quad's local origin (0,0) at the bottom-left corner of the anchor block's
// wall face, so the clicked block becomes the lower-left and the video grows up & right.
// Push it onto the wall surface (-0.5 along local +Z, the outward normal) plus a tiny
// epsilon outward so the quad doesn't z-fight with the wall.
pose.translate(-0.5F, -0.5F, -0.5F + SURFACE_EPSILON);
final Matrix4f mat = new Matrix4f(pose.last().pose());
// 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) -> {
// 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
// per anchor and removes the mirrored-texture artifact a player would see if they
// somehow clipped behind the wall.
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
});
pose.popPose();
}
/**
* Rotate so local +Z (the quad's outward normal in its base orientation) becomes world {@code f},
* with local +X mapped to the natural "right" direction the player sees when looking at the face.
* Derivation: for each face {@code f}, pick the rotation that maps local +Z → f, +Y → world up
* (or a sensible substitute for top/bottom), so the quad lies flush against the wall, oriented
* the way the player intuits.
*/
private static void applyFaceRotation(PoseStack pose, Direction f) {
switch (f) {
case SOUTH -> { /* identity: local +Z = world +Z (south). +X = east, +Y = up. */ }
case NORTH -> pose.mulPose(Axis.YP.rotationDegrees(180F)); // +Z → -Z, +X → -X (west)
case EAST -> pose.mulPose(Axis.YP.rotationDegrees(90F)); // +Z → +X, +X → -Z (north)
case WEST -> pose.mulPose(Axis.YP.rotationDegrees(-90F)); // +Z → -X, +X → +Z (south)
case UP -> pose.mulPose(Axis.XP.rotationDegrees(-90F)); // +Z → +Y, +Y → -Z (north)
case DOWN -> pose.mulPose(Axis.XP.rotationDegrees(90F)); // +Z → -Y, +Y → +Z (south)
}
}
private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v, int light) {
vc.addVertex(mat, x, y, z)
.setColor(255, 255, 255, 255)
.setUv(u, v)
.setOverlay(net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY)
.setLight(light)
.setNormal(0F, 0F, 1F);
}
@Override
public boolean shouldRenderOffScreen() {
return true; return true;
} }
@Override @Override
public int getRenderDistance() { public int getViewDistance() {
return 128; // Driven by server config (video_player.json:render_distance_blocks), pushed to clients
// on join. Default 128 = legacy behavior, so an unpaired client behaves identically.
return ClientPolicy.renderDistanceBlocks();
}
/** Per-frame render data extracted from the BE. */
public static final class State extends BlockEntityRenderState {
public Identifier textureId;
public int width = 1;
public int height = 1;
public Direction facing = Direction.NORTH;
} }
} }

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

@@ -0,0 +1,174 @@
package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.VideoPlayerConfig;
import com.ejclaw.videoplayer.net.ClearCachePayload;
import com.ejclaw.videoplayer.net.DeleteCachePayload;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import java.net.URI;
import java.util.Map;
/**
* {@code /videocache add <name> <url>} — name a URL, store it in server config, and broadcast a
* preload request to every client.
* <br>{@code /videocache list} — print the named index with clickable URLs.
* <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}. 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() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoCache"));
}
private static LiteralArgumentBuilder<CommandSourceStack> build(String root) {
return Commands.literal(root)
.requires(CommandPermissions::opOrServer)
.then(Commands.literal("add")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("url", StringArgumentType.greedyString())
.executes(VideoCacheCommand::runAdd))))
.then(Commands.literal("list")
.executes(VideoCacheCommand::runList))
.then(Commands.literal("remove")
.then(Commands.argument("name", StringArgumentType.word())
.executes(VideoCacheCommand::runRemove)))
.then(Commands.literal("clear")
.executes(VideoCacheCommand::runClear));
}
private static int runAdd(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource();
String name = StringArgumentType.getString(ctx, "name").trim();
String url = StringArgumentType.getString(ctx, "url").trim();
if (name.isEmpty() || name.length() > 64) {
src.sendFailure(Component.literal("이름은 1~64자여야 합니다"));
return 0;
}
if (url.isEmpty() || !(url.startsWith("http://") || url.startsWith("https://"))) {
src.sendFailure(Component.literal("url 은 http:// 또는 https:// 로 시작해야 합니다"));
return 0;
}
if (url.length() > 256) {
src.sendFailure(Component.literal("url 이 너무 깁니다 (최대 256자)"));
return 0;
}
if (VideoPlayerConfig.cacheUrl(name) != null) {
src.sendFailure(Component.literal("이미 사용 중인 이름입니다: " + name));
return 0;
}
if (!VideoPlayerConfig.addCacheEntry(name, url)) {
src.sendFailure(Component.literal("저장 실패: 이름 중복 또는 IO 오류"));
return 0;
}
MinecraftServer server = src.getServer();
PreloadPayload payload = new PreloadPayload(url);
int sent = 0;
for (ServerPlayer p : PlayerLookup.all(server)) {
ServerPlayNetworking.send(p, payload);
sent++;
}
final int sentFinal = sent;
src.sendSuccess(() -> Component.literal(
"[videocache] 추가됨: " + name + "" + url
+ " (" + sentFinal + " 클라이언트에 preload 전송)"), false);
return 1;
}
private static int runList(CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
Map<String, String> entries = VideoPlayerConfig.cacheEntries();
if (entries.isEmpty()) {
src.sendSuccess(() -> Component.literal("[videocache] 저장된 항목이 없습니다")
.withStyle(ChatFormatting.GRAY), false);
return 0;
}
src.sendSuccess(() -> Component.literal("[videocache] 저장된 항목 " + entries.size() + "개:")
.withStyle(ChatFormatting.YELLOW), false);
for (Map.Entry<String, String> e : entries.entrySet()) {
String url = e.getValue();
ClickEvent click;
try {
click = new ClickEvent.OpenUrl(URI.create(url));
} catch (Throwable t) {
click = null; // bad URI — show without click action rather than failing the whole list
}
Style urlStyle = Style.EMPTY.withColor(ChatFormatting.AQUA).withUnderlined(true);
if (click != null) urlStyle = urlStyle.withClickEvent(click);
MutableComponent line = Component.literal("" + e.getKey() + " : ")
.withStyle(ChatFormatting.WHITE)
.append(Component.literal(url).withStyle(urlStyle));
src.sendSuccess(() -> line, false);
}
return entries.size();
}
private static int runClear(CommandContext<CommandSourceStack> ctx) {
CommandSourceStack src = ctx.getSource();
// Drop server-side named entries (preload_urls is left alone — it's an admin-edited
// list, not something /videoCache manages).
java.util.List<String> urls = VideoPlayerConfig.clearCacheEntries();
// Always broadcast a full-cache wipe to every client, even if the named index was
// empty — leftover files from prior sessions, legacy preload_urls downloads, and
// any in-flight stragglers all get scrubbed in one shot.
MinecraftServer server = src.getServer();
ClearCachePayload payload = new ClearCachePayload();
int clients = 0;
for (ServerPlayer p : PlayerLookup.all(server)) {
ServerPlayNetworking.send(p, payload);
clients++;
}
final int sentFinal = clients;
final int countFinal = urls.size();
src.sendSuccess(() -> Component.literal(
"[videocache] 전체 삭제: 등록 항목 " + countFinal + "개 제거"
+ " + " + sentFinal + " 클라이언트 디스크 캐시 전체 wipe"), false);
return Math.max(countFinal, 1);
}
private static int runRemove(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource();
String name = StringArgumentType.getString(ctx, "name").trim();
String url = VideoPlayerConfig.removeCacheEntry(name);
if (url == null) {
src.sendFailure(Component.literal("해당 이름의 항목이 없습니다: " + name));
return 0;
}
MinecraftServer server = src.getServer();
DeleteCachePayload payload = new DeleteCachePayload(url);
int sent = 0;
for (ServerPlayer p : PlayerLookup.all(server)) {
ServerPlayNetworking.send(p, payload);
sent++;
}
final int sentFinal = sent;
src.sendSuccess(() -> Component.literal(
"[videocache] 삭제됨: " + name + "" + url
+ " (" + sentFinal + " 클라이언트에 cache_delete 전송)"), false);
return 1;
}
}

View File

@@ -3,42 +3,42 @@ package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.block.Blocks; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.command.argument.BlockPosArgumentType; import net.minecraft.commands.Commands;
import net.minecraft.server.command.CommandManager; import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.server.command.ServerCommandSource; import net.minecraft.core.BlockPos;
import net.minecraft.server.world.ServerWorld; import net.minecraft.network.chat.Component;
import net.minecraft.text.Text; import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.math.BlockPos; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/** SPEC §4.5.1 — {@code /videoDelete <pos>} */ /** SPEC §4.5.1 — {@code /videoDelete <pos>} */
public final class VideoDeleteCommand { public final class VideoDeleteCommand {
private VideoDeleteCommand() {} private VideoDeleteCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) { public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(register("videoDelete")); dispatcher.register(build("videoDelete"));
dispatcher.register(register("videodelete"));
} }
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
register(String name) { build(String name) {
return CommandManager.literal(name) return Commands.literal(name)
.requires(s -> s.hasPermissionLevel(2)) .requires(CommandPermissions::opOrServer)
.then(CommandManager.argument("pos", BlockPosArgumentType.blockPos()) .then(Commands.argument("pos", BlockPosArgument.blockPos())
.executes(VideoDeleteCommand::run)); .executes(VideoDeleteCommand::run));
} }
private static int run(com.mojang.brigadier.context.CommandContext<ServerCommandSource> ctx) private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException { throws CommandSyntaxException {
ServerCommandSource src = ctx.getSource(); CommandSourceStack src = ctx.getSource();
ServerWorld world = src.getWorld(); ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos"); BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) { if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) {
src.sendError(Text.literal("no anchor at that position")); src.sendFailure(Component.literal("no anchor at that position"));
return 0; return 0;
} }
world.setBlockState(pos, Blocks.AIR.getDefaultState()); level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
src.sendFeedback(() -> Text.literal("anchor deleted at " + pos.toShortString()), true); src.sendSuccess(() -> Component.literal("anchor deleted at " + pos.toShortString()), true);
return 1; return 1;
} }
} }

View File

@@ -7,55 +7,54 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup; import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.command.argument.BlockPosArgumentType; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.server.command.CommandManager; import net.minecraft.commands.Commands;
import net.minecraft.server.command.ServerCommandSource; import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.core.BlockPos;
import net.minecraft.server.world.ServerWorld; import net.minecraft.network.chat.Component;
import net.minecraft.text.Text; import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.math.BlockPos; import net.minecraft.server.level.ServerPlayer;
/** 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 {
private VideoMuteCommand() {} private VideoMuteCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) { public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(register("videoMute")); dispatcher.register(build("videoMute"));
dispatcher.register(register("videomute"));
} }
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
register(String name) { build(String name) {
return CommandManager.literal(name) return Commands.literal(name)
.requires(s -> s.hasPermissionLevel(2)) .requires(CommandPermissions::opOrServer)
.then(CommandManager.argument("pos", BlockPosArgumentType.blockPos()) .then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(CommandManager.argument("state", StringArgumentType.word()) .then(Commands.argument("state", StringArgumentType.word())
.executes(VideoMuteCommand::run))); .executes(VideoMuteCommand::run)));
} }
private static int run(com.mojang.brigadier.context.CommandContext<ServerCommandSource> ctx) private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException { throws CommandSyntaxException {
ServerCommandSource src = ctx.getSource(); CommandSourceStack src = ctx.getSource();
ServerWorld world = src.getWorld(); ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos"); BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
String state = StringArgumentType.getString(ctx, "state").toLowerCase(); String state = StringArgumentType.getString(ctx, "state").toLowerCase();
boolean muted; boolean muted;
if ("on".equals(state) || "true".equals(state)) muted = true; if ("on".equals(state) || "true".equals(state)) muted = true;
else if ("off".equals(state) || "false".equals(state)) muted = false; else if ("off".equals(state) || "false".equals(state)) muted = false;
else { else {
src.sendError(Text.literal("state must be on/off")); src.sendFailure(Component.literal("state must be on/off"));
return 0; return 0;
} }
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) { if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
src.sendError(Text.literal("no anchor at that position")); src.sendFailure(Component.literal("no anchor at that position"));
return 0; return 0;
} }
be.setMuted(muted); be.setMuted(muted);
for (ServerPlayerEntity p : PlayerLookup.tracking(world, pos)) { for (ServerPlayer p : PlayerLookup.tracking(level, pos)) {
ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, be.toNbt())); ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, be.toNbt()));
} }
final boolean mFinal = muted; final boolean mFinal = muted;
src.sendFeedback(() -> Text.literal("anchor " + (mFinal ? "muted" : "unmuted")), true); src.sendSuccess(() -> Component.literal("anchor " + (mFinal ? "muted" : "unmuted")), true);
return 1; return 1;
} }
} }

View File

@@ -1,81 +1,142 @@
package com.ejclaw.videoplayer.command; package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.VideoPlayerConfig;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.net.SyncAnchorPayload; import com.ejclaw.videoplayer.net.SyncAnchorPayload;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.FloatArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup; import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.command.argument.BlockPosArgumentType; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.nbt.NbtCompound; import net.minecraft.commands.Commands;
import net.minecraft.server.command.CommandManager; import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.server.command.ServerCommandSource; import net.minecraft.core.BlockPos;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.core.Direction;
import net.minecraft.server.world.ServerWorld; import net.minecraft.nbt.CompoundTag;
import net.minecraft.text.Text; import net.minecraft.network.chat.Component;
import net.minecraft.util.math.BlockPos; import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.math.Direction; import net.minecraft.server.level.ServerPlayer;
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() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) { /** Default volume (percent) applied to the legacy 5-arg form. */
dispatcher.register(register("videoPlace")); private static final int DEFAULT_VOLUME_PCT = 50;
dispatcher.register(register("videoplace"));
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoPlace"));
} }
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
register(String name) { build(String name) {
return CommandManager.literal(name) return Commands.literal(name)
.requires(s -> s.hasPermissionLevel(2)) .requires(CommandPermissions::opOrServer)
.then(CommandManager.argument("pos", BlockPosArgumentType.blockPos()) .then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(CommandManager.argument("facing", StringArgumentType.word()) .then(Commands.argument("facing", StringArgumentType.word())
.then(CommandManager.argument("width", IntegerArgumentType.integer(1, 32)) .then(Commands.argument("width", IntegerArgumentType.integer(1, 32))
.then(CommandManager.argument("height", IntegerArgumentType.integer(1, 32)) .then(Commands.argument("height", IntegerArgumentType.integer(1, 32))
.then(CommandManager.argument("url", StringArgumentType.greedyString()) // New form: volume (int) + greedy url
.executes(VideoPlaceCommand::run)))))); .then(Commands.argument("volume", IntegerArgumentType.integer(-1, 100))
.then(Commands.argument("url", StringArgumentType.greedyString())
.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<ServerCommandSource> ctx) private static int runNew(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException { throws CommandSyntaxException {
ServerCommandSource src = ctx.getSource(); int volumeArg = IntegerArgumentType.getInteger(ctx, "volume");
ServerWorld world = src.getWorld(); String url = StringArgumentType.getString(ctx, "url");
BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos"); return runWithValues(ctx, volumeArg, url);
Direction facing = Direction.byId(StringArgumentType.getString(ctx, "facing")); }
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();
ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
Direction facing = directionFromName(StringArgumentType.getString(ctx, "facing"));
if (facing == null) { if (facing == null) {
src.sendError(Text.literal("facing must be north/south/east/west/up/down")); src.sendFailure(Component.literal("facing must be north/south/east/west/up/down"));
return 0; return 0;
} }
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 url = StringArgumentType.getString(ctx, "url").trim(); // -1 is the CLI mute shortcut; the BE keeps the underlying volume so an admin can
if (!url.isEmpty() && !(url.startsWith("http://") || url.startsWith("https://"))) { // /videoMute false later without re-typing a level. Anything 0..100 sets %-volume and
src.sendError(Text.literal("url must be http:// or https:// (or empty)")); // 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()
// returns the canonical URL in both cases, or null when a non-URL string didn't match
// any named entry.
String url = VideoPlayerConfig.resolveUrlOrName(raw);
if (url == null) {
src.sendFailure(Component.literal(
"url 은 http(s):// 로 시작하거나 /videoCache add 로 등록된 이름이어야 합니다: " + raw));
return 0; return 0;
} }
if (url.length() > 256) url = url.substring(0, 256); if (url.length() > 256) url = url.substring(0, 256);
world.setBlockState(pos, VideoPlayerBlocks.VIDEO_ANCHOR.getDefaultState()); level.setBlock(pos, VideoPlayerBlocks.VIDEO_ANCHOR.defaultBlockState(), Block.UPDATE_ALL);
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) { if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
src.sendError(Text.literal("failed to place anchor")); src.sendFailure(Component.literal("failed to place anchor"));
return 0; return 0;
} }
be.setFacing(facing); be.setFacing(facing);
be.setWidth(width); be.setWidth(width);
be.setHeight(height); be.setHeight(height);
be.setUrl(url); be.setUrl(url);
be.setVolume(placeVolume);
be.setMuted(placeMuted);
NbtCompound nbt = be.toNbt(); CompoundTag nbt = be.toNbt();
for (ServerPlayerEntity p : PlayerLookup.tracking(world, pos)) { for (ServerPlayer p : PlayerLookup.tracking(level, pos)) {
ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, nbt)); ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, nbt));
} }
final BlockPos fp = pos; final BlockPos fp = pos;
src.sendFeedback(() -> Text.literal("anchor placed at " + fp.toShortString()), true); src.sendSuccess(() -> Component.literal("anchor placed at " + fp.toShortString()), true);
return 1; return 1;
} }
private static Direction directionFromName(String name) {
if (name == null) return null;
for (Direction d : Direction.values()) {
if (d.getSerializedName().equalsIgnoreCase(name)) return d;
}
return null;
}
} }

View File

@@ -2,35 +2,36 @@ package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.registry.VideoPlayerItems; import com.ejclaw.videoplayer.registry.VideoPlayerItems;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.item.ItemStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.server.command.CommandManager; import net.minecraft.commands.Commands;
import net.minecraft.server.command.ServerCommandSource; import net.minecraft.network.chat.Component;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.text.Text; import net.minecraft.world.item.ItemStack;
public final class VideoStickCommand { public final class VideoStickCommand {
private VideoStickCommand() {} private VideoStickCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) { public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(CommandManager.literal("videoStick") // 플레이어는 OP(level 2+) 만, 콘솔/커맨드블럭/함수(/function) 는 무조건 통과.
.executes(ctx -> run(ctx.getSource()))); // 따라서 functionPermissionLevel 같은 gamerule 을 만질 필요가 없다.
// Lowercase alias — Brigadier is case-sensitive. // 일반 플레이어(level 0) 는 탭 자동완성에도 안 떠야 정상.
dispatcher.register(CommandManager.literal("videostick") dispatcher.register(Commands.literal("videoStick")
.requires(CommandPermissions::opOrServer)
.executes(ctx -> run(ctx.getSource()))); .executes(ctx -> run(ctx.getSource())));
} }
private static int run(ServerCommandSource source) { private static int run(CommandSourceStack source) {
ServerPlayerEntity player = source.getPlayer(); ServerPlayer player = source.getPlayer();
if (player == null) { if (player == null) {
source.sendError(Text.literal("플레이어만 이 명령을 사용할 수 있습니다.")); source.sendFailure(Component.literal("플레이어만 이 명령을 사용할 수 있습니다."));
return 0; return 0;
} }
ItemStack stack = new ItemStack(VideoPlayerItems.VIDEO_STICK); ItemStack stack = new ItemStack(VideoPlayerItems.VIDEO_STICK);
boolean inserted = player.getInventory().insertStack(stack); boolean inserted = player.getInventory().add(stack);
if (!inserted || !stack.isEmpty()) { if (!inserted || !stack.isEmpty()) {
player.dropItem(stack, false); player.drop(stack, false, false);
} }
source.sendFeedback(() -> Text.literal("비디오 스틱을 지급했습니다."), false); source.sendSuccess(() -> Component.literal("비디오 스틱을 지급했습니다."), false);
return 1; return 1;
} }
} }

View File

@@ -4,57 +4,68 @@ import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.net.OpenScreenPayload; import com.ejclaw.videoplayer.net.OpenScreenPayload;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Block; import net.minecraft.core.BlockPos;
import net.minecraft.block.BlockState; import net.minecraft.core.Direction;
import net.minecraft.entity.player.PlayerEntity; import net.minecraft.server.level.ServerLevel;
import net.minecraft.item.Item; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.item.ItemUsageContext; import net.minecraft.world.InteractionResult;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.world.entity.player.Player;
import net.minecraft.server.world.ServerWorld; import net.minecraft.world.item.Item;
import net.minecraft.util.ActionResult; import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.util.math.BlockPos; import net.minecraft.world.level.Level;
import net.minecraft.util.math.Direction; import net.minecraft.world.level.block.Block;
import net.minecraft.world.World; import net.minecraft.world.level.block.state.BlockState;
/** SPEC §4.2 — right-click empty face → place anchor + open GUI. Right-click existing anchor → edit. */ /**
* Right-click a block's face with the video stick:
* <ul>
* <li>If a video anchor already exists in the adjacent air (= an anchor already drawn on this
* face), reopen its config GUI.</li>
* <li>Otherwise place an invisible anchor in the adjacent air block, set its facing to the
* clicked face direction (so the renderer draws the quad flush against this face), and
* open the config GUI.</li>
* </ul>
* The anchor block itself is invisible / non-collidable, so visually no new block appears —
* the video just shows up on the face the user clicked.
*/
public class VideoStickItem extends Item { public class VideoStickItem extends Item {
public VideoStickItem(Settings settings) { public VideoStickItem(Properties properties) {
super(settings); super(properties);
} }
@Override @Override
public ActionResult useOnBlock(ItemUsageContext ctx) { public InteractionResult useOn(UseOnContext ctx) {
World world = ctx.getWorld(); Level level = ctx.getLevel();
if (world.isClient) { if (level.isClientSide()) {
// server is authoritative; client just consumes the gesture return InteractionResult.SUCCESS;
return ActionResult.SUCCESS; }
if (!(level instanceof ServerLevel sl)) return InteractionResult.PASS;
Player player = ctx.getPlayer();
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
BlockPos hit = ctx.getClickedPos();
Direction face = ctx.getClickedFace();
BlockPos anchorPos = hit.relative(face);
// Existing anchor on this face → reopen edit GUI.
if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, existing.toNbt()));
return InteractionResult.SUCCESS;
} }
ServerWorld sw = (ServerWorld) world; // Need an empty / replaceable space in front of the clicked face.
PlayerEntity player = ctx.getPlayer(); BlockState there = sl.getBlockState(anchorPos);
if (!(player instanceof ServerPlayerEntity sp)) return ActionResult.PASS; if (!there.canBeReplaced()) return InteractionResult.PASS;
BlockPos hit = ctx.getBlockPos();
// Existing anchor → edit
if (sw.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt()));
return ActionResult.SUCCESS;
}
// Empty face → place anchor on top of the clicked face
Direction side = ctx.getSide();
BlockPos placeAt = hit.offset(side);
BlockState there = sw.getBlockState(placeAt);
if (!there.isReplaceable()) return ActionResult.PASS;
Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR; Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR;
sw.setBlockState(placeAt, anchor.getDefaultState()); sl.setBlock(anchorPos, anchor.defaultBlockState(), Block.UPDATE_ALL);
if (sw.getBlockEntity(placeAt) instanceof VideoAnchorBlockEntity be) { if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity be) {
be.setFacing(ctx.getHorizontalPlayerFacing().getOpposite()); // Surface normal of the wall we're painting on points outward in the same direction
ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt())); // as the face the player clicked.
be.setFacing(face);
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, be.toNbt()));
} }
return ActionResult.SUCCESS; return InteractionResult.SUCCESS;
} }
} }

View File

@@ -0,0 +1,32 @@
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;
/**
* S2C — broadcasts the server-configured client-side policy bundle on join, before any
* {@link PreloadPayload}. Carries: {@code maxPerVideoBytes} (per-video download cap),
* {@code maxCacheBytes} (total-cache directory cap), and {@code renderDistanceBlocks}
* (anchor BE view-distance cap).
*/
public record CachePolicyPayload(long maxPerVideoBytes, long maxCacheBytes, int renderDistanceBlocks)
implements CustomPacketPayload {
public static final CustomPacketPayload.Type<CachePolicyPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "cache_policy"));
public static final StreamCodec<RegistryFriendlyByteBuf, CachePolicyPayload> CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_LONG, CachePolicyPayload::maxPerVideoBytes,
ByteBufCodecs.VAR_LONG, CachePolicyPayload::maxCacheBytes,
ByteBufCodecs.VAR_INT, CachePolicyPayload::renderDistanceBlocks,
CachePolicyPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -0,0 +1,26 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/**
* S2C — tell every client to wipe its entire {@code video_player_cache} directory and reset
* the in-memory READY/IN_FLIGHT index. Sent by {@code /videoCache clear}. Carries no payload —
* the receiving client just nukes everything under the cache dir regardless of how the file
* got there (named entry, legacy preload_url, or leftover from a prior session).
*/
public record ClearCachePayload() implements CustomPacketPayload {
public static final CustomPacketPayload.Type<ClearCachePayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "cache_clear"));
public static final StreamCodec<RegistryFriendlyByteBuf, ClearCachePayload> CODEC =
StreamCodec.unit(new ClearCachePayload());
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,24 +1,24 @@
package com.ejclaw.videoplayer.net; package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.network.RegistryByteBuf; import net.minecraft.core.BlockPos;
import net.minecraft.network.codec.PacketCodec; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.packet.CustomPayload; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.util.Identifier; import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.util.math.BlockPos; import net.minecraft.resources.Identifier;
/** C2S — delete an anchor from the VideoConfigScreen. */ /** C2S — delete an anchor from the VideoConfigScreen. */
public record DeleteAnchorPayload(BlockPos pos) implements CustomPayload { public record DeleteAnchorPayload(BlockPos pos) implements CustomPacketPayload {
public static final CustomPayload.Id<DeleteAnchorPayload> ID = public static final CustomPacketPayload.Type<DeleteAnchorPayload> TYPE =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "delete_anchor")); new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "delete_anchor"));
public static final PacketCodec<RegistryByteBuf, DeleteAnchorPayload> CODEC = PacketCodec.tuple( public static final StreamCodec<RegistryFriendlyByteBuf, DeleteAnchorPayload> CODEC = StreamCodec.composite(
BlockPos.PACKET_CODEC, DeleteAnchorPayload::pos, BlockPos.STREAM_CODEC, DeleteAnchorPayload::pos,
DeleteAnchorPayload::new DeleteAnchorPayload::new
); );
@Override @Override
public Id<? extends CustomPayload> getId() { public Type<? extends CustomPacketPayload> type() {
return ID; return TYPE;
} }
} }

View File

@@ -0,0 +1,28 @@
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;
/**
* S2C — tell connected clients to drop a previously preloaded URL from their on-disk cache.
* Sent by {@code /videocache remove <name>}. Each client deletes the matching cache file and
* drops it from {@code VideoCache.READY}.
*/
public record DeleteCachePayload(String url) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<DeleteCachePayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "cache_delete"));
public static final StreamCodec<RegistryFriendlyByteBuf, DeleteCachePayload> CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, DeleteCachePayload::url,
DeleteCachePayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

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

@@ -1,27 +1,27 @@
package com.ejclaw.videoplayer.net; package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.nbt.NbtCompound; import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryByteBuf; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.codec.PacketCodec; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.PacketCodecs; import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.packet.CustomPayload; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.util.Identifier; import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.util.math.BlockPos; import net.minecraft.resources.Identifier;
/** S2C — open the VideoConfigScreen for an anchor on the client. */ /** S2C — open the VideoConfigScreen for an anchor on the client. */
public record OpenScreenPayload(BlockPos pos, NbtCompound data) implements CustomPayload { public record OpenScreenPayload(BlockPos pos, CompoundTag data) implements CustomPacketPayload {
public static final CustomPayload.Id<OpenScreenPayload> ID = public static final CustomPacketPayload.Type<OpenScreenPayload> TYPE =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "open_screen")); new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "open_screen"));
public static final PacketCodec<RegistryByteBuf, OpenScreenPayload> CODEC = PacketCodec.tuple( public static final StreamCodec<RegistryFriendlyByteBuf, OpenScreenPayload> CODEC = StreamCodec.composite(
BlockPos.PACKET_CODEC, OpenScreenPayload::pos, BlockPos.STREAM_CODEC, OpenScreenPayload::pos,
PacketCodecs.NBT_COMPOUND, OpenScreenPayload::data, ByteBufCodecs.COMPOUND_TAG, OpenScreenPayload::data,
OpenScreenPayload::new OpenScreenPayload::new
); );
@Override @Override
public Id<? extends CustomPayload> getId() { public Type<? extends CustomPacketPayload> type() {
return ID; return TYPE;
} }
} }

View File

@@ -0,0 +1,27 @@
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;
/**
* S2C — instruct connected clients to prefetch {@code url} into their local
* {@code video_player_cache/} directory. Triggered by {@code /videopreload}.
*/
public record PreloadPayload(String url) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<PreloadPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "preload"));
public static final StreamCodec<RegistryFriendlyByteBuf, PreloadPayload> CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, PreloadPayload::url,
PreloadPayload::new
);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,27 +1,27 @@
package com.ejclaw.videoplayer.net; package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.nbt.NbtCompound; import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryByteBuf; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.codec.PacketCodec; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.PacketCodecs; import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.packet.CustomPayload; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.util.Identifier; import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.util.math.BlockPos; import net.minecraft.resources.Identifier;
/** C2S — save edited config from VideoConfigScreen back to the server. */ /** C2S — save edited config from VideoConfigScreen back to the server. */
public record SaveConfigPayload(BlockPos pos, NbtCompound data) implements CustomPayload { public record SaveConfigPayload(BlockPos pos, CompoundTag data) implements CustomPacketPayload {
public static final CustomPayload.Id<SaveConfigPayload> ID = public static final CustomPacketPayload.Type<SaveConfigPayload> TYPE =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "save_config")); new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "save_config"));
public static final PacketCodec<RegistryByteBuf, SaveConfigPayload> CODEC = PacketCodec.tuple( public static final StreamCodec<RegistryFriendlyByteBuf, SaveConfigPayload> CODEC = StreamCodec.composite(
BlockPos.PACKET_CODEC, SaveConfigPayload::pos, BlockPos.STREAM_CODEC, SaveConfigPayload::pos,
PacketCodecs.NBT_COMPOUND, SaveConfigPayload::data, ByteBufCodecs.COMPOUND_TAG, SaveConfigPayload::data,
SaveConfigPayload::new SaveConfigPayload::new
); );
@Override @Override
public Id<? extends CustomPayload> getId() { public Type<? extends CustomPacketPayload> type() {
return ID; return TYPE;
} }
} }

View File

@@ -1,27 +1,27 @@
package com.ejclaw.videoplayer.net; package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.nbt.NbtCompound; import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryByteBuf; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.codec.PacketCodec; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.PacketCodecs; import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.packet.CustomPayload; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.util.Identifier; import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.util.math.BlockPos; import net.minecraft.resources.Identifier;
/** S2C — push current anchor state (URL/dims/loop/volume/muted/autoplay) to clients in range. */ /** S2C — push current anchor state to clients tracking the chunk. */
public record SyncAnchorPayload(BlockPos pos, NbtCompound data) implements CustomPayload { public record SyncAnchorPayload(BlockPos pos, CompoundTag data) implements CustomPacketPayload {
public static final CustomPayload.Id<SyncAnchorPayload> ID = public static final CustomPacketPayload.Type<SyncAnchorPayload> TYPE =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "sync_anchor")); new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "sync_anchor"));
public static final PacketCodec<RegistryByteBuf, SyncAnchorPayload> CODEC = PacketCodec.tuple( public static final StreamCodec<RegistryFriendlyByteBuf, SyncAnchorPayload> CODEC = StreamCodec.composite(
BlockPos.PACKET_CODEC, SyncAnchorPayload::pos, BlockPos.STREAM_CODEC, SyncAnchorPayload::pos,
PacketCodecs.NBT_COMPOUND, SyncAnchorPayload::data, ByteBufCodecs.COMPOUND_TAG, SyncAnchorPayload::data,
SyncAnchorPayload::new SyncAnchorPayload::new
); );
@Override @Override
public Id<? extends CustomPayload> getId() { public Type<? extends CustomPacketPayload> type() {
return ID; return TYPE;
} }
} }

View File

@@ -1,15 +1,18 @@
package com.ejclaw.videoplayer.net; package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerConfig;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup; import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks; import net.minecraft.core.BlockPos;
import net.minecraft.nbt.NbtCompound; import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.world.ServerWorld; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.math.BlockPos; import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/** /**
* Registers all four payload types and the two C2S server-side receivers. * Registers all four payload types and the two C2S server-side receivers.
@@ -20,84 +23,90 @@ public final class VideoPlayerNetwork {
public static void registerPayloadTypes() { public static void registerPayloadTypes() {
// S2C // S2C
PayloadTypeRegistry.playS2C().register(OpenScreenPayload.ID, OpenScreenPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(OpenScreenPayload.TYPE, OpenScreenPayload.CODEC);
PayloadTypeRegistry.playS2C().register(SyncAnchorPayload.ID, SyncAnchorPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(SyncAnchorPayload.TYPE, SyncAnchorPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(PreloadPayload.TYPE, PreloadPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(CachePolicyPayload.TYPE, CachePolicyPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(DeleteCachePayload.TYPE, DeleteCachePayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(ClearCachePayload.TYPE, ClearCachePayload.CODEC);
// C2S // C2S
PayloadTypeRegistry.playC2S().register(SaveConfigPayload.ID, SaveConfigPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC);
PayloadTypeRegistry.playC2S().register(DeleteAnchorPayload.ID, DeleteAnchorPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(MqHelloPayload.TYPE, MqHelloPayload.CODEC);
} }
public static void registerServerReceivers() { public static void registerServerReceivers() {
ServerPlayNetworking.registerGlobalReceiver(SaveConfigPayload.ID, (payload, context) -> { ServerPlayNetworking.registerGlobalReceiver(SaveConfigPayload.TYPE, (payload, context) -> {
ServerPlayerEntity player = context.player(); ServerPlayer player = context.player();
ServerWorld world = player.getWorld(); ServerLevel level = player.level();
BlockPos pos = payload.pos(); BlockPos pos = payload.pos();
context.server().execute(() -> handleSave(world, player, pos, payload.data())); CompoundTag data = payload.data();
context.server().execute(() -> handleSave(level, player, pos, data));
}); });
ServerPlayNetworking.registerGlobalReceiver(DeleteAnchorPayload.ID, (payload, context) -> { ServerPlayNetworking.registerGlobalReceiver(DeleteAnchorPayload.TYPE, (payload, context) -> {
ServerPlayerEntity player = context.player(); ServerPlayer player = context.player();
ServerWorld world = player.getWorld(); ServerLevel level = player.level();
BlockPos pos = payload.pos(); BlockPos pos = payload.pos();
context.server().execute(() -> handleDelete(world, player, pos)); context.server().execute(() -> handleDelete(level, player, pos));
}); });
} }
private static void handleSave(ServerWorld world, ServerPlayerEntity player, BlockPos pos, NbtCompound data) { private static void handleSave(ServerLevel level, ServerPlayer player, BlockPos pos, CompoundTag data) {
if (!canModify(player, pos)) { if (!canModify(player, pos)) {
VideoPlayerMod.LOG.warn("[{}] {} attempted save without permission at {}", VideoPlayerMod.LOG.warn("[{}] {} attempted save without permission at {}",
VideoPlayerMod.MOD_ID, player.getName().getString(), pos); VideoPlayerMod.MOD_ID, player.getName().getString(), pos);
return; return;
} }
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) { if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
return; return;
} }
be.applyFromNbt(sanitize(data)); be.applyFromNbt(sanitize(data));
// broadcast updated state to all players tracking the chunk // broadcast updated state to all players tracking the chunk
SyncAnchorPayload sync = new SyncAnchorPayload(pos, be.toNbt()); SyncAnchorPayload sync = new SyncAnchorPayload(pos, be.toNbt());
for (ServerPlayerEntity watcher : PlayerLookup.tracking(world, pos)) { for (ServerPlayer watcher : PlayerLookup.tracking(level, pos)) {
ServerPlayNetworking.send(watcher, sync); ServerPlayNetworking.send(watcher, sync);
} }
} }
private static void handleDelete(ServerWorld world, ServerPlayerEntity player, BlockPos pos) { private static void handleDelete(ServerLevel level, ServerPlayer player, BlockPos pos) {
if (!canModify(player, pos)) { if (!canModify(player, pos)) {
return; return;
} }
if (world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) { if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
world.setBlockState(pos, Blocks.AIR.getDefaultState()); level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
} }
} }
/** Permission check: creative players or operators may modify anchors. */ /** Permission check: creative players or operators may modify anchors. */
public static boolean canModify(ServerPlayerEntity player, BlockPos pos) { public static boolean canModify(ServerPlayer player, BlockPos pos) {
if (player.isCreative()) return true; if (player.isCreative()) return true;
return player.hasPermissionLevel(2); return player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER);
} }
/** Strip out unexpected keys from C2S NBT before applying. */ /** Strip out unexpected keys from C2S NBT before applying. */
private static NbtCompound sanitize(NbtCompound in) { private static CompoundTag sanitize(CompoundTag in) {
NbtCompound out = new NbtCompound(); CompoundTag out = new CompoundTag();
out.putString("url", trimUrl(in.getString("url", ""))); out.putString("url", trimUrl(in.getStringOr("url", "")));
out.putInt("width", clamp(in.getInt("width", 1), 1, 32)); out.putInt("width", clamp(in.getIntOr("width", 1), 1, 32));
out.putInt("height", clamp(in.getInt("height", 1), 1, 32)); out.putInt("height", clamp(in.getIntOr("height", 1), 1, 32));
out.putString("facing", in.getString("facing", "north")); out.putString("facing", in.getStringOr("facing", "north"));
out.putBoolean("loop", in.getBoolean("loop", true)); out.putBoolean("loop", in.getBooleanOr("loop", true));
out.putFloat("volume", Math.max(0F, Math.min(1F, in.getFloat("volume", 0.5F)))); out.putFloat("volume", Math.max(0F, Math.min(1F, in.getFloatOr("volume", 0.5F))));
out.putBoolean("muted", in.getBoolean("muted", false)); out.putBoolean("muted", in.getBooleanOr("muted", false));
out.putBoolean("autoplay", in.getBoolean("autoplay", true)); out.putBoolean("autoplay", in.getBooleanOr("autoplay", true));
return out; return out;
} }
private static String trimUrl(String s) { private static String trimUrl(String s) {
if (s == null) return ""; if (s == null) return "";
String t = s.trim(); // GUI / C2S accepts either an http(s) URL or a /videoCache add <name>. Names resolve
if (t.length() > 256) t = t.substring(0, 256); // to their stored URL; URLs pass through verbatim. Anything else collapses to empty
// SPEC §4.4: only https?:// or empty // (SPEC §4.4: anchors with non-URL urls are no-ops).
if (!t.isEmpty() && !(t.startsWith("http://") || t.startsWith("https://"))) { String resolved = VideoPlayerConfig.resolveUrlOrName(s);
return ""; if (resolved == null) return "";
} if (resolved.length() > 256) resolved = resolved.substring(0, 256);
return t; return resolved;
} }
private static int clamp(int v, int lo, int hi) { private static int clamp(int v, int lo, int hi) {

View File

@@ -3,17 +3,17 @@ package com.ejclaw.videoplayer.registry;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.block.entity.BlockEntityType; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.registry.Registries; import net.minecraft.core.Registry;
import net.minecraft.registry.Registry; import net.minecraft.resources.Identifier;
import net.minecraft.util.Identifier; import net.minecraft.world.level.block.entity.BlockEntityType;
public final class VideoPlayerBlockEntities { public final class VideoPlayerBlockEntities {
private VideoPlayerBlockEntities() {} private VideoPlayerBlockEntities() {}
public static final BlockEntityType<VideoAnchorBlockEntity> VIDEO_ANCHOR = Registry.register( public static final BlockEntityType<VideoAnchorBlockEntity> VIDEO_ANCHOR = Registry.register(
Registries.BLOCK_ENTITY_TYPE, BuiltInRegistries.BLOCK_ENTITY_TYPE,
Identifier.of(VideoPlayerMod.MOD_ID, "video_anchor"), Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "video_anchor"),
FabricBlockEntityTypeBuilder.create(VideoAnchorBlockEntity::new, VideoPlayerBlocks.VIDEO_ANCHOR).build() FabricBlockEntityTypeBuilder.create(VideoAnchorBlockEntity::new, VideoPlayerBlocks.VIDEO_ANCHOR).build()
); );

View File

@@ -2,20 +2,24 @@ package com.ejclaw.videoplayer.registry;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlock; import com.ejclaw.videoplayer.block.VideoAnchorBlock;
import net.minecraft.block.AbstractBlock; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.block.Block; import net.minecraft.core.Registry;
import net.minecraft.registry.Registries; import net.minecraft.resources.Identifier;
import net.minecraft.registry.Registry; import net.minecraft.resources.ResourceKey;
import net.minecraft.registry.RegistryKey; import net.minecraft.world.level.block.Block;
import net.minecraft.registry.RegistryKeys; import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.util.Identifier;
public final class VideoPlayerBlocks { public final class VideoPlayerBlocks {
private VideoPlayerBlocks() {} private VideoPlayerBlocks() {}
public static final Block VIDEO_ANCHOR = register( public static final Block VIDEO_ANCHOR = register(
"video_anchor", "video_anchor",
AbstractBlock.Settings.create().strength(1.0F).nonOpaque(), BlockBehaviour.Properties.of()
.noCollision()
.noOcclusion()
.instabreak()
.replaceable()
.strength(0F),
VideoAnchorBlock::new VideoAnchorBlock::new
); );
@@ -25,13 +29,13 @@ public final class VideoPlayerBlocks {
@FunctionalInterface @FunctionalInterface
private interface BlockFactory<B extends Block> { private interface BlockFactory<B extends Block> {
B create(AbstractBlock.Settings settings); B create(BlockBehaviour.Properties properties);
} }
private static <B extends Block> B register(String name, AbstractBlock.Settings settings, BlockFactory<B> factory) { private static <B extends Block> B register(String name, BlockBehaviour.Properties props, BlockFactory<B> factory) {
Identifier id = Identifier.of(VideoPlayerMod.MOD_ID, name); Identifier id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, name);
RegistryKey<Block> key = RegistryKey.of(RegistryKeys.BLOCK, id); ResourceKey<Block> key = ResourceKey.create(BuiltInRegistries.BLOCK.key(), id);
B block = factory.create(settings.registryKey(key)); B block = factory.create(props.setId(key));
return Registry.register(Registries.BLOCK, key, block); return Registry.register(BuiltInRegistries.BLOCK, key, block);
} }
} }

View File

@@ -2,34 +2,33 @@ package com.ejclaw.videoplayer.registry;
import com.ejclaw.videoplayer.VideoPlayerMod; import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.item.VideoStickItem; import com.ejclaw.videoplayer.item.VideoStickItem;
import net.minecraft.item.Item; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.registry.Registries; import net.minecraft.core.Registry;
import net.minecraft.registry.Registry; import net.minecraft.resources.Identifier;
import net.minecraft.registry.RegistryKey; import net.minecraft.resources.ResourceKey;
import net.minecraft.registry.RegistryKeys; import net.minecraft.world.item.Item;
import net.minecraft.util.Identifier;
public final class VideoPlayerItems { public final class VideoPlayerItems {
private VideoPlayerItems() {} private VideoPlayerItems() {}
public static final Item VIDEO_STICK = register( public static final Item VIDEO_STICK = register(
"video_stick", "video_stick",
settings -> new VideoStickItem(settings.maxCount(1)) props -> new VideoStickItem(props.stacksTo(1))
); );
public static void register() { public static void register() {
// For M1 we don't add to a vanilla item group; players get the stick via /videoStick. // players get the stick via /videoStick command
} }
@FunctionalInterface @FunctionalInterface
private interface ItemFactory<I extends Item> { private interface ItemFactory<I extends Item> {
I create(Item.Settings settings); I create(Item.Properties properties);
} }
private static <I extends Item> I register(String name, ItemFactory<I> factory) { private static <I extends Item> I register(String name, ItemFactory<I> factory) {
Identifier id = Identifier.of(VideoPlayerMod.MOD_ID, name); Identifier id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, name);
RegistryKey<Item> key = RegistryKey.of(RegistryKeys.ITEM, id); ResourceKey<Item> key = ResourceKey.create(BuiltInRegistries.ITEM.key(), id);
I item = factory.create(new Item.Settings().registryKey(key)); I item = factory.create(new Item.Properties().setId(key));
return Registry.register(Registries.ITEM, key, item); return Registry.register(BuiltInRegistries.ITEM, key, item);
} }
} }

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);
}
}

View File

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "video_player:item/video_stick"
}
}

View File

@@ -1,21 +1,5 @@
{ {
"parent": "block/block",
"textures": { "textures": {
"all": "video_player:block/video_anchor",
"particle": "video_player:block/video_anchor" "particle": "video_player:block/video_anchor"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 2, 16],
"faces": {
"down": { "texture": "#all", "uv": [0, 0, 16, 16] },
"up": { "texture": "#all", "uv": [0, 0, 16, 16] },
"north": { "texture": "#all", "uv": [0, 0, 16, 2] },
"south": { "texture": "#all", "uv": [0, 0, 16, 2] },
"east": { "texture": "#all", "uv": [0, 0, 16, 2] },
"west": { "texture": "#all", "uv": [0, 0, 16, 2] }
} }
} }
]
}

View File

@@ -1,5 +1,5 @@
{ {
"parent": "item/generated", "parent": "minecraft:item/generated",
"textures": { "textures": {
"layer0": "video_player:item/video_stick" "layer0": "video_player:item/video_stick"
} }

View File

@@ -16,9 +16,9 @@
"client": [ "com.ejclaw.videoplayer.VideoPlayerClient" ] "client": [ "com.ejclaw.videoplayer.VideoPlayerClient" ]
}, },
"depends": { "depends": {
"fabricloader": ">=0.16.0", "fabricloader": ">=0.19.0",
"fabric-api": "*", "fabric-api": "*",
"minecraft": "${target_minecraft}", "minecraft": "${target_minecraft}",
"java": ">=21" "java": ">=25"
} }
} }