diff --git a/src/client/java/com/pixelreel/client/ClientNetworking.java b/src/client/java/com/pixelreel/client/ClientNetworking.java index e687bb8..29ea768 100644 --- a/src/client/java/com/pixelreel/client/ClientNetworking.java +++ b/src/client/java/com/pixelreel/client/ClientNetworking.java @@ -26,6 +26,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; public final class ClientNetworking { private static OnDemandProvider pendingBrowseProvider = OnDemandProvider.JELLYFIN; @@ -275,6 +276,33 @@ public static void unequipPixelGlasses() { } } + public static void placeCustomScreen( + BlockPos pos, int width, int height, int audioDepth, int audioSide, InteractionHand hand + ) { + if (ClientPlayNetworking.canSend(ModNetworkPayloads.PlaceCustomScreen.TYPE)) { + ClientPlayNetworking.send(new ModNetworkPayloads.PlaceCustomScreen( + pos, width, height, ModNetworkPayloads.PlaceCustomScreen.packAudioSettings(audioDepth, audioSide), + hand == InteractionHand.OFF_HAND + )); + } + } + + public static void configureScreenwriterLink(BlockPos pos, int auditorium, boolean unlink) { + if (ClientPlayNetworking.canSend(ModNetworkPayloads.ScreenwriterLink.TYPE)) { + ClientPlayNetworking.send(new ModNetworkPayloads.ScreenwriterLink(pos, auditorium, unlink)); + } + } + + public static void resetLocalPlaybackConnection(BlockPos pos) { + Minecraft minecraft = Minecraft.getInstance(); + boolean reset = PlaybackManager.INSTANCE.resetConnection(pos); + if (minecraft.player != null) { + minecraft.player.displayClientMessage(Component.translatable( + reset ? "message.pixelreel.playback.connection_reset" : "message.pixelreel.playback.no_connection" + ), true); + } + } + public static void sendControl(BlockPos pos, ScreenAction action, float value) { if (ClientPlayNetworking.canSend(ModNetworkPayloads.ScreenControl.TYPE)) { ClientPlayNetworking.send(new ModNetworkPayloads.ScreenControl(pos, action, value)); diff --git a/src/client/java/com/pixelreel/client/PixelReelClient.java b/src/client/java/com/pixelreel/client/PixelReelClient.java index 2b2efea..35fcd88 100644 --- a/src/client/java/com/pixelreel/client/PixelReelClient.java +++ b/src/client/java/com/pixelreel/client/PixelReelClient.java @@ -3,6 +3,8 @@ import com.pixelreel.ClientBridge; import com.pixelreel.blockentities.DisplayBlockEntity; import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.client.gui.CustomScreenPlacementScreen; +import com.pixelreel.client.gui.ScreenwriterLinkScreen; import com.pixelreel.client.playback.PlaybackManager; import com.pixelreel.client.playback.video.VlcRuntime; import com.pixelreel.client.render.DisplayBlockEntityRenderer; @@ -19,6 +21,7 @@ import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; @@ -42,6 +45,18 @@ public void openChannelMenu(BlockPos controllerPos) { public void releasePlayback(BlockPos controllerPos) { PlaybackManager.INSTANCE.release(controllerPos); } + + @Override + public void openCustomScreenPlacement(BlockPos controllerPos, InteractionHand hand, boolean curved) { + Minecraft minecraft = Minecraft.getInstance(); + minecraft.execute(() -> minecraft.setScreen(new CustomScreenPlacementScreen(controllerPos, hand, curved))); + } + + @Override + public void openScreenwriterLink(BlockPos controllerPos) { + Minecraft minecraft = Minecraft.getInstance(); + minecraft.execute(() -> minecraft.setScreen(new ScreenwriterLinkScreen(controllerPos))); + } }); ClientTickEvents.END_CLIENT_TICK.register(minecraft -> { diff --git a/src/client/java/com/pixelreel/client/gui/CustomScreenPlacementScreen.java b/src/client/java/com/pixelreel/client/gui/CustomScreenPlacementScreen.java new file mode 100644 index 0000000..c41a2d6 --- /dev/null +++ b/src/client/java/com/pixelreel/client/gui/CustomScreenPlacementScreen.java @@ -0,0 +1,124 @@ +package com.pixelreel.client.gui; + +import com.pixelreel.blockentities.DisplayBlockEntity; +import com.pixelreel.blocks.DisplayGeometry; +import com.pixelreel.client.ClientNetworking; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; + +/** Size/audio prompt shown when placing either custom cinema screen item. */ +public class CustomScreenPlacementScreen extends Screen { + private final BlockPos controllerPos; + private final InteractionHand hand; + private final boolean curved; + private EditBox widthBox; + private EditBox heightBox; + private EditBox audioDepthBox; + private EditBox audioSideBox; + private Component validationMessage = Component.empty(); + + public CustomScreenPlacementScreen(BlockPos controllerPos, InteractionHand hand, boolean curved) { + super(Component.translatable(curved + ? "gui.pixelreel.custom_curved_screen.title" + : "gui.pixelreel.custom_screen.title")); + this.controllerPos = controllerPos.immutable(); + this.hand = hand; + this.curved = curved; + } + + @Override + protected void init() { + int left = this.width / 2 - 90; + int top = this.height / 2 - 92; + + this.widthBox = new EditBox(this.font, left, top, 180, 20, Component.translatable("gui.pixelreel.custom_screen.width")); + this.widthBox.setMaxLength(2); + this.widthBox.setFilter(CustomScreenPlacementScreen::digitsOnly); + this.widthBox.setValue("27"); + this.addRenderableWidget(this.widthBox); + + this.heightBox = new EditBox(this.font, left, top + 34, 180, 20, Component.translatable("gui.pixelreel.custom_screen.height")); + this.heightBox.setMaxLength(2); + this.heightBox.setFilter(CustomScreenPlacementScreen::digitsOnly); + this.heightBox.setValue("11"); + this.addRenderableWidget(this.heightBox); + + this.audioDepthBox = new EditBox(this.font, left, top + 68, 180, 20, Component.translatable("gui.pixelreel.custom_screen.audio_depth")); + this.audioDepthBox.setMaxLength(3); + this.audioDepthBox.setFilter(CustomScreenPlacementScreen::digitsOnly); + this.audioDepthBox.setValue(Integer.toString(DisplayBlockEntity.DEFAULT_CUSTOM_AUDIO_DEPTH)); + this.addRenderableWidget(this.audioDepthBox); + + this.audioSideBox = new EditBox(this.font, left, top + 102, 180, 20, Component.translatable("gui.pixelreel.custom_screen.audio_side")); + this.audioSideBox.setMaxLength(2); + this.audioSideBox.setFilter(CustomScreenPlacementScreen::digitsOnly); + this.audioSideBox.setValue(Integer.toString(DisplayBlockEntity.DEFAULT_CUSTOM_AUDIO_SIDE)); + this.addRenderableWidget(this.audioSideBox); + + this.addRenderableWidget(Button.builder(Component.translatable("gui.pixelreel.custom_screen.place"), button -> this.place()) + .bounds(left, top + 140, 86, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("gui.pixelreel.custom_screen.cancel"), button -> this.onClose()) + .bounds(left + 94, top + 140, 86, 20).build()); + this.setInitialFocus(this.widthBox); + } + + private static boolean digitsOnly(String value) { + if (value.isEmpty()) return true; + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) return false; + } + return true; + } + + private void place() { + int width = parse(this.widthBox.getValue()); + int height = parse(this.heightBox.getValue()); + int audioDepth = parse(this.audioDepthBox.getValue()); + int audioSide = parse(this.audioSideBox.getValue()); + if (width < DisplayGeometry.MIN_WIDTH || width > DisplayGeometry.MAX_WIDTH + || height < DisplayGeometry.MIN_HEIGHT || height > DisplayGeometry.MAX_HEIGHT) { + this.validationMessage = Component.translatable("gui.pixelreel.custom_screen.invalid", + DisplayGeometry.MIN_WIDTH, DisplayGeometry.MAX_WIDTH, DisplayGeometry.MIN_HEIGHT, DisplayGeometry.MAX_HEIGHT); + return; + } + if (audioDepth < DisplayBlockEntity.MIN_CUSTOM_AUDIO_DEPTH || audioDepth > DisplayBlockEntity.MAX_CUSTOM_AUDIO_DEPTH + || audioSide < DisplayBlockEntity.MIN_CUSTOM_AUDIO_SIDE || audioSide > DisplayBlockEntity.MAX_CUSTOM_AUDIO_SIDE) { + this.validationMessage = Component.translatable("gui.pixelreel.custom_screen.audio_invalid", + DisplayBlockEntity.MIN_CUSTOM_AUDIO_DEPTH, DisplayBlockEntity.MAX_CUSTOM_AUDIO_DEPTH, + DisplayBlockEntity.MIN_CUSTOM_AUDIO_SIDE, DisplayBlockEntity.MAX_CUSTOM_AUDIO_SIDE); + return; + } + ClientNetworking.placeCustomScreen(this.controllerPos, width, height, audioDepth, audioSide, this.hand); + this.onClose(); + } + + private static int parse(String value) { + try { return Integer.parseInt(value); } + catch (NumberFormatException ignored) { return -1; } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTicks) { + super.render(graphics, mouseX, mouseY, partialTicks); + int left = this.width / 2 - 90; + int top = this.height / 2 - 92; + graphics.drawCenteredString(this.font, this.title, this.width / 2, top - 28, GuiColors.TEXT); + graphics.drawCenteredString(this.font, Component.translatable("gui.pixelreel.custom_screen.subtitle"), this.width / 2, top - 15, GuiColors.TEXT_DIM); + graphics.drawString(this.font, Component.translatable("gui.pixelreel.custom_screen.width"), left, top - 10, GuiColors.TEXT_DIM); + graphics.drawString(this.font, Component.translatable("gui.pixelreel.custom_screen.height"), left, top + 24, GuiColors.TEXT_DIM); + graphics.drawString(this.font, Component.translatable("gui.pixelreel.custom_screen.audio_depth"), left, top + 58, GuiColors.TEXT_DIM); + graphics.drawString(this.font, Component.translatable("gui.pixelreel.custom_screen.audio_side"), left, top + 92, GuiColors.TEXT_DIM); + graphics.drawCenteredString(this.font, Component.translatable("gui.pixelreel.custom_screen.audio_hint"), this.width / 2, top + 126, GuiColors.TEXT_DIM); + if (!this.validationMessage.getString().isEmpty()) { + graphics.drawCenteredString(this.font, this.validationMessage, this.width / 2, top + 166, GuiColors.ERROR); + } + } + + @Override + public boolean isPauseScreen() { return false; } +} diff --git a/src/client/java/com/pixelreel/client/gui/MediaSourceScreen.java b/src/client/java/com/pixelreel/client/gui/MediaSourceScreen.java index 346a0f1..9206558 100644 --- a/src/client/java/com/pixelreel/client/gui/MediaSourceScreen.java +++ b/src/client/java/com/pixelreel/client/gui/MediaSourceScreen.java @@ -105,12 +105,18 @@ protected void init() { y += smallH + gap; if (showPlayback) { + int playbackHalf = (PANEL_WIDTH - gap) / 2; this.addRenderableWidget( Button.builder(Component.translatable("gui.pixelreel.playback.open"), button -> { if (this.minecraft != null) { this.minecraft.setScreen(new PlaybackControlScreen(this.display)); } - }).bounds(centreX - PANEL_WIDTH / 2, y, PANEL_WIDTH, smallH).build() + }).bounds(centreX - PANEL_WIDTH / 2, y, playbackHalf, smallH).build() + ); + this.addRenderableWidget( + Button.builder(Component.translatable("gui.pixelreel.playback.reset_connection"), button -> + ClientNetworking.resetLocalPlaybackConnection(this.display.getBlockPos()) + ).bounds(centreX - PANEL_WIDTH / 2 + playbackHalf + gap, y, playbackHalf, smallH).build() ); y += smallH + gap; } diff --git a/src/client/java/com/pixelreel/client/gui/PlaybackControlScreen.java b/src/client/java/com/pixelreel/client/gui/PlaybackControlScreen.java index dd72593..a2a247f 100644 --- a/src/client/java/com/pixelreel/client/gui/PlaybackControlScreen.java +++ b/src/client/java/com/pixelreel/client/gui/PlaybackControlScreen.java @@ -53,6 +53,11 @@ protected void init() { ClientNetworking.sendControl(this.display.getBlockPos(), ScreenAction.STOP, 0.0F) ).bounds(centre + 110, y, 50, 20).build() ); + this.addRenderableWidget( + Button.builder(Component.translatable("gui.pixelreel.playback.reset_connection"), button -> + ClientNetworking.resetLocalPlaybackConnection(this.display.getBlockPos()) + ).bounds(centre - 140, y - 56, 280, 20).build() + ); this.subtitleButton = this.addRenderableWidget( Button.builder(this.subtitleLabel(), button -> { if (this.minecraft != null) { diff --git a/src/client/java/com/pixelreel/client/gui/ScreenwriterLinkScreen.java b/src/client/java/com/pixelreel/client/gui/ScreenwriterLinkScreen.java new file mode 100644 index 0000000..aca0500 --- /dev/null +++ b/src/client/java/com/pixelreel/client/gui/ScreenwriterLinkScreen.java @@ -0,0 +1,79 @@ +package com.pixelreel.client.gui; + +import com.pixelreel.client.ClientNetworking; +import com.pixelreel.server.ScreenwriterManager; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; + +/** Auditorium-number prompt used when linking a custom screen to Screenwriter. */ +public class ScreenwriterLinkScreen extends Screen { + private final BlockPos controllerPos; + private EditBox auditoriumBox; + private Component validationMessage = Component.empty(); + + public ScreenwriterLinkScreen(BlockPos controllerPos) { + super(Component.translatable("gui.pixelreel.screenwriter_link.title")); + this.controllerPos = controllerPos.immutable(); + } + + @Override + protected void init() { + int left = this.width / 2 - 90; + int top = this.height / 2 - 52; + this.auditoriumBox = new EditBox(this.font, left, top, 180, 20, Component.translatable("gui.pixelreel.screenwriter_link.auditorium")); + this.auditoriumBox.setMaxLength(2); + this.auditoriumBox.setFilter(ScreenwriterLinkScreen::digitsOnly); + this.addRenderableWidget(this.auditoriumBox); + this.addRenderableWidget(Button.builder(Component.translatable("gui.pixelreel.screenwriter_link.link"), button -> this.link()) + .bounds(left, top + 38, 86, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("gui.pixelreel.screenwriter_link.unlink"), button -> this.unlink()) + .bounds(left + 94, top + 38, 86, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("gui.pixelreel.screenwriter_link.cancel"), button -> this.onClose()) + .bounds(left + 47, top + 66, 86, 20).build()); + this.setInitialFocus(this.auditoriumBox); + } + + private static boolean digitsOnly(String value) { + if (value.isEmpty()) return true; + for (int i = 0; i < value.length(); i++) if (!Character.isDigit(value.charAt(i))) return false; + return true; + } + + private void link() { + int auditorium = parse(this.auditoriumBox.getValue()); + if (auditorium < 1 || auditorium > ScreenwriterManager.MAX_SCREENS) { + this.validationMessage = Component.translatable("gui.pixelreel.screenwriter_link.invalid", 1, ScreenwriterManager.MAX_SCREENS); + return; + } + ClientNetworking.configureScreenwriterLink(this.controllerPos, auditorium, false); + this.onClose(); + } + + private void unlink() { + ClientNetworking.configureScreenwriterLink(this.controllerPos, 0, true); + this.onClose(); + } + + private static int parse(String value) { + try { return Integer.parseInt(value.trim()); } + catch (NumberFormatException ignored) { return -1; } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTicks) { + super.render(graphics, mouseX, mouseY, partialTicks); + int left = this.width / 2 - 90; + int top = this.height / 2 - 52; + graphics.drawCenteredString(this.font, this.title, this.width / 2, top - 32, GuiColors.TEXT); + graphics.drawCenteredString(this.font, Component.translatable("gui.pixelreel.screenwriter_link.subtitle", ScreenwriterManager.MAX_SCREENS), this.width / 2, top - 18, GuiColors.TEXT_DIM); + graphics.drawString(this.font, Component.translatable("gui.pixelreel.screenwriter_link.auditorium"), left, top - 10, GuiColors.TEXT_DIM); + if (!this.validationMessage.getString().isEmpty()) graphics.drawCenteredString(this.font, this.validationMessage, this.width / 2, top + 94, GuiColors.ERROR); + } + + @Override + public boolean isPauseScreen() { return false; } +} diff --git a/src/client/java/com/pixelreel/client/playback/PlaybackManager.java b/src/client/java/com/pixelreel/client/playback/PlaybackManager.java index 1477f18..2a728d5 100644 --- a/src/client/java/com/pixelreel/client/playback/PlaybackManager.java +++ b/src/client/java/com/pixelreel/client/playback/PlaybackManager.java @@ -43,11 +43,26 @@ public final class PlaybackManager { private int tickCounter; private long audioRefreshUntilMs; + private record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { + } + private PlaybackManager() { } public @Nullable ChannelPlayer player(String url) { - return url == null || url.isEmpty() ? null : this.players.get(url); + if (url == null || url.isEmpty()) { + return null; + } + for (ChannelPlayer player : this.players.values()) { + if (url.equals(player.url())) { + return player; + } + } + return null; + } + + private static String sessionKey(DisplayBlockEntity display, String url) { + return display.getBlockPos().asLong() + "|" + url; } public int activePlayerCount() { @@ -60,13 +75,41 @@ public void prewarm(List urls) { public void retry(BlockPos controllerPos) { Minecraft minecraft = Minecraft.getInstance(); if (minecraft.level != null && minecraft.level.getBlockEntity(controllerPos) instanceof DisplayBlockEntity display) { - ChannelPlayer player = this.players.get(ClientPlaybackSecrets.knownStreamUrl(display)); + String url = ClientPlaybackSecrets.knownStreamUrl(display); + ChannelPlayer player = url.isEmpty() ? null : this.players.get(sessionKey(display, url)); if (player != null) { player.retry(); + if (display.isOnDemand()) { + player.requestSeek(display.currentPlaybackPositionMs()); + } } } } + /** Recreate one screen's local decoder at the authoritative synchronized position. */ + public boolean resetConnection(BlockPos controllerPos) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null + || !(minecraft.level.getBlockEntity(controllerPos) instanceof DisplayBlockEntity display) + || !display.shouldPlay()) { + return false; + } + String url = ClientPlaybackSecrets.knownStreamUrl(display); + if (url.isEmpty()) { + return false; + } + String key = sessionKey(display, url); + ChannelPlayer old = this.players.remove(key); + this.playerEpochs.remove(key); + this.endedReports.remove(key); + this.durationReports.remove(key); + if (old != null) { + old.close(); + } + this.nearbyControllers.add(controllerPos.immutable()); + return true; + } + public void clientTick() { Minecraft minecraft = Minecraft.getInstance(); ClientLevel level = minecraft.level; @@ -114,8 +157,6 @@ private void driveSessions(ClientLevel level, Player player) { double keepAliveDistance = startDistance * KEEP_ALIVE_MULTIPLIER; double keepAliveDistanceSqr = keepAliveDistance * keepAliveDistance; - record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { - } Map> desired = new HashMap<>(); for (BlockPos pos : this.nearbyControllers) { @@ -135,12 +176,13 @@ record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { if (url.isEmpty()) { continue; } - boolean alreadyPlaying = this.players.containsKey(url); + String key = sessionKey(display, url); + boolean alreadyPlaying = this.players.containsKey(key); double limitSqr = alreadyPlaying ? keepAliveDistanceSqr : startDistanceSqr; if (distanceSqr > limitSqr) { continue; } - desired.computeIfAbsent(url, key -> new ArrayList<>()).add(new Binding(display, centre, distanceSqr)); + desired.computeIfAbsent(key, ignored -> new ArrayList<>()).add(new Binding(display, centre, distanceSqr)); } LinkedHashSet ranked = desired.entrySet().stream() @@ -157,8 +199,8 @@ record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { allowed.add(url); } - for (String url : allowed) { - List bindings = desired.get(url); + for (String key : allowed) { + List bindings = desired.get(key); Binding nearest = bindings == null || bindings.isEmpty() ? null : bindings.stream().min(Comparator.comparingDouble(Binding::distanceSqr)).orElse(null); @@ -166,10 +208,14 @@ record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { continue; } DisplayBlockEntity display = nearest.display(); + String url = ClientPlaybackSecrets.knownStreamUrl(display); + if (url.isEmpty()) { + continue; + } boolean onDemand = display.isOnDemand(); int epoch = display.getChannelEpoch(); - ChannelPlayer channelPlayer = this.players.get(url); - Integer previousEpoch = this.playerEpochs.get(url); + ChannelPlayer channelPlayer = this.players.get(key); + Integer previousEpoch = this.playerEpochs.get(key); if (channelPlayer == null) { channelPlayer = new ChannelPlayer( url, @@ -177,15 +223,15 @@ record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { onDemand ? display.currentPlaybackPositionMs() : 0L, toneMapMode(display, onDemand) ); - this.players.put(url, channelPlayer); - this.playerEpochs.put(url, epoch); - this.endedReports.remove(url); - this.durationReports.remove(url); + this.players.put(key, channelPlayer); + this.playerEpochs.put(key, epoch); + this.endedReports.remove(key); + this.durationReports.remove(key); PixelReel.LOGGER.info("Playing {} stream ({}) — {} active", onDemand ? "on-demand" : "channel", host(url), this.players.size()); } else if (previousEpoch == null || previousEpoch != epoch) { - this.playerEpochs.put(url, epoch); - this.endedReports.remove(url); - this.durationReports.remove(url); + this.playerEpochs.put(key, epoch); + this.endedReports.remove(key); + this.durationReports.remove(key); if (onDemand) { channelPlayer.requestSeek(display.currentPlaybackPositionMs()); } @@ -202,7 +248,7 @@ record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { if (active) { continue; } - PixelReel.LOGGER.info("Stopping channel stream ({}) — out of range or switched away", host(entry.getKey())); + PixelReel.LOGGER.info("Stopping channel stream ({}) — out of range or switched away", host(entry.getValue().url())); closer.remove(); this.playerEpochs.remove(entry.getKey()); this.endedReports.remove(entry.getKey()); @@ -218,7 +264,7 @@ record Binding(DisplayBlockEntity display, Vec3 centre, double distanceSqr) { double edgeFade = config.audioZoneEdgeFadeBlocks; boolean glasses = PixelGlassesItem.isWearing(player); - record Audible(String url, ChannelPlayer channelPlayer, DisplayBlockEntity display, double distanceSqr, float gain, boolean zoned, double zoneDepth, double zoneOutside) { + record Audible(String key, ChannelPlayer channelPlayer, DisplayBlockEntity display, double distanceSqr, float gain, boolean zoned, double zoneDepth, double zoneOutside) { } List audibles = new ArrayList<>(); @@ -262,11 +308,14 @@ record Audible(String url, ChannelPlayer channelPlayer, DisplayBlockEntity displ volume = Math.max(volume, binding.display().getVolume()); audioRange = Math.max(audioRange, binding.display().type().audioRange()); } - boolean zoned = display.hasAudioZone(); - double zoneGain = display.audioZoneGain(listener, edgeFade); + boolean customZoned = display.isCustomScreen(); + boolean zoned = customZoned || display.hasAudioZone(); + double zoneGain = customZoned + ? customScreenAudioGain(player, display) + : display.audioZoneGain(listener, edgeFade); double normalized; if (zoned) { - // Glasses only boost inside the room; outside stays silent. + // Custom auditorium zones are a hard boundary; claimed upstream zones keep their configured fade. normalized = zoneGain; } else { double distance = Math.sqrt(nearest.distanceSqr()); @@ -283,8 +332,8 @@ record Audible(String url, ChannelPlayer channelPlayer, DisplayBlockEntity displ nearest.distanceSqr(), gain, zoned, - zoned ? display.audioZoneDepth(listener) : 0.0, - zoned ? display.audioZoneOutsideDistance(listener) : Double.POSITIVE_INFINITY + zoned ? (customZoned ? (zoneGain > 0.0 ? 1.0 : 0.0) : display.audioZoneDepth(listener)) : 0.0, + zoned ? (customZoned ? Double.POSITIVE_INFINITY : display.audioZoneOutsideDistance(listener)) : Double.POSITIVE_INFINITY )); } @@ -300,7 +349,7 @@ record Audible(String url, ChannelPlayer channelPlayer, DisplayBlockEntity displ || (audible.zoneDepth() == preferDepth && audible.distanceSqr() < preferDistance)) { preferDepth = audible.zoneDepth(); preferDistance = audible.distanceSqr(); - preferZonedUrl = audible.url(); + preferZonedUrl = audible.key(); } } final String exclusiveUrl = preferZonedUrl; @@ -318,7 +367,7 @@ record Audible(String url, ChannelPlayer channelPlayer, DisplayBlockEntity displ double outside = audible.zoneOutside(); if (outside > 0.0 && outside <= warmOutsideBlocks && outside < warmOutside) { warmOutside = outside; - warmUrl = audible.url(); + warmUrl = audible.key(); } } } @@ -329,10 +378,10 @@ record GainPlan(Audible audible, float gain, boolean warm) { List plans = new ArrayList<>(audibles.size()); for (Audible audible : audibles) { float gain = audible.gain(); - if (insideClaimedTheater && !exclusiveUrl.equals(audible.url())) { + if (insideClaimedTheater && !exclusiveUrl.equals(audible.key())) { gain = 0.0F; } - boolean warm = gain <= 0.0F && warmTrackUrl != null && warmTrackUrl.equals(audible.url()); + boolean warm = gain <= 0.0F && warmTrackUrl != null && warmTrackUrl.equals(audible.key()); plans.add(new GainPlan(audible, gain, warm)); } // Enable / raise volume first, then mute others — avoids a silent gap while VLC reselects a track. @@ -345,6 +394,24 @@ record GainPlan(Audible audible, float gain, boolean warm) { } } + private static double customScreenAudioGain(Player player, DisplayBlockEntity display) { + Vec3 offset = player.position().subtract(display.screenCentre()); + var facing = display.facing(); + var sideAxis = facing.getCounterClockWise(); + + // Hard auditorium-shaped zone. One block behind remains audible because the + // custom screen plane sits against the back face of its occupied block cells. + double forward = offset.x * facing.getStepX() + offset.z * facing.getStepZ(); + if (forward < -1.0 || forward > display.customAudioDepthBlocks()) { + return 0.0; + } + + double lateral = Math.abs(offset.x * sideAxis.getStepX() + offset.z * sideAxis.getStepZ()); + double halfScreenWidth = display.geometry().widthBlocks() * 0.5; + double maximumLateral = halfScreenWidth + display.customAudioSideBlocks(); + return lateral <= maximumLateral ? 1.0 : 0.0; + } + private static double softDistanceGain(double distance, double audioRange) { if (distance <= 0.0 || audioRange <= 0.0) { return 1.0; @@ -375,7 +442,7 @@ private static ToneMapMode toneMapMode(DisplayBlockEntity display, boolean onDem } public @Nullable PictureHandle pictureFor(DisplayBlockEntity display) { - ChannelPlayer current = this.players.get(ClientPlaybackSecrets.knownStreamUrl(display)); + ChannelPlayer current = this.players.get(sessionKey(display, ClientPlaybackSecrets.knownStreamUrl(display))); if (current != null && current.hasPicture() && current.uploadFrame()) { VideoTexture texture = current.videoTexture(); return new PictureHandle( @@ -391,7 +458,7 @@ private static ToneMapMode toneMapMode(DisplayBlockEntity display, boolean onDem } public @Nullable PlaybackStatus statusFor(DisplayBlockEntity display) { - ChannelPlayer player = this.players.get(ClientPlaybackSecrets.knownStreamUrl(display)); + ChannelPlayer player = this.players.get(sessionKey(display, ClientPlaybackSecrets.knownStreamUrl(display))); return player == null ? null : player.status(); } diff --git a/src/client/java/com/pixelreel/client/render/DisplayBlockEntityRenderer.java b/src/client/java/com/pixelreel/client/render/DisplayBlockEntityRenderer.java index 6efc90c..1c8219a 100644 --- a/src/client/java/com/pixelreel/client/render/DisplayBlockEntityRenderer.java +++ b/src/client/java/com/pixelreel/client/render/DisplayBlockEntityRenderer.java @@ -6,6 +6,7 @@ import com.pixelreel.PixelReel; import com.pixelreel.blockentities.DisplayBlockEntity; import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.blocks.DisplayGeometry; import com.pixelreel.blocks.DisplayType; import com.pixelreel.client.playback.PlaybackManager; import com.pixelreel.client.playback.PlaybackStatus; @@ -52,6 +53,7 @@ public void render( if (type == null) { return; } + DisplayGeometry geometry = blockEntity.geometry(); Direction facing = blockEntity.facing(); ResourceLocation pictureTexture = null; @@ -90,22 +92,22 @@ public void render( poseStack.mulPose(Axis.YP.rotationDegrees(180.0F - facing.toYRot())); poseStack.translate(-0.5F, -0.5F, -0.5F); - Sheet screen = new Sheet(type.screenLeft(), type.screenBottom(), type.screenRight(), type.screenTop()); - submitSheet(type, screen, BACKING, BACKING_BIAS, false, packedLight, poseStack, bufferSource); - submitSheet(type, screen, BACKING, BACK_FACE_BIAS, true, packedLight, poseStack, bufferSource); - submitSheet(type, screen, SCREEN_OFF, SCREEN_BIAS, false, packedLight, poseStack, bufferSource); + Sheet screen = new Sheet(geometry.screenLeft(), geometry.screenBottom(), geometry.screenRight(), geometry.screenTop()); + submitSheet(geometry, screen, BACKING, BACKING_BIAS, false, packedLight, poseStack, bufferSource); + submitSheet(geometry, screen, BACKING, BACK_FACE_BIAS, true, packedLight, poseStack, bufferSource); + submitSheet(geometry, screen, SCREEN_OFF, SCREEN_BIAS, false, packedLight, poseStack, bufferSource); if (pictureTexture != null) { boolean statusGraphic = Math.abs(pictureAspect - STATUS_ASPECT) < 0.001F; FittedPicture fitted = statusGraphic ? FittedPicture.full(screen) - : containContent(type, screen, pictureAspect); + : fitContent(type, geometry, screen, pictureAspect); float u0 = lerp(contentU0, contentU1, fitted.uMin()); float u1 = lerp(contentU0, contentU1, fitted.uMax()); float v0 = lerp(contentV0, contentV1, fitted.vMin()); float v1 = lerp(contentV0, contentV1, fitted.vMax()); submitSheet( - type, + geometry, fitted.sheet(), pictureTexture, PICTURE_BIAS, @@ -123,11 +125,19 @@ public void render( poseStack.popPose(); } - private static FittedPicture containContent(DisplayType type, Sheet screen, float pictureAspect) { + private static FittedPicture fitContent(DisplayType type, DisplayGeometry geometry, Sheet screen, float pictureAspect) { + // The repurposed Wall Television is the 27x13 crop-to-fill screen. + if (type == DisplayType.WALL_TELEVISION) { + return coverContent(geometry, screen, pictureAspect); + } + return containContent(geometry, screen, pictureAspect); + } + + private static FittedPicture containContent(DisplayGeometry geometry, Sheet screen, float pictureAspect) { if (pictureAspect <= 0.05F || screen.height() <= 0.0F) { return FittedPicture.full(screen); } - float screenWidth = type.isCurved() ? type.arcLengthPx(screen.left(), screen.right()) : screen.width(); + float screenWidth = geometry.isCurved() ? geometry.arcLengthPx(screen.left(), screen.right()) : screen.width(); float screenAspect = screenWidth / screen.height(); if (Math.abs(screenAspect - pictureAspect) < 0.001F) { return FittedPicture.full(screen); @@ -137,12 +147,31 @@ private static FittedPicture containContent(DisplayType type, Sheet screen, floa float inset = (screen.height() - fittedHeight) * 0.5F; return FittedPicture.full(new Sheet(screen.left(), screen.bottom() + inset, screen.right(), screen.top() - inset)); } - float inset = type.isCurved() - ? type.pillarboxInsetForAspect(screen.left(), screen.right(), screen.height(), pictureAspect) + float inset = geometry.isCurved() + ? geometry.pillarboxInsetForAspect(screen.left(), screen.right(), screen.height(), pictureAspect) : (screen.width() - screen.height() * pictureAspect) * 0.5F; return FittedPicture.full(new Sheet(screen.left() + inset, screen.bottom(), screen.right() - inset, screen.top())); } + private static FittedPicture coverContent(DisplayGeometry geometry, Sheet screen, float pictureAspect) { + if (pictureAspect <= 0.05F || screen.height() <= 0.0F) { + return FittedPicture.full(screen); + } + float screenWidth = geometry.isCurved() ? geometry.arcLengthPx(screen.left(), screen.right()) : screen.width(); + float screenAspect = screenWidth / screen.height(); + if (Math.abs(screenAspect - pictureAspect) < 0.001F) { + return FittedPicture.full(screen); + } + if (pictureAspect > screenAspect) { + float keptWidth = screenAspect / pictureAspect; + float inset = (1.0F - keptWidth) * 0.5F; + return new FittedPicture(screen, inset, 1.0F - inset, 0.0F, 1.0F); + } + float keptHeight = pictureAspect / screenAspect; + float inset = (1.0F - keptHeight) * 0.5F; + return new FittedPicture(screen, 0.0F, 1.0F, inset, 1.0F - inset); + } + private record FittedPicture(Sheet sheet, float uMin, float uMax, float vMin, float vMax) { static FittedPicture full(Sheet sheet) { return new FittedPicture(sheet, 0.0F, 1.0F, 0.0F, 1.0F); @@ -150,7 +179,7 @@ static FittedPicture full(Sheet sheet) { } private static void submitSheet( - DisplayType type, + DisplayGeometry geometry, Sheet sheet, ResourceLocation texture, float bias, @@ -159,11 +188,11 @@ private static void submitSheet( PoseStack poseStack, MultiBufferSource bufferSource ) { - submitSheet(type, sheet, texture, bias, backFace, packedLight, poseStack, bufferSource, 0.0F, 1.0F, 0.0F, 1.0F); + submitSheet(geometry, sheet, texture, bias, backFace, packedLight, poseStack, bufferSource, 0.0F, 1.0F, 0.0F, 1.0F); } private static void submitSheet( - DisplayType type, + DisplayGeometry geometry, Sheet sheet, ResourceLocation texture, float bias, @@ -180,13 +209,13 @@ private static void submitSheet( return; } VertexConsumer buffer = bufferSource.getBuffer(RenderType.entityCutout(texture)); - emit(poseStack.last(), buffer, type, sheet, bias, backFace, packedLight, uMin, uMax, vMin, vMax); + emit(poseStack.last(), buffer, geometry, sheet, bias, backFace, packedLight, uMin, uMax, vMin, vMax); } private static void emit( PoseStack.Pose pose, VertexConsumer buffer, - DisplayType type, + DisplayGeometry geometry, Sheet sheet, float bias, boolean backFace, @@ -196,9 +225,9 @@ private static void emit( float vMin, float vMax ) { - float displayWidth = type.displayWidthPx(); - float controllerRight = (type.controllerColumn() + 1) * 16.0F; - int segments = type.isCurved() ? Math.max(1, Math.round((float)type.curveSegments() * sheet.width() / displayWidth)) : 1; + float displayWidth = geometry.displayWidthPx(); + float controllerRight = (geometry.controllerColumn() + 1) * 16.0F; + int segments = geometry.isCurved() ? Math.max(1, Math.round((float)geometry.curveSegments() * sheet.width() / displayWidth)) : 1; float vTop = vMin; float vBottom = vMax; float yBottom = sheet.bottom(); @@ -209,7 +238,7 @@ private static void emit( for (int i = 1; i <= segments; i++) { float d0 = lerp(sheet.left(), sheet.right(), (float)(i - 1) / segments); float d1 = lerp(sheet.left(), sheet.right(), (float)i / segments); - arcAt[i] = arcAt[i - 1] + type.arcLengthPx(d0, d1); + arcAt[i] = arcAt[i - 1] + geometry.arcLengthPx(d0, d1); } float totalArc = Math.max(arcAt[segments], 1.0E-4F); @@ -218,8 +247,8 @@ private static void emit( float d1 = lerp(sheet.left(), sheet.right(), (float)(i + 1) / segments); float x0 = controllerRight - d0; float x1 = controllerRight - d1; - float z0 = type.depthAt(d0 / displayWidth) - bias; - float z1 = type.depthAt(d1 / displayWidth) - bias; + float z0 = geometry.depthAt(d0 / displayWidth) - bias; + float z1 = geometry.depthAt(d1 / displayWidth) - bias; float u0 = uMin + (uMax - uMin) * (arcAt[i] / totalArc); float u1 = uMin + (uMax - uMin) * (arcAt[i + 1] / totalArc); float slope = Math.abs(x1 - x0) < 1.0E-5F ? 0.0F : (z1 - z0) / (x1 - x0); diff --git a/src/main/java/com/pixelreel/ClientBridge.java b/src/main/java/com/pixelreel/ClientBridge.java index e467b23..33013cd 100644 --- a/src/main/java/com/pixelreel/ClientBridge.java +++ b/src/main/java/com/pixelreel/ClientBridge.java @@ -1,6 +1,7 @@ package com.pixelreel; import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionHand; import org.jetbrains.annotations.Nullable; public final class ClientBridge { @@ -27,9 +28,27 @@ public static void releasePlayback(BlockPos controllerPos) { } } + public static void openCustomScreenPlacement(BlockPos controllerPos, InteractionHand hand, boolean curved) { + Handler current = handler; + if (current != null) { + current.openCustomScreenPlacement(controllerPos, hand, curved); + } + } + + public static void openScreenwriterLink(BlockPos controllerPos) { + Handler current = handler; + if (current != null) { + current.openScreenwriterLink(controllerPos); + } + } + public interface Handler { void openChannelMenu(BlockPos controllerPos); void releasePlayback(BlockPos controllerPos); + + void openCustomScreenPlacement(BlockPos controllerPos, InteractionHand hand, boolean curved); + + void openScreenwriterLink(BlockPos controllerPos); } } diff --git a/src/main/java/com/pixelreel/PixelReel.java b/src/main/java/com/pixelreel/PixelReel.java index 982764f..ed7902b 100644 --- a/src/main/java/com/pixelreel/PixelReel.java +++ b/src/main/java/com/pixelreel/PixelReel.java @@ -2,6 +2,7 @@ import com.pixelreel.audio.AudioZoneClaimSessions; import com.pixelreel.channels.ChannelService; +import com.pixelreel.commands.ScreenwriterCommand; import com.pixelreel.commands.TvCommand; import com.pixelreel.config.ConfigManager; import com.pixelreel.networking.ModNetworkPayloads; @@ -12,8 +13,10 @@ import com.pixelreel.registry.ModBlocks; import com.pixelreel.registry.ModCreativeTabs; import com.pixelreel.registry.ModItems; +import com.pixelreel.server.ScreenwriterManager; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.minecraft.resources.ResourceLocation; import org.slf4j.Logger; @@ -33,16 +36,20 @@ public void onInitialize() { ModNetworkPayloads.register(); ServerNetworking.register(); TvCommand.register(); + ScreenwriterCommand.register(); AudioZoneClaimSessions.register(); + ServerTickEvents.END_SERVER_TICK.register(ScreenwriterManager::tick); ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> PlaybackSecrets.forget(handler.getPlayer())); ServerLifecycleEvents.SERVER_STARTING.register(server -> com.pixelreel.media.MediaProxy.start()); ServerLifecycleEvents.SERVER_STARTED.register(server -> { + ScreenwriterManager.start(server); LOGGER.info("pixelReel configuration file: {}", ConfigManager.path()); com.pixelreel.poster.CustomPosterStore.attach(server); ChannelService.INSTANCE.channels(false); OnDemandCatalog.refreshConfigured(false); }); ServerLifecycleEvents.SERVER_STOPPED.register(server -> { + ScreenwriterManager.stop(server); com.pixelreel.media.MediaProxy.stop(); com.pixelreel.poster.CustomPosterStore.detach(); ChannelService.INSTANCE.invalidateCache(); diff --git a/src/main/java/com/pixelreel/blockentities/DisplayBlockEntity.java b/src/main/java/com/pixelreel/blockentities/DisplayBlockEntity.java index c77307a..77731ec 100644 --- a/src/main/java/com/pixelreel/blockentities/DisplayBlockEntity.java +++ b/src/main/java/com/pixelreel/blockentities/DisplayBlockEntity.java @@ -3,6 +3,7 @@ import com.pixelreel.ClientBridge; import com.pixelreel.audio.AudioZone; import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.blocks.DisplayGeometry; import com.pixelreel.blocks.DisplayType; import com.pixelreel.channels.Channel; import com.pixelreel.config.ConfigManager; @@ -65,9 +66,19 @@ public class DisplayBlockEntity extends BlockEntity { private static final String KEY_PLEX_PART = "PlexPartIdx"; private static final String KEY_PLEX_PART_KEY = "PlexPartKey"; private static final String KEY_AUDIO_ZONE = "AudioZone"; + private static final String KEY_CUSTOM_WIDTH = "CustomWidth"; + private static final String KEY_CUSTOM_HEIGHT = "CustomHeight"; + private static final String KEY_CUSTOM_AUDIO_DEPTH = "CustomAudioDepth"; + private static final String KEY_CUSTOM_AUDIO_SIDE = "CustomAudioSide"; public static final int MAX_STREAM_URL = 2048; public static final int MAX_OVERVIEW = 1024; + public static final int DEFAULT_CUSTOM_AUDIO_DEPTH = 40; + public static final int DEFAULT_CUSTOM_AUDIO_SIDE = 2; + public static final int MIN_CUSTOM_AUDIO_DEPTH = 1; + public static final int MAX_CUSTOM_AUDIO_DEPTH = 256; + public static final int MIN_CUSTOM_AUDIO_SIDE = 0; + public static final int MAX_CUSTOM_AUDIO_SIDE = 64; private boolean powered; private boolean suspended; @@ -113,6 +124,10 @@ public class DisplayBlockEntity extends BlockEntity { private int plexPartIndex; private String plexPartKey = ""; private @Nullable AudioZone audioZone; + private int customWidthBlocks; + private int customHeightBlocks; + private int customAudioDepthBlocks = DEFAULT_CUSTOM_AUDIO_DEPTH; + private int customAudioSideBlocks = DEFAULT_CUSTOM_AUDIO_SIDE; public DisplayBlockEntity(BlockPos pos, BlockState state) { super(ModBlockEntities.DISPLAY, pos, state); @@ -128,6 +143,59 @@ public DisplayType type() { return block == null ? DisplayType.COMPACT_TELEVISION : block.type(); } + public boolean isCustomScreen() { + DisplayType type = this.type(); + return type == DisplayType.CURVED_CINEMA_SCREEN || type == DisplayType.CINEMA_SCREEN; + } + + public int customWidthBlocks() { + return this.geometry().widthBlocks(); + } + + public int customHeightBlocks() { + return this.geometry().heightBlocks(); + } + + public void setCustomSize(int widthBlocks, int heightBlocks) { + if (!this.isCustomScreen()) { + return; + } + this.customWidthBlocks = Math.clamp(widthBlocks, DisplayGeometry.MIN_WIDTH, DisplayGeometry.MAX_WIDTH); + this.customHeightBlocks = Math.clamp(heightBlocks, DisplayGeometry.MIN_HEIGHT, DisplayGeometry.MAX_HEIGHT); + this.panelsValidated = true; + this.markDirtyAndSync(); + } + + public int customAudioDepthBlocks() { + return this.customAudioDepthBlocks; + } + + public int customAudioSideBlocks() { + return this.customAudioSideBlocks; + } + + public void setCustomAudioZone(int depthBlocks, int sideBlocks) { + if (!this.isCustomScreen()) { + return; + } + this.customAudioDepthBlocks = Math.clamp(depthBlocks, MIN_CUSTOM_AUDIO_DEPTH, MAX_CUSTOM_AUDIO_DEPTH); + this.customAudioSideBlocks = Math.clamp(sideBlocks, MIN_CUSTOM_AUDIO_SIDE, MAX_CUSTOM_AUDIO_SIDE); + this.markDirtyAndSync(); + } + + public DisplayGeometry geometry() { + DisplayType type = this.type(); + if (type == DisplayType.CURVED_CINEMA_SCREEN || type == DisplayType.CINEMA_SCREEN) { + int width = this.customWidthBlocks > 0 ? this.customWidthBlocks : type.widthBlocks(); + int height = this.customHeightBlocks > 0 ? this.customHeightBlocks : type.heightBlocks(); + if (type == DisplayType.CINEMA_SCREEN) { + return DisplayGeometry.customCurved(width, height, type.audioRange()); + } + return DisplayGeometry.customFlat(width, height, type.audioRange()); + } + return DisplayGeometry.fromType(type); + } + public Direction facing() { BlockState state = this.getBlockState(); return state.hasProperty(DisplayBlock.FACING) ? state.getValue(DisplayBlock.FACING) : Direction.NORTH; @@ -380,14 +448,14 @@ private long clampPos(long positionMs) { } public Vec3 screenCentre() { - DisplayType type = this.type(); + DisplayGeometry geometry = this.geometry(); Direction facing = this.facing(); Direction axis = facing.getCounterClockWise(); - double halfWidthOffset = (type.widthBlocks() - 1) / 2.0 - type.controllerColumn(); + double halfWidthOffset = (geometry.widthBlocks() - 1) / 2.0 - geometry.controllerColumn(); Vec3 base = Vec3.atCenterOf(this.worldPosition); return base.add( axis.getStepX() * halfWidthOffset, - type.heightBlocks() / 2.0 - 0.5, + geometry.heightBlocks() / 2.0 - 0.5, axis.getStepZ() * halfWidthOffset ); } @@ -680,7 +748,7 @@ public void serverTick() { this.panelsValidated = true; DisplayBlock block = this.displayBlock(); if (block != null) { - DisplayBlock.placePanels(this.level, this.worldPosition, block.type(), this.facing(), true); + DisplayBlock.placePanels(this.level, this.worldPosition, this.geometry(), this.facing(), true); } } } @@ -795,6 +863,16 @@ protected void saveAdditional(CompoundTag output, HolderLookup.Provider registri this.audioZone.write(zoneTag); output.put(KEY_AUDIO_ZONE, zoneTag); } + if (this.customWidthBlocks > 0) { + output.putInt(KEY_CUSTOM_WIDTH, this.customWidthBlocks); + } + if (this.customHeightBlocks > 0) { + output.putInt(KEY_CUSTOM_HEIGHT, this.customHeightBlocks); + } + if (this.isCustomScreen()) { + output.putInt(KEY_CUSTOM_AUDIO_DEPTH, this.customAudioDepthBlocks); + output.putInt(KEY_CUSTOM_AUDIO_SIDE, this.customAudioSideBlocks); + } } @Override @@ -838,6 +916,22 @@ protected void loadAdditional(CompoundTag input, HolderLookup.Provider registrie this.plexPartIndex = Math.max(0, input.contains(KEY_PLEX_PART) ? input.getInt(KEY_PLEX_PART) : 0); this.plexPartKey = clampLength(input.contains(KEY_PLEX_PART_KEY) ? input.getString(KEY_PLEX_PART_KEY) : "", MAX_STREAM_URL); this.audioZone = input.contains(KEY_AUDIO_ZONE) ? AudioZone.read(input.getCompound(KEY_AUDIO_ZONE)) : null; + this.customWidthBlocks = input.contains(KEY_CUSTOM_WIDTH) + ? Math.clamp(input.getInt(KEY_CUSTOM_WIDTH), DisplayGeometry.MIN_WIDTH, DisplayGeometry.MAX_WIDTH) + : 0; + this.customHeightBlocks = input.contains(KEY_CUSTOM_HEIGHT) + ? Math.clamp(input.getInt(KEY_CUSTOM_HEIGHT), DisplayGeometry.MIN_HEIGHT, DisplayGeometry.MAX_HEIGHT) + : 0; + this.customAudioDepthBlocks = Math.clamp( + input.contains(KEY_CUSTOM_AUDIO_DEPTH) ? input.getInt(KEY_CUSTOM_AUDIO_DEPTH) : DEFAULT_CUSTOM_AUDIO_DEPTH, + MIN_CUSTOM_AUDIO_DEPTH, + MAX_CUSTOM_AUDIO_DEPTH + ); + this.customAudioSideBlocks = Math.clamp( + input.contains(KEY_CUSTOM_AUDIO_SIDE) ? input.getInt(KEY_CUSTOM_AUDIO_SIDE) : DEFAULT_CUSTOM_AUDIO_SIDE, + MIN_CUSTOM_AUDIO_SIDE, + MAX_CUSTOM_AUDIO_SIDE + ); this.needsStreamResolve = this.streamUrl.isEmpty() && this.hasChannel(); if (input.contains(KEY_STREAM_URL) || input.contains(KEY_SUB_URL) || input.contains(KEY_PLEX_PART_KEY) || input.contains(KEY_MEDIA_IMAGE)) { this.setChanged(); diff --git a/src/main/java/com/pixelreel/blockentities/ScreenPanelBlockEntity.java b/src/main/java/com/pixelreel/blockentities/ScreenPanelBlockEntity.java index 8d26dd8..f15af4f 100644 --- a/src/main/java/com/pixelreel/blockentities/ScreenPanelBlockEntity.java +++ b/src/main/java/com/pixelreel/blockentities/ScreenPanelBlockEntity.java @@ -1,6 +1,7 @@ package com.pixelreel.blockentities; import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.blocks.DisplayGeometry; import com.pixelreel.blocks.ScreenShapes; import com.pixelreel.registry.ModBlockEntities; import net.minecraft.core.BlockPos; @@ -57,11 +58,12 @@ public void bindTo(BlockPos pos) { return null; } Direction facing = controller.facing(); - ScreenShapes.Cell cell = ScreenShapes.cellOf(controller.getBlockPos(), block.type(), facing, this.worldPosition); + DisplayGeometry geometry = controller.geometry(); + ScreenShapes.Cell cell = ScreenShapes.cellOf(controller.getBlockPos(), geometry, facing, this.worldPosition); if (cell == null) { return null; } - VoxelShape shape = ScreenShapes.cellShapeForRow(block.type(), facing, cell.column(), cell.row(), cell.depthCell()); + VoxelShape shape = ScreenShapes.cellShapeForRow(geometry, facing, cell.column(), cell.row(), cell.depthCell()); this.cachedShape = shape; return shape; } diff --git a/src/main/java/com/pixelreel/blocks/DisplayBlock.java b/src/main/java/com/pixelreel/blocks/DisplayBlock.java index 3649089..631b654 100644 --- a/src/main/java/com/pixelreel/blocks/DisplayBlock.java +++ b/src/main/java/com/pixelreel/blocks/DisplayBlock.java @@ -84,8 +84,9 @@ protected void createBlockStateDefinition(StateDefinition.Builder 0) { continue; } @@ -140,9 +144,9 @@ public static int placePanels(Level level, BlockPos controllerPos, DisplayType t return placed; } - public static void removePanels(Level level, BlockPos controllerPos, DisplayType type, Direction facing) { - for (ScreenShapes.Cell cell : ScreenShapes.cells(type)) { - BlockPos cellPos = ScreenShapes.cellPos(controllerPos, type, facing, cell); + public static void removePanels(Level level, BlockPos controllerPos, DisplayGeometry geometry, Direction facing) { + for (ScreenShapes.Cell cell : ScreenShapes.cells(geometry)) { + BlockPos cellPos = ScreenShapes.cellPos(controllerPos, geometry, facing, cell); if (cellPos.equals(controllerPos)) { continue; } @@ -154,28 +158,48 @@ public static void removePanels(Level level, BlockPos controllerPos, DisplayType } } + private static void removeBoundPanels(Level level, BlockPos controllerPos, Direction facing) { + Direction columnAxis = facing.getCounterClockWise(); + Direction back = facing.getOpposite(); + int horizontalRadius = DisplayGeometry.MAX_WIDTH / 2 + 1; + for (int offset = -horizontalRadius; offset <= horizontalRadius; offset++) { + for (int row = 0; row < DisplayGeometry.MAX_HEIGHT; row++) { + for (int depth = -1; depth <= 1; depth++) { + BlockPos panelPos = controllerPos.relative(columnAxis, offset).above(row).relative(back, depth); + if (panelPos.equals(controllerPos)) continue; + if (level.getBlockEntity(panelPos) instanceof ScreenPanelBlockEntity panel && panel.isBoundTo(controllerPos)) { + level.removeBlock(panelPos, false); + } + } + } + } + } + @Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { - return controllerShape(state); + return controllerShape(state, level, pos); } @Override protected VoxelShape getCollisionShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { - return controllerShape(state); + return controllerShape(state, level, pos); } @Override protected VoxelShape getInteractionShape(BlockState state, BlockGetter level, BlockPos pos) { - return controllerShape(state); + return controllerShape(state, level, pos); } - private VoxelShape controllerShape(BlockState state) { + private VoxelShape controllerShape(BlockState state, BlockGetter level, BlockPos pos) { Direction facing = state.getValue(FACING); - VoxelShape screen = ScreenShapes.cellShapeForRow(this.type, facing, this.type.controllerColumn(), 0, 0); + DisplayGeometry geometry = level.getBlockEntity(pos) instanceof DisplayBlockEntity display + ? display.geometry() + : DisplayGeometry.fromType(this.type); + VoxelShape screen = ScreenShapes.cellShapeForRow(geometry, facing, geometry.controllerColumn(), 0, 0); if (!screen.isEmpty()) { return screen; } - return ScreenShapes.controllerStandShape(this.type, facing); + return ScreenShapes.controllerStandShape(geometry, facing); } @Override diff --git a/src/main/java/com/pixelreel/blocks/DisplayGeometry.java b/src/main/java/com/pixelreel/blocks/DisplayGeometry.java new file mode 100644 index 0000000..66f4ed0 --- /dev/null +++ b/src/main/java/com/pixelreel/blocks/DisplayGeometry.java @@ -0,0 +1,162 @@ +package com.pixelreel.blocks; + +/** + * Runtime display geometry. Legacy fixed displays copy values from DisplayType; + * the custom flat and curved cinema screens override geometry per placed screen. + */ +public record DisplayGeometry( + int widthBlocks, + int heightBlocks, + int controllerColumn, + float screenLeft, + float screenBottom, + float screenRight, + float screenTop, + float baseZ, + float curveDepth, + int curveSegments, + float audioRange +) { + public static final int MIN_WIDTH = 1; + public static final int MIN_HEIGHT = 1; + public static final int MAX_WIDTH = 64; + public static final int MAX_HEIGHT = 36; + + public static DisplayGeometry fromType(DisplayType type) { + return new DisplayGeometry( + type.widthBlocks(), + type.heightBlocks(), + type.controllerColumn(), + type.screenLeft(), + type.screenBottom(), + type.screenRight(), + type.screenTop(), + type.baseZ(), + type.curveDepth(), + type.curveSegments(), + type.audioRange() + ); + } + + public static DisplayGeometry customFlat(int widthBlocks, int heightBlocks, float audioRange) { + int width = Math.clamp(widthBlocks, MIN_WIDTH, MAX_WIDTH); + int height = Math.clamp(heightBlocks, MIN_HEIGHT, MAX_HEIGHT); + int controller = (width - 1) / 2; + return new DisplayGeometry( + width, + height, + controller, + 0.0F, + 0.0F, + width * 16.0F, + height * 16.0F, + 15.0F, + 0.0F, + 1, + audioRange + ); + } + + /** Configurable curved cinema screen using the original PixelReel curved-screen profile. */ + public static DisplayGeometry customCurved(int widthBlocks, int heightBlocks, float audioRange) { + int width = Math.clamp(widthBlocks, MIN_WIDTH, MAX_WIDTH); + int height = Math.clamp(heightBlocks, MIN_HEIGHT, MAX_HEIGHT); + int controller = (width - 1) / 2; + // Keep the centre against the back of the occupied block space, then bow + // the left/right edges toward the viewer. Cap the depth at the original + // curved cinema screen's 22-pixel profile so even very wide custom screens + // remain within the renderer/panel depth budget. + float curveDepth = Math.min(22.0F, width * 16.0F * (22.0F / 256.0F)); + int curveSegments = Math.clamp(width * 2, 8, 64); + return new DisplayGeometry( + width, + height, + controller, + 0.0F, + 0.0F, + width * 16.0F, + height * 16.0F, + 15.0F, + curveDepth, + curveSegments, + audioRange + ); + } + + public float displayWidthPx() { + return this.widthBlocks * 16.0F; + } + + public float displayHeightPx() { + return this.heightBlocks * 16.0F; + } + + public boolean isCurved() { + return this.curveDepth > 0.0F; + } + + public float screenAspect() { + float height = this.screenTop - this.screenBottom; + if (height <= 0.0F) { + return 1.0F; + } + return this.arcLengthPx(this.screenLeft, this.screenRight) / height; + } + + public float depthAt(float u) { + if (!this.isCurved()) { + return this.baseZ; + } + float t = u * 2.0F - 1.0F; + return this.baseZ - this.curveDepth * t * t; + } + + public float arcLengthPx(float fromD, float toD) { + float start = Math.min(fromD, toD); + float end = Math.max(fromD, toD); + if (end <= start) { + return 0.0F; + } + if (!this.isCurved()) { + return end - start; + } + int samples = Math.max(8, Math.round((end - start) / 2.0F)); + float width = this.displayWidthPx(); + float length = 0.0F; + float prevD = start; + float prevZ = this.depthAt(start / width); + for (int i = 1; i <= samples; i++) { + float d = start + (end - start) * i / samples; + float z = this.depthAt(d / width); + float dx = d - prevD; + float dz = z - prevZ; + length += (float)Math.sqrt(dx * dx + dz * dz); + prevD = d; + prevZ = z; + } + return length; + } + + public float pillarboxInsetForAspect(float left, float right, float height, float targetAspect) { + if (height <= 0.0F || targetAspect <= 0.0F || right <= left) { + return 0.0F; + } + float fullArc = this.arcLengthPx(left, right); + float targetWidth = height * targetAspect; + if (targetWidth >= fullArc) { + return 0.0F; + } + float lo = 0.0F; + float hi = (right - left) * 0.5F; + for (int i = 0; i < 24; i++) { + float mid = (lo + hi) * 0.5F; + float arc = this.arcLengthPx(left + mid, right - mid); + if (arc > targetWidth) { + lo = mid; + } else { + hi = mid; + } + } + return (lo + hi) * 0.5F; + } +} diff --git a/src/main/java/com/pixelreel/blocks/DisplayType.java b/src/main/java/com/pixelreel/blocks/DisplayType.java index b5a1af3..8a58754 100644 --- a/src/main/java/com/pixelreel/blocks/DisplayType.java +++ b/src/main/java/com/pixelreel/blocks/DisplayType.java @@ -5,8 +5,11 @@ public enum DisplayType { COMPACT_TELEVISION("compact_television", 3, 2, 1, 0.0F, 0.0F, 48.0F, 32.0F, 1.0F, 0.0F, 1, 16.0F), WALL_TELEVISION("wall_television", 6, 4, 2, 0.0F, 0.0F, 96.0F, 64.0F, 1.0F, 0.0F, 1, 24.0F), ULTRAWIDE_MONITOR("ultrawide_monitor", 8, 4, 3, 0.0F, 0.0F, 128.0F, 64.0F, 1.0F, 0.0F, 1, 24.0F), - CINEMA_SCREEN("cinema_screen", 14, 8, 6, 0.0F, 0.0F, 224.0F, 128.0F, 1.0F, 0.0F, 1, 80.0F), - CURVED_CINEMA_SCREEN("curved_cinema_screen", 16, 7, 7, 0.0F, 0.0F, 256.0F, 112.0F, 15.2F, 22.0F, 32, 80.0F), + // Internal cinema_screen slot is the configurable curved screen. + CINEMA_SCREEN("cinema_screen", 27, 11, 13, 0.0F, 0.0F, 432.0F, 176.0F, 1.0F, 0.0F, 1, 80.0F), + // Internal curved_cinema_screen slot is the configurable flat screen. + CURVED_CINEMA_SCREEN("curved_cinema_screen", 27, 11, 13, 0.0F, 0.0F, 432.0F, 176.0F, 1.0F, 0.0F, 1, 80.0F), + // Kept registered for existing-world compatibility, but hidden from normal obtaining. GIANT_CINEMA_SCREEN("giant_cinema_screen", 20, 10, 9, 0.0F, 0.0F, 320.0F, 160.0F, 15.2F, 5.0F, 28, 100.0F); private final String id; diff --git a/src/main/java/com/pixelreel/blocks/ScreenPanelBlock.java b/src/main/java/com/pixelreel/blocks/ScreenPanelBlock.java index 8d6ff78..bc941c0 100644 --- a/src/main/java/com/pixelreel/blocks/ScreenPanelBlock.java +++ b/src/main/java/com/pixelreel/blocks/ScreenPanelBlock.java @@ -92,6 +92,20 @@ private static VoxelShape shapeAt(BlockGetter level, BlockPos pos) { return FALLBACK; } + @Override + public BlockState playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) { + BlockPos controllerPos = null; + if (level.getBlockEntity(pos) instanceof ScreenPanelBlockEntity panel) { + controllerPos = panel.controllerPos(); + } + + BlockState result = super.playerWillDestroy(level, pos, state, player); + if (!level.isClientSide() && controllerPos != null && DisplayBlock.displayAt(level, controllerPos) != null) { + level.destroyBlock(controllerPos, !player.getAbilities().instabuild, player); + } + return result; + } + @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hitResult) { if (level.getBlockEntity(pos) instanceof ScreenPanelBlockEntity panel) { diff --git a/src/main/java/com/pixelreel/blocks/ScreenShapes.java b/src/main/java/com/pixelreel/blocks/ScreenShapes.java index e47b742..967c8aa 100644 --- a/src/main/java/com/pixelreel/blocks/ScreenShapes.java +++ b/src/main/java/com/pixelreel/blocks/ScreenShapes.java @@ -20,89 +20,89 @@ private ScreenShapes() { public record Cell(int column, int row, int depthCell) { } - public static List cells(DisplayType type) { + public static List cells(DisplayGeometry geometry) { List cells = new ArrayList<>(); boolean controllerAdded = false; - int colStart = Math.max(0, (int)Math.floor(type.screenLeft() / 16.0F)); - int colEnd = Math.min(type.widthBlocks() - 1, (int)Math.floor((type.screenRight() - 0.001F) / 16.0F)); - int rowStart = Math.max(0, (int)Math.floor(type.screenBottom() / 16.0F)); - int rowEnd = Math.min(type.heightBlocks() - 1, (int)Math.floor((type.screenTop() - 0.001F) / 16.0F)); + int colStart = Math.max(0, (int)Math.floor(geometry.screenLeft() / 16.0F)); + int colEnd = Math.min(geometry.widthBlocks() - 1, (int)Math.floor((geometry.screenRight() - 0.001F) / 16.0F)); + int rowStart = Math.max(0, (int)Math.floor(geometry.screenBottom() / 16.0F)); + int rowEnd = Math.min(geometry.heightBlocks() - 1, (int)Math.floor((geometry.screenTop() - 0.001F) / 16.0F)); for (int column = colStart; column <= colEnd; column++) { - float[] range = columnDepthRange(type, column); + float[] range = columnDepthRange(geometry, column); int kMin = Math.floorDiv((int)Math.floor(range[0] - HALF_THICKNESS), 16); int kMax = Math.floorDiv((int)Math.floor(range[1] + HALF_THICKNESS), 16); kMin = Math.max(-1, kMin); kMax = Math.min(1, kMax); for (int k = kMin; k <= kMax; k++) { for (int row = rowStart; row <= rowEnd; row++) { - if (!cellIntersectsScreen(type, column, row)) { + if (!cellIntersectsScreen(geometry, column, row)) { continue; } cells.add(new Cell(column, row, k)); - if (column == type.controllerColumn() && row == 0 && k == 0) { + if (column == geometry.controllerColumn() && row == 0 && k == 0) { controllerAdded = true; } } } } if (!controllerAdded) { - cells.add(new Cell(type.controllerColumn(), 0, 0)); + cells.add(new Cell(geometry.controllerColumn(), 0, 0)); } return cells; } - private static boolean cellIntersectsScreen(DisplayType type, int column, int row) { + private static boolean cellIntersectsScreen(DisplayGeometry geometry, int column, int row) { float cellLeft = column * 16.0F; float cellRight = cellLeft + 16.0F; float cellBottom = row * 16.0F; float cellTop = cellBottom + 16.0F; - return cellRight > type.screenLeft() - && cellLeft < type.screenRight() - && cellTop > type.screenBottom() - && cellBottom < type.screenTop(); + return cellRight > geometry.screenLeft() + && cellLeft < geometry.screenRight() + && cellTop > geometry.screenBottom() + && cellBottom < geometry.screenTop(); } - public static BlockPos cellPos(BlockPos controllerPos, DisplayType type, Direction facing, Cell cell) { + public static BlockPos cellPos(BlockPos controllerPos, DisplayGeometry geometry, Direction facing, Cell cell) { Direction columnAxis = facing.getCounterClockWise(); Direction back = facing.getOpposite(); - return controllerPos.relative(columnAxis, cell.column() - type.controllerColumn()) + return controllerPos.relative(columnAxis, cell.column() - geometry.controllerColumn()) .above(cell.row()) .relative(back, cell.depthCell()); } - public static @Nullable Cell cellOf(BlockPos controllerPos, DisplayType type, Direction facing, BlockPos pos) { + public static @Nullable Cell cellOf(BlockPos controllerPos, DisplayGeometry geometry, Direction facing, BlockPos pos) { Direction columnAxis = facing.getCounterClockWise(); Direction back = facing.getOpposite(); BlockPos delta = pos.subtract(controllerPos); - int column = type.controllerColumn() + int column = geometry.controllerColumn() + delta.getX() * columnAxis.getStepX() + delta.getY() * columnAxis.getStepY() + delta.getZ() * columnAxis.getStepZ(); int row = delta.getY(); int depthCell = delta.getX() * back.getStepX() + delta.getZ() * back.getStepZ(); - if (column < 0 || column >= type.widthBlocks() || row < 0 || row >= type.heightBlocks()) { + if (column < 0 || column >= geometry.widthBlocks() || row < 0 || row >= geometry.heightBlocks()) { return null; } return new Cell(column, row, depthCell); } - public static VoxelShape cellShape(DisplayType type, Direction facing, int column, int depthCell) { - return cellShapeForRow(type, facing, column, 0, depthCell); + public static VoxelShape cellShape(DisplayGeometry geometry, Direction facing, int column, int depthCell) { + return cellShapeForRow(geometry, facing, column, 0, depthCell); } - public static VoxelShape controllerStandShape(DisplayType type, Direction facing) { - float z = Math.clamp(type.baseZ() - 1.0F, 1.0F, 14.0F); + public static VoxelShape controllerStandShape(DisplayGeometry geometry, Direction facing) { + float z = Math.clamp(geometry.baseZ() - 1.0F, 1.0F, 14.0F); VoxelShape north = Block.box(4.0, 0.0, z - 2.0F, 12.0, 8.0, z + 2.0F); return rotateFromNorth(north, facing); } - private static VoxelShape northCellShape(DisplayType type, int column, int depthCell) { - return northCellShape(type, column, 0, depthCell); + private static VoxelShape northCellShape(DisplayGeometry geometry, int column, int depthCell) { + return northCellShape(geometry, column, 0, depthCell); } - private static VoxelShape northCellShape(DisplayType type, int column, int row, int depthCell) { - float width = type.displayWidthPx(); - float localBottom = Math.max(0.0F, type.screenBottom() - row * 16.0F); - float localTop = Math.min(16.0F, type.screenTop() - row * 16.0F); + private static VoxelShape northCellShape(DisplayGeometry geometry, int column, int row, int depthCell) { + float width = geometry.displayWidthPx(); + float localBottom = Math.max(0.0F, geometry.screenBottom() - row * 16.0F); + float localTop = Math.min(16.0F, geometry.screenTop() - row * 16.0F); if (localTop - localBottom < 0.01F) { return Shapes.empty(); } @@ -110,10 +110,10 @@ private static VoxelShape northCellShape(DisplayType type, int column, int row, for (int i = 0; i < SUB_BOXES_PER_CELL; i++) { float dLo = column * 16.0F + i * (16.0F / SUB_BOXES_PER_CELL); float dHi = dLo + 16.0F / SUB_BOXES_PER_CELL; - if (dHi <= type.screenLeft() || dLo >= type.screenRight()) { + if (dHi <= geometry.screenLeft() || dLo >= geometry.screenRight()) { continue; } - float[] range = depthRange(type, dLo / width, dHi / width); + float[] range = depthRange(geometry, dLo / width, dHi / width); float zMin = range[0] - HALF_THICKNESS - depthCell * 16.0F; float zMax = range[1] + HALF_THICKNESS - depthCell * 16.0F; zMin = Math.max(0.0F, zMin); @@ -128,8 +128,8 @@ private static VoxelShape northCellShape(DisplayType type, int column, int row, return shape; } - public static VoxelShape cellShapeForRow(DisplayType type, Direction facing, int column, int row, int depthCell) { - VoxelShape north = northCellShape(type, column, row, depthCell); + public static VoxelShape cellShapeForRow(DisplayGeometry geometry, Direction facing, int column, int row, int depthCell) { + VoxelShape north = northCellShape(geometry, column, row, depthCell); if (north.isEmpty()) { return north; } @@ -157,18 +157,18 @@ private static VoxelShape rotateFromNorth(VoxelShape north, Direction facing) { return buffer[0]; } - private static float[] columnDepthRange(DisplayType type, int column) { - float width = type.displayWidthPx(); - return depthRange(type, column * 16.0F / width, (column + 1) * 16.0F / width); + private static float[] columnDepthRange(DisplayGeometry geometry, int column) { + float width = geometry.displayWidthPx(); + return depthRange(geometry, column * 16.0F / width, (column + 1) * 16.0F / width); } - private static float[] depthRange(DisplayType type, float u0, float u1) { - float a = type.depthAt(u0); - float b = type.depthAt(u1); + private static float[] depthRange(DisplayGeometry geometry, float u0, float u1) { + float a = geometry.depthAt(u0); + float b = geometry.depthAt(u1); float min = Math.min(a, b); float max = Math.max(a, b); if (u0 < 0.5F && u1 > 0.5F) { - max = Math.max(max, type.depthAt(0.5F)); + max = Math.max(max, geometry.depthAt(0.5F)); } return new float[]{min, max}; } diff --git a/src/main/java/com/pixelreel/blocks/ScreenwriterConsoleBlock.java b/src/main/java/com/pixelreel/blocks/ScreenwriterConsoleBlock.java new file mode 100644 index 0000000..a8ba36e --- /dev/null +++ b/src/main/java/com/pixelreel/blocks/ScreenwriterConsoleBlock.java @@ -0,0 +1,33 @@ +package com.pixelreel.blocks; + +import com.pixelreel.server.ScreenwriterManager; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; + +/** Central console for Screenwriter theatre automation. */ +public class ScreenwriterConsoleBlock extends Block { + public ScreenwriterConsoleBlock(BlockBehaviour.Properties properties) { + super(properties); + } + + @Override + protected InteractionResult useWithoutItem( + BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hitResult + ) { + if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer && level.getServer() != null) { + if (player.isShiftKeyDown()) { + ScreenwriterManager.toggleEnabled(level.getServer(), serverPlayer); + } else { + ScreenwriterManager.selectConsole(serverPlayer, pos); + } + } + return InteractionResult.SUCCESS; + } +} diff --git a/src/main/java/com/pixelreel/commands/ScreenwriterCommand.java b/src/main/java/com/pixelreel/commands/ScreenwriterCommand.java new file mode 100644 index 0000000..5a0ca84 --- /dev/null +++ b/src/main/java/com/pixelreel/commands/ScreenwriterCommand.java @@ -0,0 +1,179 @@ +package com.pixelreel.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.pixelreel.server.ScreenwriterManager; +import java.util.ArrayList; +import java.util.List; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.Filterable; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.WrittenBookContent; + +/** /screenwriter commands for theatre automation and schedule output. */ +public final class ScreenwriterCommand { + private ScreenwriterCommand() { + } + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> build(dispatcher)); + } + + private static void build(CommandDispatcher dispatcher) { + dispatcher.register( + Commands.literal("screenwriter") + .executes(context -> status(context.getSource())) + .then(Commands.literal("status").executes(context -> status(context.getSource()))) + .then(Commands.literal("on").executes(context -> setEnabled(context.getSource(), true))) + .then(Commands.literal("off").executes(context -> setEnabled(context.getSource(), false))) + .then(Commands.literal("schedule").executes(context -> schedule(context.getSource()))) + .then(Commands.literal("book").executes(context -> book(context.getSource()))) + .then(Commands.literal("generate").executes(context -> generate(context.getSource(), false))) + .then(Commands.literal("regenerate").executes(context -> generate(context.getSource(), true))) + .then(Commands.literal("source") + .then(Commands.argument("provider", StringArgumentType.word()) + .executes(context -> source(context.getSource(), StringArgumentType.getString(context, "provider"))))) + .then(Commands.literal("open") + .then(Commands.argument("time", StringArgumentType.word()) + .executes(context -> open(context.getSource(), StringArgumentType.getString(context, "time"))))) + .then(Commands.literal("close") + .then(Commands.argument("time", StringArgumentType.word()) + .executes(context -> close(context.getSource(), StringArgumentType.getString(context, "time"))))) + .then(Commands.literal("turnaround") + .then(Commands.argument("minutes", IntegerArgumentType.integer(0, 120)) + .executes(context -> turnaround(context.getSource(), IntegerArgumentType.getInteger(context, "minutes"))))) + .then(Commands.literal("stagger") + .then(Commands.argument("minutes", IntegerArgumentType.integer(0, 180)) + .executes(context -> stagger(context.getSource(), IntegerArgumentType.getInteger(context, "minutes"))))) + .then(Commands.literal("maxstreams") + .then(Commands.argument("shows", IntegerArgumentType.integer(0, ScreenwriterManager.MAX_SCREENS)) + .executes(context -> maxStreams(context.getSource(), IntegerArgumentType.getInteger(context, "shows"))))) + ); + } + + private static int status(CommandSourceStack source) { + source.sendSuccess(ScreenwriterManager::statusComponent, false); + source.sendSuccess( + () -> Component.literal("Console: right-click to arm linking; Shift + right-click to toggle automation.") + .withStyle(ChatFormatting.GRAY), + false + ); + source.sendSuccess( + () -> Component.literal("Linking: after selecting the console, sneak + right-click a custom screen with the Remote Control.") + .withStyle(ChatFormatting.GRAY), + false + ); + return 1; + } + + private static int setEnabled(CommandSourceStack source, boolean enabled) { + ScreenwriterManager.setEnabled(source.getServer(), enabled, source.getPlayer()); + if (source.getPlayer() == null) { + source.sendSuccess(() -> Component.literal("Screenwriter automation " + (enabled ? "ON" : "OFF") + "."), false); + } + return 1; + } + + private static int schedule(CommandSourceStack source) { + for (Component line : ScreenwriterManager.scheduleChatLines()) { + source.sendSuccess(() -> line, false); + } + return 1; + } + + private static int book(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + source.sendFailure(Component.literal("Only a player can receive the Screenwriter schedule book.")); + return 0; + } + List pageText = ScreenwriterManager.scheduleBookPages(); + List> pages = new ArrayList<>(); + for (String page : pageText) { + pages.add(Filterable.passThrough(Component.literal(page))); + } + WrittenBookContent content = new WrittenBookContent( + Filterable.passThrough("Today's Movie Schedule"), + "Screenwriter", + 0, + pages, + true + ); + ItemStack stack = new ItemStack(Items.WRITTEN_BOOK); + stack.set(DataComponents.WRITTEN_BOOK_CONTENT, content); + if (!player.getInventory().add(stack)) { + player.drop(stack, false); + } + player.sendSystemMessage(Component.literal("Received today's Screenwriter schedule book.").withStyle(ChatFormatting.GREEN)); + return 1; + } + + private static int generate(CommandSourceStack source, boolean force) { + ScreenwriterManager.generateToday( + source.getServer(), + force, + component -> source.sendSuccess(() -> component, false) + ); + return 1; + } + + private static int source(CommandSourceStack source, String provider) { + ScreenwriterManager.setProviderPreference( + source.getServer(), + provider, + component -> source.sendSuccess(() -> component, false) + ); + return 1; + } + + private static int open(CommandSourceStack source, String time) { + return ScreenwriterManager.setOpenTime( + source.getServer(), + time, + component -> source.sendSuccess(() -> component, false) + ) ? 1 : 0; + } + + private static int close(CommandSourceStack source, String time) { + return ScreenwriterManager.setCloseTime( + source.getServer(), + time, + component -> source.sendSuccess(() -> component, false) + ) ? 1 : 0; + } + + private static int turnaround(CommandSourceStack source, int minutes) { + ScreenwriterManager.setTurnaroundMinutes( + source.getServer(), + minutes, + component -> source.sendSuccess(() -> component, false) + ); + return 1; + } + + private static int stagger(CommandSourceStack source, int minutes) { + ScreenwriterManager.setStaggerMinutes( + source.getServer(), + minutes, + component -> source.sendSuccess(() -> component, false) + ); + return 1; + } + + private static int maxStreams(CommandSourceStack source, int shows) { + ScreenwriterManager.setMaxConcurrentShows( + source.getServer(), + shows, + component -> source.sendSuccess(() -> component, false) + ); + return 1; + } +} diff --git a/src/main/java/com/pixelreel/commands/TvCommand.java b/src/main/java/com/pixelreel/commands/TvCommand.java index dd44aea..3dbb131 100644 --- a/src/main/java/com/pixelreel/commands/TvCommand.java +++ b/src/main/java/com/pixelreel/commands/TvCommand.java @@ -692,7 +692,7 @@ private static int rebuild(CommandContext context) { source.sendFailure(Component.translatable("command.pixelreel.no_screen_in_sight")); return 0; } - int placed = DisplayBlock.placePanels(display.getLevel(), display.getBlockPos(), block.type(), display.facing(), false); + int placed = DisplayBlock.placePanels(display.getLevel(), display.getBlockPos(), display.geometry(), display.facing(), false); source.sendSuccess(() -> Component.translatable("command.pixelreel.rebuild.done", placed).withStyle(ChatFormatting.GREEN), false); return 1; } diff --git a/src/main/java/com/pixelreel/items/CustomScreenBlockItem.java b/src/main/java/com/pixelreel/items/CustomScreenBlockItem.java new file mode 100644 index 0000000..31180a1 --- /dev/null +++ b/src/main/java/com/pixelreel/items/CustomScreenBlockItem.java @@ -0,0 +1,32 @@ +package com.pixelreel.items; + +import com.pixelreel.ClientBridge; +import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.block.Block; + +/** + * Configurable cinema-screen item. Both flat and curved custom screens use the + * same size/audio placement dialog; the server selects geometry from the held item. + */ +public class CustomScreenBlockItem extends DisplayBlockItem { + private final boolean curved; + + public CustomScreenBlockItem(Block block, Item.Properties properties, String tooltipKey, boolean curved) { + super(block, properties, tooltipKey); + this.curved = curved; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + BlockPlaceContext placement = new BlockPlaceContext(context); + BlockPos controllerPos = placement.getClickedPos(); + if (context.getLevel().isClientSide()) { + ClientBridge.openCustomScreenPlacement(controllerPos, context.getHand(), this.curved); + } + return InteractionResult.SUCCESS; + } +} diff --git a/src/main/java/com/pixelreel/items/RemoteControlItem.java b/src/main/java/com/pixelreel/items/RemoteControlItem.java new file mode 100644 index 0000000..89291db --- /dev/null +++ b/src/main/java/com/pixelreel/items/RemoteControlItem.java @@ -0,0 +1,55 @@ +package com.pixelreel.items; + +import com.pixelreel.ClientBridge; +import com.pixelreel.blockentities.DisplayBlockEntity; +import com.pixelreel.blocks.DisplayBlock; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; + +/** Handheld remote for controlling screens and linking them to Screenwriter. */ +public class RemoteControlItem extends Item { + private static final double RANGE = 48.0D; + + public RemoteControlItem(Properties properties) { + super(properties); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + ItemStack held = player.getItemInHand(hand); + HitResult hit = player.pick(RANGE, 1.0F, false); + if (hit instanceof BlockHitResult blockHit && hit.getType() == HitResult.Type.BLOCK) { + DisplayBlockEntity display = DisplayBlock.controllerAt(level, blockHit.getBlockPos()); + if (display != null) { + if (level.isClientSide()) { + if (player.isShiftKeyDown() && display.isCustomScreen()) { + ClientBridge.openScreenwriterLink(display.getBlockPos()); + } else if (!player.isShiftKeyDown()) { + ClientBridge.openChannelMenu(display.getBlockPos()); + } + } + return InteractionResultHolder.sidedSuccess(held, level.isClientSide()); + } + } + if (level.isClientSide()) { + player.displayClientMessage(Component.translatable("message.pixelreel.remote.no_screen"), true); + } + return InteractionResultHolder.sidedSuccess(held, level.isClientSide()); + } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, List tooltipComponents, TooltipFlag flag) { + super.appendHoverText(stack, context, tooltipComponents, flag); + tooltipComponents.add(Component.translatable("item.pixelreel.remote_control.tooltip").withStyle(ChatFormatting.GRAY)); + } +} diff --git a/src/main/java/com/pixelreel/networking/ModNetworkPayloads.java b/src/main/java/com/pixelreel/networking/ModNetworkPayloads.java index e00dcfa..3a6e5aa 100644 --- a/src/main/java/com/pixelreel/networking/ModNetworkPayloads.java +++ b/src/main/java/com/pixelreel/networking/ModNetworkPayloads.java @@ -31,6 +31,8 @@ private ModNetworkPayloads() { public static void register() { PayloadTypeRegistry serverbound = PayloadTypeRegistry.playC2S(); serverbound.register(ScreenControl.TYPE, ScreenControl.CODEC); + serverbound.register(PlaceCustomScreen.TYPE, PlaceCustomScreen.CODEC); + serverbound.register(ScreenwriterLink.TYPE, ScreenwriterLink.CODEC); serverbound.register(ScreenTune.TYPE, ScreenTune.CODEC); serverbound.register(RequestChannels.TYPE, RequestChannels.CODEC); serverbound.register(RequestMediaFeatures.TYPE, RequestMediaFeatures.CODEC); @@ -270,6 +272,48 @@ public Type type() { } } + public record PlaceCustomScreen( + BlockPos pos, int width, int height, int audioSettings, boolean offHand + ) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(PixelReel.id("place_custom_screen")); + public static final StreamCodec CODEC = StreamCodec.composite( + BlockPos.STREAM_CODEC, + PlaceCustomScreen::pos, + ByteBufCodecs.VAR_INT, + PlaceCustomScreen::width, + ByteBufCodecs.VAR_INT, + PlaceCustomScreen::height, + ByteBufCodecs.VAR_INT, + PlaceCustomScreen::audioSettings, + ByteBufCodecs.BOOL, + PlaceCustomScreen::offHand, + PlaceCustomScreen::new + ); + + public static int packAudioSettings(int depth, int side) { + return (Math.max(0, depth) << 7) | (Math.max(0, side) & 0x7F); + } + + public int audioDepth() { return this.audioSettings >>> 7; } + public int audioSide() { return this.audioSettings & 0x7F; } + + @Override + public Type type() { return TYPE; } + } + + public record ScreenwriterLink(BlockPos pos, int auditorium, boolean unlink) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(PixelReel.id("screenwriter_link")); + public static final StreamCodec CODEC = StreamCodec.composite( + BlockPos.STREAM_CODEC, ScreenwriterLink::pos, + ByteBufCodecs.VAR_INT, ScreenwriterLink::auditorium, + ByteBufCodecs.BOOL, ScreenwriterLink::unlink, + ScreenwriterLink::new + ); + + @Override + public Type type() { return TYPE; } + } + public record ScreenControl(BlockPos pos, ScreenAction action, float value) implements CustomPacketPayload { public static final Type TYPE = new Type<>(PixelReel.id("screen_control")); public static final StreamCodec CODEC = StreamCodec.composite( diff --git a/src/main/java/com/pixelreel/networking/ServerNetworking.java b/src/main/java/com/pixelreel/networking/ServerNetworking.java index 3811918..7714fd4 100644 --- a/src/main/java/com/pixelreel/networking/ServerNetworking.java +++ b/src/main/java/com/pixelreel/networking/ServerNetworking.java @@ -3,6 +3,10 @@ import com.pixelreel.PixelReel; import com.pixelreel.blockentities.DisplayBlockEntity; import com.pixelreel.blockentities.WallPosterBlockEntity; +import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.blocks.DisplayGeometry; +import com.pixelreel.blocks.DisplayType; +import com.pixelreel.blocks.ScreenShapes; import com.pixelreel.blocks.WallPosterBlock; import com.pixelreel.channels.Channel; import com.pixelreel.channels.ChannelEntry; @@ -26,15 +30,25 @@ import com.pixelreel.poster.WatchingDisplays; import com.pixelreel.poster.CustomPosterStore; import com.pixelreel.poster.CustomPosterUploads; +import com.pixelreel.registry.ModBlocks; +import com.pixelreel.registry.ModItems; import com.pixelreel.server.ScreenControllerLogic; +import com.pixelreel.server.ScreenwriterManager; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; import org.jetbrains.annotations.Nullable; public final class ServerNetworking { @@ -52,6 +66,12 @@ public static void register() { ServerPlayNetworking.registerGlobalReceiver( ModNetworkPayloads.ScreenControl.TYPE, (payload, context) -> handleControl(payload, context.player()) ); + ServerPlayNetworking.registerGlobalReceiver( + ModNetworkPayloads.PlaceCustomScreen.TYPE, (payload, context) -> handlePlaceCustomScreen(payload, context.player()) + ); + ServerPlayNetworking.registerGlobalReceiver( + ModNetworkPayloads.ScreenwriterLink.TYPE, (payload, context) -> handleScreenwriterLink(payload, context.player()) + ); ServerPlayNetworking.registerGlobalReceiver( ModNetworkPayloads.ScreenTune.TYPE, (payload, context) -> handleTune(payload, context.player()) ); @@ -120,6 +140,102 @@ public static void register() { ); } + private static void handleScreenwriterLink(ModNetworkPayloads.ScreenwriterLink payload, ServerPlayer player) { + DisplayBlockEntity display = ScreenControllerLogic.resolve(player, payload.pos()); + if (display == null) { + player.sendSystemMessage(Component.literal("That screen is too far away or is no longer available.")); + return; + } + ScreenwriterManager.configureLinkedScreen(player, display, payload.auditorium(), payload.unlink()); + } + + private static void handlePlaceCustomScreen(ModNetworkPayloads.PlaceCustomScreen payload, ServerPlayer player) { + int width = payload.width(); + int height = payload.height(); + int audioDepth = payload.audioDepth(); + int audioSide = payload.audioSide(); + if (width < DisplayGeometry.MIN_WIDTH || width > DisplayGeometry.MAX_WIDTH + || height < DisplayGeometry.MIN_HEIGHT || height > DisplayGeometry.MAX_HEIGHT) { + player.sendSystemMessage(Component.translatable( + "message.pixelreel.custom_screen.invalid", + DisplayGeometry.MIN_WIDTH, DisplayGeometry.MAX_WIDTH, + DisplayGeometry.MIN_HEIGHT, DisplayGeometry.MAX_HEIGHT + )); + return; + } + if (audioDepth < DisplayBlockEntity.MIN_CUSTOM_AUDIO_DEPTH || audioDepth > DisplayBlockEntity.MAX_CUSTOM_AUDIO_DEPTH + || audioSide < DisplayBlockEntity.MIN_CUSTOM_AUDIO_SIDE || audioSide > DisplayBlockEntity.MAX_CUSTOM_AUDIO_SIDE) { + player.sendSystemMessage(Component.translatable("message.pixelreel.custom_screen.audio_invalid")); + return; + } + + InteractionHand hand = payload.offHand() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; + ItemStack held = player.getItemInHand(hand); + boolean curved = held.is(ModItems.CINEMA_SCREEN); + boolean flat = held.is(ModItems.CURVED_CINEMA_SCREEN); + if (!curved && !flat) { + return; + } + + BlockPos controllerPos = payload.pos(); + if (player.distanceToSqr(Vec3.atCenterOf(controllerPos)) > 64.0D) { + player.sendSystemMessage(Component.translatable("message.pixelreel.custom_screen.too_far")); + return; + } + ServerLevel level = player.serverLevel(); + Direction facing = player.getDirection().getOpposite(); + DisplayType displayType = curved ? DisplayType.CINEMA_SCREEN : DisplayType.CURVED_CINEMA_SCREEN; + DisplayGeometry geometry = curved + ? DisplayGeometry.customCurved(width, height, displayType.audioRange()) + : DisplayGeometry.customFlat(width, height, displayType.audioRange()); + + if (!canPlaceCustomScreen(level, controllerPos, geometry, facing)) { + player.sendSystemMessage(Component.translatable("message.pixelreel.custom_screen.blocked", width, height)); + return; + } + + DisplayBlock controllerBlock = curved ? ModBlocks.CINEMA_SCREEN : ModBlocks.CURVED_CINEMA_SCREEN; + BlockState controllerState = controllerBlock.defaultBlockState() + .setValue(DisplayBlock.FACING, facing) + .setValue(DisplayBlock.POWERED, Boolean.FALSE); + if (!level.setBlock(controllerPos, controllerState, Block.UPDATE_ALL)) { + player.sendSystemMessage(Component.translatable("message.pixelreel.custom_screen.blocked", width, height)); + return; + } + + if (!(level.getBlockEntity(controllerPos) instanceof DisplayBlockEntity display)) { + level.removeBlock(controllerPos, false); + player.sendSystemMessage(Component.translatable("message.pixelreel.custom_screen.failed")); + return; + } + display.setCustomSize(width, height); + display.setCustomAudioZone(audioDepth, audioSide); + DisplayBlock.placePanels(level, controllerPos, display.geometry(), facing, false); + if (!player.getAbilities().instabuild) { + held.shrink(1); + } + player.sendSystemMessage(Component.translatable("message.pixelreel.custom_screen.placed", width, height)); + } + + private static boolean canPlaceCustomScreen( + ServerLevel level, BlockPos controllerPos, DisplayGeometry geometry, Direction facing + ) { + for (ScreenShapes.Cell cell : ScreenShapes.cells(geometry)) { + if (cell.depthCell() > 0) { + continue; + } + BlockPos pos = ScreenShapes.cellPos(controllerPos, geometry, facing, cell); + if (!level.isInWorldBounds(pos) || !level.isLoaded(pos)) { + return false; + } + BlockState state = level.getBlockState(pos); + if (!state.isAir() && !state.canBeReplaced()) { + return false; + } + } + return true; + } + private static void handleControl(ModNetworkPayloads.ScreenControl payload, ServerPlayer player) { DisplayBlockEntity display = ScreenControllerLogic.resolve(player, payload.pos()); if (display == null) { diff --git a/src/main/java/com/pixelreel/ondemand/OnDemandCatalog.java b/src/main/java/com/pixelreel/ondemand/OnDemandCatalog.java index e2b2965..542ed2b 100644 --- a/src/main/java/com/pixelreel/ondemand/OnDemandCatalog.java +++ b/src/main/java/com/pixelreel/ondemand/OnDemandCatalog.java @@ -65,6 +65,15 @@ public static void refreshConfigured(boolean force) { } } + /** Cached movie list from the provider's configured libraries. */ + public static List movies(OnDemandProvider provider) { + return switch (provider) { + case JELLYFIN -> JellyfinService.INSTANCE.movies(null); + case EMBY -> EmbyService.INSTANCE.movies(null); + case PLEX -> PlexService.INSTANCE.movies(null); + }; + } + public static Page page(OnDemandProvider provider, BrowseKind kind, String search, int page) { return switch (provider) { case JELLYFIN -> { diff --git a/src/main/java/com/pixelreel/registry/ModBlocks.java b/src/main/java/com/pixelreel/registry/ModBlocks.java index 10a89ae..2ef3b69 100644 --- a/src/main/java/com/pixelreel/registry/ModBlocks.java +++ b/src/main/java/com/pixelreel/registry/ModBlocks.java @@ -5,6 +5,7 @@ import com.pixelreel.blocks.DisplayType; import com.pixelreel.blocks.PosterPanelBlock; import com.pixelreel.blocks.ScreenPanelBlock; +import com.pixelreel.blocks.ScreenwriterConsoleBlock; import com.pixelreel.blocks.WallPosterBlock; import com.pixelreel.poster.PosterSize; import java.util.List; @@ -27,9 +28,12 @@ public final class ModBlocks { public static final WallPosterBlock WALL_POSTER = registerWallPoster(PosterSize.STANDARD); public static final WallPosterBlock HUGE_WALL_POSTER = registerWallPoster(PosterSize.HUGE); public static final PosterPanelBlock POSTER_PANEL = registerPosterPanel(); + public static final ScreenwriterConsoleBlock SCREENWRITER_CONSOLE = registerScreenwriterConsole(); + /** Keep every legacy display registered so existing worlds continue to load. */ public static final List ALL_DISPLAYS = List.of( - COMPACT_TELEVISION, WALL_TELEVISION, ULTRAWIDE_MONITOR, CINEMA_SCREEN, CURVED_CINEMA_SCREEN, GIANT_CINEMA_SCREEN + COMPACT_TELEVISION, WALL_TELEVISION, ULTRAWIDE_MONITOR, + CINEMA_SCREEN, CURVED_CINEMA_SCREEN, GIANT_CINEMA_SCREEN ); private ModBlocks() { @@ -51,7 +55,7 @@ private static DisplayBlock registerDisplay(DisplayType type) { private static ScreenPanelBlock registerPanel() { BlockBehaviour.Properties properties = BlockBehaviour.Properties.of() .mapColor(MapColor.NONE) - .strength(-1.0F, 3600000.0F) + .strength(1.5F, 6.0F) .noOcclusion() .pushReaction(PushReaction.BLOCK) .noLootTable() @@ -90,6 +94,18 @@ private static BlockBehaviour.Properties posterProperties() { .isViewBlocking((state, level, pos) -> false); } + private static ScreenwriterConsoleBlock registerScreenwriterConsole() { + BlockBehaviour.Properties properties = BlockBehaviour.Properties.of() + .mapColor(MapColor.COLOR_BLACK) + .strength(2.5F, 8.0F) + .sound(SoundType.METAL); + return Registry.register( + BuiltInRegistries.BLOCK, + PixelReel.id("screenwriter_console"), + new ScreenwriterConsoleBlock(properties) + ); + } + public static void init() { } } diff --git a/src/main/java/com/pixelreel/registry/ModCreativeTabs.java b/src/main/java/com/pixelreel/registry/ModCreativeTabs.java index 3515f63..c0721a0 100644 --- a/src/main/java/com/pixelreel/registry/ModCreativeTabs.java +++ b/src/main/java/com/pixelreel/registry/ModCreativeTabs.java @@ -22,7 +22,7 @@ public static void init() { PIXEL_REEL, FabricItemGroup.builder() .title(Component.translatable("itemGroup.pixelreel.pixelreel")) - .icon(() -> new ItemStack(ModItems.WALL_TELEVISION)) + .icon(() -> new ItemStack(ModItems.CURVED_CINEMA_SCREEN)) .displayItems((parameters, output) -> ModItems.TAB_CONTENTS.forEach(output::accept)) .build() ); diff --git a/src/main/java/com/pixelreel/registry/ModItems.java b/src/main/java/com/pixelreel/registry/ModItems.java index 70acede..6cccbb9 100644 --- a/src/main/java/com/pixelreel/registry/ModItems.java +++ b/src/main/java/com/pixelreel/registry/ModItems.java @@ -2,8 +2,10 @@ import com.pixelreel.PixelReel; import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.items.CustomScreenBlockItem; import com.pixelreel.items.DisplayBlockItem; import com.pixelreel.items.PixelGlassesItem; +import com.pixelreel.items.RemoteControlItem; import com.pixelreel.items.WallPosterItem; import java.util.List; import java.util.function.Function; @@ -13,11 +15,12 @@ import net.minecraft.world.item.Item; public final class ModItems { + // Legacy display items remain registered for existing-world compatibility, but are hidden from normal obtaining. public static final Item COMPACT_TELEVISION = registerDisplayItem(ModBlocks.COMPACT_TELEVISION, "item.pixelreel.display.type.television"); public static final Item WALL_TELEVISION = registerDisplayItem(ModBlocks.WALL_TELEVISION, "item.pixelreel.display.type.television"); public static final Item ULTRAWIDE_MONITOR = registerDisplayItem(ModBlocks.ULTRAWIDE_MONITOR, "item.pixelreel.display.type.monitor"); - public static final Item CINEMA_SCREEN = registerDisplayItem(ModBlocks.CINEMA_SCREEN, "item.pixelreel.display.type.cinema"); - public static final Item CURVED_CINEMA_SCREEN = registerDisplayItem(ModBlocks.CURVED_CINEMA_SCREEN, "item.pixelreel.display.type.cinema"); + public static final Item CINEMA_SCREEN = registerCustomScreenItem(ModBlocks.CINEMA_SCREEN, true); + public static final Item CURVED_CINEMA_SCREEN = registerCustomScreenItem(ModBlocks.CURVED_CINEMA_SCREEN, false); public static final Item GIANT_CINEMA_SCREEN = registerDisplayItem(ModBlocks.GIANT_CINEMA_SCREEN, "item.pixelreel.display.type.cinema"); public static final Item WALL_POSTER = register( "wall_poster", @@ -29,20 +32,28 @@ public final class ModItems { properties -> new WallPosterItem(ModBlocks.HUGE_WALL_POSTER, properties), new Item.Properties() ); + public static final Item SCREENWRITER_CONSOLE = register( + "screenwriter_console", + properties -> new BlockItem(ModBlocks.SCREENWRITER_CONSOLE, properties), + new Item.Properties() + ); + public static final Item REMOTE_CONTROL = register( + "remote_control", + RemoteControlItem::new, + new Item.Properties().stacksTo(1) + ); public static final Item PIXEL_GLASSES = register( "pixel_glasses", PixelGlassesItem::new, - // PixelGlassesItem.use() handles equip/unequip to avoid creative-dupe swap behaviour. new Item.Properties().stacksTo(1) ); + /** Only the two custom screens are exposed as screen items; poster tools remain available. */ public static final List TAB_CONTENTS = List.of( - COMPACT_TELEVISION, - WALL_TELEVISION, - ULTRAWIDE_MONITOR, - CINEMA_SCREEN, CURVED_CINEMA_SCREEN, - GIANT_CINEMA_SCREEN, + CINEMA_SCREEN, + SCREENWRITER_CONSOLE, + REMOTE_CONTROL, WALL_POSTER, HUGE_WALL_POSTER, PIXEL_GLASSES @@ -51,6 +62,15 @@ public final class ModItems { private ModItems() { } + private static Item registerCustomScreenItem(DisplayBlock block, boolean curved) { + String name = BuiltInRegistries.BLOCK.getKey(block).getPath(); + return register( + name, + properties -> new CustomScreenBlockItem(block, properties, "item.pixelreel.display.type.custom", curved), + new Item.Properties().stacksTo(1) + ); + } + private static Item registerDisplayItem(DisplayBlock block, String tooltipKey) { String name = BuiltInRegistries.BLOCK.getKey(block).getPath(); return register(name, properties -> new DisplayBlockItem(block, properties, tooltipKey), new Item.Properties()); diff --git a/src/main/java/com/pixelreel/server/ScreenwriterManager.java b/src/main/java/com/pixelreel/server/ScreenwriterManager.java new file mode 100644 index 0000000..90a244c --- /dev/null +++ b/src/main/java/com/pixelreel/server/ScreenwriterManager.java @@ -0,0 +1,1049 @@ +package com.pixelreel.server; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.pixelreel.PixelReel; +import com.pixelreel.blockentities.DisplayBlockEntity; +import com.pixelreel.blocks.DisplayBlock; +import com.pixelreel.config.ConfigManager; +import com.pixelreel.config.PixelReelConfig; +import com.pixelreel.jellyfin.JellyfinItemKind; +import com.pixelreel.jellyfin.JellyfinItemSummary; +import com.pixelreel.networking.ScreenAction; +import com.pixelreel.ondemand.OnDemandCatalog; +import com.pixelreel.ondemand.OnDemandProvider; +import com.pixelreel.registry.ModBlocks; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelResource; +import org.jetbrains.annotations.Nullable; + +/** + * Real-time theatre automation for linked PixelReel screens. + * + *

Schedules are persisted in the world folder, so a server restart does not + * reroll the day. When the server comes back during a scheduled movie, playback + * resumes at the elapsed real-world position.

+ */ +public final class ScreenwriterManager { + public static final String ACTOR = "Screenwriter"; + public static final int MAX_SCREENS = 30; + public static final int HISTORY_DAYS = 3; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + private static final DateTimeFormatter DISPLAY_TIME = DateTimeFormatter.ofPattern("h:mm a"); + private static final long LINK_ARM_MILLIS = 120_000L; + private static final long GENERATION_RETRY_MILLIS = 60_000L; + private static final long START_RETRY_MILLIS = 20_000L; + + private static final Map LINK_ARMED = new HashMap<>(); + private static final Map START_ATTEMPTS = new HashMap<>(); + private static State state = new State(); + private static @Nullable MinecraftServer activeServer; + private static boolean generationInProgress; + private static long lastGenerationAttemptMillis; + private static int tickCounter; + + private ScreenwriterManager() { + } + + public static void start(MinecraftServer server) { + activeServer = server; + state = load(server); + sanitizeState(); + generationInProgress = false; + lastGenerationAttemptMillis = 0L; + tickCounter = 0; + LINK_ARMED.clear(); + START_ATTEMPTS.clear(); + PixelReel.LOGGER.info( + "Screenwriter loaded: {} linked screen(s), automation {}", + state.screens.size(), + state.enabled ? "enabled" : "disabled" + ); + } + + public static void stop(MinecraftServer server) { + if (activeServer == server) { + save(server); + } + activeServer = null; + LINK_ARMED.clear(); + START_ATTEMPTS.clear(); + } + + public static void tick(MinecraftServer server) { + if (activeServer != server) { + return; + } + if (++tickCounter < 20) { + return; + } + tickCounter = 0; + LINK_ARMED.entrySet().removeIf(entry -> entry.getValue() < System.currentTimeMillis()); + START_ATTEMPTS.entrySet().removeIf(entry -> System.currentTimeMillis() - entry.getValue() > 120_000L); + if (!state.enabled) { + return; + } + + LocalDate today = LocalDate.now(); + if (!today.toString().equals(state.scheduleDate) && !state.screens.isEmpty()) { + long now = System.currentTimeMillis(); + if (!generationInProgress && now - lastGenerationAttemptMillis >= GENERATION_RETRY_MILLIS) { + generateToday(server, false, null); + } + } + runSchedule(server, System.currentTimeMillis()); + } + + public static boolean isEnabled() { + return state.enabled; + } + + public static void setEnabled(MinecraftServer server, boolean enabled, @Nullable ServerPlayer actor) { + state.enabled = enabled; + if (!enabled) { + stopControlledScreens(server); + } + save(server); + if (actor != null) { + actor.sendSystemMessage( + Component.literal("Screenwriter automation " + (enabled ? "ON" : "OFF") + ".") + .withStyle(enabled ? ChatFormatting.GREEN : ChatFormatting.RED)); + } + if (enabled && !LocalDate.now().toString().equals(state.scheduleDate) && !state.screens.isEmpty()) { + generateToday(server, false, actor == null ? null : component -> actor.sendSystemMessage(component)); + } + } + + public static void toggleEnabled(MinecraftServer server, ServerPlayer actor) { + setEnabled(server, !state.enabled, actor); + } + + public static void selectConsole(ServerPlayer player, BlockPos consolePos) { + if (!(player.level().getBlockState(consolePos).getBlock() == ModBlocks.SCREENWRITER_CONSOLE)) { + return; + } + LINK_ARMED.put(player.getUUID(), System.currentTimeMillis() + LINK_ARM_MILLIS); + player.sendSystemMessage( + Component.literal("Screenwriter selected. Sneak + right-click a custom cinema screen with the Remote Control, then choose its auditorium number.") + .withStyle(ChatFormatting.AQUA)); + player.sendSystemMessage( + Component.literal("Linked screens: " + state.screens.size() + "/" + MAX_SCREENS + " | Automation: " + (state.enabled ? "ON" : "OFF")) + .withStyle(ChatFormatting.GRAY)); + sendConsoleOverview(player); + } + + /** Applies a user-selected auditorium number to a linked custom screen. */ + public static void configureLinkedScreen(ServerPlayer player, DisplayBlockEntity display, int auditorium, boolean unlink) { + Long until = LINK_ARMED.get(player.getUUID()); + if (until == null || until < System.currentTimeMillis()) { + player.sendSystemMessage( + Component.literal("Select a Screenwriter Console first, then try again within 2 minutes.") + .withStyle(ChatFormatting.RED)); + return; + } + if (!display.isCustomScreen()) { + player.sendSystemMessage(Component.literal("Only custom cinema screens can be linked to Screenwriter.").withStyle(ChatFormatting.RED)); + return; + } + MinecraftServer server = player.level().getServer(); + if (server == null) { + return; + } + + String dimension = player.level().dimension().location().toString(); + BlockPos pos = display.getBlockPos(); + Optional existing = state.screens.stream() + .filter(screen -> screen.matches(dimension, pos)) + .findFirst(); + + if (unlink) { + if (existing.isEmpty()) { + player.sendSystemMessage(Component.literal("That screen is not linked to Screenwriter.").withStyle(ChatFormatting.YELLOW)); + return; + } + LinkedScreen removed = existing.get(); + state.screens.remove(removed); + save(server); + player.sendSystemMessage( + Component.literal("Unlinked " + removed.name + " from Screenwriter.").withStyle(ChatFormatting.YELLOW)); + return; + } + + if (auditorium < 1 || auditorium > MAX_SCREENS) { + player.sendSystemMessage( + Component.literal("Auditorium number must be between 1 and " + MAX_SCREENS + ".").withStyle(ChatFormatting.RED)); + return; + } + + Optional numberConflict = state.screens.stream() + .filter(screen -> screen.auditorium == auditorium) + .filter(screen -> !screen.matches(dimension, pos)) + .findFirst(); + if (numberConflict.isPresent()) { + player.sendSystemMessage( + Component.literal("Auditorium " + auditorium + " is already linked to another screen.").withStyle(ChatFormatting.RED)); + return; + } + + if (existing.isPresent()) { + LinkedScreen linked = existing.get(); + int oldAuditorium = linked.auditorium; + linked.auditorium = auditorium; + linked.name = "Auditorium " + auditorium; + if (oldAuditorium != auditorium) { + for (ScheduledShow show : state.shows) { + if (show.auditorium == oldAuditorium) { + show.auditorium = auditorium; + } + } + } + state.screens.sort(Comparator.comparingInt(screen -> screen.auditorium)); + save(server); + player.sendSystemMessage( + Component.literal(oldAuditorium == auditorium + ? linked.name + " is already linked to this screen." + : "Changed this screen from Auditorium " + oldAuditorium + " to " + linked.name + ".") + .withStyle(ChatFormatting.GREEN)); + return; + } + + if (state.screens.size() >= MAX_SCREENS) { + player.sendSystemMessage(Component.literal("Screenwriter already has " + MAX_SCREENS + " linked screens.").withStyle(ChatFormatting.RED)); + return; + } + + LinkedScreen linked = new LinkedScreen(); + linked.auditorium = auditorium; + linked.name = "Auditorium " + auditorium; + linked.dimension = dimension; + linked.x = pos.getX(); + linked.y = pos.getY(); + linked.z = pos.getZ(); + state.screens.add(linked); + state.screens.sort(Comparator.comparingInt(screen -> screen.auditorium)); + save(server); + player.sendSystemMessage( + Component.literal("Linked screen as " + linked.name + " (" + state.screens.size() + "/" + MAX_SCREENS + ").") + .withStyle(ChatFormatting.GREEN)); + } + + private static void sendConsoleOverview(ServerPlayer player) { + long now = System.currentTimeMillis(); + if (state.shows.isEmpty()) { + player.sendSystemMessage(Component.literal("Today's schedule has not been generated yet. Use /screenwriter generate if needed.").withStyle(ChatFormatting.YELLOW)); + return; + } + player.sendSystemMessage(Component.literal("Today's Screenwriter overview:").withStyle(ChatFormatting.GOLD)); + for (LinkedScreen screen : state.screens.stream().sorted(Comparator.comparingInt(s -> s.auditorium)).toList()) { + ScheduledShow current = currentShow(screen.auditorium, now); + ScheduledShow next = null; + for (ScheduledShow show : showsFor(screen.auditorium)) { + if (show.startEpochMs > now) { + next = show; + break; + } + } + String text; + if (current != null) { + text = screen.name + ": PLAYING " + current.title + " until " + formatTime(current.endEpochMs); + } else if (next != null) { + text = screen.name + ": next " + next.title + " at " + formatTime(next.startEpochMs); + } else { + text = screen.name + ": no more shows today"; + } + player.sendSystemMessage(Component.literal(text).withStyle(ChatFormatting.GRAY)); + } + } + + public static void setProviderPreference(MinecraftServer server, String raw, Consumer feedback) { + String value = raw == null ? "AUTO" : raw.trim().toUpperCase(); + if (!value.equals("AUTO") && !value.equals("JELLYFIN") && !value.equals("PLEX")) { + feedback.accept(Component.literal("Provider must be auto, jellyfin, or plex.").withStyle(ChatFormatting.RED)); + return; + } + state.provider = value; + save(server); + feedback.accept(Component.literal("Screenwriter media provider set to " + value + ".").withStyle(ChatFormatting.GREEN)); + } + + public static boolean setOpenTime(MinecraftServer server, String raw, Consumer feedback) { + LocalTime time = parseTime(raw); + if (time == null) { + feedback.accept(Component.literal("Use 24-hour time without a colon, such as 1100 or 1830 (a colon is also accepted). ").withStyle(ChatFormatting.RED)); + return false; + } + LocalTime close = parseTime(state.closeTime); + if (close != null && !time.isBefore(close)) { + feedback.accept(Component.literal("Opening time must be before closing time.").withStyle(ChatFormatting.RED)); + return false; + } + state.openTime = TIME_FORMAT.format(time); + save(server); + feedback.accept(Component.literal("Screenwriter opening time set to " + DISPLAY_TIME.format(time) + ".").withStyle(ChatFormatting.GREEN)); + return true; + } + + public static boolean setCloseTime(MinecraftServer server, String raw, Consumer feedback) { + LocalTime time = parseTime(raw); + if (time == null) { + feedback.accept(Component.literal("Use 24-hour time without a colon, such as 2330 or 1800 (a colon is also accepted). ").withStyle(ChatFormatting.RED)); + return false; + } + LocalTime open = parseTime(state.openTime); + if (open != null && !time.isAfter(open)) { + feedback.accept(Component.literal("Closing time must be after opening time.").withStyle(ChatFormatting.RED)); + return false; + } + state.closeTime = TIME_FORMAT.format(time); + save(server); + feedback.accept(Component.literal("Screenwriter closing time set to " + DISPLAY_TIME.format(time) + ".").withStyle(ChatFormatting.GREEN)); + return true; + } + + public static void setTurnaroundMinutes(MinecraftServer server, int minutes, Consumer feedback) { + state.turnaroundMinutes = Math.clamp(minutes, 0, 120); + save(server); + feedback.accept(Component.literal("Screenwriter turnaround time set to " + state.turnaroundMinutes + " minutes.").withStyle(ChatFormatting.GREEN)); + } + + public static void setStaggerMinutes(MinecraftServer server, int minutes, Consumer feedback) { + state.staggerMinutes = Math.clamp(minutes, 0, 180); + save(server); + feedback.accept(Component.literal( + state.staggerMinutes == 0 + ? "Screenwriter first-show staggering disabled." + : "Screenwriter will spread first showtimes across " + state.staggerMinutes + " minutes." + ).withStyle(ChatFormatting.GREEN)); + if (!state.shows.isEmpty()) { + feedback.accept(Component.literal("Use /screenwriter regenerate to apply the new stagger to today's existing schedule.") + .withStyle(ChatFormatting.GRAY)); + } + } + + public static void setMaxConcurrentShows(MinecraftServer server, int maximum, Consumer feedback) { + state.maxConcurrentShows = Math.clamp(maximum, 0, MAX_SCREENS); + save(server); + feedback.accept(Component.literal( + state.maxConcurrentShows == 0 + ? "Screenwriter simultaneous-show limit disabled (unlimited)." + : "Screenwriter will schedule at most " + state.maxConcurrentShows + " simultaneous show(s)." + ).withStyle(ChatFormatting.GREEN)); + if (!state.shows.isEmpty()) { + feedback.accept(Component.literal("Use /screenwriter regenerate to apply the new limit to today's existing schedule.") + .withStyle(ChatFormatting.GRAY)); + } + } + + public static void generateToday(MinecraftServer server, boolean force, @Nullable Consumer feedback) { + if (generationInProgress) { + if (feedback != null) { + feedback.accept(Component.literal("Screenwriter is already generating a schedule.").withStyle(ChatFormatting.YELLOW)); + } + return; + } + if (state.screens.isEmpty()) { + if (feedback != null) { + feedback.accept(Component.literal("Link at least one custom cinema screen first.").withStyle(ChatFormatting.RED)); + } + return; + } + LocalDate today = LocalDate.now(); + if (!force && today.toString().equals(state.scheduleDate) && !state.shows.isEmpty()) { + if (feedback != null) { + feedback.accept(Component.literal("Today's Screenwriter schedule already exists.").withStyle(ChatFormatting.YELLOW)); + } + return; + } + OnDemandProvider provider = resolvedProvider(); + if (provider == null) { + if (feedback != null) { + feedback.accept(Component.literal("No configured Jellyfin/Plex movie provider is available for Screenwriter.").withStyle(ChatFormatting.RED)); + } + lastGenerationAttemptMillis = System.currentTimeMillis(); + return; + } + + generationInProgress = true; + lastGenerationAttemptMillis = System.currentTimeMillis(); + if (feedback != null) { + feedback.accept(Component.literal("Screenwriter is reading the " + provider.displayName() + " movie libraries...").withStyle(ChatFormatting.AQUA)); + } + OnDemandCatalog.refresh(provider, true).whenComplete((status, error) -> server.execute(() -> { + generationInProgress = false; + if (error != null) { + PixelReel.LOGGER.warn("Screenwriter library refresh failed", error); + if (feedback != null) { + feedback.accept(Component.literal("Screenwriter could not refresh the movie library: " + error.getMessage()).withStyle(ChatFormatting.RED)); + } + return; + } + List movies = OnDemandCatalog.movies(provider).stream() + .filter(item -> item.kind() == JellyfinItemKind.MOVIE) + .filter(item -> item.runtimeMs() > 0L) + .toList(); + if (movies.isEmpty()) { + if (feedback != null) { + feedback.accept(Component.literal("No movies with runtimes were found in the selected libraries.").withStyle(ChatFormatting.RED)); + } + return; + } + int count = buildSchedule(today, provider, movies); + save(server); + if (feedback != null) { + feedback.accept(Component.literal("Generated today's Screenwriter schedule with " + count + " show(s).") + .withStyle(count > 0 ? ChatFormatting.GREEN : ChatFormatting.YELLOW)); + } + })); + } + + private static int buildSchedule(LocalDate date, OnDemandProvider provider, List sourceMovies) { + LocalTime open = parseTime(state.openTime); + LocalTime close = parseTime(state.closeTime); + if (open == null) { + open = LocalTime.of(11, 0); + } + if (close == null || !close.isAfter(open)) { + close = LocalTime.of(23, 30); + } + ZoneId zone = ZoneId.systemDefault(); + long openMs = date.atTime(open).atZone(zone).toInstant().toEpochMilli(); + long closeMs = date.atTime(close).atZone(zone).toInstant().toEpochMilli(); + Set blocked = blockedByHistory(date); + Set usedToday = new HashSet<>(); + List pool = new ArrayList<>(sourceMovies); + Collections.shuffle(pool, new Random(System.nanoTime() ^ date.toEpochDay())); + List generated = new ArrayList<>(); + List orderedScreens = state.screens.stream() + .sorted(Comparator.comparingInt(s -> s.auditorium)) + .toList(); + + // Build the day chronologically across all auditoriums instead of filling one + // auditorium's entire day first. This keeps a simultaneous-show cap fair: if + // the cap is 4 and 10 screens are linked, later auditorium numbers still get + // showtimes as capacity opens up rather than being starved by auditoriums 1-4. + Map nextStarts = new HashMap<>(); + Set exhausted = new HashSet<>(); + for (int screenIndex = 0; screenIndex < orderedScreens.size(); screenIndex++) { + LinkedScreen screen = orderedScreens.get(screenIndex); + nextStarts.put( + screen.auditorium, + openMs + firstShowOffsetMillis(screenIndex, orderedScreens.size(), state.staggerMinutes) + ); + } + + while (!pool.isEmpty() && exhausted.size() < orderedScreens.size()) { + LinkedScreen screen = null; + long cursor = Long.MAX_VALUE; + for (LinkedScreen candidateScreen : orderedScreens) { + if (exhausted.contains(candidateScreen.auditorium)) { + continue; + } + long candidateCursor = nextStarts.getOrDefault(candidateScreen.auditorium, closeMs); + if (candidateCursor < cursor + || (candidateCursor == cursor && screen != null && candidateScreen.auditorium < screen.auditorium)) { + screen = candidateScreen; + cursor = candidateCursor; + } + } + if (screen == null || cursor >= closeMs) { + break; + } + + JellyfinItemSummary chosen = null; + long chosenStart = -1L; + for (JellyfinItemSummary item : pool) { + String key = mediaKey(provider, item.id()); + if (blocked.contains(key) || usedToday.contains(key)) { + continue; + } + long candidateStart = findNextCapacityStart( + cursor, + item.runtimeMs(), + closeMs, + generated, + state.maxConcurrentShows + ); + if (candidateStart < 0L) { + continue; + } + if (chosen == null || candidateStart < chosenStart) { + chosen = item; + chosenStart = candidateStart; + if (candidateStart == cursor) { + break; + } + } + } + + if (chosen == null) { + exhausted.add(screen.auditorium); + continue; + } + + ScheduledShow show = new ScheduledShow(); + show.auditorium = screen.auditorium; + show.provider = provider.name(); + show.itemId = chosen.id(); + show.title = chosen.title(); + show.runtimeMs = chosen.runtimeMs(); + show.startEpochMs = chosenStart; + show.endEpochMs = chosenStart + chosen.runtimeMs(); + generated.add(show); + String key = mediaKey(provider, chosen.id()); + usedToday.add(key); + pool.remove(chosen); + nextStarts.put( + screen.auditorium, + roundUp(show.endEpochMs + state.turnaroundMinutes * 60_000L, state.roundMinutes) + ); + } + + state.scheduleDate = date.toString(); + state.shows = generated; + state.history.put(date.toString(), new ArrayList<>(usedToday)); + pruneHistory(date); + return generated.size(); + } + + /** + * Finds the earliest time at or after {@code desiredStart} where adding one + * more show would not exceed Screenwriter's simultaneous-show limit. + * A limit of zero means unlimited. + */ + private static long findNextCapacityStart( + long desiredStart, + long runtimeMs, + long closeMs, + List existing, + int maxConcurrentShows + ) { + if (runtimeMs <= 0L || desiredStart + runtimeMs > closeMs) { + return -1L; + } + if (maxConcurrentShows <= 0) { + return desiredStart; + } + + long start = desiredStart; + while (start + runtimeMs <= closeMs) { + long end = start + runtimeMs; + long conflictAt = firstCapacityConflict(start, end, existing, maxConcurrentShows); + if (conflictAt < 0L) { + return start; + } + + long nextFree = Long.MAX_VALUE; + for (ScheduledShow show : existing) { + if (show.startEpochMs <= conflictAt && show.endEpochMs > conflictAt) { + nextFree = Math.min(nextFree, show.endEpochMs); + } + } + if (nextFree == Long.MAX_VALUE || nextFree <= start) { + return -1L; + } + start = roundUp(nextFree, state.roundMinutes); + } + return -1L; + } + + /** Returns the first instant where the existing schedule is already at capacity. */ + private static long firstCapacityConflict( + long start, + long end, + List existing, + int maxConcurrentShows + ) { + List checkpoints = new ArrayList<>(); + checkpoints.add(start); + for (ScheduledShow show : existing) { + if (show.startEpochMs > start && show.startEpochMs < end) { + checkpoints.add(show.startEpochMs); + } + } + Collections.sort(checkpoints); + for (long point : checkpoints) { + int active = 0; + for (ScheduledShow show : existing) { + if (show.startEpochMs <= point && show.endEpochMs > point) { + active++; + } + } + if (active >= maxConcurrentShows) { + return point; + } + } + return -1L; + } + + private static void runSchedule(MinecraftServer server, long now) { + if (!LocalDate.now().toString().equals(state.scheduleDate)) { + return; + } + for (LinkedScreen linked : state.screens) { + DisplayBlockEntity display = resolveDisplay(server, linked); + if (display == null) { + continue; + } + ScheduledShow current = currentShow(linked.auditorium, now); + if (current == null) { + if (ACTOR.equals(display.getControllingPlayer()) && display.isPowered()) { + ScreenControllerLogic.apply(display, ScreenAction.POWER_OFF, 0.0F, ACTOR); + } + continue; + } + OnDemandProvider provider = OnDemandProvider.byName(current.provider); + boolean correctMovie = display.isOnDemand() + && display.onDemandProvider() == provider + && current.itemId.equals(display.getJellyfinItemId()); + if (correctMovie) { + if (!display.isPowered()) { + ScreenControllerLogic.apply(display, ScreenAction.POWER_ON, 0.0F, ACTOR); + } + if (display.isSuspended() || display.isPlaybackPaused()) { + ScreenControllerLogic.apply(display, ScreenAction.UNPAUSE, 0.0F, ACTOR); + } + continue; + } + + String attemptKey = linked.auditorium + ":" + current.startEpochMs + ":" + current.itemId; + long previousAttempt = START_ATTEMPTS.getOrDefault(attemptKey, 0L); + if (now - previousAttempt < START_RETRY_MILLIS) { + continue; + } + START_ATTEMPTS.put(attemptKey, now); + long elapsed = Math.clamp(now - current.startEpochMs, 0L, Math.max(0L, current.runtimeMs - 1L)); + ScreenControllerLogic.playOnDemandItem(display, provider, current.itemId, elapsed, ACTOR); + } + } + + private static @Nullable ScheduledShow currentShow(int auditorium, long now) { + for (ScheduledShow show : state.shows) { + if (show.auditorium == auditorium && now >= show.startEpochMs && now < show.endEpochMs) { + return show; + } + } + return null; + } + + private static @Nullable DisplayBlockEntity resolveDisplay(MinecraftServer server, LinkedScreen linked) { + ResourceLocation id = ResourceLocation.tryParse(linked.dimension); + if (id == null) { + return null; + } + ResourceKey key = ResourceKey.create(Registries.DIMENSION, id); + ServerLevel level = server.getLevel(key); + if (level == null) { + return null; + } + BlockPos pos = new BlockPos(linked.x, linked.y, linked.z); + if (!level.isLoaded(pos)) { + return null; + } + return DisplayBlock.displayAt(level, pos); + } + + private static void stopControlledScreens(MinecraftServer server) { + for (LinkedScreen linked : state.screens) { + DisplayBlockEntity display = resolveDisplay(server, linked); + if (display != null && ACTOR.equals(display.getControllingPlayer()) && display.isPowered()) { + ScreenControllerLogic.apply(display, ScreenAction.POWER_OFF, 0.0F, ACTOR); + } + } + } + + private static @Nullable OnDemandProvider resolvedProvider() { + PixelReelConfig config = ConfigManager.get(); + String preference = state.provider == null ? "AUTO" : state.provider.toUpperCase(); + if (preference.equals("JELLYFIN")) { + return config.isJellyfinConfigured() && config.jellyfinMoviesEnabled ? OnDemandProvider.JELLYFIN : null; + } + if (preference.equals("PLEX")) { + return config.isPlexConfigured() && config.plexMoviesEnabled ? OnDemandProvider.PLEX : null; + } + if (config.isJellyfinConfigured() && config.jellyfinMoviesEnabled) { + return OnDemandProvider.JELLYFIN; + } + if (config.isPlexConfigured() && config.plexMoviesEnabled) { + return OnDemandProvider.PLEX; + } + return null; + } + + private static Set blockedByHistory(LocalDate today) { + Set blocked = new HashSet<>(); + for (int i = 1; i <= HISTORY_DAYS; i++) { + List entries = state.history.get(today.minusDays(i).toString()); + if (entries != null) { + blocked.addAll(entries); + } + } + return blocked; + } + + private static void pruneHistory(LocalDate today) { + state.history.entrySet().removeIf(entry -> { + try { + LocalDate date = LocalDate.parse(entry.getKey()); + return date.isBefore(today.minusDays(HISTORY_DAYS + 2L)); + } catch (DateTimeParseException ignored) { + return true; + } + }); + } + + private static long firstShowOffsetMillis(int screenIndex, int screenCount, int staggerMinutes) { + if (screenIndex <= 0 || screenCount <= 1 || staggerMinutes <= 0) { + return 0L; + } + double fraction = screenIndex / (double)(screenCount - 1); + long offsetMinutes = Math.round(fraction * staggerMinutes); + return offsetMinutes * 60_000L; + } + + private static long roundUp(long epochMs, int minutes) { + int safeMinutes = Math.clamp(minutes, 1, 60); + long quantum = safeMinutes * 60_000L; + long remainder = Math.floorMod(epochMs, quantum); + return remainder == 0L ? epochMs : epochMs + quantum - remainder; + } + + private static String mediaKey(OnDemandProvider provider, String itemId) { + return provider.name() + ":" + itemId; + } + + public static Component statusComponent() { + OnDemandProvider provider = resolvedProvider(); + String providerText = state.provider + (provider == null ? " (unavailable)" : " -> " + provider.displayName()); + return Component.literal( + "Screenwriter " + (state.enabled ? "ON" : "OFF") + + " | " + state.screens.size() + "/" + MAX_SCREENS + " screens" + + " | provider " + providerText + + " | " + state.openTime + "-" + state.closeTime + + " | turnaround " + state.turnaroundMinutes + "m" + + " | first-show stagger " + state.staggerMinutes + "m" + + " | max simultaneous " + (state.maxConcurrentShows == 0 ? "unlimited" : state.maxConcurrentShows) + + " | schedule " + (state.scheduleDate.isBlank() ? "none" : state.scheduleDate) + ).withStyle(ChatFormatting.AQUA); + } + + public static List scheduleChatLines() { + List lines = new ArrayList<>(); + if (state.shows.isEmpty()) { + lines.add(Component.literal("No Screenwriter schedule has been generated yet.").withStyle(ChatFormatting.YELLOW)); + return lines; + } + lines.add(Component.literal("SCREENWRITER — " + state.scheduleDate).withStyle(ChatFormatting.AQUA)); + for (LinkedScreen screen : state.screens.stream().sorted(Comparator.comparingInt(s -> s.auditorium)).toList()) { + lines.add(Component.literal(screen.name).withStyle(ChatFormatting.GOLD)); + List shows = showsFor(screen.auditorium); + if (shows.isEmpty()) { + lines.add(Component.literal(" No shows scheduled").withStyle(ChatFormatting.GRAY)); + continue; + } + for (ScheduledShow show : shows) { + lines.add(Component.literal(" " + formatShow(show)).withStyle(ChatFormatting.WHITE)); + } + } + return lines; + } + + /** + * Builds Written Book pages using a conservative visual-line budget instead of + * a raw character limit. Minecraft books only display a small number of lines + * per page, so long auditorium schedules are continued onto as many pages as + * needed instead of being visually cut off at the bottom. + */ + public static List scheduleBookPages() { + final int maxLinesPerPage = 14; + final int wrapAtCharacters = 22; + List pages = new ArrayList<>(); + if (state.shows.isEmpty()) { + pages.add("SCREENWRITER\n" + LocalDate.now() + "\n\nNo schedule generated."); + return pages; + } + + for (LinkedScreen screen : state.screens.stream().sorted(Comparator.comparingInt(s -> s.auditorium)).toList()) { + List shows = showsFor(screen.auditorium); + int part = 1; + List pageLines = bookHeaderLines(screen.name, part); + + if (shows.isEmpty()) { + pageLines.add("No shows scheduled"); + pages.add(String.join("\n", pageLines)); + continue; + } + + for (ScheduledShow show : shows) { + List entryLines = new ArrayList<>(); + entryLines.addAll(wrapBookText(formatTime(show.startEpochMs) + " - " + formatTime(show.endEpochMs), wrapAtCharacters)); + entryLines.addAll(wrapBookText(show.title == null || show.title.isBlank() ? "Untitled" : show.title, wrapAtCharacters)); + + // Normally keep a show together. If a very long title cannot fit on one + // page, continue it line-by-line rather than truncating any information. + if (pageLines.size() > 4 && pageLines.size() + entryLines.size() > maxLinesPerPage) { + pages.add(String.join("\n", pageLines)); + part++; + pageLines = bookHeaderLines(screen.name, part); + } + + for (String line : entryLines) { + if (pageLines.size() >= maxLinesPerPage) { + pages.add(String.join("\n", pageLines)); + part++; + pageLines = bookHeaderLines(screen.name, part); + } + pageLines.add(line); + } + } + + pages.add(String.join("\n", pageLines)); + } + return pages; + } + + private static List bookHeaderLines(String screenName, int part) { + List lines = new ArrayList<>(); + lines.add("SCREENWRITER"); + lines.add(state.scheduleDate); + lines.add(""); + lines.add(part == 1 ? screenName : screenName + " (cont. " + part + ")"); + lines.add(""); + return lines; + } + + private static List wrapBookText(String text, int maxCharacters) { + List lines = new ArrayList<>(); + String remaining = text == null ? "" : text.trim(); + if (remaining.isEmpty()) { + lines.add(""); + return lines; + } + while (remaining.length() > maxCharacters) { + int split = remaining.lastIndexOf(' ', maxCharacters); + if (split <= 0) { + split = maxCharacters; + } + lines.add(remaining.substring(0, split).stripTrailing()); + remaining = remaining.substring(split).stripLeading(); + } + if (!remaining.isEmpty()) { + lines.add(remaining); + } + return lines; + } + + private static List showsFor(int auditorium) { + return state.shows.stream() + .filter(show -> show.auditorium == auditorium) + .sorted(Comparator.comparingLong(show -> show.startEpochMs)) + .toList(); + } + + private static String formatShow(ScheduledShow show) { + return formatTime(show.startEpochMs) + " - " + formatTime(show.endEpochMs) + " " + show.title; + } + + private static String formatTime(long epochMs) { + return DISPLAY_TIME.format(Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault())); + } + + private static @Nullable LocalTime parseTime(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String value = raw.trim(); + + // Command-friendly 24-hour forms that do not require a colon: + // 9 -> 09:00, 11 -> 11:00, 930 -> 09:30, 1100 -> 11:00, + // 2330 -> 23:30. The original HH:mm form remains supported too. + if (value.chars().allMatch(Character::isDigit)) { + try { + int hour; + int minute; + if (value.length() <= 2) { + hour = Integer.parseInt(value); + minute = 0; + } else if (value.length() == 3) { + hour = Integer.parseInt(value.substring(0, 1)); + minute = Integer.parseInt(value.substring(1)); + } else if (value.length() == 4) { + hour = Integer.parseInt(value.substring(0, 2)); + minute = Integer.parseInt(value.substring(2)); + } else { + return null; + } + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null; + } + return LocalTime.of(hour, minute); + } catch (NumberFormatException ignored) { + return null; + } + } + + try { + return LocalTime.parse(value, TIME_FORMAT); + } catch (DateTimeParseException ignored) { + return null; + } + } + + private static void sanitizeState() { + if (state == null) { + state = new State(); + } + if (state.provider == null || state.provider.isBlank()) { + state.provider = "AUTO"; + } + if (state.openTime == null || parseTime(state.openTime) == null) { + state.openTime = "11:00"; + } + if (state.closeTime == null || parseTime(state.closeTime) == null) { + state.closeTime = "23:30"; + } + state.turnaroundMinutes = Math.clamp(state.turnaroundMinutes, 0, 120); + // Existing Screenwriter save files predate some newer scheduling settings. + // Preserve their existing enabled state while migrating missing numeric values. + if (state.dataVersion < 2) { + state.staggerMinutes = 30; + } else { + state.staggerMinutes = Math.clamp(state.staggerMinutes, 0, 180); + } + if (state.dataVersion < 3) { + state.maxConcurrentShows = 4; + state.dataVersion = 3; + } else { + state.maxConcurrentShows = Math.clamp(state.maxConcurrentShows, 0, MAX_SCREENS); + } + state.roundMinutes = Math.clamp(state.roundMinutes, 1, 60); + if (state.screens == null) { + state.screens = new ArrayList<>(); + } + if (state.shows == null) { + state.shows = new ArrayList<>(); + } + if (state.history == null) { + state.history = new LinkedHashMap<>(); + } + if (state.scheduleDate == null) { + state.scheduleDate = ""; + } + state.screens.removeIf(screen -> screen == null || screen.dimension == null || screen.dimension.isBlank()); + state.screens.sort(Comparator.comparingInt(screen -> screen.auditorium)); + if (state.screens.size() > MAX_SCREENS) { + state.screens = new ArrayList<>(state.screens.subList(0, MAX_SCREENS)); + } + } + + private static State load(MinecraftServer server) { + Path path = statePath(server); + if (!Files.exists(path)) { + return new State(); + } + try (Reader reader = Files.newBufferedReader(path)) { + State loaded = GSON.fromJson(reader, State.class); + return loaded == null ? new State() : loaded; + } catch (Exception e) { + PixelReel.LOGGER.warn("Could not load Screenwriter state from {}", path, e); + return new State(); + } + } + + private static void save(MinecraftServer server) { + Path path = statePath(server); + Path temp = path.resolveSibling(path.getFileName() + ".tmp"); + try { + Files.createDirectories(path.getParent()); + try (Writer writer = Files.newBufferedWriter(temp)) { + GSON.toJson(state, writer); + } + try { + Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicFailure) { + Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + PixelReel.LOGGER.warn("Could not save Screenwriter state to {}", path, e); + } + } + + private static Path statePath(MinecraftServer server) { + return server.getWorldPath(LevelResource.ROOT).resolve("pixelreel-screenwriter.json"); + } + + private static final class State { + int dataVersion = 3; + boolean enabled = false; + String provider = "AUTO"; + String openTime = "11:00"; + String closeTime = "23:30"; + int turnaroundMinutes = 20; + int staggerMinutes = 30; + int maxConcurrentShows = 4; + int roundMinutes = 10; + String scheduleDate = ""; + List screens = new ArrayList<>(); + List shows = new ArrayList<>(); + Map> history = new LinkedHashMap<>(); + } + + private static final class LinkedScreen { + int auditorium; + String name = ""; + String dimension = "minecraft:overworld"; + int x; + int y; + int z; + + boolean matches(String dimension, BlockPos pos) { + return this.dimension.equals(dimension) && this.x == pos.getX() && this.y == pos.getY() && this.z == pos.getZ(); + } + } + + private static final class ScheduledShow { + int auditorium; + String provider = "JELLYFIN"; + String itemId = ""; + String title = ""; + long runtimeMs; + long startEpochMs; + long endEpochMs; + } +} diff --git a/src/main/resources/assets/pixelreel/blockstates/screenwriter_console.json b/src/main/resources/assets/pixelreel/blockstates/screenwriter_console.json new file mode 100644 index 0000000..9ba2bf3 --- /dev/null +++ b/src/main/resources/assets/pixelreel/blockstates/screenwriter_console.json @@ -0,0 +1,5 @@ +{ + "variants": { + "": { "model": "pixelreel:block/screenwriter_console" } + } +} diff --git a/src/main/resources/assets/pixelreel/lang/en_us.json b/src/main/resources/assets/pixelreel/lang/en_us.json index c67f202..353c6c1 100644 --- a/src/main/resources/assets/pixelreel/lang/en_us.json +++ b/src/main/resources/assets/pixelreel/lang/en_us.json @@ -1,17 +1,15 @@ { - "block.pixelreel.compact_television": "Compact Television", - "block.pixelreel.wall_television": "Wall Television", - "block.pixelreel.ultrawide_monitor": "Ultrawide Monitor", - "block.pixelreel.cinema_screen": "Cinema Screen", - "block.pixelreel.curved_cinema_screen": "Curved Cinema Screen", + "block.pixelreel.compact_television": "Cinema Screen 23x13", + "block.pixelreel.wall_television": "Cinema Screen 27x13 (Cropped)", + "block.pixelreel.ultrawide_monitor": "Cinema Screen 27x13", + "block.pixelreel.cinema_screen": "Custom Curved Cinema Screen", + "block.pixelreel.curved_cinema_screen": "Custom Cinema Screen", "block.pixelreel.giant_cinema_screen": "Giant Cinema Screen", "block.pixelreel.screen_panel": "Screen Panel", "block.pixelreel.wall_poster": "Movie Poster", "block.pixelreel.huge_wall_poster": "Huge Movie Poster", "block.pixelreel.poster_panel": "Poster Panel", - "itemGroup.pixelreel.pixelreel": "pixelReel", - "item.pixelreel.pixel_glasses": "Pixel Glasses", "item.pixelreel.pixel_glasses.tooltip": "Right-click to wear. Shows a nearby playing screen in fullscreen.", "item.pixelreel.pixel_glasses.remove_tooltip": "Press Esc or X to take them off. Right-click again while holding them to unequip.", @@ -19,16 +17,13 @@ "gui.pixelreel.glasses.escape_hint": "Press %s or Esc to exit", "key.pixelreel.remove_pixel_glasses": "Remove Pixel Glasses", "key.category.pixelreel.pixelreel": "pixelReel", - "item.pixelreel.display.keywords": "pixelReel · Live TV · television · screen · monitor · jellyfin", "item.pixelreel.display.type.television": "A television for watching live channels and on-demand media.", "item.pixelreel.display.type.monitor": "An ultrawide monitor for watching live channels and on-demand media.", "item.pixelreel.display.type.cinema": "A cinema screen for watching live channels and on-demand media together.", - "item.pixelreel.wall_poster.keywords": "pixelReel · poster · painting · art · lobby · decor", "item.pixelreel.wall_poster.tooltip": "Use on a wall to hang a 2x3 poster. Click anywhere on a large enough wall — it will fit itself.", "item.pixelreel.huge_wall_poster.tooltip": "Hangs a large movie poster on a wall — 10x15 if it fits, otherwise the biggest 2:3 size that does. The whole artwork is shown.", - "gui.pixelreel.poster.title": "Choose Poster Artwork", "gui.pixelreel.poster.tab_movies": "Movies", "gui.pixelreel.poster.tab_shows": "TV Shows", @@ -48,7 +43,6 @@ "gui.pixelreel.poster.custom_help": "Paste a link or choose a png, jpg, or gif. The game stays open. Files are saved in the world, not config.", "gui.pixelreel.poster.url_invalid": "That URL must start with http:// or https://", "gui.pixelreel.poster.uploading": "Uploading poster…", - "gui.pixelreel.menu.title": "Live TV - Tunarr", "gui.pixelreel.menu.subtitle": "%s channels · %s", "gui.pixelreel.menu.loading": "Loading channels...", @@ -69,7 +63,6 @@ "gui.pixelreel.tooltip.progress": "%s%% elapsed", "gui.pixelreel.tooltip.click": "Click to watch this channel", "gui.pixelreel.tooltip.click_media": "Click to open", - "gui.pixelreel.source.title": "Cinema Media", "gui.pixelreel.source.live": "Live TV — Tunarr", "gui.pixelreel.source.movies": "Movies", @@ -91,11 +84,9 @@ "gui.pixelreel.source.tunarr_ready": "Tunarr ready · %s channels", "gui.pixelreel.source.both_ready": "Tunarr %s channels · Jellyfin %s movies / %s shows", "gui.pixelreel.source.now_playing": "Now playing: %s", - "gui.pixelreel.provider.movies.title": "Choose Movies Source", "gui.pixelreel.provider.shows.title": "Choose TV Shows Source", "gui.pixelreel.provider.subtitle": "Select a media server", - "gui.pixelreel.tunarr.config.title": "Tunarr Configuration", "gui.pixelreel.tunarr.config.subtitle": "Set your live TV playlist · admins only by default", "gui.pixelreel.tunarr.config.m3u": "Tunarr URL or M3U playlist", @@ -105,7 +96,6 @@ "gui.pixelreel.tunarr.config.save": "Save & Test", "gui.pixelreel.tunarr.config.not_configured": "No Tunarr playlist configured yet", "gui.pixelreel.tunarr.config.ready": "Connected · %s channels", - "gui.pixelreel.jellyfin.movies.title": "Movies — %s", "gui.pixelreel.jellyfin.shows.title": "TV Shows — %s", "gui.pixelreel.jellyfin.search": "Search", @@ -134,7 +124,6 @@ "gui.pixelreel.jellyfin.config.autoplay_on": "Autoplay next episode: On", "gui.pixelreel.jellyfin.config.autoplay_off": "Autoplay next episode: Off", "gui.pixelreel.jellyfin.config.save": "Save & Test", - "gui.pixelreel.emby.config.title": "Emby Configuration", "gui.pixelreel.emby.config.subtitle": "Administrators only · API key is never shown to players", "gui.pixelreel.emby.config.url": "Server URL", @@ -146,7 +135,6 @@ "gui.pixelreel.emby.config.shows_on": "TV Shows: On", "gui.pixelreel.emby.config.shows_off": "TV Shows: Off", "gui.pixelreel.emby.config.save": "Save & Test", - "gui.pixelreel.plex.config.title": "Plex Configuration", "gui.pixelreel.plex.config.subtitle": "Administrators only · token is never shown to players", "gui.pixelreel.plex.config.url": "Server URL", @@ -157,7 +145,6 @@ "gui.pixelreel.plex.config.shows_on": "TV Shows: On", "gui.pixelreel.plex.config.shows_off": "TV Shows: Off", "gui.pixelreel.plex.config.save": "Save & Test", - "gui.pixelreel.playback.title": "Playback Controls", "gui.pixelreel.playback.open": "Playback Controls", "gui.pixelreel.playback.pause": "Pause", @@ -173,10 +160,8 @@ "gui.pixelreel.playback.subtitles_pick_empty": "Subtitles: none on this file", "gui.pixelreel.playback.subtitles_title": "Subtitles", "gui.pixelreel.playback.subtitles_current": "Current: %s", - "chat.pixelreel.status.player": "Player: %s · frame %s · host %s · buffer %s · last problem: %s", "chat.pixelreel.status.player_idle": "Player: idle (no local decoder for this display)", - "command.pixelreel.players_only": "This command can only be used by a player", "command.pixelreel.no_screen_in_sight": "Look at a television or cinema screen first", "command.pixelreel.channels.header": "Live channels (%s):", @@ -242,7 +227,6 @@ "command.pixelreel.jellyfin.refresh.done": "Jellyfin library refreshed (%s movies, %s shows)", "command.pixelreel.jellyfin.refresh.failed": "Jellyfin refresh failed: %s", "command.pixelreel.no_permission": "You do not have permission for that", - "message.pixelreel.no_screen": "That display no longer exists", "message.pixelreel.no_channels": "No channels available yet - check /tv status", "message.pixelreel.unknown_channel": "That channel is no longer in the playlist", @@ -274,5 +258,39 @@ "message.pixelreel.ondemand.refresh_failed": "On-demand library refresh failed", "message.pixelreel.tunarr.config_saved": "Tunarr configuration saved", "message.pixelreel.tunarr.config_failed": "Tunarr configuration saved, but the playlist could not be loaded", - "message.pixelreel.tunarr.invalid_url": "Enter a valid Tunarr base URL or M3U playlist URL" + "message.pixelreel.tunarr.invalid_url": "Enter a valid Tunarr base URL or M3U playlist URL", + "item.pixelreel.remote_control": "Remote Control", + "item.pixelreel.remote_control.tooltip": "Can be used to control screens or link to Screenwriter", + "message.pixelreel.remote.no_screen": "Aim directly at a PixelReel screen.", + "item.pixelreel.display.type.custom": "Choose the size and auditorium audio zone when you place this screen.", + "gui.pixelreel.custom_screen.title": "Custom Cinema Screen", + "gui.pixelreel.custom_screen.subtitle": "Choose screen size and auditorium audio zone", + "gui.pixelreel.custom_screen.width": "Width (blocks)", + "gui.pixelreel.custom_screen.height": "Height (blocks)", + "gui.pixelreel.custom_screen.place": "Place Screen", + "gui.pixelreel.custom_screen.cancel": "Cancel", + "gui.pixelreel.custom_screen.invalid": "Width %s-%s, height %s-%s", + "message.pixelreel.custom_screen.invalid": "That custom screen size is not allowed.", + "message.pixelreel.custom_screen.too_far": "Move closer to the placement location.", + "message.pixelreel.custom_screen.blocked": "A %sx%s screen will not fit there.", + "message.pixelreel.custom_screen.failed": "The custom screen could not be placed.", + "message.pixelreel.custom_screen.placed": "Placed a %sx%s custom screen.", + "block.pixelreel.screenwriter_console": "Screenwriter Console", + "item.pixelreel.screenwriter_console": "Screenwriter Console", + "gui.pixelreel.custom_curved_screen.title": "Custom Curved Cinema Screen", + "gui.pixelreel.custom_screen.audio_depth": "Audio depth (blocks)", + "gui.pixelreel.custom_screen.audio_hint": "Default: 40 deep, 2 blocks past each side", + "gui.pixelreel.custom_screen.audio_invalid": "Depth %s-%s, side range %s-%s", + "gui.pixelreel.custom_screen.audio_side": "Side range past screen edge (blocks)", + "gui.pixelreel.playback.reset_connection": "Reset Connection", + "gui.pixelreel.screenwriter_link.auditorium": "Auditorium number", + "gui.pixelreel.screenwriter_link.cancel": "Cancel", + "gui.pixelreel.screenwriter_link.invalid": "Enter an auditorium number from %s to %s", + "gui.pixelreel.screenwriter_link.link": "Link / Update", + "gui.pixelreel.screenwriter_link.subtitle": "Choose a unique auditorium number (1-%s)", + "gui.pixelreel.screenwriter_link.title": "Screenwriter Screen Link", + "gui.pixelreel.screenwriter_link.unlink": "Unlink", + "message.pixelreel.custom_screen.audio_invalid": "That custom screen audio zone is not allowed.", + "message.pixelreel.playback.connection_reset": "Screen connection reset — resyncing audio/video...", + "message.pixelreel.playback.no_connection": "No active screen connection to reset." } diff --git a/src/main/resources/assets/pixelreel/models/block/screenwriter_console.json b/src/main/resources/assets/pixelreel/models/block/screenwriter_console.json new file mode 100644 index 0000000..8e0a252 --- /dev/null +++ b/src/main/resources/assets/pixelreel/models/block/screenwriter_console.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "pixelreel:block/screenwriter_console" + } +} diff --git a/src/main/resources/assets/pixelreel/models/item/cinema_screen.json b/src/main/resources/assets/pixelreel/models/item/cinema_screen.json index 162a7d0..7c9e356 100644 --- a/src/main/resources/assets/pixelreel/models/item/cinema_screen.json +++ b/src/main/resources/assets/pixelreel/models/item/cinema_screen.json @@ -1 +1 @@ -{"parent":"minecraft:item/generated","textures":{"layer0":"pixelreel:item/cinema_screen"}} +{"parent":"minecraft:item/generated","textures":{"layer0":"pixelreel:item/curved_cinema_screen"}} diff --git a/src/main/resources/assets/pixelreel/models/item/curved_cinema_screen.json b/src/main/resources/assets/pixelreel/models/item/curved_cinema_screen.json index 7c9e356..162a7d0 100644 --- a/src/main/resources/assets/pixelreel/models/item/curved_cinema_screen.json +++ b/src/main/resources/assets/pixelreel/models/item/curved_cinema_screen.json @@ -1 +1 @@ -{"parent":"minecraft:item/generated","textures":{"layer0":"pixelreel:item/curved_cinema_screen"}} +{"parent":"minecraft:item/generated","textures":{"layer0":"pixelreel:item/cinema_screen"}} diff --git a/src/main/resources/assets/pixelreel/models/item/remote_control.json b/src/main/resources/assets/pixelreel/models/item/remote_control.json new file mode 100644 index 0000000..a794ac6 --- /dev/null +++ b/src/main/resources/assets/pixelreel/models/item/remote_control.json @@ -0,0 +1 @@ +{"parent":"minecraft:item/generated","textures":{"layer0":"pixelreel:item/remote_control"}} diff --git a/src/main/resources/assets/pixelreel/models/item/screenwriter_console.json b/src/main/resources/assets/pixelreel/models/item/screenwriter_console.json new file mode 100644 index 0000000..7625d61 --- /dev/null +++ b/src/main/resources/assets/pixelreel/models/item/screenwriter_console.json @@ -0,0 +1 @@ +{"parent":"pixelreel:block/screenwriter_console"} diff --git a/src/main/resources/assets/pixelreel/textures/block/screenwriter_console.png b/src/main/resources/assets/pixelreel/textures/block/screenwriter_console.png new file mode 100644 index 0000000..ac8af60 Binary files /dev/null and b/src/main/resources/assets/pixelreel/textures/block/screenwriter_console.png differ diff --git a/src/main/resources/assets/pixelreel/textures/item/remote_control.png b/src/main/resources/assets/pixelreel/textures/item/remote_control.png new file mode 100644 index 0000000..2391ede Binary files /dev/null and b/src/main/resources/assets/pixelreel/textures/item/remote_control.png differ diff --git a/src/main/resources/data/pixelreel/loot_table/blocks/screenwriter_console.json b/src/main/resources/data/pixelreel/loot_table/blocks/screenwriter_console.json new file mode 100644 index 0000000..678d298 --- /dev/null +++ b/src/main/resources/data/pixelreel/loot_table/blocks/screenwriter_console.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "pixelreel:screenwriter_console" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/pixelreel/recipe/cinema_screen.json b/src/main/resources/data/pixelreel/recipe/cinema_screen.json index 8d8c2a9..a04894e 100644 --- a/src/main/resources/data/pixelreel/recipe/cinema_screen.json +++ b/src/main/resources/data/pixelreel/recipe/cinema_screen.json @@ -1,24 +1,11 @@ { - "type": "minecraft:crafting_shaped", - "category": "misc", - "pattern": [ - "WWW", - "WRW", - "III" - ], - "key": { - "W": { - "item": "minecraft:white_wool" - }, - "R": { - "item": "minecraft:redstone" - }, - "I": { - "item": "minecraft:iron_ingot" - } - }, - "result": { - "id": "pixelreel:cinema_screen", - "count": 1 - } + "type":"minecraft:crafting_shaped","category":"misc", + "pattern":["WWW","QRQ","III"], + "key":{ + "I":{"item":"minecraft:iron_ingot"}, + "W":{"item":"minecraft:white_wool"}, + "Q":{"item":"minecraft:quartz"}, + "R":{"item":"minecraft:redstone"} + }, + "result":{"id":"pixelreel:cinema_screen","count":1} } diff --git a/src/main/resources/data/pixelreel/recipe/curved_cinema_screen.json b/src/main/resources/data/pixelreel/recipe/curved_cinema_screen.json index 1c8ed5c..14b73bf 100644 --- a/src/main/resources/data/pixelreel/recipe/curved_cinema_screen.json +++ b/src/main/resources/data/pixelreel/recipe/curved_cinema_screen.json @@ -1,27 +1,10 @@ { - "type": "minecraft:crafting_shaped", - "category": "misc", - "pattern": [ - "WWW", - "QRQ", - "III" - ], - "key": { - "Q": { - "item": "minecraft:quartz" - }, - "W": { - "item": "minecraft:white_wool" - }, - "R": { - "item": "minecraft:redstone" - }, - "I": { - "item": "minecraft:iron_ingot" - } - }, - "result": { - "id": "pixelreel:curved_cinema_screen", - "count": 1 - } + "type":"minecraft:crafting_shaped","category":"misc", + "pattern":["WWW","WRW","III"], + "key":{ + "I":{"item":"minecraft:iron_ingot"}, + "W":{"item":"minecraft:white_wool"}, + "R":{"item":"minecraft:redstone"} + }, + "result":{"id":"pixelreel:curved_cinema_screen","count":1} } diff --git a/src/main/resources/data/pixelreel/recipe/remote_control.json b/src/main/resources/data/pixelreel/recipe/remote_control.json new file mode 100644 index 0000000..4b2713d --- /dev/null +++ b/src/main/resources/data/pixelreel/recipe/remote_control.json @@ -0,0 +1,10 @@ +{ + "type":"minecraft:crafting_shaped","category":"misc", + "pattern":["II","RB","II"], + "key":{ + "I":{"item":"minecraft:iron_ingot"}, + "R":{"item":"minecraft:redstone"}, + "B":{"tag":"minecraft:buttons"} + }, + "result":{"id":"pixelreel:remote_control","count":1} +} diff --git a/src/main/resources/data/pixelreel/recipe/screenwriter_console.json b/src/main/resources/data/pixelreel/recipe/screenwriter_console.json new file mode 100644 index 0000000..4f78ba2 --- /dev/null +++ b/src/main/resources/data/pixelreel/recipe/screenwriter_console.json @@ -0,0 +1,10 @@ +{ + "type":"minecraft:crafting_shaped","category":"misc", + "pattern":["III","IRI","DDD"], + "key":{ + "I":{"item":"minecraft:iron_ingot"}, + "R":{"item":"minecraft:redstone"}, + "D":{"item":"minecraft:polished_deepslate"} + }, + "result":{"id":"pixelreel:screenwriter_console","count":1} +}