18 Commits

Author SHA1 Message Date
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
23 changed files with 1147 additions and 155 deletions

196
README.md
View File

@@ -1,29 +1,189 @@
# video_player (영상재생모드)
마인크래프트(Fabric, MC 1.21.6+) 안에서 임의의 mp4 URL을 블록 표면에 재생하는 모드.
마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드.
- 모드 ID: `video_player`
- 한글명: 영상재생모드
- 로더: Fabric (싱글플레이어 / 전용 서버 양쪽 지원)
- 명세: 별도 SPEC 문서 참조
- 현재 버전: **0.4.12**
- 마인크래프트 버전: **26.1.2**
- 필요 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` 명령.
- M2 — 정적 렌더
- M3 — JavaCV mp4 재생
- M4 — GUI / 네트워크
- M5 — 좌클릭 삭제 + `/videoPlace`, `/videoDelete`
- M6 — 오디오 + 거리감 + Mute(소리 on/off)
- M7 — WaterMedia 백엔드 + 자동 선택
- M8 — Stonecutter 멀티버전
> 0.4.5 부터 다운로드 시작 / 완료 / 실패가 채팅창에 표시됩니다. 커맨드블럭으로 `/videopreload` 후 `/videoplace` 를 이어 실행할 때는 `[videopreload] 완료` 메시지를 본 뒤에 재생해야 로컬 파일에서 재생됩니다 (그 전에 재생하면 일반 스트리밍으로 떨어집니다).
> 0.4.5 부터 오디오 거리 감쇠가 **판때기 중앙**을 기준으로 계산됩니다. 예전엔 앵커 블록(보통 화면 모서리)을 기준으로 측정해서 큰 화면일수록 소리가 한쪽에서 들리는 느낌이었습니다.
### 서버 config 로 자동 프리로드 (0.4.6+)
서버에 모드를 넣고 한 번 실행하면 `<서버폴더>/config/video_player.json` 가 자동 생성됩니다. 이 파일에 자주 쓰는 영상 URL 을 적어두면, 플레이어가 접속할 때마다 서버가 자동으로 그 URL 들의 프리로드 요청을 보냅니다 (= `/videopreload` 를 사람마다 친 것과 같음).
기본 생성된 파일 예시:
```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

@@ -7,8 +7,25 @@ plugins {
version = project.mod_version
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 {
archivesName = project.archives_base_name
archivesName = bundleJavaCv
? "${project.archives_base_name}-${javacvPlatform}"
: project.archives_base_name
}
repositories {
@@ -26,6 +43,19 @@ dependencies {
implementation "net.fabricmc:fabric-loader:${project.loader_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 {

View File

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

View File

@@ -10,9 +10,11 @@ import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientBlockEntityEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
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.level.LevelRenderEvents;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
@@ -33,38 +35,67 @@ public class VideoPlayerClient implements ClientModInitializer {
);
AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> {
if (level.isClientSide()
&& player.getMainHandItem().getItem() instanceof VideoStickItem
&& level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
if (!level.isClientSide()) return InteractionResult.PASS;
if (!(player.getMainHandItem().getItem() instanceof VideoStickItem)) return InteractionResult.PASS;
// The anchor itself is invisible / non-collidable so the player cannot left-click it
// directly. Sneak + left-click on the wall the video sits on → delete the anchor in
// 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;
}
}
// 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 -> {
VideoPlayback.tick();
updateDistanceGains(client);
});
// Pump frame uploads on every render frame (60+Hz) rather than every client tick
// (20Hz). At 24fps source, a 20Hz pump can skip a frame whenever a tick window happens
// 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());
// 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
});
// 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());
}
});
VideoPlayerMod.LOG.info("[{}] client initialized", VideoPlayerMod.MOD_ID);
}
/** SPEC §6 — recompute per-anchor audio gain from player distance every tick. */
/**
* SPEC §6 — recompute per-anchor audio gain from player distance every tick.
* Distance is measured from the player's eye to the <em>panel center</em>, not the anchor
* block corner — for a 4×4 panel the corner is ~2 blocks off from where the screen visually
* sits, which made the audio feel like it was off to the side.
*/
private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player;
if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition();
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
double dx = (pos.getX() + 0.5) - eye.x;
double dy = (pos.getY() + 0.5) - eye.y;
double dz = (pos.getZ() + 0.5) - eye.z;
double d = Math.sqrt(dx * dx + dy * dy + dz * dz);
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 gain = be.isMuted() ? 0F : be.getVolume() * attenuation;
VideoPlayback.setGain(pos, gain);

