Merge branch 'nitehu-refactor/artist_list' into develop

This commit is contained in:
Óscar García Amor 2020-11-29 11:11:30 +01:00
commit b471b9cad0
No known key found for this signature in database
GPG Key ID: E18B2370D3D566EE
27 changed files with 840 additions and 494 deletions

View File

@ -38,6 +38,7 @@ ext.versions = [
robolectric : "4.4",
dexter : "6.1.2",
timber : "4.7.1",
fastScroll : "2.0.1",
]
ext.gradlePlugins = [
@ -77,6 +78,7 @@ ext.other = [
picasso : "com.squareup.picasso:picasso:$versions.picasso",
dexter : "com.karumi:dexter:$versions.dexter",
timber : "com.jakewharton.timber:timber:$versions.timber",
fastScroll : "com.simplecityapps:recyclerview-fastscroll:$versions.fastScroll",
]
ext.testing = [

View File

@ -77,6 +77,7 @@ dependencies {
implementation other.koinAndroid
implementation other.koinViewModel
implementation other.okhttpLogging
implementation other.fastScroll
kapt androidSupport.room

View File

@ -1,374 +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.activity;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.TextView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.data.ServerSetting;
import org.moire.ultrasonic.domain.Artist;
import org.moire.ultrasonic.domain.Indexes;
import org.moire.ultrasonic.domain.MusicFolder;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import org.moire.ultrasonic.util.BackgroundTask;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.TabActivityBackgroundTask;
import org.moire.ultrasonic.util.Util;
import org.moire.ultrasonic.view.ArtistAdapter;
import java.util.ArrayList;
import java.util.List;
import kotlin.Lazy;
import static org.koin.android.viewmodel.compat.ViewModelCompat.viewModel;
import static org.koin.java.KoinJavaComponent.inject;
public class SelectArtistActivity extends SubsonicTabActivity implements AdapterView.OnItemClickListener
{
private Lazy<ActiveServerProvider> activeServerProvider = inject(ActiveServerProvider.class);
private Lazy<ServerSettingsModel> serverSettingsModel = viewModel(this, ServerSettingsModel.class);
private static final int MENU_GROUP_MUSIC_FOLDER = 10;
private SwipeRefreshLayout refreshArtistListView;
private ListView artistListView;
private View folderButton;
private TextView folderName;
private List<MusicFolder> musicFolders;
/**
* Called when the activity is first created.
*/
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.select_artist);
refreshArtistListView = findViewById(R.id.select_artist_refresh);
artistListView = findViewById(R.id.select_artist_list);
refreshArtistListView.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener()
{
@Override
public void onRefresh()
{
new GetDataTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
});
artistListView.setOnItemClickListener(this);
folderButton = LayoutInflater.from(this).inflate(R.layout.select_artist_header, artistListView, false);
if (folderButton != null)
{
folderName = (TextView) folderButton.findViewById(R.id.select_artist_folder_2);
}
if (!ActiveServerProvider.Companion.isOffline(this) && !Util.getShouldUseId3Tags(this))
{
artistListView.addHeaderView(folderButton);
}
registerForContextMenu(artistListView);
String title = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE);
if (title == null)
{
setActionBarSubtitle(ActiveServerProvider.Companion.isOffline(this) ? R.string.music_library_label_offline : R.string.music_library_label);
}
else
{
setActionBarSubtitle(title);
}
View browseMenuItem = findViewById(R.id.menu_browse);
menuDrawer.setActiveView(browseMenuItem);
musicFolders = null;
load();
}
@Override
public boolean onCreateOptionsMenu(Menu menu)
{
super.onCreateOptionsMenu(menu);
return true;
}
private void refresh()
{
finish();
Intent intent = getIntent();
String title = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE);
intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, title);
intent.putExtra(Constants.INTENT_EXTRA_NAME_REFRESH, true);
startActivityForResultWithoutTransition(this, intent);
}
private void selectFolder()
{
folderButton.showContextMenu();
}
private void load()
{
BackgroundTask<Indexes> task = new TabActivityBackgroundTask<Indexes>(this, true)
{
@Override
protected Indexes doInBackground() throws Throwable
{
boolean refresh = getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_REFRESH, false);
MusicService musicService = MusicServiceFactory.getMusicService(SelectArtistActivity.this);
boolean isOffline = ActiveServerProvider.Companion.isOffline(SelectArtistActivity.this);
boolean useId3Tags = Util.getShouldUseId3Tags(SelectArtistActivity.this);
if (!isOffline && !useId3Tags)
{
musicFolders = musicService.getMusicFolders(refresh, SelectArtistActivity.this, this);
}
String musicFolderId = activeServerProvider.getValue().getActiveServer().getMusicFolderId();
return !isOffline && useId3Tags ? musicService.getArtists(refresh, SelectArtistActivity.this, this) : musicService.getIndexes(musicFolderId, refresh, SelectArtistActivity.this, this);
}
@Override
protected void done(Indexes result)
{
if (result != null)
{
List<Artist> artists = new ArrayList<Artist>(result.getShortcuts().size() + result.getArtists().size());
artists.addAll(result.getShortcuts());
artists.addAll(result.getArtists());
artistListView.setAdapter(new ArtistAdapter(SelectArtistActivity.this, artists));
}
// Display selected music folder
if (musicFolders != null)
{
String musicFolderId = activeServerProvider.getValue().getActiveServer().getMusicFolderId();
if (musicFolderId == null || musicFolderId.equals(""))
{
if (folderName != null)
{
folderName.setText(R.string.select_artist_all_folders);
}
}
else
{
for (MusicFolder musicFolder : musicFolders)
{
if (musicFolder.getId().equals(musicFolderId))
{
if (folderName != null)
{
folderName.setText(musicFolder.getName());
}
break;
}
}
}
}
}
};
task.execute();
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id)
{
if (view == folderButton)
{
selectFolder();
}
else
{
Artist artist = (Artist) parent.getItemAtPosition(position);
if (artist != null)
{
Intent intent = new Intent(this, SelectAlbumActivity.class);
intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, artist.getId());
intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, artist.getName());
intent.putExtra(Constants.INTENT_EXTRA_NAME_PARENT_ID, artist.getId());
intent.putExtra(Constants.INTENT_EXTRA_NAME_ARTIST, true);
startActivityForResultWithoutTransition(this, intent);
}
}
}
@Override
public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo)
{
super.onCreateContextMenu(menu, view, menuInfo);
AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
if (artistListView.getItemAtPosition(info.position) instanceof Artist)
{
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.select_artist_context, menu);
}
else if (info.position == 0)
{
String musicFolderId = activeServerProvider.getValue().getActiveServer().getMusicFolderId();
MenuItem menuItem = menu.add(MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders);
if (musicFolderId == null || musicFolderId.isEmpty())
{
menuItem.setChecked(true);
}
if (musicFolders != null)
{
for (int i = 0; i < musicFolders.size(); i++)
{
MusicFolder musicFolder = musicFolders.get(i);
menuItem = menu.add(MENU_GROUP_MUSIC_FOLDER, i, i + 1, musicFolder.getName());
if (musicFolder.getId().equals(musicFolderId))
{
menuItem.setChecked(true);
}
}
}
menu.setGroupCheckable(MENU_GROUP_MUSIC_FOLDER, true, true);
}
MenuItem downloadMenuItem = menu.findItem(R.id.artist_menu_download);
if (downloadMenuItem != null)
{
downloadMenuItem.setVisible(!ActiveServerProvider.Companion.isOffline(this));
}
}
@Override
public boolean onContextItemSelected(MenuItem menuItem)
{
AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
if (info == null)
{
return true;
}
Artist artist = (Artist) artistListView.getItemAtPosition(info.position);
if (artist != null)
{
switch (menuItem.getItemId())
{
case R.id.artist_menu_play_now:
downloadRecursively(artist.getId(), false, false, true, false, false, false, false, true);
break;
case R.id.artist_menu_play_next:
downloadRecursively(artist.getId(), false, false, true, true, false, true, false, true);
break;
case R.id.artist_menu_play_last:
downloadRecursively(artist.getId(), false, true, false, false, false, false, false, true);
break;
case R.id.artist_menu_pin:
downloadRecursively(artist.getId(), true, true, false, false, false, false, false, true);
break;
case R.id.artist_menu_unpin:
downloadRecursively(artist.getId(), false, false, false, false, false, false, true, true);
break;
case R.id.artist_menu_download:
downloadRecursively(artist.getId(), false, false, false, false, true, false, false, true);
break;
default:
return super.onContextItemSelected(menuItem);
}
}
else if (info.position == 0)
{
MusicFolder selectedFolder = menuItem.getItemId() == -1 ? null : musicFolders.get(menuItem.getItemId());
String musicFolderId = selectedFolder == null ? null : selectedFolder.getId();
String musicFolderName = selectedFolder == null ? getString(R.string.select_artist_all_folders) : selectedFolder.getName();
if (!ActiveServerProvider.Companion.isOffline(this)) {
ServerSetting currentSetting = activeServerProvider.getValue().getActiveServer();
currentSetting.setMusicFolderId(musicFolderId);
serverSettingsModel.getValue().updateItem(currentSetting);
}
folderName.setText(musicFolderName);
refresh();
}
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
menuDrawer.toggleMenu();
return true;
case R.id.main_shuffle:
Intent intent = new Intent(this, DownloadActivity.class);
intent.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, true);
startActivityForResultWithoutTransition(this, intent);
return true;
}
return false;
}
private class GetDataTask extends AsyncTask<Void, Void, String[]>
{
@Override
protected void onPostExecute(String[] result)
{
super.onPostExecute(result);
}
@Override
protected String[] doInBackground(Void... params)
{
refresh();
return null;
}
}
}

