Skip to content

Addon API

Yevhen Harasymchuk edited this page Aug 5, 2026 · 3 revisions

Addon API

Mixer addons are ordinary Paper plugins. They declare Mixer as a dependency, obtain MixerApi from Bukkit's Services API, and register an addon instance. An addon must only depend on the api module; classes under me.andromedov.mixer.core are internal.

The API examples below target Mixer 2.3.0, Paper 1.21.4+, and Java 21. Mixer itself is validated on Paper versions from 1.21.4 through 26.2.

Add the API dependency

Until the API artifact is published to a remote Maven repository, install the current checkout into Maven Local:

git clone https://github.com/Andromedov/Mixer.git
cd Mixer
gradlew :api:publishToMavenLocal

Then configure the addon project:

repositories {
    mavenLocal()
    maven("https://repo.papermc.io/repository/maven-public/")
}

dependencies {
    compileOnly("me.andromedov:mixer-api:2.3.0")
    compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
}

java {
    toolchain.languageVersion.set(JavaLanguageVersion.of(21))
}

The addon's plugin.yml must load after Mixer:

name: RadioAddon
version: 1.0.0
main: com.example.radio.RadioAddonPlugin
api-version: '1.21.4'
depend: [Mixer]

Register an addon and a source protocol

package com.example.radio;

import me.andromedov.mixer.api.MixerApi;
import me.andromedov.mixer.api.addon.MixerAddon;
import me.andromedov.mixer.api.addon.MixerAddonContext;
import me.andromedov.mixer.api.source.MixerAudioSourceResolver;
import org.bukkit.plugin.java.JavaPlugin;

public final class RadioAddonPlugin extends JavaPlugin {
    @Override
    public void onEnable() {
        MixerApi.get().addons().register(this, new MixerAddon() {
            @Override
            public String id() {
                return "radio";
            }

            @Override
            public String name() {
                return "Radio Addon";
            }

            @Override
            public String version() {
                return getPluginMeta().getVersion();
            }

            @Override
            public void onEnable(MixerAddonContext context) {
                context.registerSourceResolver(new MixerAudioSourceResolver() {
                    @Override
                    public String id() {
                        return "station";
                    }

                    @Override
                    public int priority() {
                        return 100;
                    }

                    @Override
                    public boolean supports(String source) {
                        return source.startsWith("radio:");
                    }

                    @Override
                    public String resolve(String source) {
                        String station = source.substring("radio:".length());
                        return switch (station) {
                            case "ambient" -> "https://radio.example/ambient.ogg";
                            case "rock" -> "https://radio.example/rock.ogg";
                            default -> throw new IllegalArgumentException("Unknown station: " + station);
                        };
                    }
                });
            }
        });
    }
}

Players and burned discs can now use radio:ambient. Mixer stores that stable source on the disc and asks the addon to resolve it each time playback starts. This allows an addon to return expiring URLs without writing those URLs into item data.

Source resolver supports and resolve methods run asynchronously. They may perform blocking HTTP or database work, but must not access Bukkit APIs that require the main server thread. Resolvers run by descending priority; equal priorities use their stable registration key as a tiebreaker.

All registrations created through MixerAddonContext are automatically removed when the addon is disabled. Manual cleanup is only necessary for resources created outside the Mixer API.

Control players and DSP

MixerApi mixer = MixerApi.get();

var audioPlayer = mixer.getOrCreatePlayer(jukebox.getLocation());
audioPlayer.clearAndPlay("radio:ambient");

audioPlayer.currentTrack().ifPresent(track ->
        getLogger().info(track.author() + " - " + track.title()));

audioPlayer.dsp().setGain(0.75);
audioPlayer.dsp().setHighPass(120.0F);
audioPlayer.dsp().setFlanger(0.003, 0.5, 1.5);

audioPlayer.clearQueue();
mixer.stopPlayer(jukebox.getLocation());

Creating/stopping players, changing DSP, and registering/unregistering addons or resolvers must happen on the Bukkit main thread. Reading player state and resolving audio sources is thread-safe. Collections returned by the API are immutable snapshots.

Portable players are also available through createPortablePlayer, findPortablePlayer, and stopPortablePlayer.

Moderated private discs

Mixer 2.3.0 exposes two APIs intended for approval workflows:

  • MixerApi.discs() probes sources and creates or reads Mixer disc items.
  • MixerApi.playbackPolicies() controls whether a physical disc may play.

