aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
authorElis Eriksson <Spelis@duck.com>2026-03-05 16:08:01 +0100
committerElis Eriksson <Spelis@duck.com>2026-03-05 16:08:01 +0100
commitbb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a (patch)
tree3ef79320c38431eb72e5735ea7fdf954740416b1 /src/main
downloadsvc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.tar
svc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.tar.gz
svc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.tar.bz2
svc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.tar.lz
svc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.tar.xz
svc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.tar.zst
svc_intercom-bb93816e9efdf3d9377ca6b940c4fcf4a9e0ca2a.zip
Initial Commit
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/projnull/spelis/svci/Intercom.java53
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/BroadcastTypeHandler.java19
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java48
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/handlers/FileBroadcastHandler.java115
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/handlers/InfoBroadcastHandler.java49
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveBroadcastHandler.java78
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/handlers/StopBroadcastHandler.java38
-rw-r--r--src/main/java/eu/projnull/spelis/svci/misc/BroadcastHudTask.java61
-rw-r--r--src/main/java/eu/projnull/spelis/svci/misc/OggDecoder.java49
-rw-r--r--src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java123
-rw-r--r--src/main/java/eu/projnull/spelis/svci/voice/FileBroadcast.java56
-rw-r--r--src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java87
-rw-r--r--src/main/resources/plugin.yml18
13 files changed, 794 insertions, 0 deletions
diff --git a/src/main/java/eu/projnull/spelis/svci/Intercom.java b/src/main/java/eu/projnull/spelis/svci/Intercom.java
new file mode 100644
index 0000000..e4cf3f5
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/Intercom.java
@@ -0,0 +1,53 @@
+package eu.projnull.spelis.svci;
+
+import de.maxhenkel.voicechat.api.BukkitVoicechatService;
+import eu.projnull.spelis.svci.commands.IntercomCommand;
+import eu.projnull.spelis.svci.misc.BroadcastHudTask;
+import eu.projnull.spelis.svci.voice.VoicePlugin;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import javax.annotation.Nullable;
+
+public final class Intercom extends JavaPlugin {
+
+ public static final String PLUGIN_ID = "SVCIntercom";
+ public static final Logger LOGGER = LogManager.getLogger(PLUGIN_ID);
+
+ private final BroadcastHudTask hudTask = new BroadcastHudTask();
+
+ @Nullable
+ private VoicePlugin voicechatPlugin;
+
+ @Override
+ public void onEnable() {
+ BukkitVoicechatService service = getServer().getServicesManager().load(BukkitVoicechatService.class);
+ if (service != null) {
+ voicechatPlugin = new VoicePlugin();
+ service.registerPlugin(voicechatPlugin);
+ LOGGER.info("Successfully registered intercom plugin");
+ } else {
+ LOGGER.info("Failed to register intercom plugin");
+ }
+
+ getCommand("intercom").setExecutor(new IntercomCommand());
+
+ hudTask.start(this);
+ }
+
+ @Override
+ public void onDisable() {
+ if (voicechatPlugin != null) {
+ getServer().getServicesManager().unregister(voicechatPlugin);
+ LOGGER.info("Successfully unregistered intercom plugin");
+ }
+ hudTask.stop();
+ }
+
+ @Nullable
+ public VoicePlugin getVoicechatPlugin() {
+ return voicechatPlugin;
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/commands/BroadcastTypeHandler.java b/src/main/java/eu/projnull/spelis/svci/commands/BroadcastTypeHandler.java
new file mode 100644
index 0000000..b394a74
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/commands/BroadcastTypeHandler.java
@@ -0,0 +1,19 @@
+package eu.projnull.spelis.svci.commands;
+
+import org.bukkit.command.CommandSender;
+
+public interface BroadcastTypeHandler {
+
+ /**
+ * Called when the command executes for this type.
+ * @param sender The executor
+ * @param args Command arguments
+ * @return true if handled successfully (for Bukkit), false to show usage
+ */
+ boolean execute(CommandSender sender, String[] args);
+
+ /**
+ * The name of this type (e.g., "file", "live")
+ */
+ String getName();
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java b/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java
new file mode 100644
index 0000000..1e9e5bc
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java
@@ -0,0 +1,48 @@
+package eu.projnull.spelis.svci.commands;
+
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class IntercomCommand implements CommandExecutor {
+
+ private final Map<String, BroadcastTypeHandler> handlers = new HashMap<>();
+
+ public IntercomCommand() {
+ registerHandler(new eu.projnull.spelis.svci.commands.handlers.LiveBroadcastHandler());
+ registerHandler(new eu.projnull.spelis.svci.commands.handlers.StopBroadcastHandler());
+ registerHandler(new eu.projnull.spelis.svci.commands.handlers.InfoBroadcastHandler());
+ registerHandler(new eu.projnull.spelis.svci.commands.handlers.FileBroadcastHandler());
+ }
+
+ public void registerHandler(BroadcastTypeHandler handler) {
+ handlers.put(handler.getName().toLowerCase(), handler);
+ }
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+
+ if (!sender.hasPermission("svcintercom.broadcast")) {
+ sender.sendMessage("§cYou don't have permission to use the intercom.");
+ return true;
+ }
+
+ if (args.length < 1) {
+ sender.sendMessage("§cUsage: /intercom <type> ...");
+ return true;
+ }
+
+ String type = args[0].toLowerCase();
+ BroadcastTypeHandler handler = handlers.get(type);
+
+ if (handler == null) {
+ sender.sendMessage("§cUnknown broadcast type: " + type);
+ return true;
+ }
+
+ return handler.execute(sender, args);
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileBroadcastHandler.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileBroadcastHandler.java
new file mode 100644
index 0000000..71f5f74
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileBroadcastHandler.java
@@ -0,0 +1,115 @@
+package eu.projnull.spelis.svci.commands.handlers;
+
+import de.maxhenkel.voicechat.api.ServerLevel;
+import de.maxhenkel.voicechat.api.VoicechatServerApi;
+import de.maxhenkel.voicechat.api.audiochannel.AudioPlayer;
+import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel;
+import de.maxhenkel.voicechat.api.VoicechatConnection;
+import eu.projnull.spelis.svci.Intercom;
+import eu.projnull.spelis.svci.commands.BroadcastTypeHandler;
+import eu.projnull.spelis.svci.voice.BroadcasterState;
+import eu.projnull.spelis.svci.voice.BroadcasterState.Broadcaster;
+import eu.projnull.spelis.svci.voice.BroadcasterState.Broadcaster.BroadcastType;
+import eu.projnull.spelis.svci.misc.OggDecoder;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.bukkit.craftbukkit.CraftWorld;
+
+import java.io.File;
+import java.util.UUID;
+
+public class FileBroadcastHandler implements BroadcastTypeHandler {
+
+ @Override
+ public boolean execute(CommandSender sender, String[] args) {
+ if (args.length < 3) {
+ sender.sendMessage("§cUsage: /intercom file <filename> <world>");
+ return true;
+ }
+
+ String fileName = args[1];
+ World world = Bukkit.getWorld(args[2]);
+ if (world == null) {
+ sender.sendMessage("§cWorld not found.");
+ return true;
+ }
+
+ if (BroadcasterState.inst().isBroadcastActive(world.getUID())) {
+ sender.sendMessage("§cA broadcast is already active in this world.");
+ return true;
+ }
+
+ File soundFile = new File(Intercom.getPlugin(Intercom.class).getDataFolder(), "sounds/" + fileName);
+ if (!soundFile.exists()) {
+ Intercom.LOGGER.warn("File not found: {}", soundFile.getAbsoluteFile());
+ sender.sendMessage("§cFile not found: sounds/" + fileName);
+ return true;
+ }
+
+ sender.sendMessage("§7Loading audio file...");
+
+ Bukkit.getScheduler().runTaskAsynchronously(Intercom.getPlugin(Intercom.class), () -> {
+ short[] pcm;
+ try {
+ pcm = OggDecoder.decode(soundFile);
+ } catch (Exception e) {
+ sender.sendMessage("§cFailed to decode audio file: " + e.getMessage());
+ return;
+ }
+
+ long durationMillis = pcm.length / 48L;
+
+ Bukkit.getScheduler().runTask(Intercom.getPlugin(Intercom.class), () -> {
+ if (BroadcasterState.inst().isBroadcastActive(world.getUID())) {
+ sender.sendMessage("§cA broadcast started in this world while the file was loading.");
+ return;
+ }
+
+ VoicechatServerApi api = Intercom.getPlugin(Intercom.class).getVoicechatPlugin().getVoicechatServerApi();
+ if (api == null) {
+ sender.sendMessage("§cVoice chat API is not available.");
+ return;
+ }
+
+ de.maxhenkel.voicechat.api.ServerLevel level = api.fromServerLevel(world);
+
+ Broadcaster broadcaster = new Broadcaster(
+ null,
+ world.getUID(),
+ BroadcastType.FILE,
+ durationMillis,
+ fileName
+ );
+ BroadcasterState.inst().startBroadcast(broadcaster);
+
+ for (Player p : world.getPlayers()) {
+ VoicechatConnection connection = api.getConnectionOf(p.getUniqueId());
+ if (connection == null) continue;
+
+ StaticAudioChannel channel = api.createStaticAudioChannel(UUID.randomUUID(), level, connection);
+ if (channel == null) continue;
+
+ AudioPlayer audioPlayer = api.createAudioPlayer(channel, api.createEncoder(), pcm);
+ audioPlayer.startPlaying();
+ }
+
+ sender.sendMessage("§aFile broadcast started: §f" + fileName + " §ain §f" + world.getName());
+
+ Bukkit.getScheduler().runTaskLaterAsynchronously(
+ Intercom.getPlugin(Intercom.class),
+ () -> BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has ended."),
+ durationMillis / 50L
+ );
+ });
+ });
+
+ return true;
+ }
+
+ @Override
+ public String getName() {
+ return "file";
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/InfoBroadcastHandler.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/InfoBroadcastHandler.java
new file mode 100644
index 0000000..7712c39
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/InfoBroadcastHandler.java
@@ -0,0 +1,49 @@
+package eu.projnull.spelis.svci.commands.handlers;
+
+import eu.projnull.spelis.svci.commands.BroadcastTypeHandler;
+import eu.projnull.spelis.svci.voice.BroadcasterState;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.CommandSender;
+
+public class InfoBroadcastHandler implements BroadcastTypeHandler {
+
+ @Override
+ public boolean execute(CommandSender sender, String[] args) {
+
+ if (args.length < 2) {
+ sender.sendMessage("§cUsage: /intercom info <world>");
+ return true;
+ }
+
+ World world = Bukkit.getWorld(args[1]);
+ if (world == null) {
+ sender.sendMessage("§cWorld not found.");
+ return true;
+ }
+
+ BroadcasterState.Broadcaster b = BroadcasterState.inst().getBroadcast(world.getUID());
+
+ if (b == null) {
+ sender.sendMessage("§aNo active broadcast in this world.");
+ return true;
+ }
+
+ String type = b.getType().name();
+ String owner = b.getType() == BroadcasterState.Broadcaster.BroadcastType.LIVE ?
+ Bukkit.getOfflinePlayer(b.getPlayerId()).getName() : b.getFileName();
+ long secondsLeft = (b.getEndTimeMillis() - System.currentTimeMillis()) / 1000;
+
+ sender.sendMessage("§eBroadcast info:");
+ sender.sendMessage(" §7Type: §f" + type);
+ sender.sendMessage(" §7Owner/File: §f" + owner);
+ sender.sendMessage(" §7Time remaining: §f" + secondsLeft + "s");
+
+ return true;
+ }
+
+ @Override
+ public String getName() {
+ return "info";
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveBroadcastHandler.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveBroadcastHandler.java
new file mode 100644
index 0000000..798ac66
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveBroadcastHandler.java
@@ -0,0 +1,78 @@
+package eu.projnull.spelis.svci.commands.handlers;
+
+import eu.projnull.spelis.svci.Intercom;
+import eu.projnull.spelis.svci.commands.BroadcastTypeHandler;
+import eu.projnull.spelis.svci.voice.BroadcasterState;
+import eu.projnull.spelis.svci.voice.BroadcasterState.Broadcaster;
+import eu.projnull.spelis.svci.voice.BroadcasterState.Broadcaster.BroadcastType;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+public class LiveBroadcastHandler implements BroadcastTypeHandler {
+
+ @Override
+ public boolean execute(CommandSender sender, String[] args) {
+
+ if (args.length < 4) {
+ sender.sendMessage("§cUsage: /intercom live <player> <world> <seconds>");
+ return true;
+ }
+
+ Player target = Bukkit.getPlayer(args[1]);
+ if (target == null) {
+ sender.sendMessage("§cPlayer not found.");
+ return true;
+ }
+
+ World world = Bukkit.getWorld(args[2]);
+ if (world == null) {
+ sender.sendMessage("§cWorld not found.");
+ return true;
+ }
+
+ int seconds;
+ try {
+ seconds = Integer.parseInt(args[3]);
+ } catch (NumberFormatException e) {
+ sender.sendMessage("§cInvalid duration.");
+ return true;
+ }
+
+ if (seconds <= 0 || seconds > 300) { // optional max
+ sender.sendMessage("§cDuration must be between 1 and 300 seconds.");
+ return true;
+ }
+
+ if (BroadcasterState.inst().isBroadcastActive(world.getUID())) {
+ sender.sendMessage("§cA broadcast is already active in this world.");
+ return true;
+ }
+
+ Broadcaster broadcaster = new Broadcaster(
+ target.getUniqueId(),
+ world.getUID(),
+ BroadcastType.LIVE,
+ seconds * 1000L,
+ null
+ );
+
+ BroadcasterState.inst().startBroadcast(broadcaster);
+ sender.sendMessage("§aLive broadcast started for " + target.getName() + " in world " + world.getName());
+
+ // Schedule auto-stop
+ Bukkit.getScheduler().runTaskLaterAsynchronously(
+ Intercom.getPlugin(Intercom.class),
+ () -> BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has expired"),
+ seconds * 20L
+ );
+
+ return true;
+ }
+
+ @Override
+ public String getName() {
+ return "live";
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/StopBroadcastHandler.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/StopBroadcastHandler.java
new file mode 100644
index 0000000..c806b47
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/StopBroadcastHandler.java
@@ -0,0 +1,38 @@
+package eu.projnull.spelis.svci.commands.handlers;
+
+import eu.projnull.spelis.svci.commands.BroadcastTypeHandler;
+import eu.projnull.spelis.svci.voice.BroadcasterState;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.CommandSender;
+
+public class StopBroadcastHandler implements BroadcastTypeHandler {
+
+ @Override
+ public boolean execute(CommandSender sender, String[] args) {
+
+ if (args.length < 2) {
+ sender.sendMessage("§cUsage: /intercom stop <world>");
+ return true;
+ }
+
+ World world = Bukkit.getWorld(args[1]);
+ if (world == null) {
+ sender.sendMessage("§cWorld not found.");
+ return true;
+ }
+
+ if (!BroadcasterState.inst().isBroadcastActive(world.getUID())) {
+ sender.sendMessage("§cNo active broadcast in this world.");
+ return true;
+ }
+
+ BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has been stopped.");
+ return true;
+ }
+
+ @Override
+ public String getName() {
+ return "stop";
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/misc/BroadcastHudTask.java b/src/main/java/eu/projnull/spelis/svci/misc/BroadcastHudTask.java
new file mode 100644
index 0000000..aecf751
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/misc/BroadcastHudTask.java
@@ -0,0 +1,61 @@
+package eu.projnull.spelis.svci.misc;
+
+import eu.projnull.spelis.svci.voice.BroadcasterState;
+import eu.projnull.spelis.svci.voice.BroadcasterState.Broadcaster;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitTask;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+public final class BroadcastHudTask {
+
+ private BukkitTask task;
+
+ public void start(JavaPlugin plugin) {
+ if (task != null) return;
+ task = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 0L, 1L);
+ }
+
+ public void stop() {
+ if (task != null) {
+ task.cancel();
+ task = null;
+ }
+ }
+
+ private void tick() {
+ BroadcasterState state = BroadcasterState.inst();
+
+ for (World world : Bukkit.getWorlds()) {
+ Broadcaster broadcaster = state.getBroadcast(world.getUID());
+ if (broadcaster == null) continue;
+
+ Component message = buildMessage(broadcaster);
+ for (Player player : world.getPlayers()) {
+ player.sendActionBar(message);
+ }
+ }
+ }
+
+ private Component buildMessage(Broadcaster broadcaster) {
+ long remaining = broadcaster.getEndTimeMillis() - System.currentTimeMillis();
+ long minutes = TimeUnit.MILLISECONDS.toMinutes(remaining);
+ long seconds = TimeUnit.MILLISECONDS.toSeconds(remaining) % 60;
+
+ String timer = String.format("%d:%02d remaining", minutes, seconds);
+
+ return switch (broadcaster.getType()) {
+ case LIVE -> Component.text("🔴 LIVE broadcast", NamedTextColor.RED, TextDecoration.BOLD)
+ .append(Component.text(" - " + timer, NamedTextColor.GRAY));
+ case FILE -> Component.text("📢 " + broadcaster.getFileName(), NamedTextColor.GOLD, TextDecoration.BOLD)
+ .append(Component.text(" - " + timer, NamedTextColor.GRAY));
+ };
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/misc/OggDecoder.java b/src/main/java/eu/projnull/spelis/svci/misc/OggDecoder.java
new file mode 100644
index 0000000..6ea5b76
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/misc/OggDecoder.java
@@ -0,0 +1,49 @@
+package eu.projnull.spelis.svci.misc;
+
+import javax.sound.sampled.*;
+import java.io.File;
+import java.io.ByteArrayOutputStream;
+
+public final class OggDecoder {
+ private OggDecoder() {}
+
+ public static short[] decode(File file) throws Exception {
+ try (AudioInputStream raw = AudioSystem.getAudioInputStream(file)) {
+
+ AudioFormat source = raw.getFormat();
+
+ AudioFormat target = new AudioFormat(
+ AudioFormat.Encoding.PCM_SIGNED,
+ source.getSampleRate(),
+ 16,
+ source.getChannels(),
+ source.getChannels() * 2,
+ source.getSampleRate(),
+ false
+ );
+
+ try (AudioInputStream pcm =
+ AudioSystem.getAudioInputStream(target, raw);
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
+
+ byte[] data = new byte[4096];
+ int read;
+
+ while ((read = pcm.read(data)) != -1) {
+ buffer.write(data, 0, read);
+ }
+
+ byte[] bytes = buffer.toByteArray();
+ short[] samples = new short[bytes.length / 2];
+
+ for (int i = 0; i < samples.length; i++) {
+ int low = bytes[i * 2] & 0xFF;
+ int high = bytes[i * 2 + 1] << 8;
+ samples[i] = (short) (high | low);
+ }
+
+ return samples;
+ }
+ }
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java b/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java
new file mode 100644
index 0000000..2512af4
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java
@@ -0,0 +1,123 @@
+package eu.projnull.spelis.svci.voice;
+
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.entity.Player;
+
+import java.util.UUID;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class BroadcasterState {
+
+ private static final BroadcasterState INSTANCE = new BroadcasterState();
+
+ // Key = worldId
+ private final Map<UUID, Broadcaster> broadcasts = new ConcurrentHashMap<>();
+
+ private BroadcasterState() {}
+
+ public static BroadcasterState inst() {
+ return INSTANCE;
+ }
+
+ public boolean startBroadcast(Broadcaster broadcaster) {
+ UUID worldId = broadcaster.getWorldId();
+
+ // only allow one broadcast per world
+ return broadcasts.putIfAbsent(worldId, broadcaster) == null;
+ }
+
+ public Broadcaster getBroadcast(UUID worldId) {
+ Broadcaster b = broadcasts.get(worldId);
+
+ if (b == null) return null;
+
+ if (b.isExpired()) {
+ broadcasts.remove(worldId);
+ return null;
+ }
+
+ return b;
+ }
+
+ public void stopBroadcastWithMessage(UUID worldId, String message) {
+ Broadcaster b = broadcasts.remove(worldId);
+ if (b == null) return;
+
+ World world = Bukkit.getWorld(worldId);
+ if (world == null) return;
+
+ for (Player p : world.getPlayers()) {
+ p.sendMessage(message);
+ }
+ }
+
+ public boolean isBroadcastActive(UUID worldId) {
+ return getBroadcast(worldId) != null;
+ }
+
+ public static final class Broadcaster {
+
+ private final UUID playerId; // null for FILE mode
+ private final UUID worldId;
+ private final BroadcastType type;
+ private final long endTimeMillis;
+ private final String fileName; // only for FILE mode
+
+ public Broadcaster(
+ UUID playerId,
+ UUID worldId,
+ BroadcastType type,
+ long durationMillis,
+ String fileName
+ ) {
+ if (worldId == null)
+ throw new IllegalArgumentException("worldId cannot be null");
+
+ if (durationMillis <= 0)
+ throw new IllegalArgumentException("duration must be > 0");
+
+ if (type == BroadcastType.LIVE && playerId == null)
+ throw new IllegalArgumentException("LIVE requires playerId");
+
+ if (type == BroadcastType.FILE && fileName == null)
+ throw new IllegalArgumentException("FILE requires fileName");
+
+ this.playerId = playerId;
+ this.worldId = worldId;
+ this.type = type;
+ this.endTimeMillis = System.currentTimeMillis() + durationMillis;
+ this.fileName = fileName;
+ }
+
+ public boolean isExpired() {
+ return System.currentTimeMillis() > endTimeMillis;
+ }
+
+ public UUID getPlayerId() {
+ return playerId;
+ }
+
+ public UUID getWorldId() {
+ return worldId;
+ }
+
+ public BroadcastType getType() {
+ return type;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public long getEndTimeMillis() {
+ return endTimeMillis;
+ }
+
+ public enum BroadcastType {
+ LIVE,
+ FILE
+ }
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/voice/FileBroadcast.java b/src/main/java/eu/projnull/spelis/svci/voice/FileBroadcast.java
new file mode 100644
index 0000000..07cb5f5
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/voice/FileBroadcast.java
@@ -0,0 +1,56 @@
+package eu.projnull.spelis.svci.voice;
+
+import de.maxhenkel.voicechat.api.VoicechatServerApi;
+import de.maxhenkel.voicechat.api.audiochannel.AudioPlayer;
+import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel;
+import de.maxhenkel.voicechat.api.VoicechatConnection;
+import eu.projnull.spelis.svci.misc.OggDecoder;
+import org.bukkit.World;
+import org.bukkit.entity.Player;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+public class FileBroadcast {
+
+ private final List<AudioPlayer> players = new ArrayList<>();
+
+ /**
+ * Starts playing an audio file to all players currently in the world.
+ * New players joining mid-broadcast won't hear it (acceptable for now).
+ *
+ * @param api the SVC server API instance
+ * @param world the target world
+ * @param audioFile the .ogg file to play
+ * @param onDone called when all players' audio finishes
+ */
+ public void start(VoicechatServerApi api, World world, File audioFile, Runnable onDone) throws Exception {
+ short[] pcm = OggDecoder.decode(audioFile); // see below
+
+ for (Player player : world.getPlayers()) {
+ VoicechatConnection connection = api.getConnectionOf(player.getUniqueId());
+ if (connection == null) continue; // player doesn't have SVC mod
+
+ StaticAudioChannel channel = api.createStaticAudioChannel(
+ UUID.randomUUID(),
+ api.fromServerLevel(/* ServerLevel */ null), // explained below
+ connection
+ );
+ if (channel == null) continue;
+
+ AudioPlayer ap = api.createAudioPlayer(channel, api.createEncoder(), pcm);
+ ap.setOnStopped(onDone);
+ ap.startPlaying();
+ players.add(ap);
+ }
+ }
+
+ public void stop() {
+ for (AudioPlayer ap : players) {
+ ap.stopPlaying();
+ }
+ players.clear();
+ }
+}
diff --git a/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java b/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java
new file mode 100644
index 0000000..ba11792
--- /dev/null
+++ b/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java
@@ -0,0 +1,87 @@
+package eu.projnull.spelis.svci.voice;
+
+import de.maxhenkel.voicechat.api.VoicechatApi;
+import de.maxhenkel.voicechat.api.VoicechatConnection;
+import de.maxhenkel.voicechat.api.VoicechatPlugin;
+import de.maxhenkel.voicechat.api.VoicechatServerApi;
+import de.maxhenkel.voicechat.api.events.EventRegistration;
+import de.maxhenkel.voicechat.api.events.MicrophonePacketEvent;
+import de.maxhenkel.voicechat.api.events.VoicechatServerStartedEvent;
+import eu.projnull.spelis.svci.Intercom;
+import org.bukkit.permissions.Permission;
+import org.bukkit.permissions.PermissionDefault;
+
+import java.util.UUID;
+
+
+public class VoicePlugin implements VoicechatPlugin {
+
+ public static Permission BROADCAST_PERMISSION = new Permission("svcintercom.broadcast", PermissionDefault.OP);
+ private VoicechatServerApi voicechatServerApi;
+
+ public VoicechatServerApi getVoicechatServerApi() {
+ return voicechatServerApi;
+ }
+
+ @Override
+ public String getPluginId() {
+ return Intercom.PLUGIN_ID;
+ }
+
+ @Override
+ public void initialize(VoicechatApi api) {
+ Intercom.LOGGER.info("Initialize Voice Chat plugin.");
+ }
+
+ @Override
+ public void registerEvents(EventRegistration registration) {
+ registration.registerEvent(MicrophonePacketEvent.class, this::onMicPacket);
+ registration.registerEvent(VoicechatServerStartedEvent.class, e->{
+ this.voicechatServerApi = e.getVoicechat();
+ });
+ }
+
+ private void onMicPacket(MicrophonePacketEvent event) {
+ VoicechatConnection senderConnection = event.getSenderConnection();
+ if (senderConnection == null) return;
+
+ if (!(senderConnection.getPlayer().getPlayer() instanceof org.bukkit.entity.Player player)) {
+ return;
+ }
+
+ if (event.getPacket().getOpusEncodedData().length == 0) return;
+
+ UUID worldId = player.getWorld().getUID();
+
+ BroadcasterState.Broadcaster broadcaster =
+ BroadcasterState.inst().getBroadcast(worldId);
+
+ if (broadcaster == null) return;
+
+ if (broadcaster.getType() != BroadcasterState.Broadcaster.BroadcastType.LIVE)
+ return;
+
+ if (!player.getUniqueId().equals(broadcaster.getPlayerId()))
+ return;
+
+ event.cancel();
+
+ VoicechatServerApi api = event.getVoicechat();
+
+ for (org.bukkit.entity.Player online : player.getWorld().getPlayers()) {
+ if (online.getUniqueId().equals(player.getUniqueId())) {
+ continue;
+ }
+
+ VoicechatConnection connection =
+ api.getConnectionOf(online.getUniqueId());
+
+ if (connection == null) continue;
+
+ api.sendStaticSoundPacketTo(
+ connection,
+ event.getPacket().toStaticSoundPacket()
+ );
+ }
+ }
+}
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
new file mode 100644
index 0000000..866188f
--- /dev/null
+++ b/src/main/resources/plugin.yml
@@ -0,0 +1,18 @@
+name: svc_intercom
+version: '1.0'
+main: eu.projnull.spelis.svci.Intercom
+api-version: '1.21'
+prefix: SVCIntercom
+load: POSTWORLD
+authors: [ Spelis ]
+description: Intercom (global broadcast) to Simple Voice Chat
+website: https://spelis.projnull.eu
+
+depend: [ voicechat ]
+
+commands:
+ intercom:
+ description: Manage intercom broadcasts
+ usage: /intercom <file|live|stop> ...
+ permission: svcintercom.broadcast
+ permission-message: You do not have permission to use the intercom.