View File

@ -63,6 +63,7 @@ public class SettingsFragment extends PreferenceFragment
private CheckBoxPreference lockScreenEnabled;
private CheckBoxPreference sendBluetoothNotifications;
private CheckBoxPreference sendBluetoothAlbumArt;
private CheckBoxPreference showArtistPicture;
private ListPreference viewRefresh;
private ListPreference imageLoaderConcurrency;
private EditTextPreference sharingDefaultDescription;
@ -121,6 +122,7 @@ public class SettingsFragment extends PreferenceFragment
resumeOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE);
pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE);
debugLogToFile = (CheckBoxPreference) findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE);
showArtistPicture = (CheckBoxPreference) findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE);
sharingDefaultGreeting.setText(Util.getShareGreeting(getActivity()));
setupClearSearchPreference();
@ -176,6 +178,9 @@ public class SettingsFragment extends PreferenceFragment
setImageLoaderConcurrency(Integer.parseInt(sharedPreferences.getString(key, "5")));
} else if (Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE.equals(key)) {
setDebugLogToFile(sharedPreferences.getBoolean(key, false));
} else if (Constants.PREFERENCES_KEY_ID3_TAGS.equals(key)) {
if (sharedPreferences.getBoolean(key, false)) showArtistPicture.setEnabled(true);
else showArtistPicture.setEnabled(false);
}
}
@ -427,6 +432,9 @@ public class SettingsFragment extends PreferenceFragment
} else {
debugLogToFile.setSummary("");
}
if (Util.getShouldUseId3Tags(getActivity())) showArtistPicture.setEnabled(true);
else showArtistPicture.setEnabled(false);
}
private static void setImageLoaderConcurrency(int concurrency) {

View File

@ -20,18 +20,7 @@ package org.moire.ultrasonic.util;
import android.app.Activity;
import android.os.Handler;
import timber.log.Timber;
import com.fasterxml.jackson.core.JsonParseException;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException;
import org.moire.ultrasonic.service.SubsonicRESTException;
import org.moire.ultrasonic.subsonic.RestErrorMapper;
import javax.net.ssl.SSLException;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.security.cert.CertPathValidatorException;
import java.security.cert.CertificateException;
import org.moire.ultrasonic.service.CommunicationErrorHandler;
/**
* @author Sindre Mehus
@ -65,41 +54,13 @@ public abstract class BackgroundTask<T> implements ProgressListener
protected void error(Throwable error)
{
Timber.w(error);
new ErrorDialog(activity, getErrorMessage(error), false);
CommunicationErrorHandler.Companion.handleError(error, activity);
}
protected String getErrorMessage(Throwable error) {
if (error instanceof IOException && !Util.isNetworkConnected(activity)) {
return activity.getResources().getString(R.string.background_task_no_network);
} else if (error instanceof FileNotFoundException) {
return activity.getResources().getString(R.string.background_task_not_found);
} else if (error instanceof JsonParseException) {
return activity.getResources().getString(R.string.background_task_parse_error);
} else if (error instanceof SSLException) {
if (error.getCause() instanceof CertificateException &&
error.getCause().getCause() instanceof CertPathValidatorException) {
return activity.getResources()
.getString(R.string.background_task_ssl_cert_error,
error.getCause().getCause().getMessage());
} else {
return activity.getResources().getString(R.string.background_task_ssl_error);
}
} else if (error instanceof ApiNotSupportedException) {
return activity.getResources().getString(R.string.background_task_unsupported_api,
((ApiNotSupportedException) error).getServerApiVersion());
} else if (error instanceof IOException) {
return activity.getResources().getString(R.string.background_task_network_error);
} else if (error instanceof SubsonicRESTException) {
return RestErrorMapper.getLocalizedErrorMessage((SubsonicRESTException) error, activity);
}
String message = error.getMessage();
if (message != null) {
return message;
}
return error.getClass().getSimpleName();
}
protected String getErrorMessage(Throwable error)
{
return CommunicationErrorHandler.Companion.getErrorMessage(error, activity);
}
@Override
public abstract void updateProgress(final String message);

View File

@ -114,6 +114,7 @@ public final class Constants
public static final String PREFERENCES_KEY_DOWNLOAD_TRANSITION = "transitionToDownloadOnPlay";
public static final String PREFERENCES_KEY_INCREMENT_TIME = "incrementTime";
public static final String PREFERENCES_KEY_ID3_TAGS = "useId3Tags";
public static final String PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture";
public static final String PREFERENCES_KEY_TEMP_LOSS = "tempLoss";
public static final String PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval";
public static final String PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime";

View File

@ -13,23 +13,16 @@ public interface ImageLoader {
void stopImageLoader();
void loadAvatarImage(
View view,
String username,
boolean large,
int size,
boolean crossFade,
boolean highQuality
);
void loadAvatarImage(View view, String username, boolean large, int size, boolean crossFade,
boolean highQuality);
void loadImage(
View view,
MusicDirectory.Entry entry,
boolean large,
int size,
boolean crossFade,
boolean highQuality
);
void loadImage(View view, MusicDirectory.Entry entry, boolean large, int size,
boolean crossFade, boolean highQuality);
void loadImage(View view, MusicDirectory.Entry entry, boolean large, int size,
boolean crossFade, boolean highQuality, int defaultResourceId);
void cancel(String coverArt);
Bitmap getImageBitmap(String username, int size);

View File

@ -38,6 +38,8 @@ import org.moire.ultrasonic.service.MusicServiceFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.SortedSet;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
@ -165,25 +167,24 @@ public class LegacyImageLoader implements Runnable, ImageLoader {
}
@Override
public void loadImage(
View view,
MusicDirectory.Entry entry,
boolean large,
int size,
boolean crossFade,
boolean highQuality
) {
public void loadImage(View view, MusicDirectory.Entry entry, boolean large, int size,
boolean crossFade, boolean highQuality) {
loadImage(view, entry, large, size, crossFade, highQuality, -1);
}
public void loadImage(View view, MusicDirectory.Entry entry, boolean large, int size,
boolean crossFade, boolean highQuality, int defaultResourceId) {
view.invalidate();
if (entry == null) {
setUnknownImage(view, large);
setUnknownImage(view, large, defaultResourceId);
return;
}
String coverArt = entry.getCoverArt();
if (TextUtils.isEmpty(coverArt)) {
setUnknownImage(view, large);
setUnknownImage(view, large, defaultResourceId);
return;
}
@ -198,11 +199,21 @@ public class LegacyImageLoader implements Runnable, ImageLoader {
return;
}
setUnknownImage(view, large);
setUnknownImage(view, large, defaultResourceId);
queue.offer(new Task(view, entry, size, large, crossFade, highQuality));
}
public void cancel(String coverArt) {
for (Object taskObject : queue.toArray()) {
Task task = (Task)taskObject;
if ((task.entry.getCoverArt() != null) && (coverArt.compareTo(task.entry.getCoverArt()) == 0)) {
queue.remove(taskObject);
break;
}
}
}
private static String getKey(String coverArtId, int size) {
return String.format("%s:%d", coverArtId, size);
}
@ -330,13 +341,18 @@ public class LegacyImageLoader implements Runnable, ImageLoader {
}
private void setUnknownImage(View view, boolean large) {
setUnknownImage(view, large, -1);
}
private void setUnknownImage(View view, boolean large, int resId) {
if (resId == -1) resId = R.drawable.unknown_album;
if (large) {
setImageBitmap(view, null, largeUnknownImage, false);
} else {
if (view instanceof TextView) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(R.drawable.unknown_album, 0, 0, 0);
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(resId, 0, 0, 0);
} else if (view instanceof ImageView) {
((ImageView) view).setImageResource(R.drawable.unknown_album);
((ImageView) view).setImageResource(resId);
}
}
}
@ -381,14 +397,7 @@ public class LegacyImageLoader implements Runnable, ImageLoader {
private final boolean crossFade;
private final boolean highQuality;
Task(
View view,
MusicDirectory.Entry entry,
int size,
boolean saveToFile,
boolean crossFade,
boolean highQuality
) {
Task(View view, MusicDirectory.Entry entry, int size, boolean saveToFile, boolean crossFade, boolean highQuality) {
this.view = view;
this.entry = entry;
this.username = null;
@ -399,14 +408,7 @@ public class LegacyImageLoader implements Runnable, ImageLoader {
handler = new Handler();
}
Task(
View view,
String username,
int size,
boolean saveToFile,
boolean crossFade,
boolean highQuality
) {
Task(View view, String username, int size, boolean saveToFile, boolean crossFade, boolean highQuality) {
this.view = view;
this.entry = null;
this.username = username;
@ -421,9 +423,9 @@ public class LegacyImageLoader implements Runnable, ImageLoader {
try {
MusicService musicService = MusicServiceFactory.getMusicService(view.getContext());
final boolean isAvatar = this.username != null && this.entry == null;
final Bitmap bitmap = this.entry != null
? musicService.getCoverArt(view.getContext(), entry, size, saveToFile, highQuality, null)
: musicService.getAvatar(view.getContext(), username, size, saveToFile, highQuality, null);
final Bitmap bitmap = this.entry != null ?
musicService.getCoverArt(view.getContext(), entry, size, saveToFile, highQuality, null) :
musicService.getAvatar(view.getContext(), username, size, saveToFile, highQuality, null);
if (bitmap == null) {
Timber.d("Found empty album art.");

View File

@ -54,6 +54,7 @@ import org.moire.ultrasonic.R;
import org.moire.ultrasonic.activity.DownloadActivity;
import org.moire.ultrasonic.activity.MainActivity;
import org.moire.ultrasonic.activity.SettingsActivity;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.*;
import org.moire.ultrasonic.domain.MusicDirectory.Entry;
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver;
@ -1181,6 +1182,15 @@ public class Util
return preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false);
}
public static boolean getShouldShowArtistPicture(Context context)
{
SharedPreferences preferences = getPreferences(context);
boolean isOffline = ActiveServerProvider.Companion.isOffline(context);
boolean isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false);
boolean shouldShowArtistPicture = preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false);
return (!isOffline) && isId3Enabled && shouldShowArtistPicture;
}
public static int getChatRefreshInterval(Context context)
{
SharedPreferences preferences = getPreferences(context);

View File

@ -0,0 +1,109 @@
/*
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 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.activity
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.CommunicationErrorHandler
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Util
/**
* Provides ViewModel which contains the list of available Artists
*/
class ArtistListModel(
private val activeServerProvider: ActiveServerProvider,
private val context: Context
) : ViewModel() {
private val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData()
private val artists: MutableLiveData<List<Artist>> = MutableLiveData()
/**
* Retrieves the available Artists in a LiveData
*/
fun getArtists(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<Artist>> {
backgroundLoadFromServer(refresh, swipe)
return artists
}
/**
* Retrieves the available Music Folders in a LiveData
*/
fun getMusicFolders(): LiveData<List<MusicFolder>> {
return musicFolders
}
/**
* Refreshes the cached Artists from the server
*/
fun refresh(swipe: SwipeRefreshLayout) {
backgroundLoadFromServer(true, swipe)
}
private fun backgroundLoadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout) {
viewModelScope.launch {
swipe.isRefreshing = true
loadFromServer(refresh, swipe)
swipe.isRefreshing = false
}
}
private suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout) =
withContext(Dispatchers.IO) {
val musicService = MusicServiceFactory.getMusicService(context)
val isOffline = ActiveServerProvider.isOffline(context)
val useId3Tags = Util.getShouldUseId3Tags(context)
try {
if (!isOffline && !useId3Tags) {
musicFolders.postValue(
musicService.getMusicFolders(refresh, context, null)
)
}
val musicFolderId = activeServerProvider.getActiveServer().musicFolderId
val result = if (!isOffline && useId3Tags)
musicService.getArtists(refresh, context, null)
else musicService.getIndexes(musicFolderId, refresh, context, null)
val retrievedArtists: MutableList<Artist> =
ArrayList(result.shortcuts.size + result.artists.size)
retrievedArtists.addAll(result.shortcuts)
retrievedArtists.addAll(result.artists)
artists.postValue(retrievedArtists)
} catch (exception: Exception) {
Handler(Looper.getMainLooper()).post {
CommunicationErrorHandler.handleError(exception, swipe.context)
}
}
}
}