View File

@@ -0,0 +1,99 @@
package com.ejclaw.videoplayer;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
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.List;
/**
* Server-side mod config, stored at {@code <gameDir>/config/video_player.json}.
*
* <p>Format (auto-generated on first start):
* <pre>{@code
* {
* // List of HTTP(S) video URLs that the server will tell every player to preload
* // into their local video_player_cache/ folder when they join. Identical to running
* // /videopreload <url> for each joining player.
* "preload_urls": [
* "https://example.com/intro.mp4"
* ]
* }
* }</pre>
*
* <p>Why a list and not a dedicated tool: the same {@link
* com.ejclaw.videoplayer.net.PreloadPayload} that powers {@code /videopreload} is reused, so the
* client-side cache, chat feedback, and {@code /videoplace} → cache lookup paths all behave
* identically for config-driven and command-driven preloads.
*/
public final class VideoPlayerConfig {
private VideoPlayerConfig() {}
private static final String FILE_NAME = "video_player.json";
private static volatile List<String> PRELOAD_URLS = Collections.emptyList();
/** Load (or create) the config file. Called once during mod initialization. */
public static void load() {
Path path = FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME);
try {
if (!Files.exists(path)) {
writeDefault(path);
VideoPlayerMod.LOG.info("[{}] created default config at {}",
VideoPlayerMod.MOD_ID, path);
PRELOAD_URLS = Collections.emptyList();
return;
}
String raw = Files.readString(path, StandardCharsets.UTF_8);
JsonObject json = JsonParser.parseString(raw).getAsJsonObject();
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 = el.getAsString().trim();
if (!u.isEmpty() && (u.startsWith("http://") || u.startsWith("https://"))
&& u.length() <= 256) {
urls.add(u);
} else if (!u.isEmpty()) {
VideoPlayerMod.LOG.warn(
"[{}] config: ignoring invalid preload url '{}' (must be http/https, ≤256 chars)",
VideoPlayerMod.MOD_ID, u);
}
}
});
}
PRELOAD_URLS = Collections.unmodifiableList(urls);
VideoPlayerMod.LOG.info("[{}] config loaded: {} preload url(s)",
VideoPlayerMod.MOD_ID, urls.size());
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] failed to read config {}: {} — using empty list",
VideoPlayerMod.MOD_ID, path, t.toString());
PRELOAD_URLS = Collections.emptyList();
}
}
/** URLs to push to each joining player. Never null; possibly empty. */
public static List<String> preloadUrls() {
return PRELOAD_URLS;
}
private static void writeDefault(Path path) throws IOException {
Files.createDirectories(path.getParent());
// Hand-rolled rather than Gson-serialized so we can carry a `_comment` field that
// explains the format directly inside the file.
JsonObject root = new JsonObject();
root.addProperty("_comment",
"preload_urls: HTTP(S) video URLs broadcast to every player on join. "
+ "Equivalent to running /videopreload <url> per joiner. Max 256 chars per url.");
root.add("preload_urls", new com.google.gson.JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
Files.writeString(path, gson.toJson(root), StandardCharsets.UTF_8);
}
}

View File

@@ -3,13 +3,17 @@ package com.ejclaw.videoplayer;
import com.ejclaw.videoplayer.command.VideoDeleteCommand;
import com.ejclaw.videoplayer.command.VideoMuteCommand;
import com.ejclaw.videoplayer.command.VideoPlaceCommand;
import com.ejclaw.videoplayer.command.VideoPreloadCommand;
import com.ejclaw.videoplayer.command.VideoStickCommand;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.ejclaw.videoplayer.net.VideoPlayerNetwork;
import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import com.ejclaw.videoplayer.registry.VideoPlayerItems;
import net.fabricmc.api.ModInitializer;
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.LoggerFactory;
@@ -26,11 +30,27 @@ public class VideoPlayerMod implements ModInitializer {
VideoPlayerNetwork.registerPayloadTypes();
VideoPlayerNetwork.registerServerReceivers();
VideoPlayerConfig.load();
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> {
VideoStickCommand.register(dispatcher);
VideoPlaceCommand.register(dispatcher);
VideoDeleteCommand.register(dispatcher);
VideoMuteCommand.register(dispatcher);
VideoPreloadCommand.register(dispatcher);
});
// When a player finishes joining, push every preload URL from the config so their
// client kicks off the background download. Reuses the same PreloadPayload that
// /videopreload sends, so the client-side caching path is identical.
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
java.util.List<String> urls = VideoPlayerConfig.preloadUrls();
if (urls.isEmpty()) return;
for (String url : urls) {
ServerPlayNetworking.send(handler.getPlayer(), new PreloadPayload(url));
}
LOG.info("[{}] sent {} config preload(s) to {}",
MOD_ID, urls.size(), handler.getPlayer().getName().getString());
});
LOG.info("[{}] initialized", MOD_ID);

