Merge remote-tracking branch 'origin/develop' into 4.8.0

This commit is contained in:
tzugen 2023-09-30 14:12:59 +02:00
commit 7f4f944d79
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
29 changed files with 475 additions and 620 deletions

View File

@ -0,0 +1,9 @@
### Features
- Added custom buttons for shuffling the current queue and setting repeat mode (Android Auto)
- Properly handling nested directory structures (Android Auto)
- Add a toast when adding tracks to the playlist
- Allow pinning when offline
### Dependencies
- Update koin
- Update media3 to v1.1.0

View File

@ -2,45 +2,45 @@
# You need to run ./gradlew wrapper after updating the version
gradle = "8.1.1"
navigation = "2.6.0"
gradlePlugin = "8.1.0"
navigation = "2.7.3"
gradlePlugin = "8.1.2"
androidxcar = "1.2.0"
androidxcore = "1.10.1"
androidxcore = "1.12.0"
ktlint = "0.43.2"
ktlintGradle = "11.5.0"
ktlintGradle = "11.6.0"
detekt = "1.23.0"
preferences = "1.2.1"
media3 = "1.1.0"
media3 = "1.1.1"
androidSupport = "1.6.0"
androidSupport = "1.7.0"
materialDesign = "1.9.0"
constraintLayout = "2.1.4"
multidex = "2.0.1"
room = "2.5.2"
kotlin = "1.8.22"
ksp = "1.8.22-1.0.11"
kotlin = "1.9.10"
ksp = "1.9.10-1.0.13"
kotlinxCoroutines = "1.7.3"
viewModelKtx = "2.6.1"
viewModelKtx = "2.6.2"
swipeRefresh = "1.1.0"
retrofit = "2.9.0"
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
jackson = "2.13.5"
okhttp = "4.11.0"
koin = "3.4.3"
koin = "3.5.0"
picasso = "2.8"
junit4 = "4.13.2"
junit5 = "5.10.0"
mockito = "5.4.0"
mockitoKotlin = "5.0.0"
mockito = "5.5.0"
mockitoKotlin = "5.1.0"
kluent = "1.73"
apacheCodecs = "1.16.0"
robolectric = "4.10.3"
timber = "5.0.1"
fastScroll = "2.0.1"
colorPicker = "2.2.4"
rxJava = "3.1.6"
rxJava = "3.1.8"
rxAndroid = "3.0.2"
multiType = "4.3.0"

View File

@ -1,5 +1,5 @@
ext.versions = [
minSdk : 21,
targetSdk : 33,
compileSdk : 33,
]
compileSdk : 34,
]

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

3
gradlew vendored
View File

@ -83,7 +83,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum

View File

@ -1,38 +0,0 @@
package org.moire.ultrasonic.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import timber.log.Timber;
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
public class UltrasonicIntentReceiver extends BroadcastReceiver
{
private Lazy<MediaPlayerLifecycleSupport> lifecycleSupport = inject(MediaPlayerLifecycleSupport.class);
@Override
public void onReceive(Context context, Intent intent)
{
String intentAction = intent.getAction();
Timber.i("Received Ultrasonic Intent: %s", intentAction);
try
{
lifecycleSupport.getValue().receiveIntent(intent);
if (isOrderedBroadcast())
{
abortBroadcast();
}
}
catch (Exception x)
{
// Ignored.
}
}
}

View File

@ -1,53 +0,0 @@
package org.moire.ultrasonic.service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import org.moire.ultrasonic.app.UApp;
import timber.log.Timber;
/**
* Monitors the state of the mobile's external storage
*/
public class ExternalStorageMonitor
{
private BroadcastReceiver ejectEventReceiver;
private boolean externalStorageAvailable = true;
public void onCreate(final Runnable ejectedCallback)
{
// Stop when SD card is ejected.
ejectEventReceiver = new BroadcastReceiver()
{
@Override
public void onReceive(Context context, Intent intent)
{
externalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED.equals(intent.getAction());
if (!externalStorageAvailable)
{
Timber.i("External media is ejecting. Stopping playback.");
ejectedCallback.run();
}
else
{
Timber.i("External media is available.");
}
}
};
IntentFilter ejectFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT);
ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED);
ejectFilter.addDataScheme("file");
UApp.Companion.applicationContext().registerReceiver(ejectEventReceiver, ejectFilter);
}
public void onDestroy()
{
UApp.Companion.applicationContext().unregisterReceiver(ejectEventReceiver);
}
public boolean isExternalStorageAvailable() { return externalStorageAvailable; }
}