View File

@ -0,0 +1,194 @@
/*
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 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.activity
import android.view.LayoutInflater
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter
import java.text.Collator
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.util.ImageLoader
import org.moire.ultrasonic.util.Util
/**
* Creates a Row in a RecyclerView which contains the details of an Artist
*/
class ArtistRowAdapter(
private var artistList: List<Artist>,
private var folderName: String,
private var shouldShowHeader: Boolean,
val onArtistClick: (Artist) -> Unit,
val onContextMenuClick: (MenuItem, Artist) -> Boolean,
val onFolderClick: (view: View) -> Unit,
private val imageLoader: ImageLoader
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), SectionedAdapter {
/**
* Sets the data to be displayed in the RecyclerView
*/
fun setData(data: List<Artist>) {
artistList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name })
notifyDataSetChanged()
}
/**
* Sets the name of the folder to be displayed n the Header (first) row
*/
fun setFolderName(name: String) {
folderName = name
notifyDataSetChanged()
}
/**
* Holds the view properties of an Artist row
*/
class ArtistViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var section: TextView = itemView.findViewById(R.id.row_section)
var textView: TextView = itemView.findViewById(R.id.row_artist_name)
var layout: RelativeLayout = itemView.findViewById(R.id.row_artist_layout)
var coverArt: ImageView = itemView.findViewById(R.id.artist_coverart)
var coverArtId: String? = null
}
/**
* Holds the view properties of the Header row
*/
class HeaderViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var folderName: TextView = itemView.findViewById(R.id.select_artist_folder_2)
var layout: LinearLayout = itemView.findViewById(R.id.select_artist_folder)
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
if (viewType == TYPE_ITEM) {
val row = LayoutInflater.from(parent.context)
.inflate(R.layout.artist_list_item, parent, false)
return ArtistViewHolder(row)
}
val header = LayoutInflater.from(parent.context)
.inflate(R.layout.select_artist_header, parent, false)
return HeaderViewHolder(header)
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if ((holder is ArtistViewHolder) && (holder.coverArtId != null)) {
imageLoader.cancel(holder.coverArtId)
}
super.onViewRecycled(holder)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ArtistViewHolder) {
val listPosition = if (shouldShowHeader) position - 1 else position
holder.textView.text = artistList[listPosition].name
holder.section.text = getSectionForArtist(listPosition)
holder.layout.setOnClickListener { onArtistClick(artistList[listPosition]) }
holder.layout.setOnLongClickListener { view -> createPopupMenu(view, listPosition) }
holder.coverArtId = artistList[listPosition].coverArt
if (Util.getShouldShowArtistPicture(holder.coverArt.context)) {
holder.coverArt.visibility = View.VISIBLE
imageLoader.loadImage(
holder.coverArt,
MusicDirectory.Entry().apply { coverArt = holder.coverArtId },
false, 0, false, true, R.drawable.ic_contact_picture
)
} else {
holder.coverArt.visibility = View.GONE
}
} else if (holder is HeaderViewHolder) {
holder.folderName.text = folderName
holder.layout.setOnClickListener { onFolderClick(holder.layout) }
}
}
override fun getItemCount() = if (shouldShowHeader) artistList.size + 1 else artistList.size
override fun getItemViewType(position: Int): Int {
return if (position == 0 && shouldShowHeader) TYPE_HEADER else TYPE_ITEM
}
override fun getSectionName(position: Int): String {
var listPosition = if (shouldShowHeader) position - 1 else position
// Show the first artist's initial in the popup when the list is
// scrolled up to the "Select Folder" row
if (listPosition < 0) listPosition = 0
return getSectionFromName(artistList[listPosition].name ?: " ")
}
private fun getSectionForArtist(artistPosition: Int): String {
if (artistPosition == 0)
return getSectionFromName(artistList[artistPosition].name ?: " ")
val previousArtistSection = getSectionFromName(
artistList[artistPosition - 1].name ?: " "
)
val currentArtistSection = getSectionFromName(
artistList[artistPosition].name ?: " "
)
return if (previousArtistSection == currentArtistSection) "" else currentArtistSection
}
private fun getSectionFromName(name: String): String {
var section = name.first().toUpperCase()
if (!section.isLetter()) section = '#'
return section.toString()
}
private fun createPopupMenu(view: View, position: Int): Boolean {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(R.menu.select_artist_context, popup.menu)
val downloadMenuItem = popup.menu.findItem(R.id.artist_menu_download)
downloadMenuItem?.isVisible = !isOffline(view.context)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, artistList[position])
}
popup.show()
return true
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ITEM = 1
}
}

