a
This commit is contained in:
241
src/client/java/download/darkworld/MinecraftMprisClient.java
Normal file
241
src/client/java/download/darkworld/MinecraftMprisClient.java
Normal file
@@ -0,0 +1,241 @@
|
||||
package download.darkworld;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
|
||||
import org.freedesktop.dbus.exceptions.DBusException;
|
||||
import org.freedesktop.dbus.interfaces.DBus;
|
||||
import org.freedesktop.dbus.interfaces.DBusSigHandler;
|
||||
import org.freedesktop.dbus.interfaces.Properties;
|
||||
import org.freedesktop.dbus.matchrules.DBusMatchRule;
|
||||
import org.freedesktop.dbus.matchrules.DBusMatchRuleBuilder;
|
||||
import org.freedesktop.dbus.types.Variant;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.components.toasts.ToastManager;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
public final class MinecraftMprisClient {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final long CACHE_TTL_MS = 1000L;
|
||||
private static final String DBUS_SERVICE = "org.freedesktop.DBus";
|
||||
private static final String DBUS_PATH = "/org/freedesktop/DBus";
|
||||
private static final String MPRIS_PATH = "/org/mpris/MediaPlayer2";
|
||||
private static final String MPRIS_PREFIX = "org.mpris.MediaPlayer2.";
|
||||
private static final String PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player";
|
||||
|
||||
@Nullable
|
||||
private static DBusConnection connection;
|
||||
@Nullable
|
||||
private static AutoCloseable propertiesChangedHandler;
|
||||
private static final Map<String, String> lastAnnouncedTrackIds = new ConcurrentHashMap<>();
|
||||
|
||||
private MinecraftMprisClient() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
synchronized (MinecraftMprisClient.class) {
|
||||
try {
|
||||
LOGGER.info("[mpris] initializing DBus MPRIS bridge");
|
||||
DBusConnection dbusConnection = getConnection();
|
||||
if (dbusConnection == null) {
|
||||
LOGGER.warn("[mpris] DBus connection was null during initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
if (propertiesChangedHandler == null) {
|
||||
LOGGER.info("[mpris] registering global PropertiesChanged handler");
|
||||
DBusMatchRule matchRule = DBusMatchRuleBuilder.create()
|
||||
.withType(Properties.PropertiesChanged.class)
|
||||
.withPath(MPRIS_PATH)
|
||||
.withArg0123(0, PLAYER_INTERFACE)
|
||||
.build();
|
||||
propertiesChangedHandler = dbusConnection.addSigHandler(matchRule, new PlayerPropertiesChangedHandler());
|
||||
}
|
||||
LOGGER.info("[mpris] initialization complete");
|
||||
} catch (DBusException | RuntimeException exception) {
|
||||
LOGGER.error("[mpris] initialization failed", exception);
|
||||
resetConnectionState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static SongInfo refreshCurrentSong(String serviceName) {
|
||||
try {
|
||||
DBusConnection dbusConnection = getConnection();
|
||||
if (dbusConnection == null) {
|
||||
LOGGER.warn("[mpris] refreshCurrentSong({}): connection was null", serviceName);
|
||||
return null;
|
||||
}
|
||||
LOGGER.info("[mpris] checking player {}", serviceName);
|
||||
Properties properties = dbusConnection.getRemoteObject(serviceName, MPRIS_PATH, Properties.class);
|
||||
String playbackStatus = properties.Get(PLAYER_INTERFACE, "PlaybackStatus");
|
||||
LOGGER.info("[mpris] player {} playbackStatus={}", serviceName, playbackStatus);
|
||||
if (!"Playing".equals(playbackStatus)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, ?> metadata = properties.Get(PLAYER_INTERFACE, "Metadata");
|
||||
if (metadata == null) {
|
||||
LOGGER.info("[mpris] player {} returned null metadata", serviceName);
|
||||
return null;
|
||||
}
|
||||
|
||||
String title = stringValue(metadata.get("xesam:title"));
|
||||
String artist = artistsValue(metadata.get("xesam:artist"));
|
||||
String trackId = stringValue(metadata.get("mpris:trackid"));
|
||||
String displayText = formatDisplayText(artist, title);
|
||||
LOGGER.info("[mpris] player {} metadata artist='{}' title='{}' trackId='{}'", serviceName, artist, title, trackId);
|
||||
if (displayText != null && trackId != null && !trackId.isBlank()) {
|
||||
return new SongInfo(serviceName, trackId, displayText);
|
||||
}
|
||||
} catch (DBusException | RuntimeException exception) {
|
||||
LOGGER.error("[mpris] refreshCurrentSong failed for {}", serviceName, exception);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static DBusConnection getConnection() throws DBusException {
|
||||
if (connection == null) {
|
||||
LOGGER.info("[mpris] opening session bus connection");
|
||||
connection = DBusConnectionBuilder.forSessionBus()
|
||||
.withShared(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static synchronized void handleSongStateChange(String serviceName) {
|
||||
LOGGER.info("[mpris] handleSongStateChange triggered for {}", serviceName);
|
||||
SongInfo song = refreshCurrentSong(serviceName);
|
||||
String currentTrackId = song != null ? song.trackId() : null;
|
||||
String lastAnnouncedTrackId = lastAnnouncedTrackIds.get(serviceName);
|
||||
LOGGER.info("[mpris] player={} currentTrackId={}, lastAnnouncedTrackId={}", serviceName, currentTrackId, lastAnnouncedTrackId);
|
||||
if (currentTrackId == null || currentTrackId.equals(lastAnnouncedTrackId)) {
|
||||
LOGGER.info("[mpris] not showing toast because track id is null or unchanged");
|
||||
return;
|
||||
}
|
||||
|
||||
lastAnnouncedTrackIds.put(serviceName, currentTrackId);
|
||||
Minecraft minecraft = Minecraft.getInstance();
|
||||
String title = song.displayText();
|
||||
LOGGER.info("[mpris] scheduling toast for player={} trackId={} title='{}'", serviceName, currentTrackId, title);
|
||||
minecraft.execute(() -> showToast(minecraft, serviceName, currentTrackId, title));
|
||||
}
|
||||
|
||||
private static void showToast(Minecraft minecraft, String playerId, String trackId, String title) {
|
||||
LOGGER.info("[mpris] showToast executing on client thread for player={} trackId={} title='{}'", playerId, trackId, title);
|
||||
ToastManager toastManager = minecraft.getToastManager();
|
||||
MprisNowPlayingToast toast = toastManager.getToast(MprisNowPlayingToast.class, playerId);
|
||||
if (toast == null) {
|
||||
LOGGER.info("[mpris] creating new toast instance");
|
||||
toast = new MprisNowPlayingToast(playerId, trackId, title);
|
||||
toast.showToast(trackId, title, minecraft.options);
|
||||
toastManager.addToast(toast);
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info("[mpris] reusing existing toast instance");
|
||||
toast.showToast(trackId, title, minecraft.options);
|
||||
}
|
||||
|
||||
private static synchronized void resetConnectionState() {
|
||||
LOGGER.warn("[mpris] resetting DBus connection state");
|
||||
lastAnnouncedTrackIds.clear();
|
||||
if (propertiesChangedHandler != null) {
|
||||
try {
|
||||
propertiesChangedHandler.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
propertiesChangedHandler = null;
|
||||
}
|
||||
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String stringValue(@Nullable Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Variant<?> variant) {
|
||||
return stringValue(variant.getValue());
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String artistsValue(@Nullable Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Variant<?> variant) {
|
||||
return artistsValue(variant.getValue());
|
||||
}
|
||||
if (value instanceof String string) {
|
||||
return string;
|
||||
}
|
||||
if (value instanceof String[] strings) {
|
||||
return String.join(", ", strings);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
return list.stream()
|
||||
.map(MinecraftMprisClient::stringValue)
|
||||
.filter(part -> part != null && !part.isBlank())
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String formatDisplayText(@Nullable String artist, @Nullable String title) {
|
||||
String cleanArtist = artist != null ? artist.trim() : null;
|
||||
String cleanTitle = title != null ? title.trim() : null;
|
||||
if (cleanArtist != null && !cleanArtist.isBlank() && cleanTitle != null && !cleanTitle.isBlank()) {
|
||||
return cleanArtist + " - " + cleanTitle;
|
||||
}
|
||||
if (cleanTitle != null && !cleanTitle.isBlank()) {
|
||||
return cleanTitle;
|
||||
}
|
||||
if (cleanArtist != null && !cleanArtist.isBlank()) {
|
||||
return cleanArtist;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private record SongInfo(String playerId, String trackId, String displayText) {
|
||||
}
|
||||
|
||||
private static final class PlayerPropertiesChangedHandler implements DBusSigHandler<Properties.PropertiesChanged> {
|
||||
@Override
|
||||
public void handle(Properties.PropertiesChanged signal) {
|
||||
LOGGER.info("[mpris] PropertiesChanged interface={} changedKeys={} removedKeys={}", signal.getInterfaceName(), signal.getPropertiesChanged().keySet(), signal.getPropertiesRemoved());
|
||||
if (!PLAYER_INTERFACE.equals(signal.getInterfaceName())) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Variant<?>> changedProperties = signal.getPropertiesChanged();
|
||||
if (!changedProperties.containsKey("Metadata") && !changedProperties.containsKey("PlaybackStatus")) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSongStateChange(signal.getSource());
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/client/java/download/darkworld/MprisClient.java
Normal file
10
src/client/java/download/darkworld/MprisClient.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package download.darkworld;
|
||||
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
|
||||
public class MprisClient implements ClientModInitializer {
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
MinecraftMprisClient.initialize();
|
||||
}
|
||||
}
|
||||
110
src/client/java/download/darkworld/MprisNowPlayingToast.java
Normal file
110
src/client/java/download/darkworld/MprisNowPlayingToast.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package download.darkworld;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.Options;
|
||||
import net.minecraft.client.color.ColorLerper;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.toasts.Toast;
|
||||
import net.minecraft.client.gui.components.toasts.ToastManager;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.DyeColor;
|
||||
|
||||
public final class MprisNowPlayingToast implements Toast {
|
||||
private static final Identifier BACKGROUND_SPRITE = Identifier.withDefaultNamespace("toast/now_playing");
|
||||
private static final Identifier MUSIC_NOTES_SPRITE = Identifier.parse("icon/music_notes");
|
||||
private static final int PADDING = 7;
|
||||
private static final int MUSIC_NOTES_SIZE = 16;
|
||||
private static final int HEIGHT = 30;
|
||||
private static final int MUSIC_NOTES_SPACE = 30;
|
||||
private static final int VISIBILITY_DURATION = 5000;
|
||||
private static final int TEXT_COLOR = DyeColor.LIGHT_GRAY.getTextColor();
|
||||
private static final long MUSIC_COLOR_CHANGE_FREQUENCY_MS = 25L;
|
||||
private static int musicNoteColorTick;
|
||||
private static long lastMusicNoteColorChange;
|
||||
private static int musicNoteColor = -1;
|
||||
|
||||
private final Minecraft minecraft;
|
||||
private final String playerId;
|
||||
private String trackId;
|
||||
private Component title;
|
||||
private boolean updateToast;
|
||||
private double notificationDisplayTimeMultiplier;
|
||||
private Visibility wantedVisibility = Visibility.HIDE;
|
||||
|
||||
public MprisNowPlayingToast(String playerId, String trackId, String title) {
|
||||
this.minecraft = Minecraft.getInstance();
|
||||
this.playerId = playerId;
|
||||
this.trackId = trackId;
|
||||
this.title = Component.literal(title);
|
||||
}
|
||||
|
||||
public void showToast(String trackId, String title, Options options) {
|
||||
this.trackId = trackId;
|
||||
this.title = Component.literal(title);
|
||||
this.updateToast = true;
|
||||
this.notificationDisplayTimeMultiplier = options.notificationDisplayTime().get();
|
||||
this.wantedVisibility = Visibility.SHOW;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getToken() {
|
||||
return this.playerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Visibility getWantedVisibility() {
|
||||
return this.wantedVisibility;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(ToastManager manager, long fullyVisibleForMs) {
|
||||
if (this.updateToast) {
|
||||
this.wantedVisibility = fullyVisibleForMs < VISIBILITY_DURATION * this.notificationDisplayTimeMultiplier ? Visibility.SHOW : Visibility.HIDE;
|
||||
tickMusicNotes();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, Font font, long fullyVisibleForMs) {
|
||||
graphics.blitSprite(RenderPipelines.GUI_TEXTURED, BACKGROUND_SPRITE, 0, 0, this.width(), HEIGHT);
|
||||
graphics.blitSprite(RenderPipelines.GUI_TEXTURED, MUSIC_NOTES_SPRITE, PADDING, PADDING, MUSIC_NOTES_SIZE, MUSIC_NOTES_SIZE, musicNoteColor);
|
||||
graphics.drawString(font, this.title, MUSIC_NOTES_SPACE, 15 - 9 / 2, TEXT_COLOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFinishedRendering() {
|
||||
this.updateToast = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int width() {
|
||||
return MUSIC_NOTES_SPACE + this.minecraft.font.width(this.title) + PADDING;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int height() {
|
||||
return HEIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float xPos(int screenWidth, float visiblePortion) {
|
||||
return this.width() * visiblePortion - this.width();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float yPos(int firstSlotIndex) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
private static void tickMusicNotes() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now > lastMusicNoteColorChange + MUSIC_COLOR_CHANGE_FREQUENCY_MS) {
|
||||
musicNoteColorTick++;
|
||||
lastMusicNoteColorChange = now;
|
||||
musicNoteColor = ColorLerper.getLerpedColor(ColorLerper.Type.MUSIC_NOTE, musicNoteColorTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user