View File

@ -1,50 +0,0 @@
package org.moire.ultrasonic.service;
import timber.log.Timber;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.Track;
/**
* Scrobbles played songs to Last.fm.
*
* @author Sindre Mehus
* @version $Id$
*/
public class Scrobbler
{
private String lastSubmission;
private String lastNowPlaying;
public void scrobble(final Track song, final boolean submission)
{
if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return;
final String id = song.getId();
// Avoid duplicate registrations.
if (submission && id.equals(lastSubmission)) return;
if (!submission && id.equals(lastNowPlaying)) return;
if (submission) lastSubmission = id;
else lastNowPlaying = id;
new Thread(String.format("Scrobble %s", song))
{
@Override
public void run()
{
MusicService service = MusicServiceFactory.getMusicService();
try
{
service.scrobble(id, submission);
Timber.i("Scrobbled '%s' for %s", submission ? "submission" : "now playing", song);
}
catch (Exception x)
{
Timber.i(x, "Failed to scrobble'%s' for %s", submission ? "submission" : "now playing", song);
}
}
}.start();
}
}

View File

@ -1,12 +0,0 @@
package org.moire.ultrasonic.service;
/**
* Abstract class for supplying items to a consumer
* @param <T> The type of the item supplied
*/
public abstract class Supplier<T>
{
public abstract T get();
}

View File

@ -1,159 +0,0 @@
package org.moire.ultrasonic.view;
import android.content.Context;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.util.Linkify;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.ChatMessage;
import org.moire.ultrasonic.imageloader.ImageLoader;
import org.moire.ultrasonic.subsonic.ImageLoaderProvider;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.regex.Pattern;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
public class ChatAdapter extends ArrayAdapter<ChatMessage>
{
private final Context context;
private final List<ChatMessage> messages;
private static final String phoneRegex = "1?\\W*([2-9][0-8][0-9])\\W*([2-9][0-9]{2})\\W*([0-9]{4})";
private static final Pattern phoneMatcher = Pattern.compile(phoneRegex);
private final Lazy<ActiveServerProvider> activeServerProvider = inject(ActiveServerProvider.class);
private final Lazy<ImageLoaderProvider> imageLoaderProvider = inject(ImageLoaderProvider.class);
public ChatAdapter(Context context, List<ChatMessage> messages)
{
super(context, R.layout.chat_item, messages);
this.context = context;
this.messages = messages;
}
@Override
public boolean areAllItemsEnabled() {
return true;
}
@Override
public boolean isEnabled(int position) {
return false;
}
@Override
public int getCount()
{
return messages.size();
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
ChatMessage message = this.getItem(position);
ViewHolder holder;
int layout;
String messageUser = message.getUsername();
Date messageTime = new java.util.Date(message.getTime());
String messageText = message.getMessage();
String me = activeServerProvider.getValue().getActiveServer().getUserName();
layout = messageUser.equals(me) ? R.layout.chat_item_reverse : R.layout.chat_item;
if (convertView == null)
{
convertView = inflateView(layout, parent);
holder = createViewHolder(layout, convertView);
}
else
{
holder = (ViewHolder) convertView.getTag();
if (!holder.chatMessage.equals(message))
{
convertView = inflateView(layout, parent);
holder = createViewHolder(layout, convertView);
}
}
holder.chatMessage = message;
DateFormat timeFormat = android.text.format.DateFormat.getTimeFormat(context);
String messageTimeFormatted = String.format("[%s]", timeFormat.format(messageTime));
ImageLoader imageLoader = imageLoaderProvider.getValue().getImageLoader();
if (holder.avatar != null && !TextUtils.isEmpty(messageUser))
{
imageLoader.loadAvatarImage(holder.avatar, messageUser);
}
holder.username.setText(messageUser);
holder.message.setText(messageText);
holder.time.setText(messageTimeFormatted);
return convertView;
}
private View inflateView(int layout, ViewGroup parent)
{
return LayoutInflater.from(context).inflate(layout, parent, false);
}
private static ViewHolder createViewHolder(int layout, View convertView)
{
ViewHolder holder = new ViewHolder();
holder.layout = layout;
TextView usernameView;
TextView timeView;
TextView messageView;
ImageView imageView;
if (convertView != null)
{
usernameView = (TextView) convertView.findViewById(R.id.chat_username);
timeView = (TextView) convertView.findViewById(R.id.chat_time);
messageView = (TextView) convertView.findViewById(R.id.chat_message);
imageView = (ImageView) convertView.findViewById(R.id.chat_avatar);
messageView.setMovementMethod(LinkMovementMethod.getInstance());
Linkify.addLinks(messageView, Linkify.ALL);
Linkify.addLinks(messageView, phoneMatcher, "tel:");
holder.avatar = imageView;
holder.message = messageView;
holder.username = usernameView;
holder.time = timeView;
convertView.setTag(holder);
}
return holder;
}
private static class ViewHolder
{
int layout;
ImageView avatar;
TextView message;
TextView username;
TextView time;
ChatMessage chatMessage;
}
}