View File

@ -0,0 +1,215 @@
/*
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 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.activity
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.PopupMenu
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koin.android.ext.android.inject
import org.koin.android.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Util
/**
* Displays the available Artists in a list
*/
class SelectArtistActivity : SubsonicTabActivity() {
private val activeServerProvider: ActiveServerProvider by inject()
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val artistListModel: ArtistListModel by viewModel()
private var refreshArtistListView: SwipeRefreshLayout? = null
private var artistListView: RecyclerView? = null
private var musicFolders: List<MusicFolder>? = null
private lateinit var viewManager: RecyclerView.LayoutManager
private lateinit var viewAdapter: ArtistRowAdapter
/**
* Called when the activity is first created.
*/
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.select_artist)
refreshArtistListView = findViewById(R.id.select_artist_refresh)
refreshArtistListView!!.setOnRefreshListener {
artistListModel.refresh(refreshArtistListView!!)
}
val shouldShowHeader = (!isOffline(this) && !Util.getShouldUseId3Tags(this))
val title = intent.getStringExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE)
if (title == null) {
setActionBarSubtitle(
if (isOffline(this)) R.string.music_library_label_offline
else R.string.music_library_label
)
} else {
actionBarSubtitle = title
}
val browseMenuItem = findViewById<View>(R.id.menu_browse)
menuDrawer.setActiveView(browseMenuItem)
musicFolders = null
val refresh = intent.getBooleanExtra(Constants.INTENT_EXTRA_NAME_REFRESH, false)
artistListModel.getMusicFolders()
.observe(
this,
Observer { changedFolders ->
if (changedFolders != null) {
musicFolders = changedFolders
viewAdapter.setFolderName(getMusicFolderName(changedFolders))
}
}
)
val artists = artistListModel.getArtists(refresh, refreshArtistListView!!)
artists.observe(
this, Observer { changedArtists -> viewAdapter.setData(changedArtists) }
)
viewManager = LinearLayoutManager(this)
viewAdapter = ArtistRowAdapter(
artists.value ?: listOf(),
getText(R.string.select_artist_all_folders).toString(),
shouldShowHeader,
{ artist -> onItemClick(artist) },
{ menuItem, artist -> onArtistMenuItemSelected(menuItem, artist) },
{ view -> onFolderClick(view) },
imageLoader
)
artistListView = findViewById<RecyclerView>(R.id.select_artist_list).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
menuDrawer.toggleMenu()
return true
}
R.id.main_shuffle -> {
val intent = Intent(this, DownloadActivity::class.java)
intent.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, true)
startActivityForResultWithoutTransition(this, intent)
return true
}
}
return false
}
private fun getMusicFolderName(musicFolders: List<MusicFolder>): String {
val musicFolderId = activeServerProvider.getActiveServer().musicFolderId
if (musicFolderId != null && musicFolderId != "") {
for ((id, name) in musicFolders) {
if (id == musicFolderId) {
return name
}
}
}
return getText(R.string.select_artist_all_folders).toString()
}
private fun onItemClick(artist: Artist) {
val intent = Intent(this, SelectAlbumActivity::class.java)
intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, artist.id)
intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, artist.name)
intent.putExtra(Constants.INTENT_EXTRA_NAME_PARENT_ID, artist.id)
intent.putExtra(Constants.INTENT_EXTRA_NAME_ARTIST, true)
startActivityForResultWithoutTransition(this, intent)
}
private fun onFolderClick(view: View) {
val popup = PopupMenu(this, view)
val musicFolderId = activeServerProvider.getActiveServer().musicFolderId
var menuItem = popup.menu.add(
MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders
)
if (musicFolderId == null || musicFolderId.isEmpty()) {
menuItem.isChecked = true
}
if (musicFolders != null) {
for (i in musicFolders!!.indices) {
val (id, name) = musicFolders!![i]
menuItem = popup.menu.add(MENU_GROUP_MUSIC_FOLDER, i, i + 1, name)
if (id == musicFolderId) {
menuItem.isChecked = true
}
}
}
popup.menu.setGroupCheckable(MENU_GROUP_MUSIC_FOLDER, true, true)
popup.setOnMenuItemClickListener { item -> onFolderMenuItemSelected(item) }
popup.show()
}
private fun onArtistMenuItemSelected(menuItem: MenuItem, artist: Artist): Boolean {
when (menuItem.itemId) {
R.id.artist_menu_play_now ->
downloadRecursively(artist.id, false, false, true, false, false, false, false, true)
R.id.artist_menu_play_next ->
downloadRecursively(artist.id, false, false, true, true, false, true, false, true)
R.id.artist_menu_play_last ->
downloadRecursively(artist.id, false, true, false, false, false, false, false, true)
R.id.artist_menu_pin ->
downloadRecursively(artist.id, true, true, false, false, false, false, false, true)
R.id.artist_menu_unpin ->
downloadRecursively(artist.id, false, false, false, false, false, false, true, true)
R.id.artist_menu_download ->
downloadRecursively(artist.id, false, false, false, false, true, false, false, true)
}
return true
}
private fun onFolderMenuItemSelected(menuItem: MenuItem): Boolean {
val selectedFolder = if (menuItem.itemId == -1) null else musicFolders!![menuItem.itemId]
val musicFolderId = selectedFolder?.id
val musicFolderName = selectedFolder?.name
?: getString(R.string.select_artist_all_folders)
if (!isOffline(this)) {
val currentSetting = activeServerProvider.getActiveServer()
currentSetting.musicFolderId = musicFolderId
serverSettingsModel.updateItem(currentSetting)
}
viewAdapter.setFolderName(musicFolderName)
artistListModel.refresh(refreshArtistListView!!)
return true
}
companion object {
private const val MENU_GROUP_MUSIC_FOLDER = 10
}
}

