Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4de8b95
fix: save studio takes asynchronously and preserve playback controls
Nowkoo Oct 11, 2026
a2ea57e
perf: share decoded note names and skip redundant project parses
Nowkoo Oct 11, 2026
e00d8b4
perf: only write unsaved studio projects on disconnect and shutdown
Nowkoo Oct 11, 2026
6c8eb9d
perf: list studio projects from a summary index
Nowkoo Oct 11, 2026
95df0f8
fix: bound published edition growth
Nowkoo Oct 11, 2026
0f6ff6e
perf: trim per-tick and per-click studio work
Nowkoo Oct 11, 2026
8d04160
fix: keep take reviews audible on muted or silent tracks
Nowkoo Oct 11, 2026
a61d0fa
perf: store the studio selection as a pointer, not a second copy
Nowkoo Oct 11, 2026
48f3fb7
perf: write studio songs and projects in a compact format
Nowkoo Oct 11, 2026
13613a6
fix: keep the studio library usable when a project file is damaged
Nowkoo Oct 11, 2026
cb76590
perf: write studio edits in the background
Nowkoo Oct 11, 2026
9a1351a
fix: limit new editions per day and keep only their audible parts
Nowkoo Oct 12, 2026
1c7af6a
perf: skip ItemsAdder station lookups while ItemsAdder is not enabled
Nowkoo Oct 12, 2026
2218d1a
fix: stop a jukebox at once when a hopper takes its disc
Nowkoo Oct 12, 2026
a58a610
refactor: drop studio data migrations
Nowkoo Oct 12, 2026
9e96baa
fix: bound studio save backlog and retry shutdown failures
Nowkoo Oct 12, 2026
55393d6
fix: reject stale project deletion confirmations
Nowkoo Oct 12, 2026
4b09d1f
test: accept filesystem warning order across platforms
Nowkoo Oct 12, 2026
ec1c761
fix: keep a recording's checkpoint when retrying a failed save
Nowkoo Oct 12, 2026
d28a452
fix: treat invalid UTF-8 in studio files as damaged content
Nowkoo Oct 12, 2026
711dc0c
fix: keep a take at full volume after its saved track is removed
Nowkoo Oct 12, 2026
bc0238f
fix: keep the project limit when resetting without an active project
Nowkoo Oct 12, 2026
a5f84e7
fix: wait for completed hopper transfer before stopping playback
Nowkoo Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public void onEnable() {
getCommand("music").setExecutor(musicCommand);
getCommand("music").setTabCompleter(musicCommand);
getServer().getPluginManager().registerEvents(studioMenu, this);
getServer().getPluginManager().registerEvents(new StudioListener(studio, studioMenu), this);
getServer().getPluginManager().registerEvents(new StudioListener(this, studio, studioMenu), this);

InstrumentCommand commandHandler = new InstrumentCommand(this, manager);
getCommand("instruments").setExecutor(commandHandler);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package net.tfminecraft.musicalinstruments.studio;

import java.util.regex.Pattern;

/** A resolved sample trigger, captured independently of subsequent config changes. */
public record Note(int tick, String instrument, String sound, float volume, float pitch) {
private static final Pattern SOUND = Pattern.compile("[a-z0-9_.-]+(:[a-z0-9_./-]+)?");

public Note {
if (tick < 0 || tick >= Song.MAX_TICKS || instrument == null || instrument.length() > 100
|| sound == null || sound.length() > 200 || !sound.matches("[a-z0-9_.-]+(:[a-z0-9_./-]+)?")
|| sound == null || sound.length() > 200 || !SOUND.matcher(sound).matches()
|| !Float.isFinite(volume) || volume < 0 || volume > 16
|| !Float.isFinite(pitch) || pitch < 0.5f || pitch > 2) {
throw new IllegalArgumentException("Invalid recorded note");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public record Project(Song song, Track pending, int bpm, boolean metronome) {
throw new IllegalArgumentException("Tempo must be between 40 and 240 BPM");
}
if (pending != null) {
// A take uses its track's current mix. Without a saved track, as after removing it, the
// take is kept at full volume like one recorded on an empty track.
Track saved = song.track(pending.slot());
pending = saved != null ? pending.mix(saved.gain(), saved.muted()) : pending.mix(1, false);
song.replace(pending); // Validate the prospective mix, including its total note bound.
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package net.tfminecraft.musicalinstruments.studio;

import java.util.Comparator;
import java.util.Objects;
import java.util.UUID;

/** What the project library shows, without loading a project's notes. */
public record ProjectSummary(UUID id, String title, int tracks, int lengthTicks, int pendingSlot) {
public static final Comparator<ProjectSummary> ORDER = Comparator.comparing(ProjectSummary::title,
String.CASE_INSENSITIVE_ORDER).thenComparing(ProjectSummary::id);

public ProjectSummary {
Objects.requireNonNull(id);
Objects.requireNonNull(title);
}

public static ProjectSummary of(Project project) {
Song song = project.song();
return new ProjectSummary(song.id(), song.title(), song.tracks().size(), song.lengthTicks(),
project.pending() == null ? 0 : project.pending().slot());
}
}
21 changes: 17 additions & 4 deletions src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ public Track track(int slot) {
return tracks.stream().filter(track -> track.slot() == slot).findFirst().orElse(null);
}

/** Whether playback emits any sound; matches the notes {@link Timeline} schedules. */
public boolean audible() {
return tracks.stream().anyMatch(track -> !track.muted() && track.gain() > 0
&& track.notes().stream().anyMatch(note -> note.volume() > 0));
}

/** The same song with only what playback emits: muted, silent and empty tracks and silent notes are left out. */
public Song audibleMix() {
List<Track> audible = new ArrayList<>();
for (Track track : tracks) {
if (track.muted() || track.gain() <= 0) continue;
List<Note> notes = track.notes().stream().filter(note -> note.volume() > 0).toList();
if (!notes.isEmpty()) audible.add(new Track(track.slot(), track.lengthTicks(), track.gain(), false, notes));
}
return new Song(id, owner, author, title, audible);
}

public Song replace(Track track) {
List<Track> result = new ArrayList<>(tracks);
result.removeIf(existing -> existing.slot() == track.slot());
Expand All @@ -52,8 +69,4 @@ public Song replace(Track track) {
public Song remove(int slot) {
return new Song(id, owner, author, title, tracks.stream().filter(t -> t.slot() != slot).toList());
}

public Song edition() {
return new Song(UUID.randomUUID(), owner, author, title, tracks);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
case "projects" -> {
var projects = studio.listProjects(player);
if (projects.isEmpty()) player.sendMessage("No projects yet. Open /music studio to create a song.");
for (Project project : projects) player.sendMessage(project.song().title() + " | " + project.song().id());
for (ProjectSummary project : projects) player.sendMessage(project.title() + " | " + project.id());
}
case "open" -> {
need(args, 2, "open <project-id>");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,25 @@
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;

import java.io.IOException;

public final class StudioListener implements Listener {
private final Plugin plugin;
private final StudioService studio;
private final StudioMenu menu;

public StudioListener(StudioService studio, StudioMenu menu) {
public StudioListener(Plugin plugin, StudioService studio, StudioMenu menu) {
this.plugin = plugin;
this.studio = studio;
this.menu = menu;
}
Expand All @@ -34,6 +38,8 @@ public StudioListener(StudioService studio, StudioMenu menu) {
public void interact(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null
|| event.useInteractedBlock() == Event.Result.DENY
// Check the type first: getState() copies block entities such as chest inventories.
|| event.getClickedBlock().getType() != Material.JUKEBOX
|| !(event.getClickedBlock().getState() instanceof Jukebox box)) {
return;
}
Expand Down Expand Up @@ -128,6 +134,25 @@ public void hopper(InventoryMoveItemEvent event) {
}
}

@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void extract(InventoryMoveItemEvent event) {
// A rejected transfer restores the source disc after the event, so check on the next tick.
// Hoppers move items constantly, so the cheap item and inventory type checks come first.
if (studio.discs().custom(event.getItem()) && event.getSource().getType() == InventoryType.JUKEBOX
&& event.getSource().getLocation() != null) {
Block block = event.getSource().getLocation().getBlock();
plugin.getServer().getScheduler().runTask(plugin, () -> {
// Do not reload a chunk that unloaded while the transfer completed.
if (block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)
&& block.getType() == Material.JUKEBOX
&& block.getState() instanceof Jukebox box
&& !studio.discs().custom(box.getRecord())) {
studio.stop(block);
}
});
}
}

@EventHandler
public void quit(PlayerQuitEvent event) {
studio.quit(event.getPlayer());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,16 @@ private void render(Player player, Holder holder) throws IOException {
}

private void renderLibrary(Player player, Holder holder) throws IOException {
List<Project> projects = studio.listProjects(player);
List<ProjectSummary> projects = studio.listProjects(player);
inventoryItem(holder, 4, Material.BOOKSHELF, "Your songs", NamedTextColor.GOLD,
projects.size() + "/36 projects", "Left-click to open a song.", "Right-click to delete a song after confirmation.");
for (int index = 0; index < Math.min(projects.size(), StudioService.MAX_PROJECTS); index++) {
Project project = projects.get(index);
ProjectSummary project = projects.get(index);
int slot = 9 + index;
holder.choices.put(slot, project.song().id());
inventoryItem(holder, slot, Material.MUSIC_DISC_13, project.song().title(), NamedTextColor.YELLOW,
project.song().tracks().size() + " tracks | " + time(project.song().lengthTicks()),
project.pending() == null ? "No pending take" : "Pending take on track " + project.pending().slot(),
holder.choices.put(slot, project.id());
inventoryItem(holder, slot, Material.MUSIC_DISC_13, project.title(), NamedTextColor.YELLOW,
project.tracks() + " tracks | " + time(project.lengthTicks()),
project.pendingSlot() == 0 ? "No pending take" : "Pending take on track " + project.pendingSlot(),
"Left-click: open this project.", "Right-click: delete with confirmation.");
}
if (projects.isEmpty()) inventoryItem(holder, 22, Material.WRITABLE_BOOK, "Your studio is empty", NamedTextColor.GOLD,
Expand Down
Loading