commit cffaf80c7174d5117f3c284062203dec298cc302
Author: Minecon724 <git@m724.eu>
Date:   Sun Dec 15 19:06:44 2024 +0100

    Intial commit 2

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4788b4b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,113 @@
+# User-specific stuff
+.idea/
+
+*.iml
+*.ipr
+*.iws
+
+# IntelliJ
+out/
+
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+target/
+
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+.mvn/wrapper/maven-wrapper.jar
+.flattened-pom.xml
+
+# Common working directory
+run/
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..f83b9ee
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>eu.m724</groupId>
+    <artifactId>music-plugin</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <packaging>jar</packaging>
+
+    <name>music-plugin</name>
+
+    <properties>
+        <java.version>21</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <build>
+        <defaultGoal>clean package</defaultGoal>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.13.0</version>
+                <configuration>
+                    <source>${java.version}</source>
+                    <target>${java.version}</target>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <repositories>
+        <repository>
+            <id>spigotmc-repo</id>
+            <url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
+        </repository>
+        <repository>
+            <id>sonatype</id>
+            <url>https://oss.sonatype.org/content/groups/public/</url>
+        </repository>
+        <repository>
+            <id>maxhenkel</id>
+            <url>https://maven.maxhenkel.de/repository/public</url>
+        </repository>
+    </repositories>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.spigotmc</groupId>
+            <artifactId>spigot-api</artifactId>
+            <version>1.21.1-R0.1-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>de.maxhenkel.voicechat</groupId>
+            <artifactId>voicechat-api</artifactId>
+            <version>2.5.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <!-- https://mvnrepository.com/artifact/org.gagravarr/vorbis-java-core -->
+        <dependency>
+            <groupId>org.gagravarr</groupId>
+            <artifactId>vorbis-java-core</artifactId>
+            <version>0.8</version>
+        </dependency>
+        <dependency>
+            <groupId>net.bramp.ffmpeg</groupId>
+            <artifactId>ffmpeg</artifactId>
+            <version>0.8.0</version>
+        </dependency>
+
+    </dependencies>
+</project>
diff --git a/src/main/java/eu/m724/musicPlugin/MusicCommands.java b/src/main/java/eu/m724/musicPlugin/MusicCommands.java
new file mode 100644
index 0000000..ccf20eb
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/MusicCommands.java
@@ -0,0 +1,108 @@
+package eu.m724.musicPlugin;
+
+import eu.m724.musicPlugin.file.AudioFileStorage;
+import eu.m724.musicPlugin.player.MusicPlayer;
+import eu.m724.musicPlugin.file.AudioFile;
+import eu.m724.musicPlugin.player.StaticMusicPlayer;
+import net.md_5.bungee.api.chat.ClickEvent;
+import net.md_5.bungee.api.chat.ComponentBuilder;
+import net.md_5.bungee.api.chat.HoverEvent;
+import net.md_5.bungee.api.chat.hover.content.Text;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Map;
+
+public class MusicCommands implements CommandExecutor {
+    private final AudioFileStorage storage;
+    private MusicPlayer player;
+
+    public MusicCommands(AudioFileStorage storage) {
+        this.storage = storage;
+    }
+
+    @Override
+    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+        if (command.getName().equals("download")) {
+            try {
+                var cf = storage.download(URI.create(args[0]).toURL());
+                sender.sendMessage("Started download");
+                cf.handle((hash, ex) -> {
+                    if (ex != null)
+                        sender.sendMessage("ERROR downloading: " + ex.getMessage());
+                    else
+                        sender.spigot().sendMessage(
+                                new ComponentBuilder("Hash: " + hash)
+                                        .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text("Click to copy")))
+                                        .event(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, hash))
+                                        .append("\nClick here to convert to 48 kbps")
+                                        .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text("Click to do that")))
+                                        .event(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/convert " + hash + " 48000"))
+                                        .create()
+                        );
+                    return null;
+                });
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        } else if (command.getName().equals("convert")) {
+            try {
+                var cf = storage.convert(args[0], Integer.parseInt(args[1]));
+                sender.sendMessage("Converting " + args[0] + " to " + args[1] + "bps...");
+                cf.thenAccept(f -> {
+                    sender.spigot().sendMessage(
+                            new ComponentBuilder("Converted " + args[0] + " to " + args[1] + "bps! Click to play")
+                                    .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text("Click to play")))
+                                    .event(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/play " + args[0] + " " + args[1]))
+                                    .create()
+                    );
+                }).exceptionally(ex -> {
+                    sender.sendMessage("ERROR converting: " + ex.getMessage());
+                    return null;
+                });
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        } else if (command.getName().equals("play")) {
+            if (args.length == 1) {
+                StringBuilder msg = new StringBuilder("Available bitrates:");
+                for (Map.Entry<Integer, File> entry : storage.getVersions(args[0]).entrySet()) {
+                    msg.append(" ").append(entry.getKey());
+                }
+                sender.sendMessage(msg.toString());
+                return true;
+            }
+            var file = storage.get(args[0], Integer.parseInt(args[1]));
+            sender.sendMessage("Initializeng");
+            player = new StaticMusicPlayer(((Player)sender).getLocation());
+            player.init();
+
+            try (FileInputStream fis = new FileInputStream(file)) {
+                var song = new AudioFile(fis);
+                song.load();
+                player.play(song);
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+
+            sender.sendMessage("done si r now playing it");
+        } else if (command.getName().equals("stop")) {
+            player.stop();
+            sender.sendMessage("stopedd");
+        } else if (command.getName().equals("pause")) {
+            player.pause();
+            sender.sendMessage("puased");
+        } else if (command.getName().equals("resume")) {
+            player.unpause();
+            sender.sendMessage("unapuised");
+        }
+
+        return true;
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/MusicPlugin.java b/src/main/java/eu/m724/musicPlugin/MusicPlugin.java
new file mode 100644
index 0000000..a1115eb
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/MusicPlugin.java
@@ -0,0 +1,52 @@
+package eu.m724.musicPlugin;
+
+import de.maxhenkel.voicechat.api.BukkitVoicechatService;
+import de.maxhenkel.voicechat.api.VoicechatConnection;
+import de.maxhenkel.voicechat.api.VoicechatServerApi;
+import eu.m724.musicPlugin.file.AudioFileStorage;
+import org.bukkit.plugin.java.JavaPlugin;
+
+public final class MusicPlugin extends JavaPlugin {
+    @Override
+    public void onEnable() {
+        BukkitVoicechatService service = getServer().getServicesManager().load(BukkitVoicechatService.class);
+
+        if (service != null) {
+            service.registerPlugin(
+                    new MyVoicechatPlugin(
+                            this::onApiStarted,
+                            this::onPlayerConnected
+                    )
+            );
+        }
+
+        getDataFolder().mkdir();
+        var mcmd = new MusicCommands(new AudioFileStorage(getDataFolder()));
+        getCommand("download").setExecutor(mcmd);
+        getCommand("convert").setExecutor(mcmd);
+        getCommand("play").setExecutor(mcmd);
+        getCommand("stop").setExecutor(mcmd);
+        getCommand("pause").setExecutor(mcmd);
+        getCommand("resume").setExecutor(mcmd);
+
+    }
+
+    private void onApiStarted(VoicechatServerApi api) {
+        getLogger().info("registerating...");
+
+        var category = api.volumeCategoryBuilder()
+                .setId("music724")
+                .setName("Music players")
+                .build();
+
+        api.registerVolumeCategory(category);
+
+        Statics.api = api;
+
+        getLogger().info("Sucess");
+    }
+
+    private void onPlayerConnected(VoicechatConnection connection) {
+        getLogger().info("Player connected: " + connection);
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/MyVoicechatPlugin.java b/src/main/java/eu/m724/musicPlugin/MyVoicechatPlugin.java
new file mode 100644
index 0000000..0a254ad
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/MyVoicechatPlugin.java
@@ -0,0 +1,45 @@
+package eu.m724.musicPlugin;
+
+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.PlayerConnectedEvent;
+import de.maxhenkel.voicechat.api.events.VoicechatServerStartedEvent;
+
+import java.util.function.Consumer;
+
+public class MyVoicechatPlugin implements VoicechatPlugin {
+    private final Consumer<VoicechatServerApi> apiConsumer;
+    private final Consumer<VoicechatConnection> playerConnected;
+
+    public MyVoicechatPlugin(Consumer<VoicechatServerApi> apiConsumer, Consumer<VoicechatConnection> playerConnected) {
+        this.apiConsumer = apiConsumer;
+        this.playerConnected = playerConnected;
+    }
+
+    @Override
+    public String getPluginId() {
+        return "myplgugonbo";
+    }
+
+    @Override
+    public void initialize(VoicechatApi api) {
+        VoicechatPlugin.super.initialize(api);
+    }
+
+    @Override
+    public void registerEvents(EventRegistration registration) {
+        registration.registerEvent(VoicechatServerStartedEvent.class, this::onServerStarted);
+        registration.registerEvent(PlayerConnectedEvent.class, this::onPlayerConnected);
+    }
+
+    public void onServerStarted(VoicechatServerStartedEvent event) {
+        apiConsumer.accept(event.getVoicechat());
+    }
+
+    public void onPlayerConnected(PlayerConnectedEvent event) {
+        playerConnected.accept(event.getConnection());
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/Statics.java b/src/main/java/eu/m724/musicPlugin/Statics.java
new file mode 100644
index 0000000..f948bb3
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/Statics.java
@@ -0,0 +1,8 @@
+package eu.m724.musicPlugin;
+
+import de.maxhenkel.voicechat.api.VoicechatServerApi;
+
+// TODO find a better way
+public class Statics {
+    public static VoicechatServerApi api;
+}
diff --git a/src/main/java/eu/m724/musicPlugin/file/AudioFile.java b/src/main/java/eu/m724/musicPlugin/file/AudioFile.java
new file mode 100644
index 0000000..95380a1
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/file/AudioFile.java
@@ -0,0 +1,71 @@
+package eu.m724.musicPlugin.file;
+
+import org.gagravarr.ogg.OggFile;
+import org.gagravarr.opus.OpusAudioData;
+import org.gagravarr.opus.OpusFile;
+
+import java.io.*;
+import java.util.ArrayList;
+
+public class AudioFile {
+    private final InputStream inputStream;
+
+    private NotEncoder encoder;
+    private String title, artist;
+    private int duration;
+
+    public AudioFile(File file) {
+        try {
+            this.inputStream = new FileInputStream(file);
+        } catch (FileNotFoundException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public AudioFile(InputStream inputStream) {
+        this.inputStream = inputStream;
+    }
+
+    public void load() throws IOException {
+        if (encoder != null) return;
+
+        var list = new ArrayList<byte[]>();
+
+        var opus = new OpusFile(new OggFile(inputStream));
+        var frames = 0;
+
+        OpusAudioData packet;
+        while ((packet = opus.getNextAudioPacket()) != null) {
+            list.add(packet.getData());
+            frames += packet.getNumberOfFrames();
+        }
+
+        this.duration = frames / 50;
+
+        this.title = opus.getTags().getTitle();
+        this.artist = opus.getTags().getArtist();
+
+        this.encoder = new NotEncoder(list.toArray(byte[][]::new));
+    }
+
+
+    public NotEncoder getEncoder() {
+        return encoder;
+    }
+
+    /**
+     * @return if data was loaded i.e. if load was called
+     */
+    public boolean isLoaded() {
+        return encoder != null;
+    }
+
+    /** @return Track duration in seconds */
+    public int getTrackDuration() { return duration; }
+
+    /** @return Track title if available */
+    public String getTrackTitle() { return title; }
+
+    /** @return Track artist if available */
+    public String getTrackArtist() { return artist; }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/file/AudioFileConverter.java b/src/main/java/eu/m724/musicPlugin/file/AudioFileConverter.java
new file mode 100644
index 0000000..af13a2e
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/file/AudioFileConverter.java
@@ -0,0 +1,39 @@
+package eu.m724.musicPlugin.file;
+
+import net.bramp.ffmpeg.FFmpeg;
+import net.bramp.ffmpeg.FFmpegExecutor;
+import net.bramp.ffmpeg.builder.FFmpegBuilder;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.concurrent.CompletableFuture;
+
+public class AudioFileConverter {
+    private final FFmpeg ffmpeg;
+
+    public AudioFileConverter(FFmpeg ffmpeg) {
+        this.ffmpeg = ffmpeg;
+    }
+
+    public CompletableFuture<Void> convert(File input, File output, int bitrate) throws IOException {
+        return convert(input.getAbsolutePath(), output, bitrate);
+    }
+
+    public CompletableFuture<Void> convert(URL source, File output, int bitrate) throws IOException {
+        return convert(source.toString(), output, bitrate);
+    }
+
+    private CompletableFuture<Void> convert(String input, File output, int bitrate) throws IOException {
+        var builder = new FFmpegBuilder()
+                .setInput(input)
+                .addOutput(output.getAbsolutePath())
+                .setAudioChannels(1)
+                .setAudioSampleRate(48_000)
+                .setAudioBitRate(bitrate)
+                .done();
+
+        var executor = new FFmpegExecutor(ffmpeg);
+        return CompletableFuture.runAsync(executor.createJob(builder));
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/file/AudioFileStorage.java b/src/main/java/eu/m724/musicPlugin/file/AudioFileStorage.java
new file mode 100644
index 0000000..c35a783
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/file/AudioFileStorage.java
@@ -0,0 +1,118 @@
+package eu.m724.musicPlugin.file;
+
+import net.bramp.ffmpeg.FFmpeg;
+import org.apache.commons.lang3.RandomStringUtils;
+
+import java.io.*;
+import java.net.URL;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.stream.Collectors;
+
+public class AudioFileStorage {
+    private final File basePath;
+    private final AudioFileConverter converter;
+
+    public AudioFileStorage(File basePath) {
+        this.basePath = basePath;
+
+        try {
+            this.converter = new AudioFileConverter(new FFmpeg()); // TODO better error handling and not here
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Get versions (bitrates) of an audio file
+     *
+     * @param hash TODO NOT SANITIZED
+     * @return Map of Bps to file, or null if no such hash
+     */
+    public Map<Integer, File> getVersions(String hash) {
+        var dir = new File(basePath, hash);
+
+        var files = dir.listFiles();
+        if (files == null) return null;
+
+        return Arrays.stream(files).collect(Collectors.toMap(
+                f -> Integer.parseInt(f.getName().split("\\.")[0]),
+                f -> f
+        ));
+    }
+
+    /**
+     * Get an audio file of given original hash and bitrate
+     *
+     * @param hash TODO NOT SANITIZED
+     * @param bitrate the bitrate in bps like 32000
+     */
+    public File get(String hash, int bitrate) {
+        return new File(basePath, hash + "/" + bitrate + ".opus");
+    }
+
+    /**
+     * Get the original file with a hash
+     */
+    public File getOriginal(String hash) {
+        return new File(basePath, hash + "/0.original");
+    }
+
+    /**
+     * Converts a (original) file to some bitrate
+     *
+     * @param hash the hash
+     * @param bitrate the target bitrate in bps
+     * @return the future with the new file
+     */
+    public CompletableFuture<File> convert(String hash, int bitrate) throws IOException {
+        var file = get(hash, bitrate);
+        if (file.exists()) return CompletableFuture.completedFuture(file);
+
+        var og = getOriginal(hash);
+        if (!og.exists()) return null;
+
+        return converter.convert(og, file, bitrate).thenApply(v -> file);
+    }
+
+    /**
+     * Downloads an audio file and saves it as original
+     *
+     * @param url the url to download from
+     * @return the hash
+     */
+    public CompletableFuture<String> download(URL url) {
+        return CompletableFuture.supplyAsync(() -> {
+            try {
+                var temp = new File(basePath, "temp_" + RandomStringUtils.randomAlphabetic(8));
+                var digest = MessageDigest.getInstance("SHA-256");
+
+                try (
+                        var input = new BufferedInputStream(url.openStream());
+                        var output = new FileOutputStream(temp)
+                ) {
+                    var dataBuffer = new byte[1024];
+                    int bytesRead;
+                    while ((bytesRead = input.read(dataBuffer, 0, 1024)) != -1) {
+                        output.write(dataBuffer, 0, bytesRead);
+                        digest.update(dataBuffer, 0, bytesRead);
+                    }
+                }
+
+                String hash = HexFormat.of().formatHex(digest.digest());
+                var og = getOriginal(hash);
+                og.getParentFile().mkdir();
+                if (!og.exists())
+                    Files.move(temp.toPath(), og.toPath());
+
+                return hash;
+            } catch (IOException | NoSuchAlgorithmException e) {
+                throw new CompletionException(e);
+            }
+        });
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/file/NotEncoder.java b/src/main/java/eu/m724/musicPlugin/file/NotEncoder.java
new file mode 100644
index 0000000..346f9f5
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/file/NotEncoder.java
@@ -0,0 +1,41 @@
+package eu.m724.musicPlugin.file;
+
+import de.maxhenkel.voicechat.api.opus.OpusEncoder;
+
+public class NotEncoder implements OpusEncoder {
+    private final byte[][] opusFrames;
+    private int i = 0;
+    private final int size;
+
+    private boolean closed = false;
+
+    public NotEncoder(byte[][] opusFrames) {
+        this.opusFrames = opusFrames;
+        this.size = opusFrames.length;
+    }
+
+    public boolean hasRemaining() {
+        return i < size;
+    }
+
+    @Override
+    public byte[] encode(short[] rawAudio) {
+        if (i < size)
+            return opusFrames[i++];
+        return null;
+    }
+
+    @Override
+    public void resetState() {
+        closed = false;
+        i = 0;
+    }
+
+    @Override
+    public boolean isClosed() {
+        return closed;
+    }
+
+    @Override
+    public void close() { closed = true; }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/player/EntityMusicPlayer.java b/src/main/java/eu/m724/musicPlugin/player/EntityMusicPlayer.java
new file mode 100644
index 0000000..fe1edc5
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/player/EntityMusicPlayer.java
@@ -0,0 +1,27 @@
+package eu.m724.musicPlugin.player;
+
+import de.maxhenkel.voicechat.api.audiochannel.AudioChannel;
+import org.bukkit.entity.Entity;
+
+import java.util.UUID;
+
+public class EntityMusicPlayer extends MusicPlayer {
+    private final Entity entity;
+
+    public EntityMusicPlayer(Entity entity) {
+        this.entity = entity;
+    }
+
+    @Override
+    AudioChannel createChannel() {
+        var channel = api.createEntityAudioChannel(
+                UUID.randomUUID(),
+                api.fromEntity(entity)
+        );
+
+        channel.setCategory("music724");
+        channel.setDistance(32);
+
+        return channel;
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/player/MusicPlayer.java b/src/main/java/eu/m724/musicPlugin/player/MusicPlayer.java
new file mode 100644
index 0000000..cca9457
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/player/MusicPlayer.java
@@ -0,0 +1,150 @@
+package eu.m724.musicPlugin.player;
+
+import de.maxhenkel.voicechat.api.VoicechatServerApi;
+import de.maxhenkel.voicechat.api.audiochannel.AudioChannel;
+import de.maxhenkel.voicechat.api.audiochannel.AudioPlayer;
+import eu.m724.musicPlugin.Statics;
+import eu.m724.musicPlugin.file.AudioFile;
+
+import java.io.IOException;
+import java.util.function.Consumer;
+
+public abstract class MusicPlayer {
+    // TODO find a better way
+    final VoicechatServerApi api = Statics.api;
+
+    private AudioChannel channel;
+
+    private boolean playing = false, paused = false;
+    private AudioPlayer player;
+    private AudioFile audioFile;
+
+    private Consumer<TrackAction> onAction = (r) -> {};
+
+    abstract AudioChannel createChannel();
+
+    /**
+     * Initializes this music player
+     */
+    public void init() {
+        this.channel = createChannel();
+    }
+
+    /**
+     * Set the consumer that will be called after playback is paused, stopped, resumed etc<br>
+     * There can be only one for one music player
+     *
+     * @see TrackAction
+     */
+    public void setOnAction(Consumer<TrackAction> onAction) {
+        this.onAction = onAction;
+    }
+
+
+    /* Playback control */
+
+    /**
+     * Starts playback of an audio file<br>
+     * If it's not loaded, it will be loaded synchronously.
+     *
+     * @see MusicPlayer#pause()
+     * @see MusicPlayer#stop()
+     */
+    public void play(AudioFile audioFile) {
+        if (audioFile != null)
+            this.audioFile = audioFile;
+
+        if (playing)
+            stop();
+
+        if (!audioFile.isLoaded()) {
+            try {
+                audioFile.load();
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        unpause();
+    }
+
+    public void unpause() {
+        var sa = new short[960];
+        var enc = audioFile.getEncoder();
+
+        player = api.createAudioPlayer(channel, enc, () -> enc.hasRemaining() ? sa : null);
+        player.setOnStopped(this::onStop);
+        player.startPlaying();
+
+        // TODO this may take a long time and it might glitch if it does
+        onAction.accept(playing ? TrackAction.UNPAUSE : TrackAction.START);
+
+        playing = true;
+        paused = false;
+
+    }
+
+    /**
+     * Stops playback and rewinds
+     */
+    public void stop() {
+        stopPlayback(false);
+        audioFile.getEncoder().resetState();
+    }
+
+    /**
+     * Pauses playback
+     */
+    public void pause() {
+        stopPlayback(true);
+    }
+
+
+    /* Internal methods */
+
+
+    private void stopPlayback(boolean pause) {
+        if (player == null) return;
+
+        paused = pause;
+        playing = false;
+
+        player.stopPlaying();
+        player = null;
+
+        onStop();
+    }
+
+    private void onStop() {
+        if (paused) // paused
+            onAction.accept(TrackAction.PAUSE);
+        else if (playing) { // not paused and still playing
+            onAction.accept(TrackAction.DURATION);
+            playing = false;
+        } else // not playing
+            onAction.accept(TrackAction.STOP);
+    }
+
+    public enum TrackAction {
+        /**
+         * A new track started playing
+         */
+        START,
+        /**
+         * Song stopped playing because it played
+         */
+        DURATION,
+        /**
+         * {@link MusicPlayer#stop()} was called, or started playing a new track
+         */
+        STOP,
+        /**
+         * {@link MusicPlayer#pause()} was called
+         */
+        PAUSE,
+        /**
+         * {@link MusicPlayer#unpause()} was called
+         */
+        UNPAUSE
+    }
+}
diff --git a/src/main/java/eu/m724/musicPlugin/player/StaticMusicPlayer.java b/src/main/java/eu/m724/musicPlugin/player/StaticMusicPlayer.java
new file mode 100644
index 0000000..1ea789d
--- /dev/null
+++ b/src/main/java/eu/m724/musicPlugin/player/StaticMusicPlayer.java
@@ -0,0 +1,28 @@
+package eu.m724.musicPlugin.player;
+
+import de.maxhenkel.voicechat.api.audiochannel.AudioChannel;
+import org.bukkit.Location;
+
+import java.util.UUID;
+
+public class StaticMusicPlayer extends MusicPlayer {
+    private final Location location;
+
+    public StaticMusicPlayer(Location location) {
+        this.location = location;
+    }
+
+    @Override
+    AudioChannel createChannel() {
+        var channel = api.createLocationalAudioChannel(
+                UUID.randomUUID(),
+                api.fromServerLevel(location.getWorld()),
+                api.createPosition(location.getX(), location.getY(), location.getZ())
+        );
+
+        channel.setCategory("music724");
+        channel.setDistance(32);
+
+        return channel;
+    }
+}
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
new file mode 100644
index 0000000..a46c688
--- /dev/null
+++ b/src/main/resources/plugin.yml
@@ -0,0 +1,30 @@
+name: music-plugin
+version: 1
+main: eu.m724.musicPlugin.MusicPlugin
+api-version: '1.21'
+
+depend: [voicechat]
+
+libraries:
+  - net.bramp.ffmpeg:ffmpeg:0.8.0
+  - org.gagravarr:vorbis-java-core:0.8
+
+commands:
+  download:
+    description: Downloads a file from URL. Run /convert next
+    usage: /<command> <url>
+  convert:
+    description: Converts original downloaded file to some bitrate (in bps)
+    usage: /<command> <hash> <bitrate>
+  play:
+    description: Plays the audio file of specified bitrate. Run /convert first
+    usage: /<command> <hash> <bitrate>
+  stop:
+    description: Stops playback
+    usage: /<command>
+  pause:
+    description: Pauses playback
+    usage: /<command>
+  resume:
+    description: Resumes playback
+    usage: /<command>
\ No newline at end of file