添加循环播放功能

This commit is contained in:
BRanulf 2025-04-20 10:53:52 +08:00
parent 1bf4e07e88
commit 98620ae5d5
13 changed files with 590 additions and 83 deletions

View File

@ -25,6 +25,7 @@ dependencies {
include modApi("me.shedaniel.cloth:cloth-config-fabric:17.0.142") { include modApi("me.shedaniel.cloth:cloth-config-fabric:17.0.142") {
exclude(group: "net.fabricmc.fabric-api") exclude(group: "net.fabricmc.fabric-api")
} }
modImplementation "me.shedaniel.cloth:cloth-config-fabric:17.0.142"
// modCompileOnly("com.terraformersmc:modmenu:13.0.3") // modCompileOnly("com.terraformersmc:modmenu:13.0.3")
modCompileOnly files("libs/modmenu-13.0.3.jar") modCompileOnly files("libs/modmenu-13.0.3.jar")

View File

@ -6,7 +6,7 @@ minecraft_version=1.21.4
yarn_mappings=1.21.4+build.8 yarn_mappings=1.21.4+build.8
loader_version=0.16.10 loader_version=0.16.10
# Mod Properties # Mod Properties
mod_version=1.14.514.013 mod_version=1.14.514.019
maven_group=semmiedev maven_group=semmiedev
archives_base_name=disc_jockey archives_base_name=disc_jockey
# Dependencies # Dependencies

View File

@ -35,12 +35,12 @@ public class Main implements ClientModInitializer {
public static final SongPlayer SONG_PLAYER = new SongPlayer(); public static final SongPlayer SONG_PLAYER = new SongPlayer();
public static File songsFolder; public static File songsFolder;
public static Config config; public static ModConfig config;
public static ConfigHolder<Config> configHolder; public static ConfigHolder<ModConfig> configHolder;
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
configHolder = AutoConfig.register(Config.class, JanksonConfigSerializer::new); configHolder = AutoConfig.register(ModConfig.class, JanksonConfigSerializer::new);
config = configHolder.getConfig(); config = configHolder.getConfig();
songsFolder = new File(FabricLoader.getInstance().getConfigDir()+File.separator+MOD_ID+File.separator+"songs"); songsFolder = new File(FabricLoader.getInstance().getConfigDir()+File.separator+MOD_ID+File.separator+"songs");

View File

@ -7,11 +7,17 @@ import java.util.ArrayList;
@me.shedaniel.autoconfig.annotation.Config(name = Main.MOD_ID) @me.shedaniel.autoconfig.annotation.Config(name = Main.MOD_ID)
@me.shedaniel.autoconfig.annotation.Config.Gui.Background("textures/block/note_block.png") @me.shedaniel.autoconfig.annotation.Config.Gui.Background("textures/block/note_block.png")
public class Config implements ConfigData { public class ModConfig implements ConfigData {
public boolean hideWarning; public boolean hideWarning;
@ConfigEntry.Gui.Tooltip(count = 2) public boolean disableAsyncPlayback; @ConfigEntry.Gui.Tooltip(count = 2) public boolean disableAsyncPlayback;
@ConfigEntry.Gui.Tooltip(count = 2) public boolean omnidirectionalNoteBlockSounds = true; @ConfigEntry.Gui.Tooltip(count = 2) public boolean omnidirectionalNoteBlockSounds = true;
@ConfigEntry.Gui.Excluded
public String currentFolderPath = "";
@ConfigEntry.Gui.Excluded
public SongPlayer.PlayMode playMode = SongPlayer.PlayMode.STOP_AFTER;
public enum ExpectedServerVersion { public enum ExpectedServerVersion {
All, All,
v1_20_4_Or_Earlier, v1_20_4_Or_Earlier,

View File

@ -7,6 +7,6 @@ import me.shedaniel.autoconfig.AutoConfig;
public class ModMenuIntegration implements ModMenuApi { public class ModMenuIntegration implements ModMenuApi {
@Override @Override
public ConfigScreenFactory<?> getModConfigScreenFactory() { public ConfigScreenFactory<?> getModConfigScreenFactory() {
return parent -> AutoConfig.getConfigScreen(Config.class, parent).get(); return parent -> AutoConfig.getConfigScreen(ModConfig.class, parent).get();
} }
} }

View File

@ -29,7 +29,7 @@ public record Note(NoteBlockInstrument instrument, byte note) {
NoteBlockInstrument.DIDGERIDOO, NoteBlockInstrument.DIDGERIDOO,
NoteBlockInstrument.BIT, NoteBlockInstrument.BIT,
NoteBlockInstrument.BANJO, NoteBlockInstrument.BANJO,
NoteBlockInstrument.PLING NoteBlockInstrument.PLING,
}; };

View File

@ -10,10 +10,11 @@ public class Song {
public long[] notes = new long[0]; public long[] notes = new long[0];
public short length, height, tempo, loopStartTick; public short length, height, tempo, loopStartTick;
public String fileName, name, author, originalAuthor, description, displayName; public String fileName, filePath, name, author, originalAuthor, description, displayName;
public byte autoSaving, autoSavingDuration, timeSignature, vanillaInstrumentCount, formatVersion, loop, maxLoopCount; public byte autoSaving, autoSavingDuration, timeSignature, vanillaInstrumentCount, formatVersion, loop, maxLoopCount;
public int minutesSpent, leftClicks, rightClicks, blocksAdded, blocksRemoved; public int minutesSpent, leftClicks, rightClicks, blocksAdded, blocksRemoved;
public String importFileName; public String importFileName;
public SongLoader.SongFolder folder;
public SongListWidget.SongEntry entry; public SongListWidget.SongEntry entry;
public String searchableFileName, searchableName; public String searchableFileName, searchableName;
@ -24,8 +25,7 @@ public class Song {
} }
public double millisecondsToTicks(long milliseconds) { public double millisecondsToTicks(long milliseconds) {
// From NBS Format: The tempo of the song multiplied by 100 (for example, 1225 instead of 12.25). Measured in ticks per second. double songSpeed = (tempo / 100.0) / 20.0;
double songSpeed = (tempo / 100.0) / 20.0; // 20 Ticks per second (temp / 100 = 20) would be 1x speed
double oneMsTo20TickFraction = 1.0 / 50.0; double oneMsTo20TickFraction = 1.0 / 50.0;
return milliseconds * oneMsTo20TickFraction * songSpeed; return milliseconds * oneMsTo20TickFraction * songSpeed;
} }
@ -39,5 +39,4 @@ public class Song {
public double getLengthInSeconds() { public double getLengthInSeconds() {
return ticksToMilliseconds(length) / 1000.0; return ticksToMilliseconds(length) / 1000.0;
} }
} }

View File

@ -11,29 +11,42 @@ import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
public class SongLoader { public class SongLoader {
public static final ArrayList<Song> SONGS = new ArrayList<>(); public static final ArrayList<Song> SONGS = new ArrayList<>();
public static final ArrayList<SongFolder> FOLDERS = new ArrayList<>();
public static final ArrayList<String> SONG_SUGGESTIONS = new ArrayList<>(); public static final ArrayList<String> SONG_SUGGESTIONS = new ArrayList<>();
public static volatile boolean loadingSongs; public static volatile boolean loadingSongs;
public static volatile boolean showToast; public static volatile boolean showToast;
public static SongFolder currentFolder = null;
public static class SongFolder {
public final String name;
public final String path;
public final ArrayList<Song> songs = new ArrayList<>();
public final ArrayList<SongFolder> subFolders = new ArrayList<>();
public SongListWidget.FolderEntry entry;
public SongFolder(String name, String path) {
this.name = name;
this.path = path;
}
}
public static void loadSongs() { public static void loadSongs() {
if (loadingSongs) return; if (loadingSongs) return;
new Thread(() -> { new Thread(() -> {
loadingSongs = true; loadingSongs = true;
SONGS.clear(); SONGS.clear();
FOLDERS.clear();
SONG_SUGGESTIONS.clear(); SONG_SUGGESTIONS.clear();
SONG_SUGGESTIONS.add("Songs are loading, please wait"); SONG_SUGGESTIONS.add("Songs are loading, please wait");
for (File file : Main.songsFolder.listFiles()) {
Song song = null; // Load root folder
try { loadFolder(Main.songsFolder, null);
song = loadSong(file);
} catch (Exception exception) {
Main.LOGGER.error("Unable to read or parse song {}", file.getName(), exception);
}
if (song != null) SONGS.add(song);
}
for (Song song : SONGS) SONG_SUGGESTIONS.add(song.displayName); for (Song song : SONGS) SONG_SUGGESTIONS.add(song.displayName);
Main.config.favorites.removeIf(favorite -> SongLoader.SONGS.stream().map(song -> song.fileName).noneMatch(favorite::equals)); Main.config.favorites.removeIf(favorite -> SongLoader.SONGS.stream().map(song -> song.fileName).noneMatch(favorite::equals));
@ -43,12 +56,44 @@ public class SongLoader {
}).start(); }).start();
} }
private static void loadFolder(File folder, SongFolder parentFolder) {
if (!folder.isDirectory()) return;
SongFolder songFolder = new SongFolder(folder.getName(), folder.getPath());
if (parentFolder == null) {
FOLDERS.add(songFolder);
} else {
parentFolder.subFolders.add(songFolder);
}
File[] files = folder.listFiles();
if (files == null) return;
for (File file : files) {
if (file.isDirectory()) {
loadFolder(file, songFolder);
} else {
try {
Song song = loadSong(file);
if (song != null) {
SONGS.add(song);
songFolder.songs.add(song);
song.folder = songFolder;
}
} catch (Exception exception) {
Main.LOGGER.error("Unable to read or parse song {}", file.getName(), exception);
}
}
}
}
public static Song loadSong(File file) throws IOException { public static Song loadSong(File file) throws IOException {
if (file.isFile()) { if (file.isFile()) {
BinaryReader reader = new BinaryReader(Files.newInputStream(file.toPath())); BinaryReader reader = new BinaryReader(Files.newInputStream(file.toPath()));
Song song = new Song(); Song song = new Song();
song.fileName = file.getName().replaceAll("[\\n\\r]", ""); song.fileName = file.getName().replaceAll("[\\n\\r]", "");
song.filePath = file.getPath();
song.length = reader.readShort(); song.length = reader.readShort();
@ -99,7 +144,6 @@ public class SongLoader {
byte noteId = (byte)(reader.readByte() - 33); byte noteId = (byte)(reader.readByte() - 33);
if (newFormat) { if (newFormat) {
// Data that is not needed as it only works with commands
reader.readByte(); // Velocity reader.readByte(); // Velocity
reader.readByte(); // Panning reader.readByte(); // Panning
reader.readShort(); // Pitch reader.readShort(); // Pitch
@ -126,5 +170,10 @@ public class SongLoader {
public static void sort() { public static void sort() {
SONGS.sort(Comparator.comparing(song -> song.displayName)); SONGS.sort(Comparator.comparing(song -> song.displayName));
FOLDERS.sort(Comparator.comparing(folder -> folder.name));
for (SongFolder folder : FOLDERS) {
folder.songs.sort(Comparator.comparing(song -> song.displayName));
folder.subFolders.sort(Comparator.comparing(subFolder -> subFolder.name));
}
} }
} }

View File

@ -91,6 +91,17 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
this.playbackThread.start(); this.playbackThread.start();
} }
public enum PlayMode {
SINGLE_LOOP, // 单曲循环
LIST_LOOP, // 列表循环
RANDOM, // 随机播放
STOP_AFTER // 播完就停
}
private PlayMode playMode = PlayMode.STOP_AFTER;
private boolean isRandomPlaying = false;
private int randomIndex = -1;
public synchronized void stopPlaybackThread() { public synchronized void stopPlaybackThread() {
this.playbackThread = null; // Should stop on its own then this.playbackThread = null; // Should stop on its own then
} }
@ -103,8 +114,7 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
} }
if (running) stop(); if (running) stop();
this.song = song; this.song = song;
//Main.LOGGER.info("Song length: " + song.length + " and tempo " + song.tempo); this.loopSong = playMode == PlayMode.SINGLE_LOOP;
//Main.TICK_LISTENERS.add(this);
if(this.playbackThread == null) startPlaybackThread(); if(this.playbackThread == null) startPlaybackThread();
running = true; running = true;
lastPlaybackTickAt = System.currentTimeMillis(); lastPlaybackTickAt = System.currentTimeMillis();
@ -116,6 +126,18 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
lastSwingSentAt = -1L; lastSwingSentAt = -1L;
missingInstrumentBlocks.clear(); missingInstrumentBlocks.clear();
didSongReachEnd = false; didSongReachEnd = false;
isRandomPlaying = playMode == PlayMode.RANDOM;
if (isRandomPlaying) {
randomIndex = getRandomSongIndex();
}
}
private int getRandomSongIndex() {
if (SongLoader.currentFolder == null) {
return (int) (Math.random() * SongLoader.SONGS.size());
} else {
return (int) (Math.random() * SongLoader.currentFolder.songs.size());
}
} }
public synchronized void stop() { public synchronized void stop() {
@ -215,8 +237,10 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
if (index >= song.notes.length) { if (index >= song.notes.length) {
stop(); stop();
didSongReachEnd = true; didSongReachEnd = true;
if(loopSong) { if (playMode == PlayMode.SINGLE_LOOP) {
start(song); start(song);
} else if (playMode == PlayMode.LIST_LOOP || playMode == PlayMode.RANDOM) {
playNextSong();
} }
break; break;
} }
@ -232,6 +256,30 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
} }
} }
private void playNextSong() {
if (SongLoader.currentFolder == null || SongLoader.currentFolder.songs.isEmpty()) return;
int currentIndex = SongLoader.currentFolder.songs.indexOf(song);
if (currentIndex == -1) return;
if (playMode == PlayMode.RANDOM) {
int newIndex;
do {
newIndex = (int) (Math.random() * SongLoader.currentFolder.songs.size());
} while (newIndex == currentIndex && SongLoader.currentFolder.songs.size() > 1);
start(SongLoader.currentFolder.songs.get(newIndex));
} else if (playMode == PlayMode.LIST_LOOP) {
int nextIndex = (currentIndex + 1) % SongLoader.currentFolder.songs.size();
start(SongLoader.currentFolder.songs.get(nextIndex));
}
}
public synchronized void setPlayMode(PlayMode mode) {
this.playMode = mode;
this.loopSong = mode == PlayMode.SINGLE_LOOP;
}
// this is the original authors comment, i dont wanna delete it
// TODO: 6/2/2022 Play note blocks every song tick, instead of every tick. That way the song will sound better // TODO: 6/2/2022 Play note blocks every song tick, instead of every tick. That way the song will sound better
// 11/1/2023 Playback now done in separate thread. Not ideal but better especially when FPS are low. // 11/1/2023 Playback now done in separate thread. Not ideal but better especially when FPS are low.
@Override @Override
@ -260,11 +308,11 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
final Vec3d playerEyePos = player.getEyePos(); final Vec3d playerEyePos = player.getEyePos();
final int maxOffset; // Rough estimates, of which blocks could be in reach final int maxOffset; // Rough estimates, of which blocks could be in reach
if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_4_Or_Earlier) { if(Main.config.expectedServerVersion == ModConfig.ExpectedServerVersion.v1_20_4_Or_Earlier) {
maxOffset = 7; maxOffset = 7;
}else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_5_Or_Later) { }else if(Main.config.expectedServerVersion == ModConfig.ExpectedServerVersion.v1_20_5_Or_Later) {
maxOffset = (int) Math.ceil(player.getBlockInteractionRange() + 1.0 + 1.0); maxOffset = (int) Math.ceil(player.getBlockInteractionRange() + 1.0 + 1.0);
}else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.All) { }else if(Main.config.expectedServerVersion == ModConfig.ExpectedServerVersion.All) {
maxOffset = Math.min(7, (int) Math.ceil(player.getBlockInteractionRange() + 1.0 + 1.0)); maxOffset = Math.min(7, (int) Math.ceil(player.getBlockInteractionRange() + 1.0 + 1.0));
}else { }else {
throw new NotImplementedException("ExpectedServerVersion Value not implemented: " + Main.config.expectedServerVersion.name()); throw new NotImplementedException("ExpectedServerVersion Value not implemented: " + Main.config.expectedServerVersion.name());
@ -494,12 +542,12 @@ public class SongPlayer implements ClientTickEvents.StartWorldTick {
// (max distance is BlockInteractRange + 1.0). // (max distance is BlockInteractRange + 1.0).
private boolean canInteractWith(ClientPlayerEntity player, BlockPos blockPos) { private boolean canInteractWith(ClientPlayerEntity player, BlockPos blockPos) {
final Vec3d eyePos = player.getEyePos(); final Vec3d eyePos = player.getEyePos();
if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_4_Or_Earlier) { if(Main.config.expectedServerVersion == ModConfig.ExpectedServerVersion.v1_20_4_Or_Earlier) {
return eyePos.squaredDistanceTo(blockPos.toCenterPos()) <= 6.0 * 6.0; return eyePos.squaredDistanceTo(blockPos.toCenterPos()) <= 6.0 * 6.0;
}else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_5_Or_Later) { }else if(Main.config.expectedServerVersion == ModConfig.ExpectedServerVersion.v1_20_5_Or_Later) {
double blockInteractRange = player.getBlockInteractionRange() + 1.0; double blockInteractRange = player.getBlockInteractionRange() + 1.0;
return new Box(blockPos).squaredMagnitude(eyePos) < blockInteractRange * blockInteractRange; return new Box(blockPos).squaredMagnitude(eyePos) < blockInteractRange * blockInteractRange;
}else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.All) { }else if(Main.config.expectedServerVersion == ModConfig.ExpectedServerVersion.All) {
// Require both checks to succeed (aka use worst distance) // Require both checks to succeed (aka use worst distance)
double blockInteractRange = player.getBlockInteractionRange() + 1.0; double blockInteractRange = player.getBlockInteractionRange() + 1.0;
return eyePos.squaredDistanceTo(blockPos.toCenterPos()) <= 6.0 * 6.0 return eyePos.squaredDistanceTo(blockPos.toCenterPos()) <= 6.0 * 6.0

View File

@ -3,6 +3,7 @@ package semmiedev.disc_jockey.gui;
import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.systems.RenderSystem;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder;
import net.minecraft.client.gui.widget.EntryListWidget; import net.minecraft.client.gui.widget.EntryListWidget;
import net.minecraft.text.Text; import net.minecraft.text.Text;
@ -10,50 +11,44 @@ import net.minecraft.util.Identifier;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import semmiedev.disc_jockey.Main; import semmiedev.disc_jockey.Main;
import semmiedev.disc_jockey.Song; import semmiedev.disc_jockey.Song;
import semmiedev.disc_jockey.SongLoader;
import semmiedev.disc_jockey.SongLoader.SongFolder;
import semmiedev.disc_jockey.gui.screen.DiscJockeyScreen;
public class SongListWidget extends EntryListWidget<SongListWidget.SongEntry> { public class SongListWidget extends EntryListWidget<SongListWidget.Entry> {
private static final String FAVORITE_EMOJI = "收藏★"; private static final String FAVORITE_EMOJI = "收藏★";
private static final String NOT_FAVORITE_EMOJI = "收藏☆"; private static final String NOT_FAVORITE_EMOJI = "收藏☆";
private static final String FOLDER_EMOJI = "📁";
private static final int LEFT_PADDING = 10;
private final Screen parentScreen;
public static abstract class Entry extends EntryListWidget.Entry<Entry> {
public abstract boolean isSelected();
public abstract void setSelected(boolean selected);
public abstract boolean isSongEntry();
public SongListWidget(MinecraftClient client, int width, int height, int top, int itemHeight) {
super(client, width, height, top, itemHeight);
} }
@Override @Nullable
public int getRowWidth() { public SongEntry getSelectedSongOrNull() {
return width - 40; Entry selected = getSelectedOrNull();
return selected instanceof SongEntry ? (SongEntry) selected : null;
} }
@Override @Nullable
protected int getScrollbarX() { public FolderEntry getSelectedFolderOrNull() {
return width - 12; Entry selected = getSelectedOrNull();
return selected instanceof FolderEntry ? (FolderEntry) selected : null;
} }
@Override public static class SongEntry extends Entry {
public void setSelected(@Nullable SongListWidget.SongEntry entry) {
SongListWidget.SongEntry selectedEntry = getSelectedOrNull();
if (selectedEntry != null) selectedEntry.selected = false;
if (entry != null) entry.selected = true;
super.setSelected(entry);
}
@Override
protected void appendClickableNarrations(NarrationMessageBuilder builder) {
// Who cares
}
// TODO: 6/2/2022 Add a delete icon
public static class SongEntry extends Entry<SongEntry> {
private static final Identifier ICONS = Identifier.of(Main.MOD_ID, "textures/gui/icons.png"); private static final Identifier ICONS = Identifier.of(Main.MOD_ID, "textures/gui/icons.png");
public final int index; public final int index;
public final Song song; public final Song song;
public boolean selected, favorite; public boolean selected, favorite;
public SongListWidget songListWidget; public SongListWidget songListWidget;
private final MinecraftClient client = MinecraftClient.getInstance(); private final MinecraftClient client = MinecraftClient.getInstance();
private int x, y, entryWidth, entryHeight; private int x, y, entryWidth, entryHeight;
public SongEntry(Song song, int index) { public SongEntry(Song song, int index) {
@ -70,15 +65,26 @@ public class SongListWidget extends EntryListWidget<SongListWidget.SongEntry> {
context.fill(x + 1, y + 1, x + entryWidth - 1, y + entryHeight - 1, 0x000000); context.fill(x + 1, y + 1, x + entryWidth - 1, y + entryHeight - 1, 0x000000);
} }
context.drawCenteredTextWithShadow(client.textRenderer, song.displayName, x + entryWidth / 2, y + 5, selected ? 0xFFFFFF : 0x808080); // 收藏图标
String emoji = String.valueOf(favorite ? FAVORITE_EMOJI : NOT_FAVORITE_EMOJI); String emoji = String.valueOf(favorite ? FAVORITE_EMOJI : NOT_FAVORITE_EMOJI);
int emojiWidth = client.textRenderer.getWidth(emoji);
context.drawTextWithShadow( context.drawTextWithShadow(
client.textRenderer, client.textRenderer,
emoji, emoji,
x + 2, y + 2, x + 4, y + 6,
favorite ? 0xFFD700 : 0x808080 favorite ? 0xFFD700 : 0x808080
); );
// 歌曲名称靠左显示从收藏图标右侧开始
int textX = x + emojiWidth + 8;
int maxWidth = entryWidth - emojiWidth - 12;
String displayText = client.textRenderer.trimToWidth(song.displayName, maxWidth);
context.drawTextWithShadow(
client.textRenderer,
displayText,
textX, y + 6,
selected ? 0xFFFFFF : 0x808080
);
} }
@Override @Override
@ -97,12 +103,159 @@ public class SongListWidget extends EntryListWidget<SongListWidget.SongEntry> {
} }
private boolean isOverFavoriteButton(double mouseX, double mouseY) { private boolean isOverFavoriteButton(double mouseX, double mouseY) {
int textWidth = client.textRenderer.getWidth(favorite ? FAVORITE_EMOJI : NOT_FAVORITE_EMOJI); int iconX = x + 2;
int textHeight = 8; int iconWidth = client.textRenderer.getWidth(favorite ? FAVORITE_EMOJI : NOT_FAVORITE_EMOJI);
return mouseX > x + 2 && int iconHeight = 8;
mouseX < x + 2 + textWidth &&
mouseY > y + 2 && return mouseX >= iconX &&
mouseY < y + 2 + textHeight; mouseX <= iconX + iconWidth &&
mouseY >= y + 6 &&
mouseY <= y + 6 + iconHeight;
}
@Override
public boolean isSelected() {
return selected;
}
@Override
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public boolean isSongEntry() {
return true;
} }
} }
public static class FolderEntry extends Entry {
public SongFolder folder;
public boolean selected;
public SongListWidget songListWidget;
public String displayName;
private final MinecraftClient client = MinecraftClient.getInstance();
private int x, y, entryWidth, entryHeight;
public FolderEntry(@Nullable SongFolder folder, SongListWidget songListWidget) {
this.folder = folder;
this.songListWidget = songListWidget;
this.displayName = folder != null ? folder.name : "..";
}
@Override
public void render(DrawContext context, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) {
this.x = x; this.y = y; this.entryWidth = entryWidth; this.entryHeight = entryHeight;
if (selected) {
context.fill(x, y, x + entryWidth, y + entryHeight, 0xFFFFFF);
context.fill(x + 1, y + 1, x + entryWidth - 1, y + entryHeight - 1, 0x000000);
}
// 文件夹图标和名称靠左显示
String displayText = FOLDER_EMOJI + " " + displayName;
int maxWidth = entryWidth - 8;
displayText = client.textRenderer.trimToWidth(displayText, maxWidth);
context.drawTextWithShadow(
client.textRenderer,
displayText,
x + 6, y + 6,
selected ? 0xFFFFFF : 0x808080
);
}
private SongFolder findParentFolder(SongFolder current) {
if (current == null) return null;
for (SongFolder folder : SongLoader.FOLDERS) {
if (folder.subFolders.contains(current)) {
return folder;
}
for (SongFolder subFolder : folder.subFolders) {
if (subFolder.subFolders.contains(current)) {
return folder;
}
}
}
return null;
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) {
if (songListWidget.getParentScreen() instanceof DiscJockeyScreen screen) {
if (this.folder == null) {
SongFolder parent = screen.findParentFolder(SongLoader.currentFolder);
if (parent != null || SongLoader.FOLDERS.contains(SongLoader.currentFolder)) {
screen.currentFolder = parent;
SongLoader.currentFolder = parent;
screen.shouldFilter = true;
}
} else {
screen.currentFolder = this.folder;
SongLoader.currentFolder = this.folder;
screen.shouldFilter = true;
}
songListWidget.setSelected(this);
return true;
}
}
return false;
}
@Override
public boolean isSelected() {
return selected;
}
@Override
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public boolean isSongEntry() {
return false;
}
}
public SongListWidget(MinecraftClient client, int width, int height, int top, int itemHeight, Screen parentScreen) {
super(client, width, height, top, itemHeight);
this.parentScreen = parentScreen;
}
public Screen getParentScreen() {
return parentScreen;
}
@Override
public int getRowLeft() {
return super.getRowLeft() + LEFT_PADDING;
}
@Override
public int getRowWidth() {
return width - 40;
}
@Override
protected int getScrollbarX() {
return width - 12;
}
@Override
public void setSelected(@Nullable Entry entry) {
Entry selectedEntry = getSelectedOrNull();
if (selectedEntry != null) selectedEntry.setSelected(false);
if (entry != null) entry.setSelected(true);
super.setSelected(entry);
}
@Override
protected void appendClickableNarrations(NarrationMessageBuilder builder) {
// Who cares
}
} }

View File

@ -24,6 +24,9 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import semmiedev.disc_jockey.SongLoader.SongFolder;
import semmiedev.disc_jockey.SongPlayer.PlayMode;
public class DiscJockeyScreen extends Screen { public class DiscJockeyScreen extends Screen {
private static final MutableText private static final MutableText
SELECT_SONG = Text.translatable(Main.MOD_ID+".screen.select_song"), SELECT_SONG = Text.translatable(Main.MOD_ID+".screen.select_song"),
@ -36,9 +39,26 @@ public class DiscJockeyScreen extends Screen {
private SongListWidget songListWidget; private SongListWidget songListWidget;
private ButtonWidget playButton, previewButton; private ButtonWidget playButton, previewButton;
private boolean shouldFilter; public boolean shouldFilter;
private String query = ""; private String query = "";
private static final MutableText
FOLDER_UP = Text.literal(""),
CURRENT_FOLDER = Text.translatable(Main.MOD_ID+".screen.current_folder"),
PLAY_MODE = Text.translatable(Main.MOD_ID+".screen.play_mode"),
MODE_SINGLE = Text.translatable(Main.MOD_ID+".screen.mode_single"),
MODE_LIST = Text.translatable(Main.MOD_ID+".screen.mode_list"),
MODE_RANDOM = Text.translatable(Main.MOD_ID+".screen.mode_random"),
MODE_STOP = Text.translatable(Main.MOD_ID+".screen.mode_stop");
private static final MutableText
OPEN_FOLDER = Text.translatable(Main.MOD_ID+".screen.open_folder"),
RELOAD = Text.translatable(Main.MOD_ID+".screen.reload");
private ButtonWidget folderUpButton, playModeButton;
public SongFolder currentFolder;
private PlayMode currentPlayMode = PlayMode.STOP_AFTER;
public DiscJockeyScreen() { public DiscJockeyScreen() {
super(Main.NAME); super(Main.NAME);
} }
@ -46,19 +66,56 @@ public class DiscJockeyScreen extends Screen {
@Override @Override
protected void init() { protected void init() {
shouldFilter = true; shouldFilter = true;
songListWidget = new SongListWidget(client, width, height - 64 - 32, 32, 20); songListWidget = new SongListWidget(client, width, height - 64 - 32, 32, 20, this);
// 恢复播放模式
currentPlayMode = Main.config.playMode;
// 恢复文件夹状态
if (!Main.config.currentFolderPath.isEmpty()) {
currentFolder = findFolderByPath(Main.config.currentFolderPath);
SongLoader.currentFolder = currentFolder;
}
addDrawableChild(songListWidget); addDrawableChild(songListWidget);
for (int i = 0; i < SongLoader.SONGS.size(); i++) { for (int i = 0; i < SongLoader.SONGS.size(); i++) {
Song song = SongLoader.SONGS.get(i); Song song = SongLoader.SONGS.get(i);
song.entry.songListWidget = songListWidget; song.entry.songListWidget = songListWidget;
if (song.entry.selected) songListWidget.setSelected(song.entry); if (song.entry.selected) songListWidget.setSelected(song.entry);
// 添加文件夹条目
if (song.folder != null && !songListWidget.children().contains(song.folder.entry)) {
song.folder.entry = new SongListWidget.FolderEntry(song.folder, songListWidget);
songListWidget.children().add(song.folder.entry);
}
} }
folderUpButton = ButtonWidget.builder(FOLDER_UP, button -> {
if (currentFolder != null) {
currentFolder = null;
SongLoader.currentFolder = null;
shouldFilter = true;
}
}).dimensions(10, 10, 20, 20).build();
addDrawableChild(folderUpButton);
playModeButton = ButtonWidget.builder(getPlayModeText(), button -> {
switch (currentPlayMode) {
case SINGLE_LOOP -> currentPlayMode = PlayMode.LIST_LOOP;
case LIST_LOOP -> currentPlayMode = PlayMode.RANDOM;
case RANDOM -> currentPlayMode = PlayMode.STOP_AFTER;
case STOP_AFTER -> currentPlayMode = PlayMode.SINGLE_LOOP;
}
Main.SONG_PLAYER.setPlayMode(currentPlayMode);
playModeButton.setMessage(getPlayModeText());
}).dimensions(width - 120, 10, 100, 20).build();
addDrawableChild(playModeButton);
playButton = ButtonWidget.builder(PLAY, button -> { playButton = ButtonWidget.builder(PLAY, button -> {
if (Main.SONG_PLAYER.running) { if (Main.SONG_PLAYER.running) {
Main.SONG_PLAYER.stop(); Main.SONG_PLAYER.stop();
} else { } else {
SongListWidget.SongEntry entry = songListWidget.getSelectedOrNull(); SongListWidget.SongEntry entry = songListWidget.getSelectedSongOrNull();
if (entry != null) { if (entry != null) {
Main.SONG_PLAYER.start(entry.song); Main.SONG_PLAYER.start(entry.song);
client.setScreen(null); client.setScreen(null);
@ -71,7 +128,7 @@ public class DiscJockeyScreen extends Screen {
if (Main.PREVIEWER.running) { if (Main.PREVIEWER.running) {
Main.PREVIEWER.stop(); Main.PREVIEWER.stop();
} else { } else {
SongListWidget.SongEntry entry = songListWidget.getSelectedOrNull(); SongListWidget.SongEntry entry = songListWidget.getSelectedSongOrNull();
if (entry != null) Main.PREVIEWER.start(entry.song); if (entry != null) Main.PREVIEWER.start(entry.song);
} }
}).dimensions(width / 2 - 50, height - 61, 100, 20).build(); }).dimensions(width / 2 - 50, height - 61, 100, 20).build();
@ -80,7 +137,7 @@ public class DiscJockeyScreen extends Screen {
addDrawableChild(ButtonWidget.builder(Text.translatable(Main.MOD_ID+".screen.blocks"), button -> { addDrawableChild(ButtonWidget.builder(Text.translatable(Main.MOD_ID+".screen.blocks"), button -> {
// TODO: 6/2/2022 Add an auto build mode // TODO: 6/2/2022 Add an auto build mode
if (BlocksOverlay.itemStacks == null) { if (BlocksOverlay.itemStacks == null) {
SongListWidget.SongEntry entry = songListWidget.getSelectedOrNull(); SongListWidget.SongEntry entry = songListWidget.getSelectedSongOrNull();
if (entry != null) { if (entry != null) {
client.setScreen(null); client.setScreen(null);
@ -116,7 +173,43 @@ public class DiscJockeyScreen extends Screen {
} }
}).dimensions(width / 2 + 60, height - 61, 100, 20).build()); }).dimensions(width / 2 + 60, height - 61, 100, 20).build());
TextFieldWidget searchBar = new TextFieldWidget(textRenderer, width / 2 - 75, height - 31, 150, 20, Text.translatable(Main.MOD_ID+".screen.search")); // 打开文件夹
addDrawableChild(ButtonWidget.builder(OPEN_FOLDER, button -> {
try {
String folderPath = currentFolder != null ?
currentFolder.path :
Main.songsFolder.getAbsolutePath(); // 使用绝对路径
File target = new File(folderPath);
if (!target.exists()) {
client.inGameHud.getChatHud().addMessage(
Text.translatable(Main.MOD_ID+".screen.folder_not_exist", folderPath)
.formatted(Formatting.RED));
return;
}
// Windows 专用命令其他的不会
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("explorer.exe", "/select,", target.getAbsolutePath()).start();
} else {
java.awt.Desktop.getDesktop().open(target);
}
} catch (Exception e) {
Main.LOGGER.error("打开文件夹失败", e);
client.inGameHud.getChatHud().addMessage(
Text.translatable(Main.MOD_ID+".screen.open_folder_failed")
.formatted(Formatting.RED));
}
}).dimensions(width / 2 - 160, height - 31, 100, 20).build());
// 重新加载
addDrawableChild(ButtonWidget.builder(RELOAD, button -> {
SongLoader.loadSongs();
client.setScreen(null);
}).dimensions(width / 2 + 60, height - 31, 100, 20).build());
TextFieldWidget searchBar = new TextFieldWidget(textRenderer, width / 2 - 50, height - 31, 100, 20, Text.translatable(Main.MOD_ID+".screen.search"));
searchBar.setChangedListener(query -> { searchBar.setChangedListener(query -> {
query = query.toLowerCase().replaceAll("\\s", ""); query = query.toLowerCase().replaceAll("\\s", "");
if (this.query.equals(query)) return; if (this.query.equals(query)) return;
@ -134,6 +227,11 @@ public class DiscJockeyScreen extends Screen {
context.drawCenteredTextWithShadow(textRenderer, DROP_HINT, width / 2, 5, 0xFFFFFF); context.drawCenteredTextWithShadow(textRenderer, DROP_HINT, width / 2, 5, 0xFFFFFF);
context.drawCenteredTextWithShadow(textRenderer, SELECT_SONG, width / 2, 20, 0xFFFFFF); context.drawCenteredTextWithShadow(textRenderer, SELECT_SONG, width / 2, 20, 0xFFFFFF);
// 显示当前文件夹和播放模式
String folderName = currentFolder == null ? "/" : currentFolder.name;
context.drawTextWithShadow(textRenderer, CURRENT_FOLDER.getString() + ": " + folderName, 35, 15, 0xFFFFFF);
context.drawTextWithShadow(textRenderer, PLAY_MODE.getString() + ":", width - 220, 15, 0xFFFFFF);
} }
@Override @Override
@ -143,15 +241,59 @@ public class DiscJockeyScreen extends Screen {
if (shouldFilter) { if (shouldFilter) {
shouldFilter = false; shouldFilter = false;
// songListWidget.setScrollAmount(0);
songListWidget.children().clear(); songListWidget.children().clear();
boolean empty = query.isEmpty(); boolean empty = query.isEmpty();
int favoriteIndex = 0;
for (Song song : SongLoader.SONGS) { boolean isInSongsOrSubfolder = currentFolder == null ||
if (empty || song.searchableFileName.contains(query) || song.searchableName.contains(query)) { currentFolder.path.startsWith(Main.songsFolder.getPath());
if (song.entry.favorite) {
songListWidget.children().add(favoriteIndex++, song.entry); if (currentFolder == null) {
} else { for (SongFolder folder : SongLoader.FOLDERS) {
if (empty || folder.name.toLowerCase().contains(query)) {
if (folder.entry == null) {
folder.entry = new SongListWidget.FolderEntry(folder, songListWidget);
}
songListWidget.children().add(folder.entry);
}
}
} else {
// 返回上级
SongListWidget.FolderEntry parentEntry = new SongListWidget.FolderEntry(null, songListWidget);
parentEntry.displayName = "..";
songListWidget.children().add(parentEntry);
// 子文件夹
for (SongFolder subFolder : currentFolder.subFolders) {
if (empty || subFolder.name.toLowerCase().contains(query)) {
if (subFolder.entry == null) {
subFolder.entry = new SongListWidget.FolderEntry(subFolder, songListWidget);
}
songListWidget.children().add(subFolder.entry);
}
}
}
// 只有在songs目录或其子目录中才显示歌曲(原作者的💩跑我这了)
if (isInSongsOrSubfolder) {
// 歌曲条目
List<Song> songsToShow = currentFolder == null ?
SongLoader.SONGS.stream()
.filter(song -> song.folder == null)
.collect(Collectors.toList()) :
currentFolder.songs.stream()
.filter(song -> song.folder == currentFolder)
.collect(Collectors.toList());
// 已收藏歌曲
for (Song song : songsToShow) {
if (song.entry.favorite && (empty || song.searchableFileName.contains(query) || song.searchableName.contains(query))) {
songListWidget.children().add(song.entry);
}
}
// 未收藏歌曲
for (Song song : songsToShow) {
if (!song.entry.favorite && (empty || song.searchableFileName.contains(query) || song.searchableName.contains(query))) {
songListWidget.children().add(song.entry); songListWidget.children().add(song.entry);
} }
} }
@ -159,6 +301,40 @@ public class DiscJockeyScreen extends Screen {
} }
} }
public SongFolder findParentFolder(SongFolder current) {
if (current == null) return null;
if (SongLoader.FOLDERS.contains(current)) {
return null;
}
for (SongFolder folder : SongLoader.FOLDERS) {
if (folder.subFolders.contains(current)) {
return folder;
}
SongFolder found = findParentInSubfolders(folder, current);
if (found != null) {
return found;
}
}
return null;
}
private SongFolder findParentInSubfolders(SongFolder parent, SongFolder target) {
for (SongFolder subFolder : parent.subFolders) {
if (subFolder == target) {
return parent;
}
SongFolder found = findParentInSubfolders(subFolder, target);
if (found != null) {
return found;
}
}
return null;
}
@Override @Override
public void onFilesDropped(List<Path> paths) { public void onFilesDropped(List<Path> paths) {
String string = paths.stream().map(Path::getFileName).map(Path::toString).collect(Collectors.joining(", ")); String string = paths.stream().map(Path::getFileName).map(Path::toString).collect(Collectors.joining(", "));
@ -196,6 +372,62 @@ public class DiscJockeyScreen extends Screen {
@Override @Override
public void close() { public void close() {
super.close(); super.close();
// 保存当前文件夹
Main.config.currentFolderPath = currentFolder != null ? currentFolder.path : "";
// 保存播放模式
Main.config.playMode = currentPlayMode;
// 异步保存配置
new Thread(() -> Main.configHolder.save()).start(); new Thread(() -> Main.configHolder.save()).start();
} }
private SongFolder findInSubFolders(SongFolder parent, SongFolder target) {
for (SongFolder subFolder : parent.subFolders) {
if (subFolder == target) {
return parent;
}
SongFolder found = findInSubFolders(subFolder, target);
if (found != null) {
return found;
}
}
return null;
}
private SongFolder findFolderByPath(String path) {
for (SongFolder folder : SongLoader.FOLDERS) {
if (folder.path.equals(path)) {
return folder;
}
}
for (SongFolder folder : SongLoader.FOLDERS) {
SongFolder found = findFolderByPathInSubfolders(folder, path);
if (found != null) {
return found;
}
}
return null;
}
private SongFolder findFolderByPathInSubfolders(SongFolder parent, String targetPath) {
for (SongFolder subFolder : parent.subFolders) {
if (subFolder.path.equals(targetPath)) {
return subFolder;
}
SongFolder found = findFolderByPathInSubfolders(subFolder, targetPath);
if (found != null) {
return found;
}
}
return null;
}
private Text getPlayModeText() {
return switch (currentPlayMode) {
case SINGLE_LOOP -> MODE_SINGLE;
case LIST_LOOP -> MODE_LIST;
case RANDOM -> MODE_RANDOM;
case STOP_AFTER -> MODE_STOP;
};
}
} }

View File

@ -34,7 +34,7 @@
"disc_jockey.loop_status": "Loop song: %s", "disc_jockey.loop_status": "Loop song: %s",
"disc_jockey.loop_enabled": "Enabled looping of current song.", "disc_jockey.loop_enabled": "Enabled looping of current song.",
"disc_jockey.loop_disabled": "Disabled looping of current song.", "disc_jockey.loop_disabled": "Disabled looping of current song.",
"disc_jockey.warning": "WARNING!!! This mod is very likely to get false flagged as hacks, please contact a server administrator before using this mod! (You can disable this warning in the mod settings)", "disc_jockey.warning": "WARNING!!! This mod is very likely to get false flagged as hacks, please contact a server administrator before using this mod! (You can disable this warning in the mod settings)\nThe current version is an unofficial version, revised by BRanulf\nFor learning reference only, please support the official, don't contact me XD",
"key.category.disc_jockey": "Disc Jockey", "key.category.disc_jockey": "Disc Jockey",
"disc_jockey.key_bind.open_screen": "Open song selection screen", "disc_jockey.key_bind.open_screen": "Open song selection screen",
"text.autoconfig.disc_jockey.title": "Disc Jockey", "text.autoconfig.disc_jockey.title": "Disc Jockey",
@ -51,5 +51,15 @@
"text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[2]": "Selecting the wrong version could cause you not to be able to play some distant note blocks which could break/worsen playback", "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[2]": "Selecting the wrong version could cause you not to be able to play some distant note blocks which could break/worsen playback",
"text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[3]": "If you're unsure, or play on many different server versions and don't mind not reaching every possible note block, select \"All\"", "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[3]": "If you're unsure, or play on many different server versions and don't mind not reaching every possible note block, select \"All\"",
"text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs": "Delay playback by (seconds)", "text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs": "Delay playback by (seconds)",
"text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs.@Tooltip": "Delays playback for specified seconds, after tuning finished, if any (e.g. 0.5 for half a second delay)." "text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs.@Tooltip": "Delays playback for specified seconds, after tuning finished, if any (e.g. 0.5 for half a second delay).",
"disc_jockey.screen.current_folder": "Current Folder",
"disc_jockey.screen.play_mode": "Play Mode",
"disc_jockey.screen.mode_single": "Single Loop",
"disc_jockey.screen.mode_list": "List Loop",
"disc_jockey.screen.mode_random": "Random",
"disc_jockey.screen.mode_stop": "Stop After",
"disc_jockey.screen.open_folder": "Open Folder",
"disc_jockey.screen.open_folder_failed": "Failed to open folder",
"disc_jockey.screen.reload": "Reload Songs",
"disc_jockey.screen.reloading": "Reloading songs..."
} }

View File

@ -34,7 +34,7 @@
"disc_jockey.loop_status": "循环播放:%s", "disc_jockey.loop_status": "循环播放:%s",
"disc_jockey.loop_enabled": "已启用当前歌曲循环播放", "disc_jockey.loop_enabled": "已启用当前歌曲循环播放",
"disc_jockey.loop_disabled": "已禁用当前歌曲循环播放", "disc_jockey.loop_disabled": "已禁用当前歌曲循环播放",
"disc_jockey.warning": "警告!此模组极易被误判为作弊工具,使用前请联系服务器管理员!(可在模组设置中关闭此警告)\n当前版本1.14.5141.21.4为非官方版本由BRanulf改版翻译也是这家伙提供的。\n仅供学习参考请支持官方别找我XD", "disc_jockey.warning": "警告!此模组极易被误判为作弊工具,使用前请联系服务器管理员!(可在模组设置中关闭此警告)\n当前版本为非官方版本由BRanulf改版翻译也是这家伙提供的。\n仅供学习参考请支持官方别找我XD",
"key.category.disc_jockey": "Disc Jockey", "key.category.disc_jockey": "Disc Jockey",
"disc_jockey.key_bind.open_screen": "打开歌曲选择界面", "disc_jockey.key_bind.open_screen": "打开歌曲选择界面",
"text.autoconfig.disc_jockey.title": "Disc Jockey", "text.autoconfig.disc_jockey.title": "Disc Jockey",
@ -51,5 +51,14 @@
"text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[2]": "版本选择错误可能导致无法触发部分音符盒,影响播放效果", "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[2]": "版本选择错误可能导致无法触发部分音符盒,影响播放效果",
"text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[3]": "若不确认版本,或需兼容多版本服务器,请选择“全部”", "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[3]": "若不确认版本,或需兼容多版本服务器,请选择“全部”",
"text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs": "播放延迟(秒)", "text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs": "播放延迟(秒)",
"text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs.@Tooltip": "调音完成后延迟指定秒数再开始播放(如 0.5 表示延迟半秒)。" "text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs.@Tooltip": "调音完成后延迟指定秒数再开始播放(如 0.5 表示延迟半秒)。",
"disc_jockey.screen.current_folder": "当前文件夹",
"disc_jockey.screen.play_mode": "播放模式",
"disc_jockey.screen.mode_single": "单曲循环",
"disc_jockey.screen.mode_list": "列表循环",
"disc_jockey.screen.mode_random": "随机播放",
"disc_jockey.screen.mode_stop": "播完停止","disc_jockey.screen.open_folder": "打开文件夹",
"disc_jockey.screen.open_folder_failed": "无法打开文件夹",
"disc_jockey.screen.reload": "重新加载",
"disc_jockey.screen.reloading": "正在重新加载..."
} }