View File

@ -1,110 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.view;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.SectionIndexer;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Genre;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
/**
* @author Sindre Mehus
*/
public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
{
private final LayoutInflater layoutInflater;
// Both arrays are indexed by section ID.
private final Object[] sections;
private final Integer[] positions;
public GenreAdapter(@NonNull Context context, List<Genre> genres)
{
super(context, R.layout.list_item_generic, genres);
layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
Collection<String> sectionSet = new LinkedHashSet<String>(30);
List<Integer> positionList = new ArrayList<Integer>(30);
for (int i = 0; i < genres.size(); i++)
{
Genre genre = genres.get(i);
String index = genre.getIndex();
if (!sectionSet.contains(index))
{
sectionSet.add(index);
positionList.add(i);
}
}
sections = sectionSet.toArray(new Object[0]);
positions = positionList.toArray(new Integer[0]);
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View rowView = convertView;
if (rowView == null) {
rowView = layoutInflater.inflate(R.layout.list_item_generic, parent, false);
}
((TextView) rowView).setText(getItem(position).getName());
return rowView;
}
@Override
public Object[] getSections()
{
return sections;
}
@Override
public int getPositionForSection(int section)
{
return positions[section];
}
@Override
public int getSectionForPosition(int pos)
{
for (int i = 0; i < sections.length - 1; i++)
{
if (pos < positions[i + 1])
{
return i;
}
}
return sections.length - 1;
}
}

View File

@ -1,56 +0,0 @@
package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Share;
import java.util.List;
/**
* @author Sindre Mehus
*/
public class ShareAdapter extends ArrayAdapter<Share>
{
private final Context context;
public ShareAdapter(Context context, List<Share> Shares)
{
super(context, R.layout.share_list_item, Shares);
this.context = context;
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
Share entry = getItem(position);
ShareView view;
if (convertView instanceof ShareView)
{
ShareView currentView = (ShareView) convertView;
ViewHolder viewHolder = (ViewHolder) convertView.getTag();
view = currentView;
view.setViewHolder(viewHolder);
}
else
{
view = new ShareView(context);
view.setLayout();
}
view.setShare(entry);
return view;
}
static class ViewHolder
{
TextView url;
TextView description;
}
}

View File

@ -1,64 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.LayoutInflater;
import android.widget.LinearLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Share;
/**
* Used to display playlists in a {@code ListView}.
*
* @author Joshua Bahnsen
*/
public class ShareView extends LinearLayout
{
private final Context context;
private ShareAdapter.ViewHolder viewHolder;
public ShareView(Context context)
{
super(context);
this.context = context;
}
public void setLayout()
{
LayoutInflater.from(context).inflate(R.layout.share_list_item, this, true);
viewHolder = new ShareAdapter.ViewHolder();
viewHolder.url = findViewById(R.id.share_url);
viewHolder.description = findViewById(R.id.share_description);
setTag(viewHolder);
}
public void setViewHolder(ShareAdapter.ViewHolder viewHolder)
{
this.viewHolder = viewHolder;
setTag(this.viewHolder);
}
public void setShare(Share share)
{
viewHolder.url.setText(share.getName());
viewHolder.description.setText(share.getDescription());
}
}