View File

@ -4,9 +4,11 @@ package org.moire.ultrasonic.di
import kotlin.math.abs
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.android.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.activity.ArtistListModel
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
@ -71,4 +73,6 @@ val musicServiceModule = module {
}
single { SubsonicImageLoader(androidContext(), get()) }
viewModel { ArtistListModel(get(), androidContext()) }
}

View File

@ -7,6 +7,7 @@ import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist
fun APIArtist.toDomainEntity(): Artist = Artist(
id = this@toDomainEntity.id,
coverArt = this@toDomainEntity.coverArt,
name = this@toDomainEntity.name
)

View File

@ -0,0 +1,85 @@
/*
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 2020 (C) Jozsef Varga
*/
package org.moire.ultrasonic.service
import android.app.AlertDialog
import android.content.Context
import com.fasterxml.jackson.core.JsonParseException
import java.io.FileNotFoundException
import java.io.IOException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* Contains helper functions to handle the exceptions
* thrown during the communication with a Subsonic server
*/
class CommunicationErrorHandler {
companion object {
fun handleError(error: Throwable?, context: Context) {
Timber.w(error)
AlertDialog.Builder(context)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.error_label)
.setMessage(getErrorMessage(error!!, context))
.setCancelable(true)
.setPositiveButton(R.string.common_ok) { _, _ -> }
.create().show()
}
fun getErrorMessage(error: Throwable, context: Context): String {
if (error is IOException && !Util.isNetworkConnected(context)) {
return context.resources.getString(R.string.background_task_no_network)
} else if (error is FileNotFoundException) {
return context.resources.getString(R.string.background_task_not_found)
} else if (error is JsonParseException) {
return context.resources.getString(R.string.background_task_parse_error)
} else if (error is SSLException) {
return if (
error.cause is CertificateException &&
error.cause?.cause is CertPathValidatorException
) {
context.resources
.getString(
R.string.background_task_ssl_cert_error, error.cause?.cause?.message
)
} else {
context.resources.getString(R.string.background_task_ssl_error)
}
} else if (error is ApiNotSupportedException) {
return context.resources.getString(
R.string.background_task_unsupported_api, error.serverApiVersion
)
} else if (error is IOException) {
return context.resources.getString(R.string.background_task_network_error)
} else if (error is SubsonicRESTException) {
return error.getLocalizedErrorMessage(context)
}
val message = error.message
return message ?: error.javaClass.simpleName
}
}
}

