diff --git a/README.md b/README.md
index 30183f9..1a620a6 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드.
- 모드 ID: `video_player`
-- 현재 버전: **0.4.4**
+- 현재 버전: **0.4.5**
- 마인크래프트 버전: **26.1.2**
- 필요 Java: **25** (마인크래프트 26.x 가 요구함)
@@ -53,7 +53,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
- 받은 `fabric-api-0.149.0+26.1.2.jar` 를 `mods` 폴더에 넣습니다.
2. **video_player** (이 모드)
- 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases
- - `video_player-0.4.4.jar` 를 다운로드해서 같은 `mods` 폴더에 넣습니다.
+ - `video_player-0.4.5.jar` 를 다운로드해서 같은 `mods` 폴더에 넣습니다.
이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다.
@@ -135,7 +135,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면:
- 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘).
-- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.4` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.)
+- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.5` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.)
---
@@ -187,7 +187,11 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
- 한 파일당 상한 512 MB
- 캐시 폴더가 너무 커지면 직접 `.minecraft/video_player_cache/` 안의 파일을 삭제해도 됩니다 (그러면 다음 사용 시 다시 받아옴)
-> 영상 삭제 시 소리가 안 멎던 문제는 0.4.4 에서 수정되었습니다 (앵커 블록이 사라지면 디코더 / 오디오 라인을 즉시 강제 종료).
+> 영상 삭제 시 소리가 안 멎던 문제는 0.4.4 에서 수정되었습니다 (앵커 블록이 사라지면 디코더 / 오디오 라인을 즉시 강제 종료). 0.4.5 에서는 `BLOCK_ENTITY_UNLOAD` 이벤트가 누락되는 엣지케이스를 대비해 매 틱마다 BE 존재를 한 번 더 검증합니다.
+
+> 0.4.5 부터 다운로드 시작 / 완료 / 실패가 채팅창에 표시됩니다. 커맨드블럭으로 `/videopreload` 후 `/videoplace` 를 이어 실행할 때는 `[videopreload] 완료` 메시지를 본 뒤에 재생해야 로컬 파일에서 재생됩니다 (그 전에 재생하면 일반 스트리밍으로 떨어집니다).
+
+> 0.4.5 부터 오디오 거리 감쇠가 **판때기 중앙**을 기준으로 계산됩니다. 예전엔 앵커 블록(보통 화면 모서리)을 기준으로 측정해서 큰 화면일수록 소리가 한쪽에서 들리는 느낌이었습니다.
---
@@ -204,7 +208,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build
```
-산출물: `build/libs/video_player-0.4.4.jar`
+산출물: `build/libs/video_player-0.4.5.jar`
JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표:
```
diff --git a/gradle.properties b/gradle.properties
index 48ea921..f1d3081 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod
mod_id=video_player
-mod_version=0.4.4
+mod_version=0.4.5
maven_group=com.ejclaw.videoplayer
archives_base_name=video_player
diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java
index 653afb4..c208190 100644
--- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java
+++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java
@@ -76,17 +76,20 @@ public class VideoPlayerClient implements ClientModInitializer {
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 panel center, 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);
diff --git a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java
index 935b5ce..720d336 100644
--- a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java
+++ b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java
@@ -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();
diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java
index 3dc20c9..bec767e 100644
--- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java
+++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java
@@ -3,7 +3,9 @@ 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;
@@ -48,12 +50,15 @@ public final class VideoCache {
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();
@@ -101,6 +106,7 @@ public final class VideoCache {
READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
+ notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
return;
}
@@ -114,6 +120,7 @@ public final class VideoCache {
if (code >= 400) {
VideoPlayerMod.LOG.warn("[{}] preload: HTTP {} for {}",
VideoPlayerMod.MOD_ID, code, url);
+ notifyChat("[videopreload] 실패 (HTTP " + code + "): " + url, ChatFormatting.RED);
return;
}
}
@@ -130,6 +137,7 @@ public final class VideoCache {
"[{}] 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);
@@ -141,14 +149,28 @@ public final class VideoCache {
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;
diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java
index 3f076e3..8ab2cf8 100644
--- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java
+++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java
@@ -95,11 +95,22 @@ 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> it = ENTRIES.entrySet().iterator();
while (it.hasNext()) {
Map.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;
diff --git a/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java b/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java
index 719d0f4..27e5f95 100644
--- a/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java
+++ b/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java
@@ -64,9 +64,11 @@ public final class VideoPreloadCommand {
}
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.
+ // 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 requested for " + sentFinal + " client(s): " + payload.url()), false);
+ "preload 요청을 " + sentFinal + " 클라이언트에 전송: " + payload.url()
+ + " (완료 알림 후 재생하세요)"), false);
return sent;
}
}