This commit is contained in:
2026-03-22 10:20:25 +02:00
commit afaed25555
14 changed files with 982 additions and 0 deletions

View 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());
}
}
}

View File

@@ -0,0 +1,10 @@
package download.darkworld;
import net.fabricmc.api.ClientModInitializer;
public class MprisClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
MinecraftMprisClient.initialize();
}
}

View 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);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "mpris",
"version": "${version}",
"name": "MPRIS",
"description": "The now playing toast, but...",
"authors": [
"Kris"
],
"contact": {
"homepage": "https://darkworld.download/",
"sources": "https://git.ocbwoy3.dev/kris/mc-mpris"
},
"license": "CC0-1.0",
"icon": "assets/modid/icon.png",
"environment": "*",
"entrypoints": {
"client": [
"download.darkworld.MprisClient"
]
},
"depends": {
"fabricloader": ">=0.18.4",
"minecraft": "~1.21.11",
"java": ">=21",
"fabric-api": "*"
}
}