2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-22 01:51:47 +00:00

Added switching between audio outputs and split player and devices into two pages

This commit is contained in:
Art Pinch 2021-02-19 19:00:29 +00:00 committed by Simon Redman
parent 2f71fab62c
commit a4b3da7a14
21 changed files with 1047 additions and 569 deletions

View File

@ -174,6 +174,7 @@
android:name="org.kde.kdeconnect.Plugins.MprisPlugin.MprisActivity"
android:label="@string/open_mpris_controls"
android:launchMode="singleTop"
android:theme="@style/KdeConnectTheme.NoActionBar"
android:parentActivityName="org.kde.kdeconnect.UserInterface.MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp"/>
<stroke android:width="3dp" android:color="?colorAccent" />
</shape>

View File

@ -1,32 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="15dp"
android:paddingLeft="25dp"
android:paddingRight="25dp"
android:paddingTop="25dp">
android:orientation="vertical">
<ImageView
android:id="@+id/album_art"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="25dp"
android:layout_weight="4"
android:contentDescription="@string/mpris_coverart_description"
android:scaleType="fitCenter" />
<include
layout="@layout/mpris_control"
android:id="@+id/mpris_control"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<fragment android:name="org.kde.kdeconnect.Plugins.SystemvolumePlugin.SystemvolumeFragment"
android:id="@+id/systemvolume_fragment"
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"/>
android:elevation="8dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/mpris_tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/toolbar_color"
app:tabIndicatorColor="?android:textColorPrimary"
app:tabSelectedTextColor="?android:textColorPrimary" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/mpris_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -1,44 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/sink_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/systemvolume_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardElevation="0dp"
app:contentPadding="8dp"
app:strokeColor="@color/card_stroke_color"
app:strokeWidth="1dp">
<LinearLayout
android:id="@+id/systemvolume_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="8dip"
android:orientation="horizontal">
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/systemvolume_mute"
android:layout_width="30dp"
android:layout_height="30dp"
android:background="@android:color/transparent"
android:contentDescription="@string/mute"
android:scaleType="fitXY"
android:src="@drawable/ic_volume_black"
app:tint="?attr/colorHighContrast" />
<RadioButton
android:id="@+id/systemvolume_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:ellipsize="end"
android:maxLines="1"
android:paddingLeft="14dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
tools:text="Device name" />
<SeekBar
android:id="@+id/systemvolume_seek"
android:layout_width="0dp"
<LinearLayout
android:id="@+id/systemvolume_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:max="100" />
android:layout_marginTop="8dip"
android:orientation="horizontal">
<ImageButton
android:id="@+id/systemvolume_mute"
android:layout_width="30dp"
android:layout_height="30dp"
android:background="@android:color/transparent"
android:contentDescription="@string/mute"
android:scaleType="fitXY"
android:src="@drawable/ic_volume_black"
app:tint="?attr/colorHighContrast" />
<SeekBar
android:id="@+id/systemvolume_seek"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:max="100" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="@dimen/activity_horizontal_margin"
android:paddingVertical="@dimen/activity_vertical_margin">
<ImageView
android:id="@+id/album_art"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="25dp"
android:layout_weight="4"
android:contentDescription="@string/mpris_coverart_description"
android:scaleType="fitCenter" />
<include
android:id="@+id/mpris_control"
layout="@layout/mpris_control"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/audio_devices_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingHorizontal="@dimen/activity_horizontal_margin"
android:paddingVertical="@dimen/activity_vertical_margin">
</androidx.recyclerview.widget.RecyclerView>

View File

@ -9,6 +9,7 @@
<color name="text_color_primary">@android:color/white</color>
<color name="text_color">@android:color/white</color>
<color name="toolbar_color">#222222</color>
<color name="card_stroke_color">#8C8C8C</color>
<!-- This is for dark theme. In dark theme both selected and unselected text in the
navigation bar should be white. This is different from the light theme as both states have

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="KdeConnectTheme.NoActionBar" parent="KdeConnectThemeBase.NoActionBar">
<item name="android:statusBarColor">#65000000</item>
</style>
</resources>

View File

@ -9,4 +9,5 @@
<color name="text_color_primary">@android:color/black</color>
<color name="text_color">@android:color/black</color>
<color name="toolbar_color">#F67400</color>
<color name="card_stroke_color">#C8C8C8</color>
</resources>

View File

