Compare commits

...

2 Commits

Author SHA1 Message Date
tkrmagid
7364e010ac v0.4.32: route video audio to RECORDS slider + make sound category configurable
Some checks failed
build / build (push) Failing after 1m18s
Previously video audio gain was gated by the PLAYERS sound category, so the
"Players" slider controlled video volume. Video playback is media, so it now
defaults to the RECORDS ("Jukebox/Note Blocks" / 음반) slider instead.

Add a `sound_category` key to config/video_player.json (auto-augmented into
existing configs) so each client can pick which volume slider gates video
audio: master, music, record, weather, block, hostile, neutral, player,
ambient, voice, ui. Invalid values fall back to record with a warning. When
the category is master itself the gain is not squared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:40:51 +09:00
tkrmagid
3f2d37587d v0.4.31: skip permission-level check for non-player sources
Some checks failed
build / build (push) Has been cancelled
The previous .requires gate used Permissions.COMMANDS_GAMEMASTER (level 2),
which is the right check for player sources but ties datapack /function
calls to the functionPermissionLevel gamerule. If admins kept that gamerule
below 2, datapack-driven /videoPlace etc. silently failed and required a
gamerule bump.

Extract a CommandPermissions.opOrServer helper used by all 5 /video*
commands. Players still need OP (level 2+); console, command block, and
datapack function sources bypass the level check entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:31:45 +09:00
9 changed files with 121 additions and 22 deletions

View File

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

View File

