From ddc16f3d90cb302e4ce02134754a77f321a04fc0 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Fri, 15 May 2026 10:45:28 +0900 Subject: [PATCH] M2-M8: renderer, playback backends, GUI/network, commands, multi-version build - M2: VideoAnchorRenderer draws width\u00d7height quad oriented by facing - M3: VideoBackend interface + JavaCV (reflection) and WaterMedia (probe) backends - M4: VideoConfigScreen GUI + 4 typed payloads + NBT persistence via ReadView/WriteView - M5: stick item useOnBlock place/edit, AttackBlockCallback delete, /videoPlace /videoDelete /videoMute - M6: per-tick distance attenuation gain = volume * clamp(1 - d/16, 0, 1), mute zeroes gain - M7: WatermediaProbe (reflection-only; reports unavailable until v2 supports 1.21.6+) - M8: multi-version build script (1.21.6/1.21.7/1.21.8) + Gitea Actions matrix workflow --- .gitea/workflows/build.yml | 41 +++++ build.gradle | 8 +- gradle.properties | 2 +- scripts/build-multiver.sh | 32 ++++ .../ejclaw/videoplayer/VideoPlayerClient.java | 66 +++++++- .../ejclaw/videoplayer/VideoPlayerMod.java | 12 +- .../videoplayer/block/VideoAnchorBlock.java | 27 ++- .../block/VideoAnchorBlockEntity.java | 83 +++++++++- .../client/gui/VideoConfigScreen.java | 154 ++++++++++++++++++ .../client/net/ClientNetworking.java | 35 ++++ .../client/playback/JavaCvBackend.java | 154 ++++++++++++++++++ .../client/playback/VideoBackend.java | 30 ++++ .../client/playback/VideoPlayback.java | 149 +++++++++++++++++ .../client/playback/WatermediaBackend.java | 50 ++++++ .../client/playback/WatermediaProbe.java | 35 ++++ .../client/render/VideoAnchorRenderer.java | 91 +++++++++++ .../command/VideoDeleteCommand.java | 44 +++++ .../videoplayer/command/VideoMuteCommand.java | 61 +++++++ .../command/VideoPlaceCommand.java | 81 +++++++++ .../videoplayer/item/VideoStickItem.java | 54 +++++- .../videoplayer/net/DeleteAnchorPayload.java | 24 +++ .../videoplayer/net/OpenScreenPayload.java | 27 +++ .../videoplayer/net/SaveConfigPayload.java | 27 +++ .../videoplayer/net/SyncAnchorPayload.java | 27 +++ .../videoplayer/net/VideoPlayerNetwork.java | 106 ++++++++++++ .../blockstates/video_anchor.json | 5 + .../models/block/video_anchor.json | 21 +++ .../video_player/models/item/video_stick.json | 6 + .../textures/block/video_anchor.png | Bin 0 -> 82 bytes .../textures/item/video_stick.png | Bin 0 -> 83 bytes src/main/resources/fabric.mod.json | 2 +- 31 files changed, 1439 insertions(+), 15 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100755 scripts/build-multiver.sh create mode 100644 src/main/java/com/ejclaw/videoplayer/client/gui/VideoConfigScreen.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaProbe.java create mode 100644 src/main/java/com/ejclaw/videoplayer/client/render/VideoAnchorRenderer.java create mode 100644 src/main/java/com/ejclaw/videoplayer/command/VideoDeleteCommand.java create mode 100644 src/main/java/com/ejclaw/videoplayer/command/VideoMuteCommand.java create mode 100644 src/main/java/com/ejclaw/videoplayer/command/VideoPlaceCommand.java create mode 100644 src/main/java/com/ejclaw/videoplayer/net/DeleteAnchorPayload.java create mode 100644 src/main/java/com/ejclaw/videoplayer/net/OpenScreenPayload.java create mode 100644 src/main/java/com/ejclaw/videoplayer/net/SaveConfigPayload.java create mode 100644 src/main/java/com/ejclaw/videoplayer/net/SyncAnchorPayload.java create mode 100644 src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java create mode 100644 src/main/resources/assets/video_player/blockstates/video_anchor.json create mode 100644 src/main/resources/assets/video_player/models/block/video_anchor.json create mode 100644 src/main/resources/assets/video_player/models/item/video_stick.json create mode 100644 src/main/resources/assets/video_player/textures/block/video_anchor.png create mode 100644 src/main/resources/assets/video_player/textures/item/video_stick.png diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..9353e1a --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,41 @@ +name: build-matrix + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - mc: "1.21.6" + yarn: "1.21.6+build.1" + fabric: "0.120.1+1.21.6" + - mc: "1.21.7" + yarn: "1.21.7+build.8" + fabric: "0.129.0+1.21.7" + - mc: "1.21.8" + yarn: "1.21.8+build.1" + fabric: "0.136.1+1.21.8" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - name: Build (MC ${{ matrix.mc }}) + run: | + ./gradlew --no-daemon \ + -Pminecraft_version=${{ matrix.mc }} \ + -Pyarn_mappings=${{ matrix.yarn }} \ + -Pfabric_version=${{ matrix.fabric }} \ + build + - uses: actions/upload-artifact@v4 + with: + name: video_player-mc${{ matrix.mc }} + path: build/libs/*.jar diff --git a/build.gradle b/build.gradle index 7f7c689..cb8a584 100644 --- a/build.gradle +++ b/build.gradle @@ -26,9 +26,15 @@ dependencies { processResources { inputs.property "version", project.version inputs.property "mod_id", project.mod_id + inputs.property "minecraft_version", project.minecraft_version + + // Pin the fabric.mod.json's minecraft constraint to the build's exact target — keeps each + // multi-version jar from claiming compatibility it doesn't have. + def mc = project.minecraft_version + def target = "~${mc}" filesMatching("fabric.mod.json") { - expand "version": project.version, "mod_id": project.mod_id + expand "version": project.version, "mod_id": project.mod_id, "target_minecraft": target } } diff --git a/gradle.properties b/gradle.properties index 3a50b9c..62695e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.parallel=true # Mod mod_id=video_player -mod_version=0.1.0 +mod_version=0.2.0 maven_group=com.ejclaw.videoplayer archives_base_name=video_player diff --git a/scripts/build-multiver.sh b/scripts/build-multiver.sh new file mode 100755 index 0000000..baec047 --- /dev/null +++ b/scripts/build-multiver.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Build the mod against all supported MC versions and collect jars under build/multiver/. +# Uses gradle property overrides so we don't have to maintain three settings.gradle copies. +set -euo pipefail + +cd "$(dirname "$0")/.." + +OUT=build/multiver +rm -rf "$OUT" +mkdir -p "$OUT" + +build_one() { + local mc="$1" yarn="$2" fab="$3" + echo "==> Building for MC $mc (yarn=$yarn, fabric-api=$fab)" + ./gradlew --no-daemon \ + -Pminecraft_version="$mc" \ + -Pyarn_mappings="$yarn" \ + -Pfabric_version="$fab" \ + build + # main jar = the one without "-sources" + local jar + jar=$(ls build/libs/video_player-*.jar | grep -v -- '-sources' | head -1) + cp "$jar" "$OUT/video_player-mc${mc}.jar" +} + +build_one 1.21.6 1.21.6+build.1 0.120.1+1.21.6 +build_one 1.21.7 1.21.7+build.8 0.129.0+1.21.7 +build_one 1.21.8 1.21.8+build.1 0.136.1+1.21.8 + +echo +echo "All jars:" +ls -la "$OUT" diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java index 15eb3a2..49857ab 100644 --- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java +++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java @@ -1,15 +1,75 @@ package com.ejclaw.videoplayer; +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.ejclaw.videoplayer.client.net.ClientNetworking; +import com.ejclaw.videoplayer.client.playback.VideoPlayback; +import com.ejclaw.videoplayer.client.render.VideoAnchorRenderer; +import com.ejclaw.videoplayer.item.VideoStickItem; +import com.ejclaw.videoplayer.net.DeleteAnchorPayload; +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.ClientTickEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry; +import net.fabricmc.fabric.api.event.player.AttackBlockCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.ActionResult; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; @Environment(EnvType.CLIENT) public class VideoPlayerClient implements ClientModInitializer { + + @SuppressWarnings("deprecation") @Override public void onInitializeClient() { - // M2+: BlockEntityRendererFactories.register(...) - // M5+: AttackBlockCallback for left-click delete - VideoPlayerMod.LOG.info("[{}] client initialized (M1 scaffold)", VideoPlayerMod.MOD_ID); + ClientNetworking.register(); + + BlockEntityRendererRegistry.register( + VideoPlayerBlockEntities.VIDEO_ANCHOR, + VideoAnchorRenderer::new + ); + + AttackBlockCallback.EVENT.register((player, world, hand, pos, direction) -> { + if (world.isClient + && player.getMainHandStack().getItem() instanceof VideoStickItem + && world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) { + ClientPlayNetworking.send(new DeleteAnchorPayload(pos)); + return ActionResult.SUCCESS; + } + return ActionResult.PASS; + }); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + VideoPlayback.tick(); + updateDistanceGains(client); + }); + + ClientTickEvents.END_WORLD_TICK.register(world -> { + // no-op for now + }); + + VideoPlayerMod.LOG.info("[{}] client initialized", VideoPlayerMod.MOD_ID); + } + + /** SPEC §6 — recompute per-anchor audio gain from player distance every tick. */ + private static void updateDistanceGains(MinecraftClient client) { + ClientPlayerEntity p = client.player; + if (p == null || client.world == null) return; + Vec3d eye = p.getEyePos(); + for (BlockPos pos : com.ejclaw.videoplayer.client.playback.VideoPlayback.activePositions()) { + if (!(client.world.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); + float attenuation = (float) Math.max(0.0, Math.min(1.0, 1.0 - d / 16.0)); + float gain = be.isMuted() ? 0F : be.getVolume() * attenuation; + com.ejclaw.videoplayer.client.playback.VideoPlayback.setGain(pos, gain); + } } } diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java index fe194cc..350f85e 100644 --- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java +++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java @@ -1,6 +1,10 @@ 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.VideoStickCommand; +import com.ejclaw.videoplayer.net.VideoPlayerNetwork; import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities; import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; import com.ejclaw.videoplayer.registry.VideoPlayerItems; @@ -19,10 +23,16 @@ public class VideoPlayerMod implements ModInitializer { VideoPlayerBlockEntities.register(); VideoPlayerItems.register(); + VideoPlayerNetwork.registerPayloadTypes(); + VideoPlayerNetwork.registerServerReceivers(); + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> { VideoStickCommand.register(dispatcher); + VideoPlaceCommand.register(dispatcher); + VideoDeleteCommand.register(dispatcher); + VideoMuteCommand.register(dispatcher); }); - LOG.info("[{}] initialized (M1 scaffold)", MOD_ID); + LOG.info("[{}] initialized", MOD_ID); } } diff --git a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlock.java b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlock.java index cf3a6d8..e76b19c 100644 --- a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlock.java +++ b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlock.java @@ -1,13 +1,22 @@ package com.ejclaw.videoplayer.block; +import com.ejclaw.videoplayer.item.VideoStickItem; +import com.ejclaw.videoplayer.net.OpenScreenPayload; import com.mojang.serialization.MapCodec; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.block.AbstractBlock; -import net.minecraft.block.Block; import net.minecraft.block.BlockEntityProvider; import net.minecraft.block.BlockState; import net.minecraft.block.BlockWithEntity; import net.minecraft.block.entity.BlockEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.hit.BlockHitResult; import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; public class VideoAnchorBlock extends BlockWithEntity implements BlockEntityProvider { public static final MapCodec CODEC = createCodec(VideoAnchorBlock::new); @@ -25,4 +34,20 @@ public class VideoAnchorBlock extends BlockWithEntity implements BlockEntityProv public BlockEntity createBlockEntity(BlockPos pos, BlockState state) { return new VideoAnchorBlockEntity(pos, state); } + + @Override + protected ActionResult onUseWithItem(ItemStack stack, BlockState state, World world, + BlockPos pos, PlayerEntity player, Hand hand, + BlockHitResult hit) { + if (!(stack.getItem() instanceof VideoStickItem)) { + return ActionResult.PASS; + } + if (world.isClient) return ActionResult.SUCCESS; + if (!(player instanceof ServerPlayerEntity sp)) return ActionResult.PASS; + if (world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be) { + ServerPlayNetworking.send(sp, new OpenScreenPayload(pos, be.toNbt())); + return ActionResult.SUCCESS; + } + return ActionResult.PASS; + } } diff --git a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java index ad694c7..845b852 100644 --- a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java +++ b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java @@ -3,12 +3,15 @@ package com.ejclaw.videoplayer.block; import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities; import net.minecraft.block.BlockState; import net.minecraft.block.entity.BlockEntity; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Direction; /** - * M1 placeholder. Holds the runtime fields described in SPEC §3.1; NBT persistence - * (ReadView/WriteView in 1.21.6) lands in M3/M4 alongside the playback and GUI work. + * Anchor BE — holds the per-block config that drives playback. NBT persistence uses + * 1.21.6's ReadView/WriteView. Network sync uses {@link #toNbt()} / {@link #fromNbt(NbtCompound)}. */ public class VideoAnchorBlockEntity extends BlockEntity { private String url = ""; @@ -33,8 +36,80 @@ public class VideoAnchorBlockEntity extends BlockEntity { public boolean isMuted() { return muted; } public boolean isAutoplay() { return autoplay; } - public void setMuted(boolean muted) { - this.muted = muted; + public void setUrl(String url) { this.url = url == null ? "" : url; markDirty(); } + public void setWidth(int width) { this.width = clamp(width, 1, 32); markDirty(); } + public void setHeight(int height) { this.height = clamp(height, 1, 32); markDirty(); } + public void setFacing(Direction facing) { this.facing = facing == null ? Direction.NORTH : facing; markDirty(); } + public void setLoop(boolean loop) { this.loop = loop; markDirty(); } + public void setVolume(float volume) { this.volume = Math.max(0F, Math.min(1F, volume)); markDirty(); } + public void setMuted(boolean muted) { this.muted = muted; markDirty(); } + public void setAutoplay(boolean autoplay) { this.autoplay = autoplay; markDirty(); } + + /** Apply server-validated config from an NBT (used by network handler). */ + public void applyFromNbt(NbtCompound nbt) { + fromNbt(nbt); markDirty(); } + + /** Wire-format NBT used by SaveConfig/SyncAnchor payloads. */ + public NbtCompound toNbt() { + NbtCompound nbt = new NbtCompound(); + nbt.putString("url", url); + nbt.putInt("width", width); + nbt.putInt("height", height); + nbt.putString("facing", facing.asString()); + nbt.putBoolean("loop", loop); + nbt.putFloat("volume", volume); + nbt.putBoolean("muted", muted); + nbt.putBoolean("autoplay", autoplay); + return nbt; + } + + public void fromNbt(NbtCompound nbt) { + this.url = clampUrl(nbt.getString("url", "")); + this.width = clamp(nbt.getInt("width", 1), 1, 32); + this.height = clamp(nbt.getInt("height", 1), 1, 32); + Direction d = Direction.byId(nbt.getString("facing", "north")); + this.facing = d == null ? Direction.NORTH : d; + this.loop = nbt.getBoolean("loop", true); + this.volume = Math.max(0F, Math.min(1F, nbt.getFloat("volume", 0.5F))); + this.muted = nbt.getBoolean("muted", false); + this.autoplay = nbt.getBoolean("autoplay", true); + } + + @Override + protected void writeData(WriteView view) { + super.writeData(view); + view.putString("url", url); + view.putInt("width", width); + view.putInt("height", height); + view.putString("facing", facing.asString()); + view.putBoolean("loop", loop); + view.putFloat("volume", volume); + view.putBoolean("muted", muted); + view.putBoolean("autoplay", autoplay); + } + + @Override + protected void readData(ReadView view) { + super.readData(view); + this.url = clampUrl(view.getString("url", "")); + this.width = clamp(view.getInt("width", 1), 1, 32); + this.height = clamp(view.getInt("height", 1), 1, 32); + Direction d = Direction.byId(view.getString("facing", "north")); + this.facing = d == null ? Direction.NORTH : d; + this.loop = view.getBoolean("loop", true); + this.volume = Math.max(0F, Math.min(1F, view.getFloat("volume", 0.5F))); + this.muted = view.getBoolean("muted", false); + this.autoplay = view.getBoolean("autoplay", true); + } + + private static int clamp(int v, int lo, int hi) { + return Math.max(lo, Math.min(hi, v)); + } + + private static String clampUrl(String s) { + if (s == null) return ""; + return s.length() > 256 ? s.substring(0, 256) : s; + } } diff --git a/src/main/java/com/ejclaw/videoplayer/client/gui/VideoConfigScreen.java b/src/main/java/com/ejclaw/videoplayer/client/gui/VideoConfigScreen.java new file mode 100644 index 0000000..d2f66c4 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/gui/VideoConfigScreen.java @@ -0,0 +1,154 @@ +package com.ejclaw.videoplayer.client.gui; + +import com.ejclaw.videoplayer.net.DeleteAnchorPayload; +import com.ejclaw.videoplayer.net.SaveConfigPayload; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.CheckboxWidget; +import net.minecraft.client.gui.widget.SliderWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.text.Text; +import net.minecraft.util.math.BlockPos; + +/** SPEC §4.3 — anchor config GUI. Opened by S2C {@code OpenScreenPayload}. */ +@Environment(EnvType.CLIENT) +public class VideoConfigScreen extends Screen { + private final BlockPos pos; + private final NbtCompound initial; + + private TextFieldWidget urlField; + private TextFieldWidget widthField; + private TextFieldWidget heightField; + private CheckboxWidget loopBox; + private CheckboxWidget muteBox; + private CheckboxWidget autoplayBox; + private VolumeSlider volumeSlider; + + public VideoConfigScreen(BlockPos pos, NbtCompound data) { + super(Text.literal("Video Anchor")); + this.pos = pos; + this.initial = data; + } + + @Override + protected void init() { + int cx = this.width / 2; + int y = 40; + + urlField = new TextFieldWidget(this.textRenderer, cx - 150, y, 300, 20, Text.literal("URL")); + urlField.setMaxLength(256); + urlField.setText(initial.getString("url", "")); + addDrawableChild(urlField); + y += 30; + + widthField = new TextFieldWidget(this.textRenderer, cx - 150, y, 60, 20, Text.literal("W")); + widthField.setMaxLength(2); + widthField.setText(Integer.toString(initial.getInt("width", 1))); + widthField.setTextPredicate(VideoConfigScreen::isDigits); + addDrawableChild(widthField); + + heightField = new TextFieldWidget(this.textRenderer, cx - 80, y, 60, 20, Text.literal("H")); + heightField.setMaxLength(2); + heightField.setText(Integer.toString(initial.getInt("height", 1))); + heightField.setTextPredicate(VideoConfigScreen::isDigits); + addDrawableChild(heightField); + + volumeSlider = new VolumeSlider(cx - 10, y, 160, 20, + Math.max(0F, Math.min(1F, initial.getFloat("volume", 0.5F)))); + addDrawableChild(volumeSlider); + y += 30; + + loopBox = CheckboxWidget.builder(Text.literal("Loop"), this.textRenderer) + .pos(cx - 150, y).checked(initial.getBoolean("loop", true)).build(); + addDrawableChild(loopBox); + + muteBox = CheckboxWidget.builder(Text.literal("Mute"), this.textRenderer) + .pos(cx - 60, y).checked(initial.getBoolean("muted", false)).build(); + addDrawableChild(muteBox); + + autoplayBox = CheckboxWidget.builder(Text.literal("Autoplay"), this.textRenderer) + .pos(cx + 30, y).checked(initial.getBoolean("autoplay", true)).build(); + addDrawableChild(autoplayBox); + y += 36; + + addDrawableChild(ButtonWidget.builder(Text.literal("Save"), b -> save()) + .dimensions(cx - 150, y, 90, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Cancel"), b -> close()) + .dimensions(cx - 45, y, 90, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Delete"), b -> delete()) + .dimensions(cx + 60, y, 90, 20).build()); + } + + private void save() { + NbtCompound out = new NbtCompound(); + out.putString("url", urlField.getText()); + out.putInt("width", parseInt(widthField.getText(), 1)); + out.putInt("height", parseInt(heightField.getText(), 1)); + out.putString("facing", initial.getString("facing", "north")); + out.putBoolean("loop", loopBox.isChecked()); + out.putFloat("volume", volumeSlider.getVolume()); + out.putBoolean("muted", muteBox.isChecked()); + out.putBoolean("autoplay", autoplayBox.isChecked()); + ClientPlayNetworking.send(new SaveConfigPayload(pos, out)); + close(); + } + + private void delete() { + ClientPlayNetworking.send(new DeleteAnchorPayload(pos)); + close(); + } + + @Override + public void render(DrawContext ctx, int mouseX, int mouseY, float delta) { + super.render(ctx, mouseX, mouseY, delta); + ctx.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, 0xFFFFFF); + } + + @Override + public boolean shouldPause() { return false; } + + @Override + public void close() { + MinecraftClient mc = this.client != null ? this.client : MinecraftClient.getInstance(); + if (mc != null) mc.setScreen(null); + } + + private static int parseInt(String s, int dflt) { + try { return Integer.parseInt(s); } catch (Exception e) { return dflt; } + } + + private static boolean isDigits(String s) { + if (s.isEmpty()) return true; + for (int i = 0; i < s.length(); i++) if (!Character.isDigit(s.charAt(i))) return false; + return true; + } + + private static final class VolumeSlider extends SliderWidget { + VolumeSlider(int x, int y, int w, int h, float initial) { + super(x, y, w, h, Text.literal("Volume: " + pct(initial)), initial); + updateMessage(); + } + + float getVolume() { return (float) this.value; } + + @Override + protected void updateMessage() { + setMessage(Text.literal("Volume: " + pct((float) this.value))); + } + + @Override + protected void applyValue() { + updateMessage(); + } + + private static int pct(float v) { + return Math.round(v * 100F); + } + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java b/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java new file mode 100644 index 0000000..a8579b2 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/net/ClientNetworking.java @@ -0,0 +1,35 @@ +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.VideoPlayback; +import com.ejclaw.videoplayer.net.OpenScreenPayload; +import com.ejclaw.videoplayer.net.SyncAnchorPayload; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.MinecraftClient; + +/** Client-side S2C receivers for OpenScreen and SyncAnchor. */ +@Environment(EnvType.CLIENT) +public final class ClientNetworking { + private ClientNetworking() {} + + public static void register() { + ClientPlayNetworking.registerGlobalReceiver(OpenScreenPayload.ID, (payload, context) -> { + MinecraftClient mc = context.client(); + mc.execute(() -> mc.setScreen(new VideoConfigScreen(payload.pos(), payload.data()))); + }); + + ClientPlayNetworking.registerGlobalReceiver(SyncAnchorPayload.ID, (payload, context) -> { + MinecraftClient mc = context.client(); + mc.execute(() -> { + if (mc.world == null) return; + if (mc.world.getBlockEntity(payload.pos()) instanceof VideoAnchorBlockEntity be) { + be.applyFromNbt(payload.data()); + VideoPlayback.onConfigChanged(be); + } + }); + }); + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java b/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java new file mode 100644 index 0000000..cb3d7e5 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java @@ -0,0 +1,154 @@ +package com.ejclaw.videoplayer.client.playback; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber. + * + * JavaCV is referenced entirely through reflection so that the mod jar stays loadable when the + * (large) JavaCV dependency isn't bundled — the backend just reports {@code !isReady()} until the + * runtime classpath contains org.bytedeco.javacv.FFmpegFrameGrabber. + */ +@Environment(EnvType.CLIENT) +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"; + + 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 latest = new AtomicReference<>(); + 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; + + @Override + public void play(String url, boolean loop) { + if (url == null || url.isEmpty()) return; + this.loop = loop; + synchronized (lock) { + stopWorker(); + running.set(true); + paused.set(false); + worker = new Thread(() -> runLoop(url), "video_player-decode"); + worker.setDaemon(true); + worker.start(); + } + } + + @Override + public void pause() { paused.set(true); } + @Override + public void resume() { paused.set(false); } + @Override + public void setVolume(float g) { this.gain = Math.max(0F, Math.min(1F, g)); } + @Override + public boolean isReady() { return ready; } + @Override + public int videoWidth() { return width; } + @Override + public int videoHeight() { return height; } + + @Override + public ByteBuffer pollFrame() { + return latest.getAndSet(null); + } + + @Override + public void close() { + closed = true; + stopWorker(); + } + + private void stopWorker() { + running.set(false); + Thread t = worker; + worker = null; + if (t != null) t.interrupt(); + ready = false; + } + + /** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */ + private void runLoop(String url) { + Object grabber = null; + try { + Class grabberCls = Class.forName(GRABBER_CLASS); + grabber = grabberCls.getConstructor(String.class).newInstance(url); + Method start = grabberCls.getMethod("start"); + Method stop = grabberCls.getMethod("stop"); + Method grab = grabberCls.getMethod("grabImage"); + Method getW = grabberCls.getMethod("getImageWidth"); + Method getH = grabberCls.getMethod("getImageHeight"); + Method setOpt = grabberCls.getMethod("setOption", String.class, String.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) {} + + start.invoke(grabber); + this.width = (int) getW.invoke(grabber); + this.height = (int) getH.invoke(grabber); + this.ready = (width > 0 && height > 0); + + Class convCls = Class.forName(CONVERTER_CLASS); + Object converter = convCls.getDeclaredConstructor().newInstance(); + Method toImage = convCls.getMethod("getBufferedImage", Class.forName(FRAME_CLASS)); + + while (running.get() && !closed) { + if (paused.get()) { Thread.sleep(20); continue; } + Object frame = grab.invoke(grabber); + if (frame == null) { + if (loop) { + try { stop.invoke(grabber); } catch (Throwable ignored) {} + try { start.invoke(grabber); } catch (Throwable ignored) {} + continue; + } + break; + } + java.awt.image.BufferedImage img = (java.awt.image.BufferedImage) toImage.invoke(converter, frame); + if (img == null) continue; + ByteBuffer buf = toRgba(img); + if (buf != null) latest.set(buf); + Thread.sleep(15); // ~60fps cap + } + } catch (ClassNotFoundException cnf) { + VideoPlayerMod.LOG.info("[{}] JavaCV not on classpath; backend inactive", 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 (grabber != null) { + try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {} + } + } + } + + 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; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java new file mode 100644 index 0000000..b759fcb --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java @@ -0,0 +1,30 @@ +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). + */ +@Environment(EnvType.CLIENT) +public interface VideoBackend { + void play(String url, boolean loop); + void pause(); + void resume(); + void setVolume(float gain); + + boolean isReady(); + int videoWidth(); + 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. + */ + ByteBuffer pollFrame(); + + void close(); +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java new file mode 100644 index 0000000..2504f75 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java @@ -0,0 +1,149 @@ +package com.ejclaw.videoplayer.client.playback; + +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * SPEC §5 — per-anchor playback registry. Maps {@link BlockPos} → ({@link VideoBackend} + dynamic + * Identifier of an {@link NativeImageBackedTexture}). The renderer reads the texture id and binds + * it to the quad; this class drives the frame pump every client tick. + */ +@Environment(EnvType.CLIENT) +public final class VideoPlayback { + private VideoPlayback() {} + + private static final Map ENTRIES = new HashMap<>(); + + public static Identifier getOrStart(VideoAnchorBlockEntity be) { + BlockPos pos = be.getPos(); + Entry e = ENTRIES.get(pos); + if (e != null && e.url.equals(be.getUrl())) { + return e.id; + } + if (e != null) { + stop(pos); + } + if (be.getUrl().isEmpty() || !be.isAutoplay()) { + return null; + } + VideoBackend backend = WatermediaProbe.isAvailable() ? new WatermediaBackend() : new JavaCvBackend(); + backend.play(be.getUrl(), be.isLoop()); + backend.setVolume(be.isMuted() ? 0F : be.getVolume()); + Entry created = new Entry(be.getUrl(), backend); + ENTRIES.put(pos, created); + return created.id; + } + + public static Identifier currentTexture(BlockPos pos) { + Entry e = ENTRIES.get(pos); + return e == null ? null : e.id; + } + + public static void stop(BlockPos pos) { + Entry e = ENTRIES.remove(pos); + if (e != null) e.close(); + } + + public static void onConfigChanged(VideoAnchorBlockEntity be) { + Entry e = ENTRIES.get(be.getPos()); + if (e == null) return; + if (!e.url.equals(be.getUrl())) { + stop(be.getPos()); + return; + } + e.backend.setVolume(be.isMuted() ? 0F : be.getVolume()); + } + + /** Called every client tick to upload new frames into the GPU texture. */ + public static void tick() { + if (MinecraftClient.getInstance() == null) return; + Iterator> it = ENTRIES.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry me = it.next(); + Entry e = me.getValue(); + if (!e.backend.isReady()) continue; + ByteBuffer buf = e.backend.pollFrame(); + if (buf == null) continue; + try { + e.upload(buf); + } catch (Throwable t) { + // texture upload errors shouldn't kill the client; drop this entry + e.close(); + it.remove(); + } + } + } + + public static java.util.Set activePositions() { + return new java.util.HashSet<>(ENTRIES.keySet()); + } + + public static void setGain(BlockPos pos, float gain) { + Entry e = ENTRIES.get(pos); + if (e != null) e.backend.setVolume(gain); + } + + public static void stopAll() { + for (Entry e : ENTRIES.values()) e.close(); + ENTRIES.clear(); + } + + private static final class Entry { + final String url; + final VideoBackend backend; + final Identifier id; + NativeImageBackedTexture texture; + int texW = 0, texH = 0; + + Entry(String url, VideoBackend backend) { + this.url = url; + this.backend = backend; + this.id = Identifier.of("video_player", "dynamic/" + Integer.toHexString(System.identityHashCode(this))); + } + + void upload(ByteBuffer rgba) { + int w = backend.videoWidth(); + int h = backend.videoHeight(); + if (w <= 0 || h <= 0) return; + if (texture == null || w != texW || h != texH) { + if (texture != null) texture.close(); + NativeImage img = new NativeImage(NativeImage.Format.RGBA, w, h, false); + texture = new NativeImageBackedTexture(() -> "video_player_dyn", img); + MinecraftClient.getInstance().getTextureManager().registerTexture(id, texture); + texW = w; texH = h; + } + NativeImage img = texture.getImage(); + if (img == null) return; + // copy buf → image pixels (RGBA bytes, native order) + 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 argb = (a << 24) | (r << 16) | (g << 8) | b; + img.setColorArgb(i % w, i / w, argb); + } + texture.upload(); + } + + void close() { + backend.close(); + if (texture != null) { + texture.close(); + texture = null; + } + } + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java b/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java new file mode 100644 index 0000000..9d68bf8 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java @@ -0,0 +1,50 @@ +package com.ejclaw.videoplayer.client.playback; + +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 + * {@code !isReady()} and {@link WatermediaProbe} reports unavailable, so callers fall through + * to {@link JavaCvBackend}. + */ +@Environment(EnvType.CLIENT) +public class WatermediaBackend implements VideoBackend { + private volatile boolean ready = false; + private volatile int width = 0; + private volatile int height = 0; + private volatile float gain = 1.0F; + private Object player; + + @Override + public void play(String url, boolean loop) { + if (!WatermediaProbe.isAvailable()) { + VideoPlayerMod.LOG.debug("[{}] WatermediaBackend.play called but probe says unavailable", + VideoPlayerMod.MOD_ID); + return; + } + // Reflection construction skipped — only reachable when WaterMedia v2 ships for the target MC. + } + + @Override public void pause() {} + @Override public void resume() {} + @Override public void setVolume(float g) { this.gain = Math.max(0F, Math.min(1F, g)); } + @Override public boolean isReady() { return ready; } + @Override public int videoWidth() { return width; } + @Override public int videoHeight() { return height; } + + @Override + public ByteBuffer pollFrame() { + return null; // no frames until v2 is wired up + } + + @Override + public void close() { + ready = false; + player = null; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaProbe.java b/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaProbe.java new file mode 100644 index 0000000..1a036e3 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaProbe.java @@ -0,0 +1,35 @@ +package com.ejclaw.videoplayer.client.playback; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +/** + * SPEC §5.4 — Reflection-only check for whether WaterMedia v2 is present and usable on this MC. + * As of 1.21.6+ WaterMedia v2 has no Yarn-mapped artifact, so this always reports false; we keep the + * shape so the mod will auto-prefer WaterMedia when a future build adds it. + */ +@Environment(EnvType.CLIENT) +public final class WatermediaProbe { + private WatermediaProbe() {} + + private static final Boolean CACHED = compute(); + + public static boolean isAvailable() { + return CACHED; + } + + private static boolean compute() { + try { + Class.forName("me.srrapero720.watermedia.api.WaterMediaAPI", false, + WatermediaProbe.class.getClassLoader()); + VideoPlayerMod.LOG.info("[{}] WaterMedia v2 detected", VideoPlayerMod.MOD_ID); + return true; + } catch (ClassNotFoundException e) { + return false; + } catch (Throwable t) { + VideoPlayerMod.LOG.warn("[{}] WaterMedia probe failed: {}", VideoPlayerMod.MOD_ID, t.toString()); + return false; + } + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/client/render/VideoAnchorRenderer.java b/src/main/java/com/ejclaw/videoplayer/client/render/VideoAnchorRenderer.java new file mode 100644 index 0000000..bb88f70 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/render/VideoAnchorRenderer.java @@ -0,0 +1,91 @@ +package com.ejclaw.videoplayer.client.render; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.ejclaw.videoplayer.client.playback.VideoPlayback; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.block.entity.BlockEntityRenderer; +import net.minecraft.client.render.block.entity.BlockEntityRendererFactory; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.RotationAxis; +import net.minecraft.util.math.Vec3d; +import org.joml.Matrix4f; + +/** SPEC §5.2 — draws a width × height quad in front of the anchor, oriented by facing. */ +@Environment(EnvType.CLIENT) +public class VideoAnchorRenderer implements BlockEntityRenderer { + + /** Placeholder texture used until a frame is uploaded. */ + private static final Identifier PLACEHOLDER = + Identifier.of(VideoPlayerMod.MOD_ID, "block/video_anchor"); + + public VideoAnchorRenderer(BlockEntityRendererFactory.Context ctx) { + // no-op — context kept for future symbol/lookup needs + } + + @Override + public void render(VideoAnchorBlockEntity be, float tickDelta, MatrixStack matrices, + VertexConsumerProvider vertices, int light, int overlay, Vec3d cam) { + Identifier tex = VideoPlayback.currentTexture(be.getPos()); + Identifier bound = tex != null ? tex : PLACEHOLDER; + + float w = be.getWidth(); + float h = be.getHeight(); + Direction facing = be.getFacing(); + + matrices.push(); + // Center the quad above the anchor's top face, then rotate to facing. + matrices.translate(0.5, 1.01, 0.5); + float rot = facing.getAxis().isHorizontal() + ? Direction.getHorizontalDegreesOrThrow(facing) + : 0F; + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(-rot)); + matrices.translate(-w / 2.0F, 0, 0); + + VertexConsumer vc = vertices.getBuffer(RenderLayer.getEntityCutoutNoCull(bound)); + Matrix4f mat = matrices.peek().getPositionMatrix(); + + // Two-sided quad in the XY plane at z=0 + emit(vc, mat, 0, 0, 0, 0, 1, light, overlay); + emit(vc, mat, w, 0, 0, 1, 1, light, overlay); + emit(vc, mat, w, h, 0, 1, 0, light, overlay); + emit(vc, mat, 0, h, 0, 0, 0, light, overlay); + // back face (so the anchor is visible from behind too) + emit(vc, mat, 0, h, 0, 0, 0, light, overlay); + emit(vc, mat, w, h, 0, 1, 0, light, overlay); + emit(vc, mat, w, 0, 0, 1, 1, light, overlay); + emit(vc, mat, 0, 0, 0, 0, 1, light, overlay); + + matrices.pop(); + + // Trigger playback startup lazily, on first frame the camera sees the BE. + VideoPlayback.getOrStart(be); + } + + private static void emit(VertexConsumer vc, Matrix4f mat, + float x, float y, float z, float u, float v, + int light, int overlay) { + vc.vertex(mat, x, y, z) + .color(255, 255, 255, 255) + .texture(u, v) + .overlay(overlay) + .light(light) + .normal(0F, 0F, 1F); + } + + @Override + public boolean rendersOutsideBoundingBox() { + return true; + } + + @Override + public int getRenderDistance() { + return 128; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/command/VideoDeleteCommand.java b/src/main/java/com/ejclaw/videoplayer/command/VideoDeleteCommand.java new file mode 100644 index 0000000..b5f912a --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/command/VideoDeleteCommand.java @@ -0,0 +1,44 @@ +package com.ejclaw.videoplayer.command; + +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.minecraft.block.Blocks; +import net.minecraft.command.argument.BlockPosArgumentType; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.math.BlockPos; + +/** SPEC §4.5.1 — {@code /videoDelete } */ +public final class VideoDeleteCommand { + private VideoDeleteCommand() {} + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(register("videoDelete")); + dispatcher.register(register("videodelete")); + } + + private static com.mojang.brigadier.builder.LiteralArgumentBuilder + register(String name) { + return CommandManager.literal(name) + .requires(s -> s.hasPermissionLevel(2)) + .then(CommandManager.argument("pos", BlockPosArgumentType.blockPos()) + .executes(VideoDeleteCommand::run)); + } + + private static int run(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerWorld world = src.getWorld(); + BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos"); + if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) { + src.sendError(Text.literal("no anchor at that position")); + return 0; + } + world.setBlockState(pos, Blocks.AIR.getDefaultState()); + src.sendFeedback(() -> Text.literal("anchor deleted at " + pos.toShortString()), true); + return 1; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/command/VideoMuteCommand.java b/src/main/java/com/ejclaw/videoplayer/command/VideoMuteCommand.java new file mode 100644 index 0000000..7ee8b9e --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/command/VideoMuteCommand.java @@ -0,0 +1,61 @@ +package com.ejclaw.videoplayer.command; + +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.ejclaw.videoplayer.net.SyncAnchorPayload; +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.command.argument.BlockPosArgumentType; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.math.BlockPos; + +/** SPEC §4.5.1 — {@code /videoMute } */ +public final class VideoMuteCommand { + private VideoMuteCommand() {} + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(register("videoMute")); + dispatcher.register(register("videomute")); + } + + private static com.mojang.brigadier.builder.LiteralArgumentBuilder + register(String name) { + return CommandManager.literal(name) + .requires(s -> s.hasPermissionLevel(2)) + .then(CommandManager.argument("pos", BlockPosArgumentType.blockPos()) + .then(CommandManager.argument("state", StringArgumentType.word()) + .executes(VideoMuteCommand::run))); + } + + private static int run(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerWorld world = src.getWorld(); + BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos"); + String state = StringArgumentType.getString(ctx, "state").toLowerCase(); + boolean muted; + if ("on".equals(state) || "true".equals(state)) muted = true; + else if ("off".equals(state) || "false".equals(state)) muted = false; + else { + src.sendError(Text.literal("state must be on/off")); + return 0; + } + if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) { + src.sendError(Text.literal("no anchor at that position")); + return 0; + } + be.setMuted(muted); + for (ServerPlayerEntity p : PlayerLookup.tracking(world, pos)) { + ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, be.toNbt())); + } + final boolean mFinal = muted; + src.sendFeedback(() -> Text.literal("anchor " + (mFinal ? "muted" : "unmuted")), true); + return 1; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/command/VideoPlaceCommand.java b/src/main/java/com/ejclaw/videoplayer/command/VideoPlaceCommand.java new file mode 100644 index 0000000..4ba3117 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/command/VideoPlaceCommand.java @@ -0,0 +1,81 @@ +package com.ejclaw.videoplayer.command; + +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.ejclaw.videoplayer.net.SyncAnchorPayload; +import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.FloatArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +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.command.argument.BlockPosArgumentType; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; + +/** SPEC §4.5.1 — {@code /videoPlace } */ +public final class VideoPlaceCommand { + private VideoPlaceCommand() {} + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(register("videoPlace")); + dispatcher.register(register("videoplace")); + } + + private static com.mojang.brigadier.builder.LiteralArgumentBuilder + register(String name) { + return CommandManager.literal(name) + .requires(s -> s.hasPermissionLevel(2)) + .then(CommandManager.argument("pos", BlockPosArgumentType.blockPos()) + .then(CommandManager.argument("facing", StringArgumentType.word()) + .then(CommandManager.argument("width", IntegerArgumentType.integer(1, 32)) + .then(CommandManager.argument("height", IntegerArgumentType.integer(1, 32)) + .then(CommandManager.argument("url", StringArgumentType.greedyString()) + .executes(VideoPlaceCommand::run)))))); + } + + private static int run(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerWorld world = src.getWorld(); + BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos"); + Direction facing = Direction.byId(StringArgumentType.getString(ctx, "facing")); + if (facing == null) { + src.sendError(Text.literal("facing must be north/south/east/west/up/down")); + return 0; + } + int width = IntegerArgumentType.getInteger(ctx, "width"); + int height = IntegerArgumentType.getInteger(ctx, "height"); + String url = StringArgumentType.getString(ctx, "url").trim(); + if (!url.isEmpty() && !(url.startsWith("http://") || url.startsWith("https://"))) { + src.sendError(Text.literal("url must be http:// or https:// (or empty)")); + return 0; + } + if (url.length() > 256) url = url.substring(0, 256); + + world.setBlockState(pos, VideoPlayerBlocks.VIDEO_ANCHOR.getDefaultState()); + if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) { + src.sendError(Text.literal("failed to place anchor")); + return 0; + } + be.setFacing(facing); + be.setWidth(width); + be.setHeight(height); + be.setUrl(url); + + NbtCompound nbt = be.toNbt(); + for (ServerPlayerEntity p : PlayerLookup.tracking(world, pos)) { + ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, nbt)); + } + final BlockPos fp = pos; + src.sendFeedback(() -> Text.literal("anchor placed at " + fp.toShortString()), true); + return 1; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/item/VideoStickItem.java b/src/main/java/com/ejclaw/videoplayer/item/VideoStickItem.java index 37c82d4..3e61bf2 100644 --- a/src/main/java/com/ejclaw/videoplayer/item/VideoStickItem.java +++ b/src/main/java/com/ejclaw/videoplayer/item/VideoStickItem.java @@ -1,12 +1,60 @@ package com.ejclaw.videoplayer.item; +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.ejclaw.videoplayer.net.OpenScreenPayload; +import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.entity.player.PlayerEntity; import net.minecraft.item.Item; +import net.minecraft.item.ItemUsageContext; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.ActionResult; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.world.World; -/** - * M1: registered placeholder. Right/left-click handlers land in M4–M5. - */ +/** SPEC §4.2 — right-click empty face → place anchor + open GUI. Right-click existing anchor → edit. */ public class VideoStickItem extends Item { public VideoStickItem(Settings settings) { super(settings); } + + @Override + public ActionResult useOnBlock(ItemUsageContext ctx) { + World world = ctx.getWorld(); + if (world.isClient) { + // server is authoritative; client just consumes the gesture + return ActionResult.SUCCESS; + } + + ServerWorld sw = (ServerWorld) world; + PlayerEntity player = ctx.getPlayer(); + if (!(player instanceof ServerPlayerEntity sp)) return ActionResult.PASS; + + BlockPos hit = ctx.getBlockPos(); + + // Existing anchor → edit + if (sw.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) { + ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt())); + return ActionResult.SUCCESS; + } + + // Empty face → place anchor on top of the clicked face + Direction side = ctx.getSide(); + BlockPos placeAt = hit.offset(side); + BlockState there = sw.getBlockState(placeAt); + if (!there.isReplaceable()) return ActionResult.PASS; + + Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR; + sw.setBlockState(placeAt, anchor.getDefaultState()); + + if (sw.getBlockEntity(placeAt) instanceof VideoAnchorBlockEntity be) { + be.setFacing(ctx.getHorizontalPlayerFacing().getOpposite()); + ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt())); + } + return ActionResult.SUCCESS; + } } diff --git a/src/main/java/com/ejclaw/videoplayer/net/DeleteAnchorPayload.java b/src/main/java/com/ejclaw/videoplayer/net/DeleteAnchorPayload.java new file mode 100644 index 0000000..0f02bfa --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/DeleteAnchorPayload.java @@ -0,0 +1,24 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; + +/** C2S — delete an anchor from the VideoConfigScreen. */ +public record DeleteAnchorPayload(BlockPos pos) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "delete_anchor")); + + public static final PacketCodec CODEC = PacketCodec.tuple( + BlockPos.PACKET_CODEC, DeleteAnchorPayload::pos, + DeleteAnchorPayload::new + ); + + @Override + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/OpenScreenPayload.java b/src/main/java/com/ejclaw/videoplayer/net/OpenScreenPayload.java new file mode 100644 index 0000000..97ea592 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/OpenScreenPayload.java @@ -0,0 +1,27 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; + +/** S2C — open the VideoConfigScreen for an anchor on the client. */ +public record OpenScreenPayload(BlockPos pos, NbtCompound data) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "open_screen")); + + public static final PacketCodec CODEC = PacketCodec.tuple( + BlockPos.PACKET_CODEC, OpenScreenPayload::pos, + PacketCodecs.NBT_COMPOUND, OpenScreenPayload::data, + OpenScreenPayload::new + ); + + @Override + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/SaveConfigPayload.java b/src/main/java/com/ejclaw/videoplayer/net/SaveConfigPayload.java new file mode 100644 index 0000000..6886220 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/SaveConfigPayload.java @@ -0,0 +1,27 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; + +/** C2S — save edited config from VideoConfigScreen back to the server. */ +public record SaveConfigPayload(BlockPos pos, NbtCompound data) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "save_config")); + + public static final PacketCodec CODEC = PacketCodec.tuple( + BlockPos.PACKET_CODEC, SaveConfigPayload::pos, + PacketCodecs.NBT_COMPOUND, SaveConfigPayload::data, + SaveConfigPayload::new + ); + + @Override + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/SyncAnchorPayload.java b/src/main/java/com/ejclaw/videoplayer/net/SyncAnchorPayload.java new file mode 100644 index 0000000..75b417f --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/SyncAnchorPayload.java @@ -0,0 +1,27 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; + +/** S2C — push current anchor state (URL/dims/loop/volume/muted/autoplay) to clients in range. */ +public record SyncAnchorPayload(BlockPos pos, NbtCompound data) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "sync_anchor")); + + public static final PacketCodec CODEC = PacketCodec.tuple( + BlockPos.PACKET_CODEC, SyncAnchorPayload::pos, + PacketCodecs.NBT_COMPOUND, SyncAnchorPayload::data, + SyncAnchorPayload::new + ); + + @Override + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java b/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java new file mode 100644 index 0000000..20e09da --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java @@ -0,0 +1,106 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.PlayerLookup; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.block.Blocks; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.math.BlockPos; + +/** + * Registers all four payload types and the two C2S server-side receivers. + * Client-side receivers are registered in {@code com.ejclaw.videoplayer.client.net.ClientNetworking}. + */ +public final class VideoPlayerNetwork { + private VideoPlayerNetwork() {} + + public static void registerPayloadTypes() { + // S2C + PayloadTypeRegistry.playS2C().register(OpenScreenPayload.ID, OpenScreenPayload.CODEC); + PayloadTypeRegistry.playS2C().register(SyncAnchorPayload.ID, SyncAnchorPayload.CODEC); + // C2S + PayloadTypeRegistry.playC2S().register(SaveConfigPayload.ID, SaveConfigPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DeleteAnchorPayload.ID, DeleteAnchorPayload.CODEC); + } + + public static void registerServerReceivers() { + ServerPlayNetworking.registerGlobalReceiver(SaveConfigPayload.ID, (payload, context) -> { + ServerPlayerEntity player = context.player(); + ServerWorld world = player.getWorld(); + BlockPos pos = payload.pos(); + context.server().execute(() -> handleSave(world, player, pos, payload.data())); + }); + + ServerPlayNetworking.registerGlobalReceiver(DeleteAnchorPayload.ID, (payload, context) -> { + ServerPlayerEntity player = context.player(); + ServerWorld world = player.getWorld(); + BlockPos pos = payload.pos(); + context.server().execute(() -> handleDelete(world, player, pos)); + }); + } + + private static void handleSave(ServerWorld world, ServerPlayerEntity player, BlockPos pos, NbtCompound data) { + if (!canModify(player, pos)) { + VideoPlayerMod.LOG.warn("[{}] {} attempted save without permission at {}", + VideoPlayerMod.MOD_ID, player.getName().getString(), pos); + return; + } + if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) { + return; + } + be.applyFromNbt(sanitize(data)); + // broadcast updated state to all players tracking the chunk + SyncAnchorPayload sync = new SyncAnchorPayload(pos, be.toNbt()); + for (ServerPlayerEntity watcher : PlayerLookup.tracking(world, pos)) { + ServerPlayNetworking.send(watcher, sync); + } + } + + private static void handleDelete(ServerWorld world, ServerPlayerEntity player, BlockPos pos) { + if (!canModify(player, pos)) { + return; + } + if (world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) { + world.setBlockState(pos, Blocks.AIR.getDefaultState()); + } + } + + /** Permission check: creative players or operators may modify anchors. */ + public static boolean canModify(ServerPlayerEntity player, BlockPos pos) { + if (player.isCreative()) return true; + return player.hasPermissionLevel(2); + } + + /** Strip out unexpected keys from C2S NBT before applying. */ + private static NbtCompound sanitize(NbtCompound in) { + NbtCompound out = new NbtCompound(); + out.putString("url", trimUrl(in.getString("url", ""))); + out.putInt("width", clamp(in.getInt("width", 1), 1, 32)); + out.putInt("height", clamp(in.getInt("height", 1), 1, 32)); + out.putString("facing", in.getString("facing", "north")); + out.putBoolean("loop", in.getBoolean("loop", true)); + out.putFloat("volume", Math.max(0F, Math.min(1F, in.getFloat("volume", 0.5F)))); + out.putBoolean("muted", in.getBoolean("muted", false)); + out.putBoolean("autoplay", in.getBoolean("autoplay", true)); + return out; + } + + private static String trimUrl(String s) { + if (s == null) return ""; + String t = s.trim(); + if (t.length() > 256) t = t.substring(0, 256); + // SPEC §4.4: only https?:// or empty + if (!t.isEmpty() && !(t.startsWith("http://") || t.startsWith("https://"))) { + return ""; + } + return t; + } + + private static int clamp(int v, int lo, int hi) { + return Math.max(lo, Math.min(hi, v)); + } +} diff --git a/src/main/resources/assets/video_player/blockstates/video_anchor.json b/src/main/resources/assets/video_player/blockstates/video_anchor.json new file mode 100644 index 0000000..fc435aa --- /dev/null +++ b/src/main/resources/assets/video_player/blockstates/video_anchor.json @@ -0,0 +1,5 @@ +{ + "variants": { + "": { "model": "video_player:block/video_anchor" } + } +} diff --git a/src/main/resources/assets/video_player/models/block/video_anchor.json b/src/main/resources/assets/video_player/models/block/video_anchor.json new file mode 100644 index 0000000..5295e9b --- /dev/null +++ b/src/main/resources/assets/video_player/models/block/video_anchor.json @@ -0,0 +1,21 @@ +{ + "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] } + } + } + ] +} diff --git a/src/main/resources/assets/video_player/models/item/video_stick.json b/src/main/resources/assets/video_player/models/item/video_stick.json new file mode 100644 index 0000000..a67709e --- /dev/null +++ b/src/main/resources/assets/video_player/models/item/video_stick.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "video_player:item/video_stick" + } +} diff --git a/src/main/resources/assets/video_player/textures/block/video_anchor.png b/src/main/resources/assets/video_player/textures/block/video_anchor.png new file mode 100644 index 0000000000000000000000000000000000000000..a410b5a240a442ff9faedea203cb39e272b7e482 GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar*6y0|GAm@aIgEFip79 e5Xjut&B!2PkuLDiXL|)u5re0zpUXO@geCyi!xYW{ literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/video_player/textures/item/video_stick.png b/src/main/resources/assets/video_player/textures/item/video_stick.png new file mode 100644 index 0000000000000000000000000000000000000000..b8236c6c0dcf3ba5136f36161f0f2f5808fd9008 GIT binary patch literal 83 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ql2i3Ar*6yZ){R{V6QqO@k&D= fvm3939#Dz$)5Y?Y@Bh33s$%eT^>bP0l+XkK1d|qP literal 0 HcmV?d00001 diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 68de57c..dc730d9 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -18,7 +18,7 @@ "depends": { "fabricloader": ">=0.16.0", "fabric-api": "*", - "minecraft": ">=1.21.6", + "minecraft": "${target_minecraft}", "java": ">=21" } }