@ -0,0 +1,54 @@
package org.kde.kdeconnect.Plugins.MprisPlugin;
import androidx.annotation.NonNull;
import androidx.collection.LongSparseArray;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.Lifecycle;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import java.lang.reflect.Field;
/**
* Provides access to adapter fragments
*/
public abstract class ExtendedFragmentAdapter extends FragmentStateAdapter {
public ExtendedFragmentAdapter(@NonNull FragmentActivity fragmentActivity) {
super(fragmentActivity);
}
public ExtendedFragmentAdapter(@NonNull Fragment fragment) {
super(fragment);
}
public ExtendedFragmentAdapter(@NonNull FragmentManager fragmentManager, @NonNull Lifecycle lifecycle) {
super(fragmentManager, lifecycle);
}
protected LongSparseArray<Fragment> getFragments() {
try {
Field fragmentsField = FragmentStateAdapter.class.getDeclaredField("mFragments");
fragmentsField.setAccessible(true);
Object fieldData = fragmentsField.get(this);
if (fieldData instanceof LongSparseArray) {
//noinspection unchecked
return (LongSparseArray<Fragment>) fieldData;
}
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
protected Fragment getFragment(int position) {
LongSparseArray<Fragment> adapterFragments = getFragments();
if (adapterFragments == null) return null;
return adapterFragments.get(position);
}
}

View File

@ -6,277 +6,41 @@
package org.kde.kdeconnect.Plugins.MprisPlugin;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.SeekBar;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import org.apache.commons.lang3.ArrayUtils;
import org.kde.kdeconnect.Backends.BaseLink;
import org.kde.kdeconnect.Backends.BaseLinkProvider;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Helpers.VideoUrlsHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.SystemvolumePlugin.SystemvolumeFragment;
import com.google.android.material.tabs.TabLayoutMediator;
import org.kde.kdeconnect.Plugins.SystemVolumePlugin.SystemVolumeFragment;
import org.kde.kdeconnect.UserInterface.ThemeUtil;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityMprisBinding;
import org.kde.kdeconnect_tp.databinding.MprisControlBinding;
import java.net.MalformedURLException;
import java.util.List;
public class MprisActivity extends AppCompatActivity {
private String deviceId;
private final Handler positionSeekUpdateHandler = new Handler();
private Runnable positionSeekUpdateRunnable = null;
private MprisPlugin.MprisPlayer targetPlayer = null;
private ActivityMprisBinding activityMprisBinding;
private MprisControlBinding mprisControlBinding;
private static String milisToProgress(long milis) {
int length = (int) (milis / 1000); //From milis to seconds
StringBuilder text = new StringBuilder();
int minutes = length / 60;
if (minutes > 60) {
int hours = minutes / 60;
minutes = minutes % 60;
text.append(hours).append(':');
if (minutes < 10) text.append('0');
}
text.append(minutes).append(':');
int seconds = (length % 60);
if (seconds < 10)
text.append('0'); // needed to show length properly (eg 4:05 instead of 4:5)
text.append(seconds);
return text.toString();
}
private void connectToPlugin(final String targetPlayerName) {
BackgroundService.RunWithPlugin(this, deviceId, MprisPlugin.class, mpris -> {
targetPlayer = mpris.getPlayerStatus(targetPlayerName);
addSystemVolumeFragment();
mpris.setPlayerStatusUpdatedHandler("activity", new Handler() {
@Override
public void handleMessage(Message msg) {
runOnUiThread(() -> updatePlayerStatus(mpris));
}
});
mpris.setPlayerListUpdatedHandler("activity", new Handler() {
@Override
public void handleMessage(Message msg) {
final List<String> playerList = mpris.getPlayerList();
final ArrayAdapter<String> adapter = new ArrayAdapter<>(MprisActivity.this,
android.R.layout.simple_spinner_item,
playerList.toArray(ArrayUtils.EMPTY_STRING_ARRAY)
);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
runOnUiThread(() -> {
mprisControlBinding.playerSpinner.setAdapter(adapter);
if (playerList.isEmpty()) {
mprisControlBinding.noPlayers.setVisibility(View.VISIBLE);
mprisControlBinding.playerSpinner.setVisibility(View.GONE);
mprisControlBinding.nowPlayingTextview.setText("");
} else {
mprisControlBinding.noPlayers.setVisibility(View.GONE);
mprisControlBinding.playerSpinner.setVisibility(View.VISIBLE);
}
mprisControlBinding.playerSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> arg0, View arg1, int pos, long id) {
if (pos >= playerList.size()) return;
String player = playerList.get(pos);
if (targetPlayer != null && player.equals(targetPlayer.getPlayer())) {
return; //Player hasn't actually changed
}
targetPlayer = mpris.getPlayerStatus(player);
updatePlayerStatus(mpris);
if (targetPlayer.isPlaying()) {
MprisMediaSession.getInstance().playerSelected(targetPlayer);
}
}
@Override
public void onNothingSelected(AdapterView<?> arg0) {
targetPlayer = null;
}
});
if (targetPlayer == null) {
//If no player is selected, try to select a playing player
targetPlayer = mpris.getPlayingPlayer();
}
//Try to select the specified player
if (targetPlayer != null) {
int targetIndex = adapter.getPosition(targetPlayer.getPlayer());
if (targetIndex >= 0) {
mprisControlBinding.playerSpinner.setSelection(targetIndex);
} else {
targetPlayer = null;
}
}
//If no player selected, select the first one (if any)
if (targetPlayer == null && !playerList.isEmpty()) {
targetPlayer = mpris.getPlayerStatus(playerList.get(0));
mprisControlBinding.playerSpinner.setSelection(0);
}
updatePlayerStatus(mpris);
});
}
});
});
}
private void addSystemVolumeFragment() {
if (findViewById(R.id.systemvolume_fragment) == null)
return;
FragmentManager fragmentManager = getSupportFragmentManager();
((SystemvolumeFragment) fragmentManager.findFragmentById(R.id.systemvolume_fragment)).connectToPlugin(deviceId);
}
private final BaseLinkProvider.ConnectionReceiver connectionReceiver = new BaseLinkProvider.ConnectionReceiver() {
@Override
public void onConnectionReceived(NetworkPacket identityPacket, BaseLink link) {
connectToPlugin(null);
}
@Override
public void onConnectionLost(BaseLink link) {
}
};
@Override
protected void onDestroy() {
super.onDestroy();
BackgroundService.RunCommand(MprisActivity.this, service -> service.removeConnectionListener(connectionReceiver));
}
private void updatePlayerStatus(MprisPlugin mpris) {
MprisPlugin.MprisPlayer playerStatus = targetPlayer;
if (playerStatus == null) {
//No player with that name found, just display "empty" data
playerStatus = mpris.getEmptyPlayer();
}
String song = playerStatus.getCurrentSong();
if (!mprisControlBinding.nowPlayingTextview.getText().toString().equals(song)) {
mprisControlBinding.nowPlayingTextview.setText(song);
}
Bitmap albumArt = playerStatus.getAlbumArt();
if (albumArt == null) {
final Drawable drawable = ContextCompat.getDrawable(this, R.drawable.ic_album_art_placeholder);
assert drawable != null;
Drawable placeholder_art = DrawableCompat.wrap(drawable);
DrawableCompat.setTint(placeholder_art, ContextCompat.getColor(this, R.color.primary));
activityMprisBinding.albumArt.setImageDrawable(placeholder_art);
} else {
activityMprisBinding.albumArt.setImageBitmap(albumArt);
}
if (playerStatus.isSeekAllowed()) {
mprisControlBinding.timeTextview.setText(milisToProgress(playerStatus.getLength()));
mprisControlBinding.positionSeek.setMax((int) (playerStatus.getLength()));
mprisControlBinding.positionSeek.setProgress((int) (playerStatus.getPosition()));
mprisControlBinding.progressSlider.setVisibility(View.VISIBLE);
} else {
mprisControlBinding.progressSlider.setVisibility(View.GONE);
}
int volume = playerStatus.getVolume();
mprisControlBinding.volumeSeek.setProgress(volume);
boolean isPlaying = playerStatus.isPlaying();
if (isPlaying) {
mprisControlBinding.playButton.setImageResource(R.drawable.ic_pause_black);
mprisControlBinding.playButton.setEnabled(playerStatus.isPauseAllowed());
} else {
mprisControlBinding.playButton.setImageResource(R.drawable.ic_play_black);
mprisControlBinding.playButton.setEnabled(playerStatus.isPlayAllowed());
}
mprisControlBinding.volumeLayout.setVisibility(playerStatus.isSetVolumeAllowed() ? View.VISIBLE : View.GONE);
mprisControlBinding.rewButton.setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
mprisControlBinding.ffButton.setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
invalidateOptionsMenu();
//Show and hide previous/next buttons simultaneously
if (playerStatus.isGoPreviousAllowed() || playerStatus.isGoNextAllowed()) {
mprisControlBinding.prevButton.setVisibility(View.VISIBLE);
mprisControlBinding.prevButton.setEnabled(playerStatus.isGoPreviousAllowed());
mprisControlBinding.nextButton.setVisibility(View.VISIBLE);
mprisControlBinding.nextButton.setEnabled(playerStatus.isGoNextAllowed());
} else {
mprisControlBinding.prevButton.setVisibility(View.GONE);
mprisControlBinding.nextButton.setVisibility(View.GONE);
}
}
/**
* Change current volume with provided step.
*
* @param step step size volume change
*/
private void updateVolume(int step) {
if (targetPlayer == null) {
return;
}
final int currentVolume = targetPlayer.getVolume();
if (currentVolume <= 100 && currentVolume >= 0) {
int newVolume = currentVolume + step;
if (newVolume > 100) {
newVolume = 100;
} else if (newVolume < 0) {
newVolume = 0;
}
targetPlayer.setVolume(newVolume);
}
}
private MprisPagerAdapter mprisPagerAdapter;
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
updateVolume(5);
if (activityMprisBinding != null && mprisPagerAdapter != null) {
int pagePosition = activityMprisBinding.mprisTabs.getSelectedTabPosition();
mprisPagerAdapter.onVolumeUp(pagePosition);
}
return true;
case KeyEvent.KEYCODE_VOLUME_DOWN:
updateVolume(-5);
if (activityMprisBinding != null && mprisPagerAdapter != null) {
int pagePosition = activityMprisBinding.mprisTabs.getSelectedTabPosition();
mprisPagerAdapter.onVolumeDown(pagePosition);
}
return true;
default:
return super.onKeyDown(keyCode, event);
@ -287,7 +51,6 @@ public class MprisActivity extends AppCompatActivity {
public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
return true;
case KeyEvent.KEYCODE_VOLUME_DOWN:
return true;
default:
@ -295,142 +58,85 @@ public class MprisActivity extends AppCompatActivity {
}
}
private interface MprisPlayerCallback {
void performAction(MprisPlugin.MprisPlayer player);
}
private void performActionOnClick(View v, MprisPlayerCallback l) {
v.setOnClickListener(view -> BackgroundService.RunCommand(MprisActivity.this, service -> {
if (targetPlayer == null) return;
l.performAction(targetPlayer);
}));
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeUtil.setUserPreferredTheme(this);
activityMprisBinding = ActivityMprisBinding.inflate(getLayoutInflater());
mprisControlBinding = activityMprisBinding.mprisControl;
setContentView(activityMprisBinding.getRoot());
String targetPlayerName = getIntent().getStringExtra("player");
getIntent().removeExtra("player");
String deviceId = getIntent().getStringExtra(MprisPlugin.DEVICE_ID_KEY);
if (TextUtils.isEmpty(targetPlayerName)) {
if (savedInstanceState != null) {
targetPlayerName = savedInstanceState.getString("targetPlayer");
}
}
mprisPagerAdapter = new MprisPagerAdapter(this, deviceId);
activityMprisBinding.mprisPager.setAdapter(mprisPagerAdapter);
deviceId = getIntent().getStringExtra("deviceId");
TabLayoutMediator tabLayoutMediator = new TabLayoutMediator(
activityMprisBinding.mprisTabs,
activityMprisBinding.mprisPager,
(tab, position) -> {
tab.setText(mprisPagerAdapter.getTitle(position));
}
);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String interval_time_str = prefs.getString(getString(R.string.mpris_time_key),
getString(R.string.mpris_time_default));
final int interval_time = Integer.parseInt(interval_time_str);
activityMprisBinding.mprisTabs.getSelectedTabPosition();
BackgroundService.RunCommand(MprisActivity.this, service -> service.addConnectionListener(connectionReceiver));
connectToPlugin(targetPlayerName);
tabLayoutMediator.attach();
performActionOnClick(mprisControlBinding.playButton, MprisPlugin.MprisPlayer::playPause);
performActionOnClick(mprisControlBinding.prevButton, MprisPlugin.MprisPlayer::previous);
performActionOnClick(mprisControlBinding.rewButton, p -> targetPlayer.seek(interval_time * -1));
performActionOnClick(mprisControlBinding.ffButton, p -> p.seek(interval_time));
performActionOnClick(mprisControlBinding.nextButton, MprisPlugin.MprisPlayer::next);
performActionOnClick(mprisControlBinding.stopButton, MprisPlugin.MprisPlayer::stop);
mprisControlBinding.volumeSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
BackgroundService.RunCommand(MprisActivity.this, service -> {
if (targetPlayer == null) return;
targetPlayer.setVolume(seekBar.getProgress());
});
}
});
positionSeekUpdateRunnable = () -> BackgroundService.RunCommand(MprisActivity.this, service -> {
if (targetPlayer != null) {
mprisControlBinding.positionSeek.setProgress((int) (targetPlayer.getPosition()));
}
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 1000);
});
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
mprisControlBinding.positionSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean byUser) {
mprisControlBinding.progressTextview.setText(milisToProgress(progress));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
BackgroundService.RunCommand(MprisActivity.this, service -> {
if (targetPlayer != null) {
targetPlayer.setPosition(seekBar.getProgress());
}
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
});
}
});
mprisControlBinding.nowPlayingTextview.setSelected(true);
setSupportActionBar(activityMprisBinding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
final static int MENU_OPEN_URL = Menu.FIRST;
static class MprisPagerAdapter extends ExtendedFragmentAdapter {
public boolean onPrepareOptionsMenu(Menu menu) {
menu.clear();
if(targetPlayer != null && !"".equals(targetPlayer.getUrl())) {
menu.add(0, MENU_OPEN_URL, Menu.NONE, R.string.mpris_open_url);
private final String deviceId;
public MprisPagerAdapter(@NonNull FragmentActivity fragmentActivity, String deviceId) {
super(fragmentActivity);
this.deviceId = deviceId;
}
return super.onPrepareOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (targetPlayer != null && item.getItemId() == MENU_OPEN_URL) {
try {
String url = VideoUrlsHelper.formatUriWithSeek(targetPlayer.getUrl(), targetPlayer.getPosition()).toString();
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
startActivity(browserIntent);
targetPlayer.pause();
return true;
} catch (MalformedURLException | ActivityNotFoundException e) {
e.printStackTrace();
Toast.makeText(getApplicationContext(), getString(R.string.cant_open_url), Toast.LENGTH_LONG).show();
@NonNull
@Override
public Fragment createFragment(int position) {
if (position == 1) {
return SystemVolumeFragment.newInstance(deviceId);
} else {
return MprisNowPlayingFragment.newInstance(deviceId);
}
}
return super.onOptionsItemSelected(item);
}
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
if (targetPlayer != null) {
outState.putString("targetPlayer", targetPlayer.getPlayer());
@Override
public int getItemCount() {
return 2;
}
@StringRes
int getTitle(int position) {
if (position == 1) {
return R.string.devices;
} else {
return R.string.mpris_play;
}
}
void onVolumeUp(int page) {
Fragment requestedFragment = getFragment(page);
if (requestedFragment == null) return;
if (requestedFragment instanceof VolumeKeyListener) {
((VolumeKeyListener) requestedFragment).onVolumeUp();
}
}
void onVolumeDown(int page) {
Fragment requestedFragment = getFragment(page);
if (requestedFragment == null) return;
if (requestedFragment instanceof VolumeKeyListener) {
((VolumeKeyListener) requestedFragment).onVolumeDown();
}
}
super.onSaveInstanceState(outState);
}
}

View File

@ -0,0 +1,426 @@
package org.kde.kdeconnect.Plugins.MprisPlugin;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.SeekBar;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.fragment.app.Fragment;
import org.apache.commons.lang3.ArrayUtils;
import org.kde.kdeconnect.Backends.BaseLink;
import org.kde.kdeconnect.Backends.BaseLinkProvider;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Helpers.VideoUrlsHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.MprisControlBinding;
import org.kde.kdeconnect_tp.databinding.MprisNowPlayingBinding;
import java.net.MalformedURLException;
import java.util.List;
public class MprisNowPlayingFragment extends Fragment implements VolumeKeyListener {
final static int MENU_OPEN_URL = Menu.FIRST;
private final Handler positionSeekUpdateHandler = new Handler();
MprisControlBinding mprisControlBinding;
private MprisNowPlayingBinding activityMprisBinding;
private String deviceId;
private Runnable positionSeekUpdateRunnable = null;
private MprisPlugin.MprisPlayer targetPlayer = null;
private final BaseLinkProvider.ConnectionReceiver connectionReceiver = new BaseLinkProvider.ConnectionReceiver() {
@Override
public void onConnectionReceived(NetworkPacket identityPacket, BaseLink link) {
connectToPlugin(null);
}
@Override
public void onConnectionLost(BaseLink link) {
}
};
public static MprisNowPlayingFragment newInstance(String deviceId) {
MprisNowPlayingFragment mprisNowPlayingFragment = new MprisNowPlayingFragment();
Bundle arguments = new Bundle();
arguments.putString(MprisPlugin.DEVICE_ID_KEY, deviceId);
mprisNowPlayingFragment.setArguments(arguments);
return mprisNowPlayingFragment;
}
private static String milisToProgress(long milis) {
int length = (int) (milis / 1000); //From milis to seconds
StringBuilder text = new StringBuilder();
int minutes = length / 60;
if (minutes > 60) {
int hours = minutes / 60;
minutes = minutes % 60;
text.append(hours).append(':');
if (minutes < 10) text.append('0');
}
text.append(minutes).append(':');
int seconds = (length % 60);
if (seconds < 10)
text.append('0'); // needed to show length properly (eg 4:05 instead of 4:5)
text.append(seconds);
return text.toString();
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
if (activityMprisBinding == null) {
activityMprisBinding = MprisNowPlayingBinding.inflate(inflater);
mprisControlBinding = activityMprisBinding.mprisControl;
String targetPlayerName = "";
Intent activityIntent = requireActivity().getIntent();
activityIntent.getStringExtra("player");
activityIntent.removeExtra("player");
if (TextUtils.isEmpty(targetPlayerName)) {
if (savedInstanceState != null) {
targetPlayerName = savedInstanceState.getString("targetPlayer");
}
}
deviceId = requireArguments().getString(MprisPlugin.DEVICE_ID_KEY);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
String interval_time_str = prefs.getString(getString(R.string.mpris_time_key),
getString(R.string.mpris_time_default));
final int interval_time = Integer.parseInt(interval_time_str);
BackgroundService.RunCommand(requireContext(), service -> service.addConnectionListener(connectionReceiver));
connectToPlugin(targetPlayerName);
performActionOnClick(mprisControlBinding.playButton, MprisPlugin.MprisPlayer::playPause);
performActionOnClick(mprisControlBinding.prevButton, MprisPlugin.MprisPlayer::previous);
performActionOnClick(mprisControlBinding.rewButton, p -> targetPlayer.seek(interval_time * -1));
performActionOnClick(mprisControlBinding.ffButton, p -> p.seek(interval_time));
performActionOnClick(mprisControlBinding.nextButton, MprisPlugin.MprisPlayer::next);
performActionOnClick(mprisControlBinding.stopButton, MprisPlugin.MprisPlayer::stop);
mprisControlBinding.volumeSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
BackgroundService.RunCommand(requireContext(), service -> {
if (targetPlayer == null) return;
targetPlayer.setVolume(seekBar.getProgress());
});
}
});
positionSeekUpdateRunnable = () -> {
Context context = getContext();
if (context == null) return; // Fragment was already detached
BackgroundService.RunCommand(context, service -> {
if (targetPlayer != null) {
mprisControlBinding.positionSeek.setProgress((int) (targetPlayer.getPosition()));
}
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 1000);
});
};
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
mprisControlBinding.positionSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean byUser) {
mprisControlBinding.progressTextview.setText(milisToProgress(progress));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable);
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
BackgroundService.RunCommand(requireContext(), service -> {
if (targetPlayer != null) {
targetPlayer.setPosition(seekBar.getProgress());
}
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200);
});
}
});
mprisControlBinding.nowPlayingTextview.setSelected(true);
}
return activityMprisBinding.getRoot();
}
private void connectToPlugin(final String targetPlayerName) {
BackgroundService.RunWithPlugin(requireContext(), deviceId, MprisPlugin.class, mpris -> {
targetPlayer = mpris.getPlayerStatus(targetPlayerName);
mpris.setPlayerStatusUpdatedHandler("activity", new Handler() {
@Override
public void handleMessage(Message msg) {
requireActivity().runOnUiThread(() -> updatePlayerStatus(mpris));
}
});
mpris.setPlayerListUpdatedHandler("activity", new Handler() {
@Override
public void handleMessage(Message msg) {
final List<String> playerList = mpris.getPlayerList();
final ArrayAdapter<String> adapter = new ArrayAdapter<>(requireContext(),
android.R.layout.simple_spinner_item,
playerList.toArray(ArrayUtils.EMPTY_STRING_ARRAY)
);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
requireActivity().runOnUiThread(() -> {
mprisControlBinding.playerSpinner.setAdapter(adapter);
if (playerList.isEmpty()) {
mprisControlBinding.noPlayers.setVisibility(View.VISIBLE);
mprisControlBinding.playerSpinner.setVisibility(View.GONE);
mprisControlBinding.nowPlayingTextview.setText("");
} else {
mprisControlBinding.noPlayers.setVisibility(View.GONE);
mprisControlBinding.playerSpinner.setVisibility(View.VISIBLE);
}
mprisControlBinding.playerSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> arg0, View arg1, int pos, long id) {
if (pos >= playerList.size()) return;
String player = playerList.get(pos);
if (targetPlayer != null && player.equals(targetPlayer.getPlayer())) {
return; //Player hasn't actually changed
}
targetPlayer = mpris.getPlayerStatus(player);
updatePlayerStatus(mpris);
if (targetPlayer.isPlaying()) {
MprisMediaSession.getInstance().playerSelected(targetPlayer);
}
}
@Override
public void onNothingSelected(AdapterView<?> arg0) {
targetPlayer = null;
}
});
if (targetPlayer == null) {
//If no player is selected, try to select a playing player
targetPlayer = mpris.getPlayingPlayer();
}
//Try to select the specified player
if (targetPlayer != null) {
int targetIndex = adapter.getPosition(targetPlayer.getPlayer());
if (targetIndex >= 0) {
mprisControlBinding.playerSpinner.setSelection(targetIndex);
} else {
targetPlayer = null;
}
}
//If no player selected, select the first one (if any)
if (targetPlayer == null && !playerList.isEmpty()) {
targetPlayer = mpris.getPlayerStatus(playerList.get(0));
mprisControlBinding.playerSpinner.setSelection(0);
}
updatePlayerStatus(mpris);
});
}
});
});
}
@Override
public void onDestroy() {
super.onDestroy();
BackgroundService.RunCommand(requireContext(), service -> service.removeConnectionListener(connectionReceiver));
}
private void performActionOnClick(View v, MprisPlayerCallback l) {
v.setOnClickListener(view -> BackgroundService.RunCommand(requireContext(), service -> {
if (targetPlayer == null) return;
l.performAction(targetPlayer);
}));
}
private void updatePlayerStatus(MprisPlugin mpris) {
MprisPlugin.MprisPlayer playerStatus = targetPlayer;
if (playerStatus == null) {
//No player with that name found, just display "empty" data
playerStatus = mpris.getEmptyPlayer();
}
String song = playerStatus.getCurrentSong();
if (!mprisControlBinding.nowPlayingTextview.getText().toString().equals(song)) {
mprisControlBinding.nowPlayingTextview.setText(song);
}
Bitmap albumArt = playerStatus.getAlbumArt();
if (albumArt == null) {
final Drawable drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_album_art_placeholder);
assert drawable != null;
Drawable placeholder_art = DrawableCompat.wrap(drawable);
DrawableCompat.setTint(placeholder_art, ContextCompat.getColor(requireContext(), R.color.primary));
activityMprisBinding.albumArt.setImageDrawable(placeholder_art);
} else {
activityMprisBinding.albumArt.setImageBitmap(albumArt);
}
if (playerStatus.isSeekAllowed()) {
mprisControlBinding.timeTextview.setText(milisToProgress(playerStatus.getLength()));
mprisControlBinding.positionSeek.setMax((int) (playerStatus.getLength()));
mprisControlBinding.positionSeek.setProgress((int) (playerStatus.getPosition()));
mprisControlBinding.progressSlider.setVisibility(View.VISIBLE);
} else {
mprisControlBinding.progressSlider.setVisibility(View.GONE);
}
int volume = playerStatus.getVolume();
mprisControlBinding.volumeSeek.setProgress(volume);
boolean isPlaying = playerStatus.isPlaying();
if (isPlaying) {
mprisControlBinding.playButton.setImageResource(R.drawable.ic_pause_black);
mprisControlBinding.playButton.setEnabled(playerStatus.isPauseAllowed());
} else {
mprisControlBinding.playButton.setImageResource(R.drawable.ic_play_black);
mprisControlBinding.playButton.setEnabled(playerStatus.isPlayAllowed());
}
mprisControlBinding.volumeLayout.setVisibility(playerStatus.isSetVolumeAllowed() ? View.VISIBLE : View.GONE);
mprisControlBinding.rewButton.setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
mprisControlBinding.ffButton.setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE);
requireActivity().invalidateOptionsMenu();
//Show and hide previous/next buttons simultaneously
if (playerStatus.isGoPreviousAllowed() || playerStatus.isGoNextAllowed()) {
mprisControlBinding.prevButton.setVisibility(View.VISIBLE);
mprisControlBinding.prevButton.setEnabled(playerStatus.isGoPreviousAllowed());
mprisControlBinding.nextButton.setVisibility(View.VISIBLE);
mprisControlBinding.nextButton.setEnabled(playerStatus.isGoNextAllowed());
} else {
mprisControlBinding.prevButton.setVisibility(View.GONE);
mprisControlBinding.nextButton.setVisibility(View.GONE);
}
}
/**
* Change current volume with provided step.
*
* @param step step size volume change
*/
private void updateVolume(int step) {
Log.e("NowPlayingVolume", String.valueOf(step));
if (targetPlayer == null) {
return;
}
Log.e("NowPlayingVolumePlayer", targetPlayer.getTitle());
Log.e("NowPlayingVolumeP", String.valueOf(targetPlayer.getVolume()));
final int currentVolume = targetPlayer.getVolume();
if (currentVolume <= 100 && currentVolume >= 0) {
int newVolume = currentVolume + step;
if (newVolume > 100) {
newVolume = 100;
} else if (newVolume < 0) {
newVolume = 0;
}
Log.e("NowPlayingVolumeN", String.valueOf(newVolume));
targetPlayer.setVolume(newVolume);
}
}
@Override
public void onVolumeUp() {
updateVolume(5);
}
@Override
public void onVolumeDown() {
updateVolume(-5);
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
menu.clear();
if (targetPlayer != null && !"".equals(targetPlayer.getUrl())) {
menu.add(0, MENU_OPEN_URL, Menu.NONE, R.string.mpris_open_url);
}
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (targetPlayer != null && item.getItemId() == MENU_OPEN_URL) {
try {
String url = VideoUrlsHelper.formatUriWithSeek(targetPlayer.getUrl(), targetPlayer.getPosition()).toString();
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
startActivity(browserIntent);
targetPlayer.pause();
return true;
} catch (MalformedURLException | ActivityNotFoundException e) {
e.printStackTrace();
Toast.makeText(requireContext(), getString(R.string.cant_open_url), Toast.LENGTH_LONG).show();
}
}
return super.onOptionsItemSelected(item);
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
if (targetPlayer != null) {
outState.putString("targetPlayer", targetPlayer.getPlayer());
}
}
private interface MprisPlayerCallback {
void performAction(MprisPlugin.MprisPlayer player);
}
}

