Files
mpris-minecraft/src/client/java/download/darkworld/MinecraftMprisClient.java
2026-03-22 10:20:25 +02:00

242 lines
8.5 KiB
Java

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