Discord requests, moderation state, persistence, and economy transactions belong to the addon. Mixer does not depend on a Discord library or Vault.

1. Probe a submission

probe resolves addon protocols and Cobalt sources, loads the first selected track, and returns the original stable source with its metadata. It always completes asynchronously and does not create an item.

MixerApi mixer = MixerApi.get();

mixer.discs().probe(submittedUrl).whenComplete((disc, error) -> {
    if (error != null) {
        // Mark the request as invalid and notify the player later on the main thread.
        return;
    }

    // Persist the pending request before sending the Discord message.
    // This callback is asynchronous: database and Discord I/O are allowed here,
    // but Bukkit ItemStack, inventory, world, and player APIs are not.
    pendingRepository.save(requestId, playerId, disc);
    discordModeration.sendRequest(requestId, disc.track().orElseThrow());
});

Discord Accept/Reject buttons should carry an opaque request ID rather than a raw URL. Verify the Discord interaction signature or bot token, make decisions idempotent, and persist the moderator ID and decision time.

2. Create the approved item

Use a stable addon-owned source such as private:<request-id>. Store the approved original URL in the addon's database and resolve it only during playback. This avoids putting private or expiring URLs into item PDC and lets policies recognize the source even when no ItemStack is available during server restore.

Bukkit.getScheduler().runTask(this, () -> {
    MixerDisc approved = new MixerDisc(
            "private:" + requestId,
            pendingDisc.track()
    );

    ItemStack template = new ItemStack(Material.MUSIC_DISC_13);
    ItemStack item = MixerApi.get().discs().createDisc(template, approved);

    item.editMeta(meta -> meta.getPersistentDataContainer().set(
            new NamespacedKey(this, "private_owner"),
            PersistentDataType.STRING,
            ownerId.toString()
    ));

    // Give it to an online player or persist it for delivery on their next login.
    owner.getInventory().addItem(item);
});

createDisc clones the template and never modifies the input. It and readDisc must run on the Bukkit main thread. Newly created discs preserve source, title, author, URI, duration, and stream metadata. readDisc can also read older Mixer discs, but their track metadata may be absent.

3. Resolve the private source

Register a resolver that accepts only approved IDs. Resolver methods run off the main thread, so a database lookup is allowed.

context.registerSourceResolver(new MixerAudioSourceResolver() {
    @Override
    public String id() {
        return "private-disc";
    }

    @Override
    public boolean supports(String source) {
        return source.startsWith("private:");
    }

    @Override
    public String resolve(String source) {
        String requestId = source.substring("private:".length());
        return approvedRepository.findPlayableUrl(requestId)
                .orElseThrow(() -> new IllegalStateException("Disc is not approved"));
    }
});

4. Enforce ownership

Playback policies cover jukebox interaction, portable speakers, redstone playlists, and server restore. Policies are synchronous and fail closed when they throw. They must use an in-memory cache and must not call Discord, Vault, HTTP, or a database.

context.registerPlaybackPolicy(new MixerPlaybackPolicy() {
    @Override
    public String id() {
        return "private-disc-owner";
    }

    @Override
    public int priority() {
        return 100;
    }

    @Override
    public MixerPlaybackDecision evaluate(MixerPlaybackRequest request) {
        if (!request.source().startsWith("private:")) {
            return MixerPlaybackDecision.allow();
        }

        String requestId = request.source().substring("private:".length());
        UUID ownerId = ownershipCache.get(requestId);
        boolean allowed = ownerId != null && request.actor()
                .map(player -> player.getUniqueId().equals(ownerId)
                        || player.hasPermission("privateplates.admin"))
                .orElse(false);

        return allowed
                ? MixerPlaybackDecision.allow()
                : MixerPlaybackDecision.deny(Component.text("This disc is private."));
    }
});

An absent actor means redstone automation or server restore. The example denies private playback in both cases. Programmatic MixerAudioPlayer.load calls are trusted addon operations and do not represent a physical disc, so an addon must authorize those calls itself.

5. Optional Vault payment

Keep payment policy in the addon and make the chosen point explicit:

  • Charge on submission and refund on rejection or technical failure; or
  • Charge on approval immediately before creating/delivering the disc.

Check for both Vault and an economy provider. Run economy and Bukkit operations on the main thread, record a unique transaction state before acting, and make Discord button retries idempotent so one request cannot be charged or delivered twice.

All source-resolver and playback-policy registrations made through MixerAddonContext are automatically removed when the addon plugin is disabled.