View File

@@ -10,13 +10,26 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
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;
/**
* 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);
@@ -34,6 +47,31 @@ public class VideoAnchorBlock extends BaseEntityBlock {
return new VideoAnchorBlockEntity(pos, state);
}
@Override
protected RenderShape getRenderShape(BlockState state) {
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,

View File

@@ -8,6 +8,7 @@ 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 per-block config that drives playback.
@@ -52,6 +53,37 @@ public class VideoAnchorBlockEntity extends BlockEntity {
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. */
public CompoundTag toNbt() {
CompoundTag nbt = new CompoundTag();

View File

@@ -2,8 +2,10 @@ package com.ejclaw.videoplayer.client.net;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
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.net.OpenScreenPayload;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.ejclaw.videoplayer.net.SyncAnchorPayload;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
@@ -31,5 +33,12 @@ public final class ClientNetworking {
}
});
});
// 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());
});
}
}

View File

@@ -4,6 +4,8 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
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;
@@ -13,7 +15,7 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
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.
@@ -29,21 +31,65 @@ import java.util.concurrent.atomic.AtomicReference;
public class JavaCvBackend implements VideoBackend {
private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber";
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 Thread worker;
private final AtomicBoolean running = 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 height = 0;
private volatile float gain = 1.0F;
private volatile boolean loop = true;
private volatile boolean ready = 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
public void play(String url, boolean loop) {
@@ -73,18 +119,58 @@ public class JavaCvBackend implements VideoBackend {
public int videoHeight() { return height; }
@Override
public ByteBuffer pollFrame() {
return latest.getAndSet(null);
public boolean consumeFrame(long dstAddr, long maxBytes) {
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
public void close() {
closed = true;
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() {
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) {}
}
// Yank the grabber too so a blocked grab() inside an HTTP read returns promptly.
// JavaCV's close() is best-effort thread-safe — worst case we trip an AVERROR which
// the catch-all in runLoop swallows.
Object g = grabberHandle;
if (g != null) {
try { g.getClass().getMethod("close").invoke(g); } catch (Throwable ignored) {}
}
Thread t = worker;
worker = null;
if (t != null) t.interrupt();
@@ -94,10 +180,11 @@ public class JavaCvBackend implements VideoBackend {
/** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */
private void runLoop(String url) {
Object grabber = null;
SourceDataLine audioLine = null;
SourceDataLine localAudioLine = null;
try {
Class<?> grabberCls = Class.forName(GRABBER_CLASS);
grabber = grabberCls.getConstructor(String.class).newInstance(url);
this.grabberHandle = grabber;
Method start = grabberCls.getMethod("start");
Method stop = grabberCls.getMethod("stop");
Method grab = grabberCls.getMethod("grab");
@@ -107,12 +194,37 @@ public class JavaCvBackend implements VideoBackend {
Method getAudioChannels = grabberCls.getMethod("getAudioChannels");
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
try { setOpt.invoke(grabber, "rw_timeout", "5000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "stimeout", "5000000"); } catch (Throwable ignored) {}
// HTTP(S) tuning for streaming URLs (webm via Range / chunked transfer).
// Lower timeouts → close() snaps shut fast when an anchor is deleted mid-stream;
// 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);
this.width = (int) getW.invoke(grabber);
@@ -121,20 +233,48 @@ public class JavaCvBackend implements VideoBackend {
int sampleRate = safeInt(getSampleRate, grabber);
int audioChannels = safeInt(getAudioChannels, grabber);
audioLine = openLine(sampleRate, audioChannels);
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");
Class<?> convCls = Class.forName(CONVERTER_CLASS);
Object converter = convCls.getDeclaredConstructor().newInstance();
Method toImage = convCls.getMethod("getBufferedImage", frameCls);
// 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) {
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 (loop) {
if (loop && running.get() && !closed) {
try { stop.invoke(grabber); } catch (Throwable ignored) {}
try { start.invoke(grabber); } catch (Throwable ignored) {}
continue;
@@ -143,37 +283,92 @@ public class JavaCvBackend implements VideoBackend {
}
Object[] samples = (Object[]) samplesField.get(frame);
if (samples != null && samples.length > 0 && audioLine != null) {
writeAudio(audioLine, samples, this.gain);
if (samples != null && samples.length > 0 && localAudioLine != null) {
writeAudio(localAudioLine, samples, this.gain);
}
Object[] images = (Object[]) imageField.get(frame);
if (images != null && images.length > 0) {
java.awt.image.BufferedImage img =
(java.awt.image.BufferedImage) toImage.invoke(converter, frame);
if (img != null) {
ByteBuffer buf = toRgba(img);
if (buf != null) latest.set(buf);
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();
if (need > 0) {
int srcPos = src.position();
long srcAddr = MemoryUtil.memAddress(src) + srcPos;
synchronized (frameLock) {
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 (audioLine == null) Thread.sleep(15);
if (localAudioLine == null) Thread.sleep(15);
}
} 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) {
Thread.currentThread().interrupt();
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] JavaCV decode error: {}", VideoPlayerMod.MOD_ID, t.toString());
} finally {
ready = false;
if (audioLine != null) {
try { audioLine.drain(); } catch (Throwable ignored) {}
try { audioLine.stop(); } catch (Throwable ignored) {}
try { audioLine.close(); } catch (Throwable ignored) {}
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) {
try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {}
}
@@ -186,7 +381,18 @@ public class JavaCvBackend implements VideoBackend {
try {
AudioFormat fmt = new AudioFormat(sampleRate, 16, channels, true, false); // signed 16-bit LE
SourceDataLine line = AudioSystem.getSourceDataLine(fmt);
line.open(fmt);
// ~0.1 s of audio buffered in the driver. 0.4.10 used 0.5 s, which let the decoder
// burst ~12 video frames between backpressure stalls — way past the video ring's
// 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
// bursts collapse to 2-3 frames (well inside FRAME_RING_SLOTS).
//
// 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) {
@@ -221,24 +427,15 @@ public class JavaCvBackend implements VideoBackend {
pcm[idx++] = (byte) ((scaled >> 8) & 0xFF);
}
}
line.write(pcm, 0, pcm.length);
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; }
}
private static ByteBuffer toRgba(java.awt.image.BufferedImage img) {
int w = img.getWidth(), h = img.getHeight();
int[] argb = img.getRGB(0, 0, w, h, null, 0, w);
ByteBuffer buf = ByteBuffer.allocateDirect(w * h * 4).order(ByteOrder.nativeOrder());
for (int p : argb) {
buf.put((byte) ((p >> 16) & 0xFF)); // R
buf.put((byte) ((p >> 8) & 0xFF)); // G
buf.put((byte) ( p & 0xFF)); // B
buf.put((byte) ((p >> 24) & 0xFF)); // A
}
buf.flip();
return buf;
}
}

View File

@@ -3,8 +3,6 @@ package com.ejclaw.videoplayer.client.playback;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import java.nio.ByteBuffer;
/**
* SPEC §5.3 — minimal playback backend abstraction. Implementations: WatermediaBackend (preferred,
* when v2 supports the target MC version) and JavaCvBackend (fallback).
@@ -21,10 +19,19 @@ public interface VideoBackend {
int videoHeight();
/**
* Poll a new decoded RGBA frame if one is ready.
* @return the frame buffer (capacity = w*h*4) or {@code null} if no new frame is ready.
* If a new RGBA frame is ready, memcpy it directly into the GPU texture buffer at
* {@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();
}

View File

@@ -0,0 +1,214 @@
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;
/**
* 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 in flight. */
private static final Set<String> IN_FLIGHT = ConcurrentHashMap.newKeySet();
/** Hard ceiling on a single preload — 512 MB. Keeps an accidental giant URL from filling disk. */
private static final long MAX_BYTES = 512L * 1024 * 1024;
/** 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;
if (READY.containsKey(url)) {
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);
Thread t = new Thread(() -> download(url), "video_player-preload");
t.setDaemon(true);
t.start();
}
/** 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();
}
/** Caller-supplied: current set of URLs that are fully cached, for diagnostics. */
public static Set<String> readyUrls() {
return new HashSet<>(READY.keySet());
}
// -- internals -----------------------------------------------------------------------
private static void download(String url) {
Path cacheDir = cacheDir();
try {
if (cacheDir == null) {
VideoPlayerMod.LOG.warn("[{}] preload: no game dir, skipping {}",
VideoPlayerMod.MOD_ID, url);
return;
}
Files.createDirectories(cacheDir);
String hash = sha256(url);
String ext = extensionFromUrl(url);
Path finalPath = cacheDir.resolve(hash + ext);
Path 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) {
READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
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;
try (InputStream in = raw.getInputStream();
OutputStream out = Files.newOutputStream(partPath)) {
byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) >= 0) {
total += n;
if (total > MAX_BYTES) {
VideoPlayerMod.LOG.warn(
"[{}] preload: {} exceeded {} MB cap; aborting",
VideoPlayerMod.MOD_ID, url, MAX_BYTES / (1024 * 1024));
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
notifyChat("[videopreload] 실패 (512MB 초과): " + url, ChatFormatting.RED);
return;
}
out.write(buf, 0, n);
}
}
Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
READY.put(url, finalPath);
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);
} 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));
});
}
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

@@ -10,7 +10,7 @@ import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
@@ -58,7 +58,12 @@ public final class VideoPlayback {
}
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());
Entry created = new Entry(be.getUrl(), backend);
@@ -89,16 +94,25 @@ public final class VideoPlayback {
/** Called every client tick to upload new frames into the GPU texture. */
public static void tick() {
if (Minecraft.getInstance() == null) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
Iterator<Map.Entry<BlockPos, Entry>> it = ENTRIES.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<BlockPos, Entry> me = it.next();
BlockPos pos = me.getKey();
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;
ByteBuffer buf = e.backend.pollFrame();
if (buf == null) continue;
try {
e.upload(buf);
e.tryUpload();
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] texture upload failed: {}", VideoPlayerMod.MOD_ID, t.toString());
e.close();
@@ -170,34 +184,45 @@ public final class VideoPlayback {
}
}
/** Copy an incoming RGBA byte buffer into the texture, resizing if dimensions changed. */
void upload(ByteBuffer rgba) {
/**
* 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;
int pixels = w * h;
for (int i = 0; i < pixels; i++) {
int r = rgba.get() & 0xFF;
int g = rgba.get() & 0xFF;
int b = rgba.get() & 0xFF;
int a = rgba.get() & 0xFF;
int abgr = (a << 24) | (b << 16) | (g << 8) | r;
img.setPixelABGR(i % w, i / w, abgr);
long maxBytes = (long) w * h * 4L;
if (backend.consumeFrame(img.getPointer(), maxBytes)) {
texture.upload();
}
texture.upload();
}
void 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) {
try { texture.close(); } catch (Throwable ignored) {}
texture = null;
}
// texture manager keeps the registration; the texture itself is closed.
}
}
}

View File

@@ -4,8 +4,6 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
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
* 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 ByteBuffer pollFrame() {
return null; // no frames until v2 is wired up
public boolean consumeFrame(long dstAddr, long maxBytes) {
return false; // no frames until v2 is wired up
}
@Override

View File

@@ -20,15 +20,19 @@ import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
/**
* SPEC §5.2 — submits a width×height textured 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>Ported to 26.1.2's render-state pipeline: per-frame BE state is captured in
* {@link State} via {@link #extractRenderState}, then drawn via
* {@link SubmitNodeCollector#submitCustomGeometry} during {@link #submit}.
* <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)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
/** Tiny outward offset so the quad doesn't z-fight with the wall. */
private static final float SURFACE_EPSILON = 0.001F;
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op
}
@@ -44,8 +48,7 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
BlockEntityRenderState.extractBase(be, state, crumbling);
state.width = be.getWidth();
state.height = be.getHeight();
Direction facing = be.getFacing();
state.yaw = facing.getAxis().isHorizontal() ? facing.toYRot() : 0F;
state.facing = be.getFacing();
state.textureId = VideoPlayback.getOrStart(be);
}
@@ -57,34 +60,53 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
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();
// Center quad on the anchor's top face, rotated to face the configured direction.
pose.translate(0.5F, 1.01F, 0.5F);
pose.mulPose(Axis.YP.rotationDegrees(-state.yaw));
pose.translate(-w / 2.0F, 0F, 0F);
// 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);
// Snapshot the matrix so the callback's matrix-aware addVertex works even though
// submitCustomGeometry hands us a fresh Pose (its `pose` parameter).
final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Front face (visible from the direction the anchor faces)
// 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);
// Back face (visible from behind)
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, 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)
@@ -110,6 +132,6 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
public Identifier textureId;
public int width = 1;
public int height = 1;
public float yaw = 0F;
public Direction facing = Direction.NORTH;
}
}

View File

@@ -0,0 +1,74 @@
package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.net.PreloadPayload;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
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.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
/**
* {@code /videoPreload <url>} — broadcast a preload request to every connected client so each
* client fully downloads the URL into its local {@code video_player_cache/} folder. Subsequent
* {@code /videoPlace} calls (or right-click placements) that use the same URL will then play
* from the local file, eliminating mid-stream stutter.
*
* <p>Uses the same {@link Permissions#COMMANDS_GAMEMASTER} gate as {@code /videoPlace} et al.,
* so command blocks (which run at op level 2 by default) can invoke it.
*/
public final class VideoPreloadCommand {
private VideoPreloadCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoPreload"));
dispatcher.register(build("videopreload"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.argument("url", StringArgumentType.greedyString())
.executes(VideoPreloadCommand::run));
}
private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource();
String url = StringArgumentType.getString(ctx, "url").trim();
if (url.isEmpty()) {
src.sendFailure(Component.literal("url is required"));
return 0;
}
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
src.sendFailure(Component.literal("url must be http:// or https://"));
return 0;
}
if (url.length() > 256) {
src.sendFailure(Component.literal("url too long (max 256)"));
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;
// Use sendSuccess(..., false) so the chat noise is local-only and command blocks don't
// spam every operator on each tick. Be explicit that this is fire-and-forget: each
// client posts its own "[videopreload] 완료" chat line when the download finishes.
src.sendSuccess(() -> Component.literal(
"preload 요청을 " + sentFinal + " 클라이언트에 전송: " + payload.url()
+ " (완료 알림 후 재생하세요)"), false);
return sent;
}
}

View File

@@ -16,7 +16,18 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** 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 VideoStickItem(Properties properties) {
super(properties);
@@ -33,25 +44,27 @@ public class VideoStickItem extends Item {
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
BlockPos hit = ctx.getClickedPos();
Direction face = ctx.getClickedFace();
BlockPos anchorPos = hit.relative(face);
// Existing anchor → edit
if (sl.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt()));
// 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;
}
// Empty face → place anchor on top of the clicked face
Direction side = ctx.getClickedFace();
BlockPos placeAt = hit.relative(side);
BlockState there = sl.getBlockState(placeAt);
// Need an empty / replaceable space in front of the clicked face.
BlockState there = sl.getBlockState(anchorPos);
if (!there.canBeReplaced()) return InteractionResult.PASS;
Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR;
sl.setBlock(placeAt, anchor.defaultBlockState(), Block.UPDATE_ALL);
sl.setBlock(anchorPos, anchor.defaultBlockState(), Block.UPDATE_ALL);
if (sl.getBlockEntity(placeAt) instanceof VideoAnchorBlockEntity be) {
be.setFacing(ctx.getHorizontalDirection().getOpposite());
ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt()));
if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity be) {
// Surface normal of the wall we're painting on points outward in the same direction
// as the face the player clicked.
be.setFacing(face);
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, be.toNbt()));
}
return InteractionResult.SUCCESS;
}

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

@@ -24,6 +24,7 @@ public final class VideoPlayerNetwork {
// S2C
PayloadTypeRegistry.clientboundPlay().register(OpenScreenPayload.TYPE, OpenScreenPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(SyncAnchorPayload.TYPE, SyncAnchorPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(PreloadPayload.TYPE, PreloadPayload.CODEC);
// C2S
PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC);

View File

@@ -14,7 +14,12 @@ public final class VideoPlayerBlocks {
public static final Block VIDEO_ANCHOR = register(
"video_anchor",
BlockBehaviour.Properties.of().strength(1.0F).noOcclusion(),
BlockBehaviour.Properties.of()
.noCollision()
.noOcclusion()
.instabreak()
.replaceable()
.strength(0F),
VideoAnchorBlock::new
);

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": {
"all": "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": {
"layer0": "video_player:item/video_stick"
}