View File

@ -1219,14 +1219,14 @@ class PlayerFragment :
@Suppress("ReturnCount")
override fun onFling(
e1: MotionEvent,
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
val e1X = e1.x
val e1X = e1?.x ?: 0F
val e2X = e2.x
val e1Y = e1.y
val e1Y = e1?.y ?: 0F
val e2Y = e2.y
val absX = abs(velocityX)
val absY = abs(velocityY)
@ -1263,7 +1263,7 @@ class PlayerFragment :
override fun onLongPress(e: MotionEvent) {}
override fun onScroll(
e1: MotionEvent,
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float

View File

@ -108,7 +108,9 @@ class SettingsFragment :
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == null || sharedPreferences == null) return
Timber.d("Preference changed: %s", key)
updateCustomPreferences()

View File

@ -1,6 +1,6 @@
/*
* SharesFragment.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
@ -58,7 +58,7 @@ class SharesFragment : Fragment(), KoinComponent {
private var sharesListView: ListView? = null
private var emptyTextView: View? = null
private var shareAdapter: ShareAdapter? = null
private val downloadHandler = inject<DownloadHandler>()
private val downloadHandler: DownloadHandler by inject()
private var cancellationToken: CancellationToken? = null
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
@ -110,8 +110,9 @@ class SharesFragment : Fragment(), KoinComponent {
}
override fun done(result: List<Share>) {
sharesListView!!.adapter = ShareAdapter(context, result).also { shareAdapter = it }
emptyTextView!!.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
shareAdapter = ShareAdapter(requireContext(), result)
sharesListView?.adapter = shareAdapter
emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE
}
}
task.execute()
@ -132,7 +133,7 @@ class SharesFragment : Fragment(), KoinComponent {
val share = sharesListView!!.getItemAtPosition(info.position) as Share
when (menuItem.itemId) {
R.id.share_menu_pin -> {
downloadHandler.value.justDownload(
downloadHandler.justDownload(
DownloadAction.PIN,
fragment = this,
id = share.id,
@ -142,7 +143,7 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_unpin -> {
downloadHandler.value.justDownload(
downloadHandler.justDownload(
DownloadAction.UNPIN,
fragment = this,
id = share.id,
@ -152,7 +153,7 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_download -> {
downloadHandler.value.justDownload(
downloadHandler.justDownload(
DownloadAction.DOWNLOAD,
fragment = this,
id = share.id,
@ -162,7 +163,7 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_play_now -> {
downloadHandler.value.fetchTracksAndAddToController(
downloadHandler.fetchTracksAndAddToController(
this,
share.id,
share.name,
@ -172,7 +173,7 @@ class SharesFragment : Fragment(), KoinComponent {
)
}
R.id.share_menu_play_shuffled -> {
downloadHandler.value.fetchTracksAndAddToController(
downloadHandler.fetchTracksAndAddToController(
this,
share.id,
share.name,

View File

@ -1,6 +1,6 @@
/*
* BluetoothIntentReceiver.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
@ -10,9 +10,6 @@ package org.moire.ultrasonic.receiver
import android.Manifest
import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.ACTION_ACL_CONNECTED
import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECTED
import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
@ -22,9 +19,6 @@ import android.os.Build
import androidx.core.app.ActivityCompat
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_A2DP
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_ALL
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_DISABLED
import org.moire.ultrasonic.util.Settings
import timber.log.Timber
@ -42,27 +36,27 @@ class BluetoothIntentReceiver : BroadcastReceiver() {
Timber.d("Bluetooth device: $name; State: $state; Action: $action")
// In these flags we store what kind of device (any or a2dp) has (dis)connected
var connectionStatus = PREFERENCE_VALUE_DISABLED
var disconnectionStatus = PREFERENCE_VALUE_DISABLED
var connectionStatus = Constants.PREFERENCE_VALUE_DISABLED
var disconnectionStatus = Constants.PREFERENCE_VALUE_DISABLED
// First check for general devices
when (action) {
ACTION_ACL_CONNECTED -> {
connectionStatus = PREFERENCE_VALUE_ALL
BluetoothDevice.ACTION_ACL_CONNECTED -> {
connectionStatus = Constants.PREFERENCE_VALUE_ALL
}
ACTION_ACL_DISCONNECTED,
ACTION_ACL_DISCONNECT_REQUESTED -> {
disconnectionStatus = PREFERENCE_VALUE_ALL
BluetoothDevice.ACTION_ACL_DISCONNECTED,
BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED -> {
disconnectionStatus = Constants.PREFERENCE_VALUE_ALL
}
}
// Then check for A2DP devices
when (state) {
BluetoothA2dp.STATE_CONNECTED -> {
connectionStatus = PREFERENCE_VALUE_A2DP
connectionStatus = Constants.PREFERENCE_VALUE_A2DP
}
BluetoothA2dp.STATE_DISCONNECTED -> {
disconnectionStatus = PREFERENCE_VALUE_A2DP
disconnectionStatus = Constants.PREFERENCE_VALUE_A2DP
}
}
@ -72,20 +66,20 @@ class BluetoothIntentReceiver : BroadcastReceiver() {
// Now check the settings and set the appropriate flags
when (Settings.resumeOnBluetoothDevice) {
PREFERENCE_VALUE_ALL -> {
shouldResume = (connectionStatus != PREFERENCE_VALUE_DISABLED)
Constants.PREFERENCE_VALUE_ALL -> {
shouldResume = (connectionStatus != Constants.PREFERENCE_VALUE_DISABLED)
}
PREFERENCE_VALUE_A2DP -> {
shouldResume = (connectionStatus == PREFERENCE_VALUE_A2DP)
Constants.PREFERENCE_VALUE_A2DP -> {
shouldResume = (connectionStatus == Constants.PREFERENCE_VALUE_A2DP)
}
}
when (Settings.pauseOnBluetoothDevice) {
PREFERENCE_VALUE_ALL -> {
shouldPause = (disconnectionStatus != PREFERENCE_VALUE_DISABLED)
Constants.PREFERENCE_VALUE_ALL -> {
shouldPause = (disconnectionStatus != Constants.PREFERENCE_VALUE_DISABLED)
}
PREFERENCE_VALUE_A2DP -> {
shouldPause = (disconnectionStatus == PREFERENCE_VALUE_A2DP)
Constants.PREFERENCE_VALUE_A2DP -> {
shouldPause = (disconnectionStatus == Constants.PREFERENCE_VALUE_A2DP)
}
}
@ -105,24 +99,24 @@ class BluetoothIntentReceiver : BroadcastReceiver() {
)
}
}
}
private fun BluetoothDevice?.getNameSafely(): String? {
val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(
ActivityCompat.checkSelfPermission(
UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
)
private fun BluetoothDevice?.getNameSafely(): String? {
val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(
ActivityCompat.checkSelfPermission(
UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
)
return if (logBluetoothName) this?.name else "Unknown"
}
return if (logBluetoothName) this?.name else "Unknown"
}
private fun Intent.getBluetoothDevice(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
private fun Intent.getBluetoothDevice(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
}
}

View File

@ -0,0 +1,34 @@
/*
* UltrasonicIntentReceiver.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import timber.log.Timber
class UltrasonicIntentReceiver : BroadcastReceiver() {
private val lifecycleSupport = inject<MediaPlayerLifecycleSupport>(
MediaPlayerLifecycleSupport::class.java
)
override fun onReceive(context: Context, intent: Intent) {
val intentAction = intent.action
Timber.i("Received Ultrasonic Intent: %s", intentAction)
try {
lifecycleSupport.value.receiveIntent(intent)
if (isOrderedBroadcast) {
abortBroadcast()
}
} catch (_: Exception) {
// Ignored.
}
}
}

View File

@ -0,0 +1,47 @@
/*
* ExternalStorageMonitor.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import timber.log.Timber
/**
* Monitors the state of the mobile's external storage
*/
class ExternalStorageMonitor {
private var ejectEventReceiver: BroadcastReceiver? = null
var isExternalStorageAvailable = true
private set
fun onCreate(ejectedCallback: Runnable) {
// Stop when SD card is ejected.
ejectEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
isExternalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED == intent.action
if (!isExternalStorageAvailable) {
Timber.i("External media is ejecting. Stopping playback.")
ejectedCallback.run()
} else {
Timber.i("External media is available.")
}
}
}
val ejectFilter = IntentFilter(Intent.ACTION_MEDIA_EJECT)
ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED)
ejectFilter.addDataScheme("file")
applicationContext().registerReceiver(ejectEventReceiver, ejectFilter)
}
fun onDestroy() {
applicationContext().unregisterReceiver(ejectEventReceiver)
}
}