@@ -100,17 +100,22 @@ public class VideoPlayerClient implements ClientModInitializer {
* *
* <p>Gain is also gated by the Minecraft sound options so the in-game sliders work as * <p>Gain is also gated by the Minecraft sound options so the in-game sliders work as
* expected: vanilla {@code SoundEngine.calculateVolume} multiplies by master × category, so * expected: vanilla {@code SoundEngine.calculateVolume} multiplies by master × category, so
* we do the same with {@link SoundSource#PLAYERS} as the category. Result: dragging the * we do the same. The category is configurable via {@code sound_category} in
* "Players" slider in Options → Music & Sounds attenuates video audio just like other * {@code config/video_player.json} (default {@link SoundSource#RECORDS} = the "Jukebox/Note
* player sounds, and "Master" still gates everything. * Blocks" slider, since video playback is media). Master always gates on top; when the
* configured category is Master itself we don't square it. Result: dragging the configured
* category's slider in Options → Music &amp; Sounds attenuates video audio, and "Master"
* still gates everything.
*/ */
private static void updateDistanceGains(Minecraft client) { private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player; LocalPlayer p = client.player;
if (p == null || client.level == null) return; if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition(); Vec3 eye = p.getEyePosition();
float masterVol = client.options.getSoundSourceVolume(SoundSource.MASTER); float masterVol = client.options.getSoundSourceVolume(SoundSource.MASTER);
float playersVol = client.options.getSoundSourceVolume(SoundSource.PLAYERS); SoundSource category = VideoPlayerConfig.soundCategory();
float categoryScale = masterVol * playersVol; float categoryScale = (category == SoundSource.MASTER)
? masterVol
: masterVol * client.options.getSoundSourceVolume(category);
for (BlockPos pos : VideoPlayback.activePositions()) { for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue; if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
Vec3 center = be.panelCenter(); Vec3 center = be.panelCenter();

View File

@@ -7,6 +7,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.sounds.SoundSource;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -62,10 +63,17 @@ public final class VideoPlayerConfig {
private static final int DEFAULT_MAX_CACHE_MB = 750; private static final int DEFAULT_MAX_CACHE_MB = 750;
/** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */ /** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */
private static final int DEFAULT_RENDER_DISTANCE = 128; private static final int DEFAULT_RENDER_DISTANCE = 128;
/**
* Default Minecraft sound category the client uses to gate video audio. RECORDS = the
* "Jukebox/Note Blocks" (음반) slider — video playback is media, so it belongs there rather
* than on the player-sound slider. Master always gates on top of whatever category is set.
*/
private static final SoundSource DEFAULT_SOUND_CATEGORY = SoundSource.RECORDS;
private static volatile int maxPreloadMb = DEFAULT_MAX_PRELOAD_MB; private static volatile int maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
private static volatile int maxCacheMb = DEFAULT_MAX_CACHE_MB; private static volatile int maxCacheMb = DEFAULT_MAX_CACHE_MB;
private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE; private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
private static volatile SoundSource soundCategory = DEFAULT_SOUND_CATEGORY;
private static volatile List<String> preloadUrls = Collections.emptyList(); private static volatile List<String> preloadUrls = Collections.emptyList();
/** Insertion-ordered name → url. Mutated only under the class monitor. */ /** Insertion-ordered name → url. Mutated only under the class monitor. */
private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>(); private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>();
@@ -80,6 +88,8 @@ public final class VideoPlayerConfig {
VideoPlayerMod.MOD_ID, path); VideoPlayerMod.MOD_ID, path);
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB; maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB; maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList(); preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear(); CACHE_ENTRIES.clear();
return; return;
@@ -127,6 +137,23 @@ public final class VideoPlayerConfig {
if (rd > 2048) rd = 2048; if (rd > 2048) rd = 2048;
renderDistanceBlocks = rd; renderDistanceBlocks = rd;
// sound_category (client uses this to pick which volume slider gates video audio)
SoundSource cat = DEFAULT_SOUND_CATEGORY;
if (json.has("sound_category") && json.get("sound_category").isJsonPrimitive()
&& json.get("sound_category").getAsJsonPrimitive().isString()) {
SoundSource parsed = parseSoundSource(json.get("sound_category").getAsString());
if (parsed != null) {
cat = parsed;
} else {
VideoPlayerMod.LOG.warn("[{}] config: unknown sound_category '{}' — using '{}'",
VideoPlayerMod.MOD_ID, json.get("sound_category").getAsString(),
DEFAULT_SOUND_CATEGORY.getName());
}
} else {
augmented = true;
}
soundCategory = cat;
// preload_urls (legacy) // preload_urls (legacy)
List<String> urls = new ArrayList<>(); List<String> urls = new ArrayList<>();
if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) { if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) {
@@ -162,9 +189,9 @@ public final class VideoPlayerConfig {
VideoPlayerMod.LOG.info( VideoPlayerMod.LOG.info(
"[{}] config loaded: per-video={} MB, total-cache={} MB, render={} blocks, " "[{}] config loaded: per-video={} MB, total-cache={} MB, render={} blocks, "
+ "preload_urls={}, cache_entries={}", + "sound_category={}, preload_urls={}, cache_entries={}",
VideoPlayerMod.MOD_ID, maxPreloadMb, maxCacheMb, renderDistanceBlocks, VideoPlayerMod.MOD_ID, maxPreloadMb, maxCacheMb, renderDistanceBlocks,
urls.size(), CACHE_ENTRIES.size()); soundCategory.getName(), urls.size(), CACHE_ENTRIES.size());
// Auto-augment: rewrite the file once so missing keys appear after a mod update. // Auto-augment: rewrite the file once so missing keys appear after a mod update.
if (augmented) { if (augmented) {
@@ -179,11 +206,23 @@ public final class VideoPlayerConfig {
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB; maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB; maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE; renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList(); preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear(); CACHE_ENTRIES.clear();
} }
} }
/** Match a config string against {@link SoundSource#getName()} (case-insensitive). Null = no match. */
private static SoundSource parseSoundSource(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty()) return null;
for (SoundSource src : SoundSource.values()) {
if (src.getName().equalsIgnoreCase(t)) return src;
}
return null;
}
// -- accessors --------------------------------------------------------------------------- // -- accessors ---------------------------------------------------------------------------
/** Hard cap on a single client-side video download, in MB. */ /** Hard cap on a single client-side video download, in MB. */
@@ -201,6 +240,12 @@ public final class VideoPlayerConfig {
/** Anchor BE view-distance cap, in blocks. */ /** Anchor BE view-distance cap, in blocks. */
public static int renderDistanceBlocks() { return renderDistanceBlocks; } public static int renderDistanceBlocks() { return renderDistanceBlocks; }
/**
* Minecraft sound category the client gates video audio with (default {@link SoundSource#RECORDS}).
* Read client-side from the local config; not pushed from the server.
*/
public static SoundSource soundCategory() { return soundCategory; }
/** Legacy un-named preload list (still pushed at join). Never null. */ /** Legacy un-named preload list (still pushed at join). Never null. */
public static List<String> preloadUrls() { return preloadUrls; } public static List<String> preloadUrls() { return preloadUrls; }
@@ -280,11 +325,15 @@ public final class VideoPlayerConfig {
"max_preload_mb: per-video download cap (each client). " "max_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). " + "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. " + "render_distance_blocks: max distance at which a video anchor still renders. "
+ "sound_category: which Minecraft volume slider gates video audio (client-side). "
+ "One of: master, music, record, weather, block, hostile, neutral, player, "
+ "ambient, voice, ui. Default 'record' = the Jukebox/Note Blocks slider. "
+ "preload_urls: HTTP(S) videos auto-pushed to every player on join (no name). " + "preload_urls: HTTP(S) videos auto-pushed to every player on join (no name). "
+ "cache_entries: named entries managed by /videoCache add|list|remove."); + "cache_entries: named entries managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB); root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB);
root.addProperty("max_cache_mb", DEFAULT_MAX_CACHE_MB); root.addProperty("max_cache_mb", DEFAULT_MAX_CACHE_MB);
root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE); root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE);
root.addProperty("sound_category", DEFAULT_SOUND_CATEGORY.getName());
root.add("preload_urls", new JsonArray()); root.add("preload_urls", new JsonArray());
root.add("cache_entries", new JsonArray()); root.add("cache_entries", new JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -301,11 +350,15 @@ public final class VideoPlayerConfig {
"max_preload_mb: per-video download cap (each client). " "max_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). " + "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. " + "render_distance_blocks: max distance at which a video anchor still renders. "
+ "sound_category: which Minecraft volume slider gates video audio (client-side). "
+ "One of: master, music, record, weather, block, hostile, neutral, player, "
+ "ambient, voice, ui. Default 'record' = the Jukebox/Note Blocks slider. "
+ "preload_urls: legacy un-named auto-preload list. " + "preload_urls: legacy un-named auto-preload list. "
+ "cache_entries: managed by /videoCache add|list|remove."); + "cache_entries: managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", maxPreloadMb); root.addProperty("max_preload_mb", maxPreloadMb);
root.addProperty("max_cache_mb", maxCacheMb); root.addProperty("max_cache_mb", maxCacheMb);
root.addProperty("render_distance_blocks", renderDistanceBlocks); root.addProperty("render_distance_blocks", renderDistanceBlocks);
root.addProperty("sound_category", soundCategory.getName());
JsonArray legacyArr = new JsonArray(); JsonArray legacyArr = new JsonArray();
for (String u : preloadUrls) legacyArr.add(u); for (String u : preloadUrls) legacyArr.add(u);
root.add("preload_urls", legacyArr); root.add("preload_urls", legacyArr);

View File

@@ -0,0 +1,45 @@
package com.ejclaw.videoplayer.command;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.entity.player.Player;
/**
* Shared {@code .requires(...)} predicate for all {@code /video*} commands.
*
* <p>Semantics:
* <ul>
* <li><b>Player source</b> — must be OP (permission level ≥ 2, via
* {@link Permissions#COMMANDS_GAMEMASTER}). Non-OP players don't even see the command
* in tab-completion.</li>
* <li><b>Non-player source</b> — server console, command block, and datapack
* {@code /function} are always allowed regardless of any permission level
* or gamerule. This means admins don't need to bump {@code functionPermissionLevel}
* just to drive {@code /videoPlace} etc. from a datapack function.</li>
* </ul>
*
* <p>The bypass for non-player sources is safe because reaching one of those execution
* contexts already requires server-operator trust:
* <ul>
* <li>Console — physical/admin access to the server.</li>
* <li>Command block — placing one requires OP + {@code /gamerule sendCommandFeedback}
* privileges, and {@code /execute as} preserves the underlying source's permissions
* (so a non-OP player can't smuggle commands through one).</li>
* <li>Datapack function — installed by the server admin.</li>
* </ul>
*/
public final class CommandPermissions {
private CommandPermissions() {}
/**
* Returns {@code true} when the source is allowed to run a {@code /video*} command.
*
* <p>Player → OP only. Anything else → always allowed.
*/
public static boolean opOrServer(CommandSourceStack s) {
if (s.getEntity() instanceof Player) {
return s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER);
}
return true;
}
}

View File

@@ -20,7 +20,6 @@ import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style; import net.minecraft.network.chat.Style;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import java.net.URI; import java.net.URI;
import java.util.Map; import java.util.Map;
@@ -32,8 +31,9 @@ import java.util.Map;
* <br>{@code /videocache remove <name>} — drop the entry from server config and tell every client * <br>{@code /videocache remove <name>} — drop the entry from server config and tell every client
* to delete the matching cache file. * to delete the matching cache file.
* *
* <p>Replaces the old {@code /videopreload}. Same permission gate * <p>Replaces the old {@code /videopreload}. Permission gate via
* ({@link Permissions#COMMANDS_GAMEMASTER}) so command blocks can drive it. * {@link CommandPermissions#opOrServer(CommandSourceStack)} so command blocks and datapack
* functions can drive it without touching {@code functionPermissionLevel}.
*/ */
public final class VideoCacheCommand { public final class VideoCacheCommand {
private VideoCacheCommand() {} private VideoCacheCommand() {}
@@ -44,7 +44,7 @@ public final class VideoCacheCommand {
private static LiteralArgumentBuilder<CommandSourceStack> build(String root) { private static LiteralArgumentBuilder<CommandSourceStack> build(String root) {
return Commands.literal(root) return Commands.literal(root)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .requires(CommandPermissions::opOrServer)
.then(Commands.literal("add") .then(Commands.literal("add")
.then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("url", StringArgumentType.greedyString()) .then(Commands.argument("url", StringArgumentType.greedyString())

View File

@@ -9,7 +9,6 @@ import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
@@ -24,7 +23,7 @@ public final class VideoDeleteCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) { build(String name) {
return Commands.literal(name) return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .requires(CommandPermissions::opOrServer)
.then(Commands.argument("pos", BlockPosArgument.blockPos()) .then(Commands.argument("pos", BlockPosArgument.blockPos())
.executes(VideoDeleteCommand::run)); .executes(VideoDeleteCommand::run));
} }

View File

@@ -14,7 +14,6 @@ import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
/** SPEC §4.5.1 — {@code /videoMute <pos> <on|off>} */ /** SPEC §4.5.1 — {@code /videoMute <pos> <on|off>} */
public final class VideoMuteCommand { public final class VideoMuteCommand {
@@ -27,7 +26,7 @@ public final class VideoMuteCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) { build(String name) {
return Commands.literal(name) return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .requires(CommandPermissions::opOrServer)
.then(Commands.argument("pos", BlockPosArgument.blockPos()) .then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(Commands.argument("state", StringArgumentType.word()) .then(Commands.argument("state", StringArgumentType.word())
.executes(VideoMuteCommand::run))); .executes(VideoMuteCommand::run)));

View File

@@ -19,7 +19,6 @@ import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
/** /**
@@ -53,7 +52,7 @@ public final class VideoPlaceCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) { build(String name) {
return Commands.literal(name) return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .requires(CommandPermissions::opOrServer)
.then(Commands.argument("pos", BlockPosArgument.blockPos()) .then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(Commands.argument("facing", StringArgumentType.word()) .then(Commands.argument("facing", StringArgumentType.word())
.then(Commands.argument("width", IntegerArgumentType.integer(1, 32)) .then(Commands.argument("width", IntegerArgumentType.integer(1, 32))

View File

@@ -6,18 +6,17 @@ import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
public final class VideoStickCommand { public final class VideoStickCommand {
private VideoStickCommand() {} private VideoStickCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) { public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
// OP/console/command-block 만 사용 가능. Permissions.COMMANDS_GAMEMASTER = level 2, // 플레이어는 OP(level 2+) 만, 콘솔/커맨드블럭/함수(/function) 는 무조건 통과.
// 즉 /op 받은 플레이어(level 4) 와 콘솔(level 4), command block(default level 2) 통과. // 따라서 functionPermissionLevel 같은 gamerule 을 만질 필요가 없다.
// 일반 플레이어(level 0) 는 탭 자동완성에도 안 떠야 정상. // 일반 플레이어(level 0) 는 탭 자동완성에도 안 떠야 정상.
dispatcher.register(Commands.literal("videoStick") dispatcher.register(Commands.literal("videoStick")
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .requires(CommandPermissions::opOrServer)
.executes(ctx -> run(ctx.getSource()))); .executes(ctx -> run(ctx.getSource())));
} }