File indexing completed on 2024-12-22 04:41:39
0001 /* 0002 * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com> 0003 * 0004 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL 0005 */ 0006 0007 package org.kde.kdeconnect.Plugins.MprisPlugin; 0008 0009 import android.Manifest; 0010 import android.app.Activity; 0011 import android.content.Context; 0012 import android.content.Intent; 0013 import android.graphics.Bitmap; 0014 import android.os.Build; 0015 import android.util.Log; 0016 0017 import androidx.annotation.DrawableRes; 0018 import androidx.annotation.NonNull; 0019 0020 import org.apache.commons.lang3.ArrayUtils; 0021 import org.kde.kdeconnect.NetworkPacket; 0022 import org.kde.kdeconnect.Plugins.Plugin; 0023 import org.kde.kdeconnect.Plugins.PluginFactory; 0024 import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; 0025 import org.kde.kdeconnect_tp.R; 0026 0027 import java.net.MalformedURLException; 0028 import java.net.URL; 0029 import java.util.ArrayList; 0030 import java.util.Collections; 0031 import java.util.HashMap; 0032 import java.util.Iterator; 0033 import java.util.List; 0034 import java.util.concurrent.ConcurrentHashMap; 0035 0036 @PluginFactory.LoadablePlugin 0037 public class MprisPlugin extends Plugin { 0038 public class MprisPlayer { 0039 private String player = ""; 0040 private boolean playing = false; 0041 private String title = ""; 0042 private String artist = ""; 0043 private String album = ""; 0044 private String albumArtUrl = ""; 0045 private String url = ""; 0046 private String loopStatus = ""; 0047 private boolean loopStatusAllowed = false; 0048 private boolean shuffle = false; 0049 private boolean shuffleAllowed = false; 0050 private int volume = 50; 0051 private long length = -1; 0052 private long lastPosition = 0; 0053 private long lastPositionTime; 0054 private boolean playAllowed = true; 0055 private boolean pauseAllowed = true; 0056 private boolean goNextAllowed = true; 0057 private boolean goPreviousAllowed = true; 0058 private boolean seekAllowed = true; 0059 0060 MprisPlayer() { 0061 lastPositionTime = System.currentTimeMillis(); 0062 } 0063 0064 public String getTitle() { 0065 return title; 0066 } 0067 0068 public String getArtist() { 0069 return artist; 0070 } 0071 0072 public String getAlbum() { 0073 return album; 0074 } 0075 0076 public String getPlayerName() { 0077 return player; 0078 } 0079 0080 boolean isSpotify() { 0081 return getPlayerName().equalsIgnoreCase("spotify"); 0082 } 0083 0084 public String getLoopStatus() { 0085 return loopStatus; 0086 } 0087 0088 public boolean getShuffle() { 0089 return shuffle; 0090 } 0091 0092 public int getVolume() { 0093 return volume; 0094 } 0095 0096 public long getLength() { 0097 return length; 0098 } 0099 0100 public boolean isPlaying() { 0101 return playing; 0102 } 0103 0104 public boolean isPlayAllowed() { 0105 return playAllowed; 0106 } 0107 0108 public boolean isPauseAllowed() { 0109 return pauseAllowed; 0110 } 0111 0112 public boolean isGoNextAllowed() { 0113 return goNextAllowed; 0114 } 0115 0116 public boolean isGoPreviousAllowed() { 0117 return goPreviousAllowed; 0118 } 0119 0120 public boolean isSeekAllowed() { 0121 return seekAllowed && getLength() >= 0 && getPosition() >= 0; 0122 } 0123 0124 public boolean hasAlbumArt() { 0125 return !albumArtUrl.isEmpty(); 0126 } 0127 0128 /** 0129 * Returns the album art (if available). Note that this can return null even if hasAlbumArt() returns true. 0130 * 0131 * @return The album art, or null if not available 0132 */ 0133 public Bitmap getAlbumArt() { 0134 return AlbumArtCache.getAlbumArt(albumArtUrl, MprisPlugin.this, player); 0135 } 0136 0137 //@NonNull 0138 public String getUrl() { 0139 return url; 0140 } 0141 0142 public boolean isLoopStatusAllowed() { 0143 return loopStatusAllowed; 0144 } 0145 0146 public boolean isShuffleAllowed() { 0147 return shuffleAllowed; 0148 } 0149 0150 public boolean isSetVolumeAllowed() { 0151 return getVolume() > -1; 0152 } 0153 0154 public long getPosition() { 0155 if (playing) { 0156 return lastPosition + (System.currentTimeMillis() - lastPositionTime); 0157 } else { 0158 return lastPosition; 0159 } 0160 } 0161 0162 public void playPause() { 0163 if (isPauseAllowed() || isPlayAllowed()) { 0164 sendCommand(getPlayerName(), "action", "PlayPause"); 0165 } 0166 } 0167 0168 public void play() { 0169 if (isPlayAllowed()) { 0170 sendCommand(getPlayerName(), "action", "Play"); 0171 } 0172 } 0173 0174 public void pause() { 0175 if (isPauseAllowed()) { 0176 sendCommand(getPlayerName(), "action", "Pause"); 0177 } 0178 } 0179 0180 public void stop() { 0181 sendCommand(getPlayerName(), "action", "Stop"); 0182 } 0183 0184 public void previous() { 0185 if (isGoPreviousAllowed()) { 0186 sendCommand(getPlayerName(), "action", "Previous"); 0187 } 0188 } 0189 0190 public void next() { 0191 if (isGoNextAllowed()) { 0192 sendCommand(getPlayerName(), "action", "Next"); 0193 } 0194 } 0195 0196 public void setLoopStatus(String loopStatus) { 0197 sendCommand(getPlayerName(), "setLoopStatus", loopStatus); 0198 } 0199 0200 public void setShuffle(boolean shuffle) { 0201 sendCommand(getPlayerName(), "setShuffle", shuffle); 0202 } 0203 0204 public void setVolume(int volume) { 0205 if (isSetVolumeAllowed()) { 0206 sendCommand(getPlayerName(), "setVolume", volume); 0207 } 0208 } 0209 0210 public void setPosition(int position) { 0211 if (isSeekAllowed()) { 0212 sendCommand(getPlayerName(), "SetPosition", position); 0213 0214 lastPosition = position; 0215 lastPositionTime = System.currentTimeMillis(); 0216 } 0217 } 0218 0219 public void seek(int offset) { 0220 if (isSeekAllowed()) { 0221 sendCommand(getPlayerName(), "Seek", offset); 0222 } 0223 } 0224 } 0225 0226 public interface Callback { 0227 void callback(); 0228 } 0229 0230 public final static String DEVICE_ID_KEY = "deviceId"; 0231 private final static String PACKET_TYPE_MPRIS = "kdeconnect.mpris"; 0232 private final static String PACKET_TYPE_MPRIS_REQUEST = "kdeconnect.mpris.request"; 0233 0234 private final ConcurrentHashMap<String, MprisPlayer> players = new ConcurrentHashMap<>(); 0235 private boolean supportAlbumArtPayload = false; 0236 private final ConcurrentHashMap<String, Callback> playerStatusUpdated = new ConcurrentHashMap<>(); 0237 private final ConcurrentHashMap<String, Callback> playerListUpdated = new ConcurrentHashMap<>(); 0238 0239 @Override 0240 public @NonNull String getDisplayName() { 0241 return context.getResources().getString(R.string.pref_plugin_mpris); 0242 } 0243 0244 @Override 0245 public @NonNull String getDescription() { 0246 return context.getResources().getString(R.string.pref_plugin_mpris_desc); 0247 } 0248 0249 @Override 0250 public @DrawableRes int getIcon() { 0251 return R.drawable.mpris_plugin_action_24dp; 0252 } 0253 0254 @Override 0255 public boolean hasSettings() { 0256 return true; 0257 } 0258 0259 @Override 0260 public PluginSettingsFragment getSettingsFragment(Activity activity) { 0261 return PluginSettingsFragment.newInstance(getPluginKey(), R.xml.mprisplugin_preferences); 0262 } 0263 0264 @Override 0265 public boolean onCreate() { 0266 MprisMediaSession.getInstance().onCreate(context.getApplicationContext(), this, device.getDeviceId()); 0267 0268 //Always request the player list so the data is up-to-date 0269 requestPlayerList(); 0270 0271 AlbumArtCache.initializeDiskCache(context); 0272 AlbumArtCache.registerPlugin(this); 0273 0274 return true; 0275 } 0276 0277 @Override 0278 public void onDestroy() { 0279 players.clear(); 0280 AlbumArtCache.deregisterPlugin(this); 0281 MprisMediaSession.getInstance().onDestroy(this, device.getDeviceId()); 0282 } 0283 0284 private void sendCommand(String player, String method, String value) { 0285 NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); 0286 np.set("player", player); 0287 np.set(method, value); 0288 device.sendPacket(np); 0289 } 0290 0291 private void sendCommand(String player, String method, boolean value) { 0292 NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); 0293 np.set("player", player); 0294 np.set(method, value); 0295 device.sendPacket(np); 0296 } 0297 0298 private void sendCommand(String player, String method, int value) { 0299 NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); 0300 np.set("player", player); 0301 np.set(method, value); 0302 device.sendPacket(np); 0303 } 0304 0305 @Override 0306 public boolean onPacketReceived(@NonNull NetworkPacket np) { 0307 if (np.getBoolean("transferringAlbumArt", false)) { 0308 AlbumArtCache.payloadToDiskCache(np.getString("albumArtUrl"), np.getPayload()); 0309 return true; 0310 } 0311 0312 if (np.has("player")) { 0313 MprisPlayer playerStatus = players.get(np.getString("player")); 0314 if (playerStatus != null) { 0315 //Note: title, artist and album will not be available for all desktop clients 0316 playerStatus.title = np.getString("title", playerStatus.title); 0317 playerStatus.artist = np.getString("artist", playerStatus.artist); 0318 playerStatus.album = np.getString("album", playerStatus.album); 0319 playerStatus.url = np.getString("url", playerStatus.url); 0320 if (np.has("loopStatus")) { 0321 playerStatus.loopStatus = np.getString("loopStatus", playerStatus.loopStatus); 0322 playerStatus.loopStatusAllowed = true; 0323 } 0324 if (np.has("shuffle")) { 0325 playerStatus.shuffle = np.getBoolean("shuffle", playerStatus.shuffle); 0326 playerStatus.shuffleAllowed = true; 0327 } 0328 playerStatus.volume = np.getInt("volume", playerStatus.volume); 0329 playerStatus.length = np.getLong("length", playerStatus.length); 0330 if (np.has("pos")) { 0331 playerStatus.lastPosition = np.getLong("pos", playerStatus.lastPosition); 0332 playerStatus.lastPositionTime = System.currentTimeMillis(); 0333 } 0334 playerStatus.playing = np.getBoolean("isPlaying", playerStatus.playing); 0335 playerStatus.playAllowed = np.getBoolean("canPlay", playerStatus.playAllowed); 0336 playerStatus.pauseAllowed = np.getBoolean("canPause", playerStatus.pauseAllowed); 0337 playerStatus.goNextAllowed = np.getBoolean("canGoNext", playerStatus.goNextAllowed); 0338 playerStatus.goPreviousAllowed = np.getBoolean("canGoPrevious", playerStatus.goPreviousAllowed); 0339 playerStatus.seekAllowed = np.getBoolean("canSeek", playerStatus.seekAllowed); 0340 String newAlbumArtUrlstring = np.getString("albumArtUrl", playerStatus.albumArtUrl); 0341 try { 0342 //Turn the url into canonical form (and check its validity) 0343 URL newAlbumArtUrl = new URL(newAlbumArtUrlstring); 0344 playerStatus.albumArtUrl = newAlbumArtUrl.toString(); 0345 } catch (MalformedURLException ignored) { 0346 playerStatus.albumArtUrl = ""; 0347 } 0348 0349 for (String key : playerStatusUpdated.keySet()) { 0350 try { 0351 playerStatusUpdated.get(key).callback(); 0352 } catch (Exception e) { 0353 Log.e("MprisControl", "Exception", e); 0354 playerStatusUpdated.remove(key); 0355 } 0356 } 0357 } 0358 } 0359 0360 //Remember if the connected device support album art payloads 0361 supportAlbumArtPayload = np.getBoolean("supportAlbumArtPayload", supportAlbumArtPayload); 0362 0363 List<String> newPlayerList = np.getStringList("playerList"); 0364 if (newPlayerList != null) { 0365 boolean equals = true; 0366 for (String newPlayer : newPlayerList) { 0367 if (!players.containsKey(newPlayer)) { 0368 equals = false; 0369 0370 MprisPlayer player = new MprisPlayer(); 0371 player.player = newPlayer; 0372 players.put(newPlayer, player); 0373 0374 //Immediately ask for the data of this player 0375 requestPlayerStatus(newPlayer); 0376 } 0377 } 0378 Iterator<HashMap.Entry<String, MprisPlayer>> iter = players.entrySet().iterator(); 0379 while (iter.hasNext()) { 0380 String oldPlayer = iter.next().getKey(); 0381 final boolean found = newPlayerList.stream().anyMatch(newPlayer -> 0382 newPlayer.equals(oldPlayer)); 0383 0384 if (!found) { 0385 iter.remove(); 0386 equals = false; 0387 } 0388 } 0389 if (!equals) { 0390 for (String key : playerListUpdated.keySet()) { 0391 try { 0392 playerListUpdated.get(key).callback(); 0393 } catch (Exception e) { 0394 Log.e("MprisControl", "Exception", e); 0395 playerListUpdated.remove(key); 0396 } 0397 } 0398 } 0399 } 0400 0401 return true; 0402 } 0403 0404 @Override 0405 public @NonNull String[] getSupportedPacketTypes() { 0406 return new String[]{PACKET_TYPE_MPRIS}; 0407 } 0408 0409 @Override 0410 public @NonNull String[] getOutgoingPacketTypes() { 0411 return new String[]{PACKET_TYPE_MPRIS_REQUEST}; 0412 } 0413 0414 public void setPlayerStatusUpdatedHandler(String id, Callback h) { 0415 playerStatusUpdated.put(id, h); 0416 h.callback(); 0417 } 0418 0419 public void removePlayerStatusUpdatedHandler(String id) { 0420 playerStatusUpdated.remove(id); 0421 } 0422 0423 public void setPlayerListUpdatedHandler(String id, Callback h) { 0424 playerListUpdated.put(id, h); 0425 0426 h.callback(); 0427 } 0428 0429 public void removePlayerListUpdatedHandler(String id) { 0430 playerListUpdated.remove(id); 0431 } 0432 0433 public List<String> getPlayerList() { 0434 List<String> playerlist = new ArrayList<>(players.keySet()); 0435 Collections.sort(playerlist); 0436 return playerlist; 0437 } 0438 0439 public MprisPlayer getPlayerStatus(String player) { 0440 if (player == null) { 0441 return null; 0442 } 0443 return players.get(player); 0444 } 0445 0446 public MprisPlayer getEmptyPlayer() { 0447 return new MprisPlayer(); 0448 } 0449 0450 /** 0451 * Returns a playing mpris player, if any exist 0452 * 0453 * @return null if no players are playing, a playing player otherwise 0454 */ 0455 public MprisPlayer getPlayingPlayer() { 0456 return players.values().stream().filter(MprisPlayer::isPlaying).findFirst().orElse(null); 0457 } 0458 0459 boolean hasPlayer(MprisPlayer player) { 0460 if (player == null) { 0461 return false; 0462 } 0463 return players.containsValue(player); 0464 } 0465 0466 private void requestPlayerList() { 0467 NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); 0468 np.set("requestPlayerList", true); 0469 device.sendPacket(np); 0470 } 0471 0472 private void requestPlayerStatus(String player) { 0473 NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); 0474 np.set("player", player); 0475 np.set("requestNowPlaying", true); 0476 np.set("requestVolume", true); 0477 device.sendPacket(np); 0478 } 0479 0480 @Override 0481 public boolean displayAsButton(Context context) { 0482 return true; 0483 } 0484 0485 @Override 0486 public void startMainActivity(Activity parentActivity) { 0487 Intent intent = new Intent(parentActivity, MprisActivity.class); 0488 intent.putExtra("deviceId", device.getDeviceId()); 0489 parentActivity.startActivity(intent); 0490 } 0491 0492 @Override 0493 public @NonNull String getActionName() { 0494 return context.getString(R.string.open_mpris_controls); 0495 } 0496 0497 public void fetchedAlbumArt(String url) { 0498 if (players.values().stream().anyMatch(player -> url.equals(player.albumArtUrl))) { 0499 for (String key : playerStatusUpdated.keySet()) { 0500 try { 0501 playerStatusUpdated.get(key).callback(); 0502 } catch (Exception e) { 0503 Log.e("MprisControl", "Exception", e); 0504 playerStatusUpdated.remove(key); 0505 } 0506 } 0507 } 0508 } 0509 0510 public boolean askTransferAlbumArt(String url, String playerName) { 0511 //First check if the remote supports transferring album art 0512 if (!supportAlbumArtPayload) return false; 0513 if (url.isEmpty()) return false; 0514 0515 MprisPlayer player = getPlayerStatus(playerName); 0516 if (player == null) return false; 0517 0518 if (player.albumArtUrl.equals(url)) { 0519 NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); 0520 np.set("player", player.getPlayerName()); 0521 np.set("albumArtUrl", url); 0522 device.sendPacket(np); 0523 return true; 0524 } 0525 return false; 0526 } 0527 0528 @NonNull 0529 @Override 0530 protected String[] getOptionalPermissions() { 0531 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 0532 return new String[]{Manifest.permission.POST_NOTIFICATIONS}; 0533 } else { 0534 return ArrayUtils.EMPTY_STRING_ARRAY; 0535 } 0536 } 0537 0538 @Override 0539 protected int getOptionalPermissionExplanation() { 0540 return R.string.mpris_notifications_explanation; 0541 } 0542 }