View File

@ -286,6 +286,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
@Deprecated("Deprecated in Java")
override fun setDeviceVolume(volume: Int) {
setDeviceVolume(volume, 0)
}

View File

@ -91,10 +91,12 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun hasPrevious(): Boolean {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun hasPreviousWindow(): Boolean {
TODO("Not yet implemented")
}
@ -103,10 +105,12 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun previous() {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun seekToPreviousWindow() {
TODO("Not yet implemented")
}
@ -115,10 +119,12 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun hasNext(): Boolean {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun hasNextWindow(): Boolean {
TODO("Not yet implemented")
}
@ -127,10 +133,12 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun next() {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun seekToNextWindow() {
TODO("Not yet implemented")
}
@ -163,10 +171,12 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun getCurrentWindowIndex(): Int {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun getNextWindowIndex(): Int {
TODO("Not yet implemented")
}
@ -175,6 +185,7 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun getPreviousWindowIndex(): Int {
TODO("Not yet implemented")
}
@ -183,14 +194,17 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun isCurrentWindowDynamic(): Boolean {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun isCurrentWindowLive(): Boolean {
TODO("Not yet implemented")
}
@Deprecated("Deprecated in Java")
override fun isCurrentWindowSeekable(): Boolean {
TODO("Not yet implemented")
}

View File

@ -0,0 +1,52 @@
/*
* Scrobbler.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isScrobblingEnabled
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import timber.log.Timber
/**
* Scrobbles played songs to Last.fm.
*/
class Scrobbler : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private var lastSubmission: String? = null
private var lastNowPlaying: String? = null
fun scrobble(song: Track?, submission: Boolean) {
if (song == null || !isScrobblingEnabled()) return
val id = song.id
// Avoid duplicate registrations.
if (submission && id == lastSubmission) return
if (!submission && id == lastNowPlaying) return
if (submission) lastSubmission = id else lastNowPlaying = id
launch {
val service = getMusicService()
try {
service.scrobble(id, submission)
Timber.i(
"Scrobbled '%s' for %s",
if (submission) "submission" else "now playing",
song
)
} catch (all: Exception) {
Timber.i(
all,
"Failed to scrobble'%s' for %s",
if (submission) "submission" else "now playing",
song
)
}
}
}
}

View File

@ -0,0 +1,108 @@
/*
* ChatAdapter.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.view
import android.content.Context
import android.text.TextUtils
import android.text.format.DateFormat
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.TextView
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
class ChatAdapter(private val context: Context, private val messages: List<ChatMessage>) :
ArrayAdapter<ChatMessage>(
context, R.layout.chat_item, messages
),
KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
override fun areAllItemsEnabled(): Boolean {
return true
}
override fun isEnabled(position: Int): Boolean {
return false
}
override fun getCount(): Int {
return messages.size
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var view = convertView
val message = getItem(position)
val holder: ViewHolder
val layout: Int
val me = activeServerProvider.getActiveServer().userName
// Different layouts based on the message sender/recipient
layout = if (message?.username == me) R.layout.chat_item_reverse else R.layout.chat_item
if (view == null || (view.tag as ViewHolder).layout != layout) {
view = inflateView(layout, parent)
holder = ViewHolder()
} else {
holder = view.tag as ViewHolder
}
linkHolder(holder, layout, view)
if (message != null) setData(holder, message)
return view
}
private fun inflateView(layout: Int, parent: ViewGroup): View {
return LayoutInflater.from(context).inflate(layout, parent, false)
}
private class ViewHolder {
var layout = R.layout.chat_item
var avatar: ImageView? = null
var message: TextView? = null
var username: TextView? = null
var time: TextView? = null
var chatMessage: ChatMessage? = null
}
private fun linkHolder(holder: ViewHolder, layout: Int, view: View) {
holder.layout = layout
holder.avatar = view.findViewById(R.id.chat_avatar)
holder.message = view.findViewById(R.id.chat_message)
holder.message?.movementMethod = LinkMovementMethod.getInstance()
holder.username = view.findViewById(R.id.chat_username)
holder.time = view.findViewById(R.id.chat_time)
view.tag = holder
}
private fun setData(
holder: ViewHolder,
message: ChatMessage
) {
holder.chatMessage = message
val timeFormat = DateFormat.getTimeFormat(context)
val messageTimeFormatted = "[${timeFormat.format(message.time)}]"
val imageLoader = imageLoaderProvider.getImageLoader()
if (holder.avatar != null && !TextUtils.isEmpty(message.username)) {
imageLoader.loadAvatarImage(holder.avatar!!, message.username)
}
holder.username?.text = message.username
holder.message?.text = message.message
holder.time?.text = messageTimeFormatted
}
}

View File

@ -0,0 +1,71 @@
/*
* GenreAdapter.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.view
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.SectionIndexer
import android.widget.TextView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Genre
class GenreAdapter(context: Context, genres: List<Genre>) :
ArrayAdapter<Genre?>(context, R.layout.list_item_generic, genres), SectionIndexer {
private val layoutInflater: LayoutInflater
// Both arrays are indexed by section ID.
private val sections: Array<Any>
private val positions: Array<Int>
init {
layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val sectionSet: MutableCollection<String> = LinkedHashSet(INITIAL_CAPACITY)
val positionList: MutableList<Int> = ArrayList(INITIAL_CAPACITY)
for (i in genres.indices) {
val (index) = genres[i]
if (!sectionSet.contains(index)) {
sectionSet.add(index)
positionList.add(i)
}
}
sections = sectionSet.toTypedArray()
positions = positionList.toTypedArray()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
var rowView = convertView
if (rowView == null) {
rowView = layoutInflater.inflate(R.layout.list_item_generic, parent, false)
}
(rowView as TextView?)!!.text = getItem(position)!!.name
return rowView!!
}
override fun getSections(): Array<Any> {
return sections
}
override fun getPositionForSection(section: Int): Int {
return positions[section]
}
override fun getSectionForPosition(pos: Int): Int {
for (i in 0 until sections.size - 1) {
if (pos < positions[i + 1]) {
return i
}
}
return sections.size - 1
}
companion object {
const val INITIAL_CAPACITY: Int = 30
}
}

View File

@ -0,0 +1,53 @@
/*
* ShareAdapter.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.view
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Share
class ShareAdapter(private val context: Context, shares: List<Share>) : ArrayAdapter<Share>(
context, R.layout.share_list_item, shares
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val entry = getItem(position)
val view: View
val holder: ViewHolder
if (convertView == null) {
holder = ViewHolder()
val inflater = LayoutInflater.from(context)
view = inflater.inflate(R.layout.share_list_item, parent, false)
holder.url = view.findViewById(R.id.share_url)
holder.description = view.findViewById(R.id.share_description)
view.tag = holder
} else {
view = convertView
holder = view.tag as ViewHolder
}
if (entry != null) setData(entry, holder)
return view
}
private fun setData(entry: Share, holder: ViewHolder) {
holder.url?.text = entry.name
holder.description?.text = entry.description
}
class ViewHolder {
var url: TextView? = null
var description: TextView? = null
}
}

View File

@ -1,15 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
a:layout_width="0dp"
a:layout_width="match_parent"
a:layout_height="56dp"
a:layout_gravity="center_vertical"
a:layout_weight="1"
a:minHeight="44dip"
a:orientation="vertical"
a:paddingStart="16dip"
a:paddingEnd="16dip"
tools:layout_width="match_parent">
a:paddingEnd="16dip">
<LinearLayout
a:layout_width="fill_parent"

View File

@ -8,7 +8,7 @@
<string name="background_task.parse_error">Antwort nicht verstanden. Bitte die Serveradresse überprüfen.</string>
<string name="background_task.ssl_cert_error">HTTPS Zertifikatsfehler: %1$s.</string>
<string name="background_task.ssl_error">SSL Verbindungsfehler. Bitte das Serverzertifikat überprüfen.</string>
<string name="background_task.wait">Bitte warten… </string>
<string name="background_task.wait">Bitte warten…</string>
<string name="button_bar.bookmarks">Lesezeichen</string>
<string name="button_bar.browse">Medienbibliothek</string>
<string name="button_bar.chat">Chat</string>
@ -59,8 +59,8 @@
<string name="download.jukebox_not_authorized">Fernbedienung ist nicht erlaubt. Bitte Jukebox Modus auf dem Subsonic Server in <b>Benutzer &gt; Einstellungen</b> aktivieren.</string>
<string name="download.jukebox_off">Fernbedienung ausgeschaltet. Musik wird auf dem Telefon wiedergegeben.</string>
<string name="download.jukebox_offline">Fernbedienungs-Modus is Offline nicht verfügbar.</string>
<string name="download.jukebox_on">Fernbedienung ausgeschaltet. Musik wird auf dem Server wiedergegeben.</string>
<string name="download.jukebox_server_too_old">Fernbedienungs Modus wird nicht unterstützt. Bitte den Subsonic Server aktualisieren.</string>
<string name="download.jukebox_on">Fernbedienung eingeschaltet. Musik wird auf dem Server wiedergegeben.</string>
<string name="download.jukebox_server_too_old">Fernbedienungs Modus wird nicht unterstützt. Bitte aktualisiere den Subsonic Server.</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox Aus</string>
<string name="download.menu_jukebox_on">Jukebox An</string>
@ -443,9 +443,15 @@
<string name="albumArt">Album-Artwork</string>
<string name="notification.permission_required">Benachrichtigungen sind für die Medienwiedergabe erforderlich. Du kannst die Erlaubnis jederzeit in den Android-Einstellungen erteilen.</string>
<string name="settings.use_hw_offload_title">Hardware-Wiedergabe verwenden (experimentell)</string>
<string name="settings.use_hw_offload_description">Versuche, die Medien mit dem Mediendecoder-Chip auf Deinem Telefon abzuspielen. Dadurch kann der Akku besser genutzt werden.</string>
<string name="settings.use_hw_offload_description">Versuche, die Medien mit dem Mediendecoder-Chip auf Deinem Telefon abzuspielen. Dadurch kann der Akku besser genutzt werden. Einige Nutzer berichten über Stotterer beim Abspielen, wenn diese Option aktiv ist.</string>
<string name="list_view">Liste</string>
<string name="grid_view">Cover</string>
<string name="supported_server_features">Unterstützte Funktionen</string>
<string name="jukebox">Jukebox</string>
<string name="shortcut_play_random_songs_long">Zufällige Titel abspielen</string>
<string name="shortcut_play_random_songs_short">Zufällige Titel</string>
<plurals name="n_songs_added_play_now">
<item quantity="one">Ein Titel wurde zur Wiedergabeliste hinzugefügt</item>
<item quantity="other">%d Titel wurden zur Wiedergabeliste hinzugefügt</item>
</plurals>
</resources>

View File

@ -455,4 +455,9 @@
<string name="shortcut_play_random_songs_short">Canciones aleatorias</string>
<string name="shortcut_play_random_songs_long">Reproducir las canciones aleatoriamente</string>
<string name="download.menu_unstar">No me gusta</string>
<plurals name="n_songs_added_play_now">
<item quantity="one">%d canción añadida a la cola de reproducción</item>
<item quantity="many">%d canciones añadidas a la cola de reproducción</item>
<item quantity="other">%d canciones añadidas a la cola de reproducción</item>
</plurals>
</resources>