View File

@ -26,18 +26,32 @@ class SubsonicImageLoaderProxy(
size: Int,
crossFade: Boolean,
highQuality: Boolean
) {
return loadImage(view, entry, large, size, crossFade, highQuality, -1)
}
override fun loadImage(
view: View?,
entry: MusicDirectory.Entry?,
large: Boolean,
size: Int,
crossFade: Boolean,
highQuality: Boolean,
defaultResourceId: Int
) {
val id = entry?.coverArt
val unknownImageId =
if (defaultResourceId == -1) R.drawable.unknown_album
else defaultResourceId
if (id != null &&
view != null &&
view is ImageView
) {
val request = ImageRequest.CoverArt(
id,
view,
placeHolderDrawableRes = R.drawable.unknown_album,
errorDrawableRes = R.drawable.unknown_album
id, view,
placeHolderDrawableRes = unknownImageId,
errorDrawableRes = unknownImageId
)
subsonicImageLoader.load(request)
}
@ -56,8 +70,7 @@ class SubsonicImageLoaderProxy(
view is ImageView
) {
val request = ImageRequest.Avatar(
username,
view,
username, view,
placeHolderDrawableRes = R.drawable.ic_contact_picture,
errorDrawableRes = R.drawable.ic_contact_picture
)

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/darker_gray" />
<padding
android:top="10dp"
android:left="10dp"
android:right="10dp"
android:bottom="10dp"/>
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:state_pressed="true"
android:drawable="@drawable/line"/>
<item
android:drawable="@drawable/line"/>
</selector>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:topLeftRadius="44dp"
android:topRightRadius="44dp"
android:bottomRightRadius="44dp"
android:bottomLeftRadius="44dp" />
<padding
android:paddingLeft="22dp"
android:paddingRight="22dp" />
<solid android:color="@color/selected_color_dark" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:state_pressed="true"
android:drawable="@drawable/thumb"/>
<item
android:drawable="@drawable/thumb"/>
</selector>

View File

@ -1,11 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:a="http://schemas.android.com/apk/res/android"
a:id="@android:id/text1"
a:drawablePadding="6dip"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:textAppearance="?android:attr/textAppearanceMedium"
a:gravity="center_vertical"
a:paddingLeft="3dip"
a:paddingRight="3dip"
a:minHeight="50dip"/>
<RelativeLayout xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
a:id="@+id/row_artist_layout"
a:layout_height="wrap_content"
a:layout_width="match_parent"
a:background="?android:attr/selectableItemBackground"
a:clickable="true"
a:focusable="true">
<TextView
a:id="@+id/row_section"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:gravity="center_horizontal|center_vertical"
a:minWidth="56dip"
a:minHeight="56dip"
a:paddingStart="8dip"
a:paddingLeft="8dip"
a:paddingEnd="8dip"
a:paddingRight="8dip"
a:text="A"
a:textAppearance="?android:attr/textAppearanceLarge"
a:textColor="@color/cyan" />
<com.google.android.material.imageview.ShapeableImageView
a:id="@+id/artist_coverart"
a:layout_width="40dp"
a:layout_height="40dp"
a:layout_gravity="center_horizontal|center_vertical"
a:layout_marginTop="8dp"
a:layout_marginStart="2dp"
a:layout_marginLeft="2dp"
a:layout_marginEnd="10dp"
a:layout_marginRight="10dp"
a:layout_toEndOf="@+id/row_section"
a:layout_toRightOf="@+id/row_section"
a:scaleType="fitCenter"
a:src="@drawable/ic_contact_picture"
app:shapeAppearanceOverlay="@style/roundedImageView" />
<TextView
a:id="@+id/row_artist_name"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:layout_toEndOf="@+id/artist_coverart"
a:layout_toRightOf="@+id/artist_coverart"
a:drawablePadding="6dip"
a:gravity="center_vertical"
a:minHeight="56dip"
a:paddingLeft="3dip"
a:paddingRight="3dip"
a:layout_marginRight="12dp"
a:layout_marginEnd="12dp"
a:textAppearance="?android:attr/textAppearanceMedium" />
</RelativeLayout>

View File

@ -2,21 +2,33 @@
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:orientation="vertical" >
xmlns:app="http://schemas.android.com/apk/res-auto"
a:orientation="vertical">
<include layout="@layout/tab_progress" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/select_artist_refresh"
android:layout_width="fill_parent"
android:layout_height="0dip"
android:layout_weight="1.0">
a:id="@+id/select_artist_refresh"
a:layout_width="fill_parent"
a:layout_height="0dip"
a:layout_weight="1.0">
<ListView
android:id="@+id/select_artist_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
a:id="@+id/select_artist_list"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:paddingTop="8dp"
a:paddingBottom="8dp"
a:clipToPadding="false"
app:fastScrollAutoHide="true"
app:fastScrollAutoHideDelay="2000"
app:fastScrollPopupTextSize="28sp"
app:fastScrollPopupBackgroundSize="42dp"
app:fastScrollPopupBgColor="@color/cyan"
app:fastScrollPopupTextColor="@android:color/primary_text_dark"
app:fastScrollPopupPosition="adjacent"
app:fastScrollTrackColor="@color/dividerColor"
app:fastScrollThumbColor="@color/cyan" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -7,7 +7,10 @@
a:orientation="horizontal"
a:paddingBottom="2dip"
a:paddingLeft="6dp"
a:paddingTop="2dip" >
a:paddingTop="2dip"
a:background="?android:attr/selectableItemBackground"
a:clickable="true"
a:focusable="true">
<ImageView
a:layout_width="wrap_content"

View File

@ -313,6 +313,8 @@
<string name="settings.use_folder_for_album_artist_summary">Feltételezi, hogy a legfelső szintű mappa az előadó neve.</string>
<string name="settings.use_id3">Böngészés ID3 Tag használatával</string>
<string name="settings.use_id3_summary">ID3 Tag módszer használata a fájlredszer alapú mód helyett.</string>
<string name="settings.show_artist_picture">Előadó képének megjelenítése</string>
<string name="settings.show_artist_picture_summary">Az előadó listában megjeleníti a képeket, amennyiben elérhetőek</string>
<string name="settings.video_title">Videó</string>
<string name="settings.video_player">Videólejátszó</string>
<string name="settings.view_refresh">Nézet frissítési gyakorisága</string>

View File

@ -316,6 +316,8 @@
<string name="settings.use_folder_for_album_artist_summary">Assume top-level folder is the name of the album artist</string>
<string name="settings.use_id3">Browse Using ID3 Tags</string>
<string name="settings.use_id3_summary">Use ID3 tag methods instead of file system based methods</string>
<string name="settings.show_artist_picture">Show artist picture in artist list</string>
<string name="settings.show_artist_picture_summary">Displays the artist picture in the artist list if available</string>
<string name="settings.video_title">Video</string>
<string name="settings.video_player">Video player</string>
<string name="settings.view_refresh">View Refresh</string>

View File

@ -35,6 +35,11 @@
<item name="android:gravity">center_vertical</item>
</style>
<style name="roundedImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">8dp</item>
</style>
<attr name="star_hollow" format="reference"/>
<attr name="star_full" format="reference"/>
<attr name="about" format="reference"/>

View File

@ -64,6 +64,11 @@
a:key="useId3Tags"
a:summary="@string/settings.use_id3_summary"
a:title="@string/settings.use_id3"/>
<CheckBoxPreference
a:defaultValue="false"
a:key="showArtistPicture"
a:summary="@string/settings.show_artist_picture_summary"
a:title="@string/settings.show_artist_picture"/>
<CheckBoxPreference
a:defaultValue="true"
a:key="mediaButtons"