View File

@ -197,6 +197,7 @@ public class MprisPlugin extends Plugin {
}
}
public final static String DEVICE_ID_KEY = "deviceId";
private final static String PACKET_TYPE_MPRIS = "kdeconnect.mpris";
private final static String PACKET_TYPE_MPRIS_REQUEST = "kdeconnect.mpris.request";

View File

@ -0,0 +1,9 @@
package org.kde.kdeconnect.Plugins.MprisPlugin;
public interface VolumeKeyListener {
void onVolumeUp();
void onVolumeDown();
}

View File

@ -0,0 +1,36 @@
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import android.graphics.Rect;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class ItemGapDecoration extends RecyclerView.ItemDecoration {
private final int gap;
ItemGapDecoration(int gap) {
this.gap = gap;
}
@Override
public void getItemOffsets(
@NonNull Rect outRect,
@NonNull View view,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state
) {
super.getItemOffsets(outRect, view, parent, state);
int itemPosition = parent.getChildAdapterPosition(view);
RecyclerView.Adapter<?> adapter = parent.getAdapter();
if (adapter == null) return;
if (itemPosition >= 0 && itemPosition < adapter.getItemCount() - 1) {
outRect.bottom = gap;
}
}
}

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SystemvolumePlugin;
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import org.json.JSONException;
import org.json.JSONObject;
@ -23,6 +23,7 @@ class Sink {
private String name;
private boolean mute;
private int maxVolume;
private boolean enabled;
private final List<UpdateListener> listeners;
@ -33,6 +34,7 @@ class Sink {
mute = obj.getBoolean("muted");
description = obj.getString("description");
maxVolume = obj.getInt("maxVolume");
enabled = obj.optBoolean("enabled", false);
}
int getVolume() {
@ -66,6 +68,17 @@ class Sink {
}
}
boolean isDefault() {
return enabled;
}
void setDefault(boolean enable) {
this.enabled = enable;
for (UpdateListener l : listeners) {
l.updateSink(this);
}
}
void addListener(UpdateListener l) {
if (!listeners.contains(l)) {

View File

@ -0,0 +1,21 @@
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
public class SinkItemCallback extends DiffUtil.ItemCallback<Sink> {
@Override
public boolean areItemsTheSame(@NonNull Sink oldItem, @NonNull Sink newItem) {
return oldItem.getName().equals(newItem.getName());
}
@Override
public boolean areContentsTheSame(@NonNull Sink oldItem, @NonNull Sink newItem) {
return oldItem.getVolume() == newItem.getVolume()
&& oldItem.isMute() == newItem.isMute()
&& oldItem.isDefault() == newItem.isDefault()
&& oldItem.getMaxVolume() == newItem.getMaxVolume() // should this be checked?
&& oldItem.getDescription().equals(newItem.getDescription()); // should this be checked?
}
}

View File

@ -0,0 +1,101 @@
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import android.view.View;
import android.widget.CompoundButton;
import android.widget.ImageButton;
import android.widget.RadioButton;
import android.widget.SeekBar;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.recyclerview.widget.RecyclerView;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ListItemSystemvolumeBinding;
class SinkItemHolder extends RecyclerView.ViewHolder
implements
SeekBar.OnSeekBarChangeListener,
ImageButton.OnClickListener,
CompoundButton.OnCheckedChangeListener,
View.OnLongClickListener {
private final ListItemSystemvolumeBinding viewBinding;
private final SystemVolumePlugin plugin;
private final Consumer<Boolean> seekBarTracking;
private Sink sink;
public SinkItemHolder(
@NonNull ListItemSystemvolumeBinding viewBinding,
@NonNull SystemVolumePlugin plugin,
@NonNull Consumer<Boolean> seekBarTracking
) {
super(viewBinding.getRoot());
this.viewBinding = viewBinding;
this.plugin = plugin;
this.seekBarTracking = seekBarTracking;
viewBinding.sinkCard.setOnLongClickListener(this);
viewBinding.systemvolumeLabel.setOnLongClickListener(this);
viewBinding.systemvolumeLabel.setOnCheckedChangeListener(this);
viewBinding.systemvolumeMute.setOnClickListener(this);
viewBinding.systemvolumeSeek.setOnSeekBarChangeListener(this);
}
@Override
public void onProgressChanged(final SeekBar seekBar, int i, boolean b) {
BackgroundService.RunCommand(seekBar.getContext(),
service -> plugin.sendVolume(sink.getName(), seekBar.getProgress()));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
seekBarTracking.accept(true);
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
seekBarTracking.accept(false);
BackgroundService.RunCommand(seekBar.getContext(),
service -> plugin.sendVolume(sink.getName(), seekBar.getProgress()));
}
@Override
public void onClick(View view) {
plugin.sendMute(sink.getName(), !sink.isMute());
}
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (isChecked) {
plugin.sendEnable(sink.getName());
}
}
@Override
public boolean onLongClick(View v) {
Toast.makeText(v.getContext(), sink.getDescription(), Toast.LENGTH_SHORT).show();
return true;
}
public void bind(Sink sink) {
this.sink = sink;
final RadioButton radioButton = viewBinding.systemvolumeLabel;
radioButton.setChecked(sink.isDefault());
radioButton.setText(sink.getDescription());
final SeekBar seekBar = viewBinding.systemvolumeSeek;
seekBar.setMax(sink.getMaxVolume());
seekBar.setProgress(sink.getVolume());
int iconRes = sink.isMute() ? R.drawable.ic_volume_mute_black : R.drawable.ic_volume_black;
ImageButton button = viewBinding.systemvolumeMute;
button.setImageResource(iconRes);
}
}

View File

@ -0,0 +1,189 @@
/*
* SPDX-FileCopyrightText: 2018 Nicolas Fella <nicolas.fella@gmx.de>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.asynclayoutinflater.view.AsyncLayoutInflater;
import androidx.core.util.Consumer;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Plugins.MprisPlugin.MprisPlugin;
import org.kde.kdeconnect.Plugins.MprisPlugin.VolumeKeyListener;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ListItemSystemvolumeBinding;
import org.kde.kdeconnect_tp.databinding.SystemVolumeFragmentBinding;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class SystemVolumeFragment
extends Fragment
implements Sink.UpdateListener, SystemVolumePlugin.SinkListener, VolumeKeyListener {
private SystemVolumePlugin plugin;
private RecyclerSinkAdapter recyclerAdapter;
private boolean tracking;
private final Consumer<Boolean> trackingConsumer = aBoolean -> tracking = aBoolean;
private SystemVolumeFragmentBinding systemVolumeFragmentBinding;
public static SystemVolumeFragment newInstance(String deviceId) {
SystemVolumeFragment systemvolumeFragment = new SystemVolumeFragment();
Bundle arguments = new Bundle();
arguments.putString(MprisPlugin.DEVICE_ID_KEY, deviceId);
systemvolumeFragment.setArguments(arguments);
return systemvolumeFragment;
}
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
if (systemVolumeFragmentBinding == null) {
systemVolumeFragmentBinding = SystemVolumeFragmentBinding.inflate(inflater);
RecyclerView recyclerView = systemVolumeFragmentBinding.audioDevicesRecycler;
int gap = requireContext().getResources().getDimensionPixelSize(R.dimen.activity_vertical_margin);
recyclerView.addItemDecoration(new ItemGapDecoration(gap));
recyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
recyclerAdapter = new RecyclerSinkAdapter();
recyclerView.setAdapter(recyclerAdapter);
}
return systemVolumeFragmentBinding.getRoot();
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
connectToPlugin(getDeviceId());
}
@Override
public void onDetach() {
super.onDetach();
disconnectFromPlugin(getDeviceId());
}
@Override
public void updateSink(final Sink sink) {
// Don't set progress while the slider is moved
if (!tracking) {
requireActivity().runOnUiThread(() -> recyclerAdapter.notifyDataSetChanged());
}
}
private void connectToPlugin(final String deviceId) {
BackgroundService.RunWithPlugin(requireActivity(), deviceId, SystemVolumePlugin.class, plugin -> {
this.plugin = plugin;
plugin.addSinkListener(SystemVolumeFragment.this);
plugin.requestSinkList();
});
}
private void disconnectFromPlugin(final String deviceId) {
BackgroundService.RunWithPlugin(requireActivity(), deviceId, SystemVolumePlugin.class, plugin ->
plugin.removeSinkListener(SystemVolumeFragment.this));
}
@Override
public void sinksChanged() {
for (Sink sink : plugin.getSinks()) {
sink.addListener(SystemVolumeFragment.this);
}
requireActivity().runOnUiThread(() -> {
List<Sink> newSinks = new ArrayList<>(plugin.getSinks());
recyclerAdapter.submitList(newSinks);
});
}
@Override
public void onVolumeUp() {
updateDefaultSinkVolume(5);
}
@Override
public void onVolumeDown() {
updateDefaultSinkVolume(-5);
}
private void updateDefaultSinkVolume(int percent) {
if (percent < -100) percent = -100;
if (percent > 100) percent = 100;
Optional<Sink> foundSink = plugin.getSinks().stream().filter(Sink::isDefault).findFirst();
if (foundSink.isPresent()) {
Sink defaultSink = foundSink.get();
int step = defaultSink.getMaxVolume() * percent / 100;
int newVolume = defaultSink.getVolume() + step;
if (newVolume > defaultSink.getMaxVolume()) {
newVolume = defaultSink.getMaxVolume();
} else if (newVolume < 0) {
newVolume = 0;
}
if (defaultSink.getVolume() == newVolume) return;
plugin.sendVolume(defaultSink.getName(), newVolume);
}
}
private String getDeviceId() {
return requireArguments().getString(MprisPlugin.DEVICE_ID_KEY);
}
private class RecyclerSinkAdapter extends ListAdapter<Sink, SinkItemHolder> {
public RecyclerSinkAdapter() {
super(new SinkItemCallback());
}
@NonNull
@Override
public SinkItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = getLayoutInflater();
ListItemSystemvolumeBinding viewBinding = ListItemSystemvolumeBinding.inflate(inflater, parent, false);
return new SinkItemHolder(viewBinding, plugin, trackingConsumer);
}
@Override
public void onBindViewHolder(@NonNull SinkItemHolder holder, int position) {
holder.bind(getItem(position));
}
}
}

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SystemvolumePlugin;
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import android.util.Log;
@ -79,6 +79,9 @@ public class SystemVolumePlugin extends Plugin {
if (np.has("muted")) {
sinks.get(name).setMute(np.getBoolean("muted"));
}
if (np.has("enabled")) {
sinks.get(name).setDefault(np.getBoolean("enabled"));
}
}
}
return true;
@ -98,6 +101,13 @@ public class SystemVolumePlugin extends Plugin {
device.sendPacket(np);
}
void sendEnable(String name) {
NetworkPacket np = new NetworkPacket(PACKET_TYPE_SYSTEMVOLUME_REQUEST);
np.set("enabled", true);
np.set("name", name);
device.sendPacket(np);
}
void requestSinkList() {
NetworkPacket np = new NetworkPacket(PACKET_TYPE_SYSTEMVOLUME_REQUEST);
np.set("requestSinks", true);

View File

@ -1,139 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Nicolas Fella <nicolas.fella@gmx.de>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SystemvolumePlugin;
import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageButton;
import android.widget.SeekBar;
import android.widget.TextView;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect_tp.R;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.ListFragment;
public class SystemvolumeFragment extends ListFragment implements Sink.UpdateListener, SystemVolumePlugin.SinkListener {
private SystemVolumePlugin plugin;
private Activity activity;
private SinkAdapter adapter;
private Context context;
private boolean tracking;
@Override
public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
getListView().setDivider(null);
setListAdapter(new SinkAdapter(getContext(), new Sink[0]));
}
@Override
public void updateSink(final Sink sink) {
// Don't set progress while the slider is moved
if (!tracking) {
activity.runOnUiThread(() -> adapter.notifyDataSetChanged());
}
}
public void connectToPlugin(final String deviceId) {
BackgroundService.RunWithPlugin(activity, deviceId, SystemVolumePlugin.class, plugin -> {
this.plugin = plugin;
plugin.addSinkListener(SystemvolumeFragment.this);
plugin.requestSinkList();
});
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
activity = getActivity();
this.context = context;
}
@Override
public void sinksChanged() {
for (Sink sink : plugin.getSinks()) {
sink.addListener(SystemvolumeFragment.this);
}
activity.runOnUiThread(() -> {
adapter = new SinkAdapter(context, plugin.getSinks().toArray(new Sink[0]));
setListAdapter(adapter);
});
}
private class SinkAdapter extends ArrayAdapter<Sink> {
private SinkAdapter(@NonNull Context context, @NonNull Sink[] objects) {
super(context, R.layout.list_item_systemvolume, objects);
}
@NonNull
@Override
public View getView(final int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = getLayoutInflater().inflate(R.layout.list_item_systemvolume, parent, false);
UIListener listener = new UIListener(getItem(position));
((TextView) view.findViewById(R.id.systemvolume_label)).setText(getItem(position).getDescription());
final SeekBar seekBar = view.findViewById(R.id.systemvolume_seek);
seekBar.setMax(getItem(position).getMaxVolume());
seekBar.setProgress(getItem(position).getVolume());
seekBar.setOnSeekBarChangeListener(listener);
ImageButton button = view.findViewById(R.id.systemvolume_mute);
int iconRes = getItem(position).isMute() ? R.drawable.ic_volume_mute_black : R.drawable.ic_volume_black;
button.setImageResource(iconRes);
button.setOnClickListener(listener);
return view;
}
}
private class UIListener implements SeekBar.OnSeekBarChangeListener, ImageButton.OnClickListener {
private final Sink sink;
private UIListener(Sink sink) {
this.sink = sink;
}
@Override
public void onProgressChanged(final SeekBar seekBar, int i, boolean b) {
BackgroundService.RunCommand(activity, service -> plugin.sendVolume(sink.getName(), seekBar.getProgress()));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
tracking = true;
}
@Override
public void onStopTrackingTouch(final SeekBar seekBar) {
tracking = false;
BackgroundService.RunCommand(activity, service -> plugin.sendVolume(sink.getName(), seekBar.getProgress()));
}
@Override
public void onClick(View view) {
plugin.sendMute(sink.getName(), !sink.isMute());
}
}
}