File indexing completed on 2024-12-22 04:41:39

0001 /*
0002  * SPDX-FileCopyrightText: 2023 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.content.ActivityNotFoundException;
0010 import android.content.Intent;
0011 import android.content.SharedPreferences;
0012 import android.graphics.Bitmap;
0013 import android.graphics.drawable.Drawable;
0014 import android.net.Uri;
0015 import android.os.Bundle;
0016 import android.os.Handler;
0017 import android.preference.PreferenceManager;
0018 import android.view.LayoutInflater;
0019 import android.view.Menu;
0020 import android.view.MenuItem;
0021 import android.view.View;
0022 import android.view.ViewGroup;
0023 import android.widget.AdapterView;
0024 import android.widget.ArrayAdapter;
0025 import android.widget.SeekBar;
0026 import android.widget.Toast;
0027 
0028 import androidx.annotation.NonNull;
0029 import androidx.annotation.Nullable;
0030 import androidx.core.content.ContextCompat;
0031 import androidx.core.graphics.drawable.DrawableCompat;
0032 import androidx.fragment.app.Fragment;
0033 
0034 import org.apache.commons.lang3.ArrayUtils;
0035 import org.apache.commons.lang3.StringUtils;
0036 import org.kde.kdeconnect.Helpers.VideoUrlsHelper;
0037 import org.kde.kdeconnect.Helpers.VolumeHelperKt;
0038 import org.kde.kdeconnect.KdeConnect;
0039 import org.kde.kdeconnect_tp.R;
0040 import org.kde.kdeconnect_tp.databinding.MprisControlBinding;
0041 import org.kde.kdeconnect_tp.databinding.MprisNowPlayingBinding;
0042 
0043 import java.net.MalformedURLException;
0044 import java.util.List;
0045 
0046 public class MprisNowPlayingFragment extends Fragment implements VolumeKeyListener {
0047 
0048     final static int MENU_OPEN_URL = Menu.FIRST;
0049     private final Handler positionSeekUpdateHandler = new Handler();
0050     MprisControlBinding mprisControlBinding;
0051     private MprisNowPlayingBinding activityMprisBinding;
0052     private String deviceId;
0053     private Runnable positionSeekUpdateRunnable = null;
0054 
0055     private String targetPlayerName = "";
0056     private MprisPlugin.MprisPlayer targetPlayer = null;
0057 
0058     public static MprisNowPlayingFragment newInstance(String deviceId) {
0059         MprisNowPlayingFragment mprisNowPlayingFragment = new MprisNowPlayingFragment();
0060 
0061         Bundle arguments = new Bundle();
0062         arguments.putString(MprisPlugin.DEVICE_ID_KEY, deviceId);
0063 
0064         mprisNowPlayingFragment.setArguments(arguments);
0065 
0066         return mprisNowPlayingFragment;
0067     }
0068 
0069     private static String milisToProgress(long milis) {
0070         int length = (int) (milis / 1000); //From milis to seconds
0071         StringBuilder text = new StringBuilder();
0072         int minutes = length / 60;
0073         if (minutes > 60) {
0074             int hours = minutes / 60;
0075             minutes = minutes % 60;
0076             text.append(hours).append(':');
0077             if (minutes < 10) text.append('0');
0078         }
0079         text.append(minutes).append(':');
0080         int seconds = (length % 60);
0081         if (seconds < 10)
0082             text.append('0'); // needed to show length properly (eg 4:05 instead of 4:5)
0083         text.append(seconds);
0084         return text.toString();
0085     }
0086 
0087     @Nullable
0088     @Override
0089     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
0090         activityMprisBinding = MprisNowPlayingBinding.inflate(inflater);
0091         mprisControlBinding = activityMprisBinding.mprisControl;
0092 
0093         deviceId = requireArguments().getString(MprisPlugin.DEVICE_ID_KEY);
0094 
0095         targetPlayerName = "";
0096         Intent activityIntent = requireActivity().getIntent();
0097         if (activityIntent.hasExtra("player")) {
0098             targetPlayerName = activityIntent.getStringExtra("player");
0099             activityIntent.removeExtra("player");
0100         } else if (savedInstanceState != null) {
0101             targetPlayerName = savedInstanceState.getString("targetPlayer");
0102         }
0103 
0104         connectToPlugin();
0105 
0106         SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
0107         String interval_time_str = prefs.getString(getString(R.string.mpris_time_key),
0108                 getString(R.string.mpris_time_default));
0109         final int interval_time = Integer.parseInt(interval_time_str);
0110 
0111         performActionOnClick(mprisControlBinding.loopButton, p -> {
0112             switch (p.getLoopStatus()) {
0113                 case "None":
0114                     p.setLoopStatus("Track");
0115                     break;
0116                 case "Track":
0117                     p.setLoopStatus("Playlist");
0118                     break;
0119                 case "Playlist":
0120                     p.setLoopStatus("None");
0121                     break;
0122             }
0123         });
0124 
0125         performActionOnClick(mprisControlBinding.playButton, MprisPlugin.MprisPlayer::playPause);
0126 
0127         performActionOnClick(mprisControlBinding.shuffleButton, p -> p.setShuffle(!p.getShuffle()));
0128 
0129         performActionOnClick(mprisControlBinding.prevButton, MprisPlugin.MprisPlayer::previous);
0130 
0131         performActionOnClick(mprisControlBinding.rewButton, p -> targetPlayer.seek(interval_time * -1));
0132 
0133         performActionOnClick(mprisControlBinding.ffButton, p -> p.seek(interval_time));
0134 
0135         performActionOnClick(mprisControlBinding.nextButton, MprisPlugin.MprisPlayer::next);
0136 
0137         performActionOnClick(mprisControlBinding.stopButton, MprisPlugin.MprisPlayer::stop);
0138 
0139         mprisControlBinding.volumeSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
0140             @Override
0141             public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
0142             }
0143 
0144             @Override
0145             public void onStartTrackingTouch(SeekBar seekBar) {
0146             }
0147 
0148             @Override
0149             public void onStopTrackingTouch(final SeekBar seekBar) {
0150                 if (targetPlayer == null) return;
0151                 targetPlayer.setVolume(seekBar.getProgress());
0152             }
0153         });
0154 
0155         positionSeekUpdateRunnable = () -> {
0156             if (!isAdded()) return; // Fragment was already detached
0157             if (targetPlayer != null) {
0158                 mprisControlBinding.positionSeek.setProgress((int) (targetPlayer.getPosition()));
0159             }
0160             positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
0161             positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 1000);
0162         };
0163         positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
0164 
0165         mprisControlBinding.positionSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
0166             @Override
0167             public void onProgressChanged(SeekBar seekBar, int progress, boolean byUser) {
0168                 mprisControlBinding.progressTextview.setText(milisToProgress(progress));
0169             }
0170 
0171             @Override
0172             public void onStartTrackingTouch(SeekBar seekBar) {
0173                 positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
0174             }
0175 
0176             @Override
0177             public void onStopTrackingTouch(final SeekBar seekBar) {
0178                 if (targetPlayer != null) {
0179                     targetPlayer.setPosition(seekBar.getProgress());
0180                 }
0181                 positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
0182             }
0183         });
0184 
0185         mprisControlBinding.nowPlayingTextview.setSelected(true);
0186 
0187         return activityMprisBinding.getRoot();
0188     }
0189 
0190     @Override
0191     public void onDestroyView() {
0192         disconnectFromPlugin();
0193         super.onDestroyView();
0194     }
0195 
0196     private void disconnectFromPlugin() {
0197         MprisPlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, MprisPlugin.class);
0198         if (plugin != null) {
0199             plugin.removePlayerListUpdatedHandler("activity");
0200             plugin.removePlayerStatusUpdatedHandler("activity");
0201         }
0202     }
0203 
0204     private void connectToPlugin() {
0205         MprisPlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, MprisPlugin.class);
0206         if (plugin == null) {
0207             if (isAdded()) {
0208                 requireActivity().finish();
0209             }
0210             return;
0211         }
0212         targetPlayer = plugin.getPlayerStatus(targetPlayerName);
0213 
0214         plugin.setPlayerStatusUpdatedHandler("activity", () -> requireActivity().runOnUiThread(() -> {
0215             updatePlayerStatus(plugin);
0216         }));
0217         plugin.setPlayerListUpdatedHandler("activity", () -> requireActivity().runOnUiThread(() -> {
0218             final List<String> playerList = plugin.getPlayerList();
0219             final ArrayAdapter<String> adapter = new ArrayAdapter<>(requireContext(),
0220                     android.R.layout.simple_spinner_item,
0221                     playerList.toArray(ArrayUtils.EMPTY_STRING_ARRAY)
0222             );
0223 
0224             adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
0225 
0226             mprisControlBinding.playerSpinner.setAdapter(adapter);
0227 
0228             if (playerList.isEmpty()) {
0229                 mprisControlBinding.noPlayers.setVisibility(View.VISIBLE);
0230                 mprisControlBinding.playerSpinner.setVisibility(View.GONE);
0231                 mprisControlBinding.nowPlayingTextview.setText("");
0232             } else {
0233                 mprisControlBinding.noPlayers.setVisibility(View.GONE);
0234                 mprisControlBinding.playerSpinner.setVisibility(View.VISIBLE);
0235             }
0236 
0237             mprisControlBinding.playerSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
0238                 @Override
0239                 public void onItemSelected(AdapterView<?> arg0, View arg1, int pos, long id) {
0240 
0241                     if (pos >= playerList.size()) return;
0242 
0243                     String player = playerList.get(pos);
0244                     if (targetPlayer != null && player.equals(targetPlayer.getPlayerName())) {
0245                         return; //Player hasn't actually changed
0246                     }
0247                     targetPlayer = plugin.getPlayerStatus(player);
0248                     if (targetPlayer != null) {
0249                         targetPlayerName = targetPlayer.getPlayerName();
0250                     }
0251 
0252                     updatePlayerStatus(plugin);
0253 
0254                     if (targetPlayer != null && targetPlayer.isPlaying()) {
0255                         MprisMediaSession.getInstance().playerSelected(targetPlayer);
0256                     }
0257                 }
0258 
0259                 @Override
0260                 public void onNothingSelected(AdapterView<?> arg0) {
0261                     targetPlayer = null;
0262                 }
0263             });
0264 
0265             if (targetPlayer == null) {
0266                 //If no player is selected, try to select a playing player
0267                 targetPlayer = plugin.getPlayingPlayer();
0268             }
0269             //Try to select the specified player
0270             if (targetPlayer != null) {
0271                 int targetIndex = adapter.getPosition(targetPlayer.getPlayerName());
0272                 if (targetIndex >= 0) {
0273                     mprisControlBinding.playerSpinner.setSelection(targetIndex);
0274                 } else {
0275                     targetPlayer = null;
0276                 }
0277             }
0278             //If no player selected, select the first one (if any)
0279             if (targetPlayer == null && !playerList.isEmpty()) {
0280                 targetPlayer = plugin.getPlayerStatus(playerList.get(0));
0281                 mprisControlBinding.playerSpinner.setSelection(0);
0282             }
0283             updatePlayerStatus(plugin);
0284         }));
0285     }
0286 
0287     private void performActionOnClick(View v, MprisPlayerCallback l) {
0288         v.setOnClickListener(view -> {
0289             if (targetPlayer == null) return;
0290             l.performAction(targetPlayer);
0291         });
0292     }
0293 
0294     private void updatePlayerStatus(MprisPlugin plugin) {
0295         if (!isAdded()) {
0296             //Fragment is not attached to an activity. We will crash if we try to do anything here.
0297             return;
0298         }
0299 
0300         MprisPlugin.MprisPlayer playerStatus = targetPlayer;
0301         if (playerStatus == null) {
0302             //No player with that name found, just display "empty" data
0303             playerStatus = plugin.getEmptyPlayer();
0304         }
0305 
0306         String song = playerStatus.getTitle();
0307         if (!StringUtils.isEmpty(playerStatus.getArtist())) {
0308             song += " - " + playerStatus.getArtist();
0309         }
0310         if (!mprisControlBinding.nowPlayingTextview.getText().toString().equals(song)) {
0311             mprisControlBinding.nowPlayingTextview.setText(song);
0312         }
0313 
0314         Bitmap albumArt = playerStatus.getAlbumArt();
0315         if (albumArt == null) {
0316             final Drawable drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_album_art_placeholder);
0317             assert drawable != null;
0318             Drawable placeholder_art = DrawableCompat.wrap(drawable);
0319             DrawableCompat.setTint(placeholder_art, ContextCompat.getColor(requireContext(), R.color.primary));
0320             activityMprisBinding.albumArt.setImageDrawable(placeholder_art);
0321         } else {
0322             activityMprisBinding.albumArt.setImageBitmap(albumArt);
0323         }
0324 
0325         if (playerStatus.isSeekAllowed()) {
0326             mprisControlBinding.timeTextview.setText(milisToProgress(playerStatus.getLength()));
0327             mprisControlBinding.positionSeek.setMax((int) (playerStatus.getLength()));
0328             mprisControlBinding.positionSeek.setProgress((int) (playerStatus.getPosition()));
0329             mprisControlBinding.progressSlider.setVisibility(View.VISIBLE);
0330         } else {
0331             mprisControlBinding.progressSlider.setVisibility(View.GONE);
0332         }
0333 
0334         int volume = playerStatus.getVolume();
0335         mprisControlBinding.volumeSeek.setProgress(volume);
0336         if(!playerStatus.isSetVolumeAllowed()) {
0337             mprisControlBinding.volumeSeek.setEnabled(false);
0338         }
0339         boolean isPlaying = playerStatus.isPlaying();
0340         if (isPlaying) {
0341             mprisControlBinding.playButton.setIconResource(R.drawable.ic_pause_black);
0342             mprisControlBinding.playButton.setEnabled(playerStatus.isPauseAllowed());
0343         } else {
0344             mprisControlBinding.playButton.setIconResource(R.drawable.ic_play_black);
0345             mprisControlBinding.playButton.setEnabled(playerStatus.isPlayAllowed());
0346         }
0347 
0348         String loopStatus = playerStatus.getLoopStatus();
0349         switch (loopStatus) {
0350             case "None":
0351                 mprisControlBinding.loopButton.setIconResource(R.drawable.ic_loop_none_black);
0352                 break;
0353             case "Track":
0354                 mprisControlBinding.loopButton.setIconResource(R.drawable.ic_loop_track_black);
0355                 break;
0356             case "Playlist":
0357                 mprisControlBinding.loopButton.setIconResource(R.drawable.ic_loop_playlist_black);
0358                 break;
0359         }
0360 
0361         boolean shuffle = playerStatus.getShuffle();
0362         if (shuffle) {
0363             mprisControlBinding.shuffleButton.setIconResource(R.drawable.ic_shuffle_on_black);
0364         } else {
0365             mprisControlBinding.shuffleButton.setIconResource(R.drawable.ic_shuffle_off_black);
0366         }
0367 
0368         mprisControlBinding.loopButton.setVisibility(playerStatus.isLoopStatusAllowed() ? View.VISIBLE : View.GONE);
0369         mprisControlBinding.shuffleButton.setVisibility(playerStatus.isShuffleAllowed() ? View.VISIBLE : View.GONE);
0370         mprisControlBinding.volumeLayout.setVisibility(playerStatus.isSetVolumeAllowed() ? View.VISIBLE : View.GONE);
0371         mprisControlBinding.rewButton.setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
0372         mprisControlBinding.ffButton.setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
0373 
0374         requireActivity().invalidateOptionsMenu();
0375 
0376         //Show and hide previous/next buttons simultaneously
0377         if (playerStatus.isGoPreviousAllowed() || playerStatus.isGoNextAllowed()) {
0378             mprisControlBinding.prevButton.setVisibility(View.VISIBLE);
0379             mprisControlBinding.prevButton.setEnabled(playerStatus.isGoPreviousAllowed());
0380             mprisControlBinding.nextButton.setVisibility(View.VISIBLE);
0381             mprisControlBinding.nextButton.setEnabled(playerStatus.isGoNextAllowed());
0382         } else {
0383             mprisControlBinding.prevButton.setVisibility(View.GONE);
0384             mprisControlBinding.nextButton.setVisibility(View.GONE);
0385         }
0386     }
0387 
0388     /**
0389      * Change current volume with provided step.
0390      *
0391      * @param step step size volume change
0392      */
0393     private void updateVolume(int step) {
0394         if (targetPlayer == null) return;
0395 
0396         int newVolume = VolumeHelperKt.calculateNewVolume(targetPlayer.getVolume(), VolumeHelperKt.DEFAULT_MAX_VOLUME, step);
0397 
0398         if (targetPlayer.getVolume() != newVolume) {
0399             targetPlayer.setVolume(newVolume);
0400         }
0401     }
0402 
0403     @Override
0404     public void onVolumeUp() {
0405         updateVolume(VolumeHelperKt.DEFAULT_VOLUME_STEP);
0406     }
0407 
0408     @Override
0409     public void onVolumeDown() {
0410         updateVolume(-VolumeHelperKt.DEFAULT_VOLUME_STEP);
0411     }
0412 
0413     @Override
0414     public void onPrepareOptionsMenu(@NonNull Menu menu) {
0415         menu.clear();
0416         if (targetPlayer != null && !"".equals(targetPlayer.getUrl())) {
0417             menu.add(0, MENU_OPEN_URL, Menu.NONE, R.string.mpris_open_url);
0418         }
0419     }
0420 
0421     @Override
0422     public boolean onOptionsItemSelected(@NonNull MenuItem item) {
0423         if (targetPlayer != null && item.getItemId() == MENU_OPEN_URL) {
0424             try {
0425                 String url = VideoUrlsHelper.formatUriWithSeek(targetPlayer.getUrl(), targetPlayer.getPosition()).toString();
0426                 Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
0427                 startActivity(browserIntent);
0428                 targetPlayer.pause();
0429                 return true;
0430             } catch (MalformedURLException | ActivityNotFoundException e) {
0431                 e.printStackTrace();
0432                 Toast.makeText(requireContext(), getString(R.string.cant_open_url), Toast.LENGTH_LONG).show();
0433             }
0434         }
0435         return super.onOptionsItemSelected(item);
0436     }
0437 
0438     @Override
0439     public void onSaveInstanceState(@NonNull Bundle outState) {
0440         if (targetPlayer != null) {
0441             outState.putString("targetPlayer", targetPlayerName);
0442         }
0443     }
0444 
0445     private interface MprisPlayerCallback {
0446         void performAction(MprisPlugin.MprisPlayer player);
0447     }
0448 }