Compare commits

...

16 commits

Author SHA1 Message Date
天クマ
a6b1bb83e4
Add files via upload 2026-03-06 08:55:22 -03:00
天クマ
ef4c1cad27
Delete metadata/en-US/images/phoneScreenshots/icon.png 2026-03-06 08:54:45 -03:00
天クマ
30cc7f663c
Merge pull request #6 from adrianvic/feature-download-playback
Fix metadata and add downloads tab with listing logic and UI updates
2026-03-06 08:45:15 -03:00
49aa6e8048 Move metadata images to correct location and expand app description. 2026-03-05 14:30:15 -03:00
1943344585 Implement barebones logic to list songs in the downloads page. 2026-01-26 16:51:58 -03:00
cd0971bf14 Change DownloadsFragment to extend AbsLibraryPagerRecyclerViewCustomGridSizeFragment and add DownloadsAdapter. 2026-01-26 15:21:45 -03:00
3a0154deea Add new tab in MainActivity and migration for listing downloads. 2026-01-26 14:21:13 -03:00
2a7350af89 Remove unnecessary activity setup in MainActivity.onStateOffline. 2026-01-26 13:51:02 -03:00
46ccf94b13 Update README.md 2026-01-24 20:22:09 -03:00
728c6068cd Synchronize writing of downloads to avoid numbered folders like 'AJR (1)'. 2026-01-24 20:02:26 -03:00
3b54d1c917 Fix compatibility with Android 4.4.
The app was pretty broken on API 19, fixes:
- Remove SelectActivity card background because API 19 does not support XML SVG as bg
- Add simple folder browser for APIs without SAF
- Add simplified version of download notification
- Downgrade and replace incompatible libraries to enable SSL 1.2 on older Android versions
2026-01-24 19:35:40 -03:00
1f2ae7f1d9 Cleanup before tagging 1.4.1 and add license to about page.
- Add missing separator in about "Special Thanks" section
- Add margin to separators in about page
- Moved registering and unregistering of receiver from onResume and onPause to onCreate and onDestroy in SplashActivity.java
- Better UX reaction to STATE_OFFLINE in MainActivity.java
- Remove redundant check for ApiClient in LoginService
- AbsMusicContentActivity calls onStateOffline() instead of handling navigation by itself.
2026-01-23 17:50:02 -03:00
Tenkuma
9748ccf592
Merge pull request #3 from adrianvic/feature-offline-screen
feature-offline-screen
2026-01-23 16:10:43 -03:00
d312d1fca8 Bump version to 1.4.1 2026-01-23 16:07:58 -03:00
34c4bcc831 Add UnreachableActivity for when the server cannot be reached. 2026-01-23 16:04:27 -03:00
50684b57f5 Miscellaneous additions related to notifications.
Add notification permissions for Android 13+

Add error notification.
2026-01-22 19:17:38 -03:00
63 changed files with 1125 additions and 285 deletions

View file

@ -23,12 +23,17 @@ This is a native music player for Android devices that connects to Jellyfin medi
* Playback history reporting * Playback history reporting
* Filter content by library * Filter content by library
# Requisites
- A Jellfin server. See how to setup one [here](https://jellyfin.org/docs/general/quick-start/).
- Android 4.4 or later
# Issues # Issues
Since this was a small project intended mainly for myself, there are some things I haven't resolved yet. I would appreciate pull requests to fix any of these issues! Since this was a small project intended mainly for myself, there are some things I haven't resolved yet. I would appreciate pull requests to fix any of these issues!
* Artist sorting isn't available through the API * Artist sorting isn't available through the API
* Playlists and favorites will not update automatically when changed * Playlists and favorites will not update automatically when changed ([#5](https://github.com/adrianvic/jamfish/issues/5))
* App may crash on really low end devices due exceeding the maximum bitmap memory ([#4](https://github.com/adrianvic/jamfish/issues/4))
# Future Plans # Future Plans

View file

@ -10,10 +10,12 @@ android {
minSdk 19 minSdk 19
targetSdk 33 targetSdk 33
versionCode 1 versionCode 2
versionName '1.4.0' versionName '1.4.1'
// for SDK < 19
multiDexEnabled true multiDexEnabled true
vectorDrawables { vectorDrawables {
useSupportLibrary true useSupportLibrary true
} }
@ -105,8 +107,9 @@ dependencies {
implementation 'com.android.support:multidex:1.0.3' implementation 'com.android.support:multidex:1.0.3'
implementation 'com.mlegy.redscreenofdeath:red-screen-of-death:0.1.3' implementation 'com.mlegy.redscreenofdeath:red-screen-of-death:0.1.3'
implementation 'com.squareup.retrofit2:retrofit:2.9.0' // old version of retrofit to work with api < 21
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.6.4'
implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
@ -117,4 +120,10 @@ dependencies {
implementation 'com.github.bumptech.glide:annotations:4.12.0' implementation 'com.github.bumptech.glide:annotations:4.12.0'
implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation 'com.github.bumptech.glide:okhttp3-integration:4.12.0' implementation 'com.github.bumptech.glide:okhttp3-integration:4.12.0'
// for supporting legacy android versions:
implementation('com.squareup.okhttp3:okhttp:3.12.13')
implementation "androidx.multidex:multidex:2.0.1"
implementation 'org.conscrypt:conscrypt-android:2.5.2'
} }

View file

@ -2,7 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
@ -49,6 +51,7 @@
<activity <activity
android:name="org.adrianvictor.geleia.activities.SettingsActivity" android:name="org.adrianvictor.geleia.activities.SettingsActivity"
android:label="@string/action_settings" /> android:label="@string/action_settings" />
<activity android:name="org.adrianvictor.geleia.activities.DirectoryPickerActivity"/>
<activity <activity
android:name="org.adrianvictor.geleia.activities.AboutActivity" android:name="org.adrianvictor.geleia.activities.AboutActivity"
android:label="@string/action_about" /> android:label="@string/action_about" />
@ -64,7 +67,7 @@
android:name="org.adrianvictor.geleia.views.shortcuts.AppShortcutLauncherActivity" android:name="org.adrianvictor.geleia.views.shortcuts.AppShortcutLauncherActivity"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:theme="@android:style/Theme.Translucent.NoTitleBar" /> android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity android:name="org.adrianvictor.geleia.activities.UnreachableActivity" />
<service android:name="org.adrianvictor.geleia.service.DownloadService" /> <service android:name="org.adrianvictor.geleia.service.DownloadService" />
<service android:name="org.adrianvictor.geleia.service.LoginService" /> <service android:name="org.adrianvictor.geleia.service.LoginService" />
<service android:name="org.adrianvictor.geleia.service.MusicService" /> <service android:name="org.adrianvictor.geleia.service.MusicService" />
@ -136,6 +139,12 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove" />
</application> </application>
</manifest> </manifest>

View file

@ -6,6 +6,9 @@ import android.content.Context;
import android.os.Build; import android.os.Build;
import android.provider.Settings; import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.multidex.MultiDexApplication;
import androidx.room.Room; import androidx.room.Room;
import org.adrianvictor.geleia.database.JellyDatabase; import org.adrianvictor.geleia.database.JellyDatabase;
@ -14,6 +17,7 @@ import org.adrianvictor.geleia.util.PreferenceUtil;
import org.adrianvictor.geleia.views.shortcuts.DynamicShortcutManager; import org.adrianvictor.geleia.views.shortcuts.DynamicShortcutManager;
import com.melegy.redscreenofdeath.RedScreenOfDeath; import com.melegy.redscreenofdeath.RedScreenOfDeath;
import org.conscrypt.Conscrypt;
import org.jellyfin.apiclient.interaction.AndroidDevice; import org.jellyfin.apiclient.interaction.AndroidDevice;
import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.VolleyHttpClient; import org.jellyfin.apiclient.interaction.VolleyHttpClient;
@ -22,7 +26,16 @@ import org.jellyfin.apiclient.interaction.http.IAsyncHttpClient;
import org.jellyfin.apiclient.logging.AndroidLogger; import org.jellyfin.apiclient.logging.AndroidLogger;
import org.jellyfin.apiclient.logging.ILogger; import org.jellyfin.apiclient.logging.ILogger;
public class App extends Application { import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader;
import com.bumptech.glide.load.model.GlideUrl;
import java.io.InputStream;
import okhttp3.OkHttpClient;
import java.security.Security;
public class App extends MultiDexApplication {
private static App app; private static App app;
private static JellyDatabase database; private static JellyDatabase database;
@ -30,8 +43,14 @@ public class App extends Application {
@Override @Override
public void onCreate() { public void onCreate() {
// Initializing stuff for older Android APIs compatibility
Security.insertProviderAt(Conscrypt.newProvider(), 1); // To have SSL 1.2 on API < 19
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); // To load vectors on API < 19
super.onCreate(); super.onCreate();
initializeGlide(this);
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
RedScreenOfDeath.init(this); RedScreenOfDeath.init(this);
} }
@ -40,7 +59,7 @@ public class App extends Application {
database = createDatabase(this); database = createDatabase(this);
apiClient = createApiClient(this); apiClient = createApiClient(this);
if (database.userDao().getUsers().size() == 0) { if (database.userDao().getUsers().isEmpty()) {
PreferenceUtil.getInstance(this).setServer(null); PreferenceUtil.getInstance(this).setServer(null);
PreferenceUtil.getInstance(this).setUser(null); PreferenceUtil.getInstance(this).setUser(null);
} }
@ -76,6 +95,7 @@ public class App extends Application {
IDevice device = new AndroidDevice(deviceId, deviceName); IDevice device = new AndroidDevice(deviceId, deviceName);
EventListener eventListener = new EventListener(); EventListener eventListener = new EventListener();
return new ApiClient(httpClient, logger, server, appName, appVersion, device, eventListener); return new ApiClient(httpClient, logger, server, appName, appVersion, device, eventListener);
} }
@ -90,4 +110,15 @@ public class App extends Application {
public static App getInstance() { public static App getInstance() {
return app; return app;
} }
private void initializeGlide(@NonNull Context context) {
// This OkHttpClient is now created with Conscrypt as the SSL provider.
OkHttpClient client = new OkHttpClient.Builder().build();
// Manually create and register Glide's OkHttp component.
OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(client);
// Ensure Glide is initialized and then register the component.
Glide.get(context).getRegistry().replace(GlideUrl.class, InputStream.class, factory);
}
} }

View file

@ -7,6 +7,7 @@ import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.adrianvictor.geleia.service.notifications.ErrorNotification;
import org.adrianvictor.geleia.util.NavigationUtil; import org.adrianvictor.geleia.util.NavigationUtil;
import org.adrianvictor.geleia.util.PreferenceUtil; import org.adrianvictor.geleia.util.PreferenceUtil;
import org.adrianvictor.geleia.databinding.ActivityAboutBinding; import org.adrianvictor.geleia.databinding.ActivityAboutBinding;
@ -102,7 +103,7 @@ public class AboutActivity extends AbsBaseActivity implements View.OnClickListen
try { try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName; return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) { } catch (PackageManager.NameNotFoundException e) {
e.printStackTrace(); ErrorNotification.show(context, e.getMessage());
} }
return "Unknown"; return "Unknown";

View file

@ -0,0 +1,127 @@
package org.adrianvictor.geleia.activities;
import android.content.Intent;
import android.os.Bundle;
import android.os.Environment;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
public class DirectoryPickerActivity extends AppCompatActivity {
public static final String EXTRA_RESULT_PATH = "result_path";
private File rootDir;
private File currentDir;
private ArrayAdapter<File> adapter;
private TextView pathView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
rootDir = Environment.getExternalStorageDirectory();
currentDir = rootDir;
LinearLayout layout = new LinearLayout(this);
layout.setOrientation(LinearLayout.VERTICAL);
pathView = new TextView(this);
pathView.setPadding(24, 24, 24, 24);
ListView listView = new ListView(this);
Button selectButton = new Button(this);
selectButton.setText("Select this folder");
layout.addView(pathView);
layout.addView(listView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
layout.addView(selectButton);
setContentView(layout);
adapter = new ArrayAdapter<File>(this,
android.R.layout.simple_list_item_1, new ArrayList<>()) {
@Override
public View getView(int position, View convertView, ViewGroup parent) {
TextView tv = (TextView) super.getView(position, convertView, parent);
File f = getItem(position);
File parentFile = currentDir.getParentFile();
if (parentFile != null && parentFile.equals(f)) {
tv.setText("..");
} else {
tv.setText(f.getName());
}
return tv;
}
};
listView.setAdapter(adapter);
listView.setOnItemClickListener((p, v, pos, id) -> {
File dir = adapter.getItem(pos);
if (dir != null) openDir(dir);
});
selectButton.setOnClickListener(v -> {
Intent result = new Intent();
result.putExtra(EXTRA_RESULT_PATH, currentDir.getAbsolutePath());
setResult(RESULT_OK, result);
finish();
});
openDir(rootDir);
}
private void openDir(File dir) {
if (!dir.exists() || !dir.canRead()) return;
File[] dirs = dir.listFiles(f ->
f.isDirectory() && f.canRead()
);
if (dirs == null) return;
adapter.clear();
File parent = dir.getParentFile();
if (parent != null && parent.getAbsolutePath().startsWith(rootDir.getAbsolutePath())) {
adapter.add(parent);
}
Arrays.sort(dirs);
for (File f : dirs) {
adapter.add(f);
}
currentDir = dir;
pathView.setText(dir.getAbsolutePath());
}
@Override
public void onBackPressed() {
File parent = currentDir.getParentFile();
if (parent != null &&
parent.getAbsolutePath().startsWith(rootDir.getAbsolutePath())) {
openDir(parent);
} else {
super.onBackPressed();
}
}
}

View file

@ -13,11 +13,14 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.drawerlayout.widget.DrawerLayout; import androidx.drawerlayout.widget.DrawerLayout;
import androidx.lifecycle.Lifecycle;
import com.afollestad.materialcab.attached.AttachedCab; import com.afollestad.materialcab.attached.AttachedCab;
import com.afollestad.materialcab.attached.AttachedCabKt; import com.afollestad.materialcab.attached.AttachedCabKt;
import org.adrianvictor.geleia.activities.base.AbsMusicContentActivity; import org.adrianvictor.geleia.activities.base.AbsMusicContentActivity;
import org.adrianvictor.geleia.fragments.OfflineFragment;
import org.adrianvictor.geleia.interfaces.CabHolder; import org.adrianvictor.geleia.interfaces.CabHolder;
import org.adrianvictor.geleia.util.NavigationUtil;
import org.adrianvictor.geleia.util.PreferenceUtil; import org.adrianvictor.geleia.util.PreferenceUtil;
import org.adrianvictor.geleia.util.ThemeUtil; import org.adrianvictor.geleia.util.ThemeUtil;
import org.adrianvictor.geleia.databinding.ActivityMainContentBinding; import org.adrianvictor.geleia.databinding.ActivityMainContentBinding;
@ -42,6 +45,7 @@ public class MainActivity extends AbsMusicContentActivity implements CabHolder {
private ActivityMainContentBinding contentBinding; private ActivityMainContentBinding contentBinding;
private NavigationDrawerHeaderBinding navigationBinding; private NavigationDrawerHeaderBinding navigationBinding;
private boolean onLogout; private boolean onLogout;
private boolean pendingShowOffline = false;
@Nullable @Nullable
private AttachedCab cab; private AttachedCab cab;
@ -97,6 +101,15 @@ public class MainActivity extends AbsMusicContentActivity implements CabHolder {
}); });
} }
@Override
public void onStateOffline() {
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
NavigationUtil.startUnreachable(this);
} else {
pendingShowOffline = true;
}
}
@Override @Override
public void onPause() { public void onPause() {
super.onPause(); super.onPause();
@ -108,6 +121,16 @@ public class MainActivity extends AbsMusicContentActivity implements CabHolder {
} }
} }
@Override
protected void onResume() {
super.onResume();
if (pendingShowOffline) {
setCurrentFragment(OfflineFragment.newInstance());
pendingShowOffline = false;
}
}
private void setCurrentFragment(Fragment fragment) { private void setCurrentFragment(Fragment fragment) {
getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, fragment, null).commit(); getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, fragment, null).commit();
} }

View file

@ -93,6 +93,9 @@ public class SearchActivity extends AbsMusicContentActivity implements SearchVie
public void onStateOnline() { public void onStateOnline() {
} }
@Override
public void onStateOffline() {}
private void setUpToolBar() { private void setUpToolBar() {
binding.toolbar.setBackgroundColor(PreferenceUtil.getInstance(this).getPrimaryColor()); binding.toolbar.setBackgroundColor(PreferenceUtil.getInstance(this).getPrimaryColor());
setSupportActionBar(binding.toolbar); setSupportActionBar(binding.toolbar);

View file

@ -51,6 +51,7 @@ public class SettingsActivity extends AbsBaseActivity {
public static class SettingsFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { public static class SettingsFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener {
private ActivityResultLauncher<Intent> dirPickerLauncher; private ActivityResultLauncher<Intent> dirPickerLauncher;
private ActivityResultLauncher<Intent> legacyDirPickerLauncher;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
@ -71,6 +72,25 @@ public class SettingsActivity extends AbsBaseActivity {
} }
} }
}); });
legacyDirPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
String path = result.getData().getStringExtra(
DirectoryPickerActivity.EXTRA_RESULT_PATH
);
if (path != null) {
SharedPreferences.Editor editor =
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit();
editor.putString(PreferenceUtil.LOCATION_DOWNLOAD, path);
editor.apply();
invalidateSettings();
}
}
}
);
} }
@Override @Override
@ -161,8 +181,13 @@ public class SettingsActivity extends AbsBaseActivity {
} }
private void openDirectoryPicker() { private void openDirectoryPicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
dirPickerLauncher.launch(intent); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
dirPickerLauncher.launch(intent);
} else {
Intent intent = new Intent(requireContext(), DirectoryPickerActivity.class);
legacyDirPickerLauncher.launch(intent);
}
} }
@Override @Override

View file

@ -1,8 +1,13 @@
package org.adrianvictor.geleia.activities; package org.adrianvictor.geleia.activities;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import androidx.annotation.NonNull;
import org.adrianvictor.geleia.App; import org.adrianvictor.geleia.App;
import org.adrianvictor.geleia.R; import org.adrianvictor.geleia.R;
@ -15,10 +20,41 @@ import org.adrianvictor.geleia.util.PreferenceUtil;
import java.util.List; import java.util.List;
public class SplashActivity extends AbsBaseActivity { public class SplashActivity extends AbsBaseActivity {
private final BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, @NonNull Intent intent) {
if (intent.getAction() == null) {
return;
}
switch (intent.getAction()) {
case LoginService.STATE_ONLINE:
NavigationUtil.startMain(context);
finish();
break;
case LoginService.STATE_OFFLINE:
NavigationUtil.startUnreachable(context);
finish();
break;
}
}
};
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
final IntentFilter filter = new IntentFilter();
filter.addAction(LoginService.STATE_ONLINE);
filter.addAction(LoginService.STATE_OFFLINE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(receiver, filter);
}
setContentView(R.layout.activity_splash); setContentView(R.layout.activity_splash);
} }
@ -35,13 +71,20 @@ public class SplashActivity extends AbsBaseActivity {
User user = App.getDatabase().userDao().getUser(PreferenceUtil.getInstance(this).getUser()); User user = App.getDatabase().userDao().getUser(PreferenceUtil.getInstance(this).getUser());
List<User> available = App.getDatabase().userDao().getUsers(); List<User> available = App.getDatabase().userDao().getUsers();
if (user == null && available.size() != 0) { if (user == null && !available.isEmpty()) {
NavigationUtil.startSelect(this); NavigationUtil.startSelect(this);
finish();
} else if (user == null) { } else if (user == null) {
NavigationUtil.startLogin(this); NavigationUtil.startLogin(this);
finish();
} else { } else {
startService(new Intent(this, LoginService.class)); startService(new Intent(this, LoginService.class));
new Handler().postDelayed(() -> NavigationUtil.startMain(this), 1000);
} }
} }
@Override
protected void onDestroy() {
unregisterReceiver(receiver);
super.onDestroy();
}
} }

View file

@ -0,0 +1,21 @@
package org.adrianvictor.geleia.activities;
import android.os.Bundle;
import android.view.View;
import org.adrianvictor.geleia.R;
import org.adrianvictor.geleia.activities.base.AbsThemeActivity;
import org.adrianvictor.geleia.util.NavigationUtil;
public class UnreachableActivity extends AbsThemeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_unreachable);
}
public void onSelectClick(View view) {
NavigationUtil.startSelect(this);
}
}

View file

@ -59,7 +59,7 @@ public abstract class AbsBaseActivity extends AbsThemeActivity {
.setPositiveButton(R.string.disable, (dialog, id) -> requestBatteryOptimization()); .setPositiveButton(R.string.disable, (dialog, id) -> requestBatteryOptimization());
new Handler().postDelayed(builder::show, 2000); new Handler().postDelayed(builder::show, 2000);
} else if (permissions.size() != 0 && ActivityCompat.shouldShowRequestPermissionRationale(this, permissions.get(0))) { } else if (!permissions.isEmpty() && ActivityCompat.shouldShowRequestPermissionRationale(this, permissions.get(0))) {
builder.setMessage(getPermissionMessage()) builder.setMessage(getPermissionMessage())
.setTitle(R.string.permissions_denied) .setTitle(R.string.permissions_denied)
.setPositiveButton(R.string.action_grant, (dialog, id) -> requestPermissions()); .setPositiveButton(R.string.action_grant, (dialog, id) -> requestPermissions());
@ -127,8 +127,11 @@ public abstract class AbsBaseActivity extends AbsThemeActivity {
private boolean checkBatteryOptimization() { private boolean checkBatteryOptimization() {
String packageName = getPackageName(); String packageName = getPackageName();
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return pm.isIgnoringBatteryOptimizations(packageName); return pm.isIgnoringBatteryOptimizations(packageName);
} else {
return true;
}
} }
@RequiresApi(api = Build.VERSION_CODES.M) @RequiresApi(api = Build.VERSION_CODES.M)
@ -138,9 +141,11 @@ public abstract class AbsBaseActivity extends AbsThemeActivity {
@RequiresApi(api = Build.VERSION_CODES.M) @RequiresApi(api = Build.VERSION_CODES.M)
private boolean checkPermissions() { private boolean checkPermissions() {
for (String permission : permissions) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { for (String permission : permissions) {
return false; if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
return false;
}
} }
} }

View file

@ -1,14 +1,19 @@
package org.adrianvictor.geleia.activities.base; package org.adrianvictor.geleia.activities.base;
import static org.adrianvictor.geleia.adapter.CustomFragmentStatePagerAdapter.TAG;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.adrianvictor.geleia.App; import org.adrianvictor.geleia.App;
import org.adrianvictor.geleia.fragments.OfflineFragment;
import org.adrianvictor.geleia.interfaces.StateListener; import org.adrianvictor.geleia.interfaces.StateListener;
import org.adrianvictor.geleia.service.LoginService; import org.adrianvictor.geleia.service.LoginService;
import org.adrianvictor.geleia.util.NavigationUtil; import org.adrianvictor.geleia.util.NavigationUtil;
@ -24,7 +29,7 @@ public abstract class AbsMusicContentActivity extends AbsMusicPanelActivity impl
onStateOnline(); onStateOnline();
break; break;
case LoginService.STATE_OFFLINE: case LoginService.STATE_OFFLINE:
NavigationUtil.startLogin(context); onStateOffline();
break; break;
} }
} }
@ -39,7 +44,11 @@ public abstract class AbsMusicContentActivity extends AbsMusicPanelActivity impl
filter.addAction(LoginService.STATE_ONLINE); filter.addAction(LoginService.STATE_ONLINE);
filter.addAction(LoginService.STATE_OFFLINE); filter.addAction(LoginService.STATE_OFFLINE);
registerReceiver(receiver, filter); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(receiver, filter);
}
if (App.getApiClient() == null) { if (App.getApiClient() == null) {
startService(new Intent(this, LoginService.class)); startService(new Intent(this, LoginService.class));
@ -51,24 +60,14 @@ public abstract class AbsMusicContentActivity extends AbsMusicPanelActivity impl
@Override @Override
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
if (App.getApiClient() == null) {
startService(new Intent(this, LoginService.class));
}
} }
@Override @Override
protected void onDestroy() { protected void onDestroy() {
unregisterReceiver(receiver); unregisterReceiver(receiver);
super.onDestroy(); super.onDestroy();
} }
@Override @Override
public void onStatePolling() { public void onStatePolling() {}
}
@Override
public void onStateOffline() {
}
} }

View file

@ -74,6 +74,11 @@ public class AlbumDetailActivity extends AbsMusicContentActivity implements Pale
}); });
} }
@Override
public void onStateOffline() {
}
@Override @Override
public void onOffsetChanged (AppBarLayout appBarLayout, int verticalOffset) { public void onOffsetChanged (AppBarLayout appBarLayout, int verticalOffset) {
float headerAlpha = Math.max(0, Math.min(1, 1 + (2 * (float) verticalOffset / headerViewHeight))); float headerAlpha = Math.max(0, Math.min(1, 1 + (2 * (float) verticalOffset / headerViewHeight)));
@ -231,6 +236,6 @@ public class AlbumDetailActivity extends AbsMusicContentActivity implements Pale
binding.durationText.setText(MusicUtil.getReadableDurationString(MusicUtil.getTotalDuration(this, album.songs))); binding.durationText.setText(MusicUtil.getReadableDurationString(MusicUtil.getTotalDuration(this, album.songs)));
binding.albumYearText.setText(MusicUtil.getYearString(album.year)); binding.albumYearText.setText(MusicUtil.getYearString(album.year));
if (album.songs.size() != 0) adapter.swapDataSet(album.songs); if (!album.songs.isEmpty()) adapter.swapDataSet(album.songs);
} }
} }

View file

@ -87,6 +87,11 @@ public class ArtistDetailActivity extends AbsMusicContentActivity implements Pal
}); });
} }
@Override
public void onStateOffline() {
}
@Override @Override
public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) { public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
float headerAlpha = Math.max(0, Math.min(1, 1 + (2 * (float) verticalOffset / headerViewHeight))); float headerAlpha = Math.max(0, Math.min(1, 1 + (2 * (float) verticalOffset / headerViewHeight)));
@ -258,7 +263,7 @@ public class ArtistDetailActivity extends AbsMusicContentActivity implements Pal
binding.albumCountText.setText(MusicUtil.getAlbumCountString(this, artist.albums.size())); binding.albumCountText.setText(MusicUtil.getAlbumCountString(this, artist.albums.size()));
binding.durationText.setText(MusicUtil.getReadableDurationString(MusicUtil.getTotalDuration(this, artist.songs))); binding.durationText.setText(MusicUtil.getReadableDurationString(MusicUtil.getTotalDuration(this, artist.songs)));
if (artist.songs.size() != 0) songAdapter.swapDataSet(artist.songs); if (!artist.songs.isEmpty()) songAdapter.swapDataSet(artist.songs);
if (artist.albums.size() != 0) albumAdapter.swapDataSet(artist.albums); if (!artist.albums.isEmpty()) albumAdapter.swapDataSet(artist.albums);
} }
} }

View file

@ -59,6 +59,11 @@ public class GenreDetailActivity extends AbsMusicContentActivity implements CabH
}); });
} }
@Override
public void onStateOffline() {
}
@Override @Override
protected View createContentView() { protected View createContentView() {
binding = ActivityGenreDetailBinding.inflate(getLayoutInflater()); binding = ActivityGenreDetailBinding.inflate(getLayoutInflater());

View file

@ -71,6 +71,11 @@ public class PlaylistDetailActivity extends AbsMusicContentActivity implements C
}); });
} }
@Override
public void onStateOffline() {
}
@Override @Override
protected View createContentView() { protected View createContentView() {
binding = ActivityPlaylistDetailBinding.inflate(getLayoutInflater()); binding = ActivityPlaylistDetailBinding.inflate(getLayoutInflater());

View file

@ -180,7 +180,7 @@ public abstract class CustomFragmentStatePagerAdapter extends PagerAdapter {
@Override @Override
public Parcelable saveState() { public Parcelable saveState() {
Bundle state = null; Bundle state = null;
if (mSavedState.size() > 0) { if (!mSavedState.isEmpty()) {
state = new Bundle(); state = new Bundle();
Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()]; Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()];
mSavedState.toArray(fss); mSavedState.toArray(fss);

View file

@ -0,0 +1,69 @@
package org.adrianvictor.geleia.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.adrianvictor.geleia.R;
import org.adrianvictor.geleia.model.Song;
import java.util.ArrayList;
import java.util.List;
public class DownloadsAdapter extends RecyclerView.Adapter<DownloadsAdapter.ViewHolder> {
private final List<Song> mSongs;
private final int mLayoutId;
public DownloadsAdapter(int layoutId) {
mLayoutId = layoutId;
this.mSongs = new ArrayList<>();
}
public void swapDataSet(List<Song> newSongs) {
mSongs.clear();
if (newSongs != null) {
mSongs.addAll(newSongs);
}
notifyDataSetChanged();
}
@NonNull
@Override
public DownloadsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(mLayoutId, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull DownloadsAdapter.ViewHolder holder, int position) {
final Song song = mSongs.get(position);
holder.title.setText(song.title);
holder.artist.setText(song.artistName);
// TODO: Load album cover into holder.cover using Glide
}
@Override
public int getItemCount() {
return mSongs.size();
}
public static class ViewHolder extends RecyclerView.ViewHolder {
public final TextView title;
public final TextView artist;
public final ImageView cover;
public ViewHolder(View itemView) {
super(itemView);
title = itemView.findViewById(R.id.title);
artist = itemView.findViewById(R.id.text);
cover = itemView.findViewById(R.id.image);
}
}
}

View file

@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter; import androidx.fragment.app.FragmentPagerAdapter;
import org.adrianvictor.geleia.activities.UnreachableActivity;
import org.adrianvictor.geleia.fragments.OfflineFragment;
import org.adrianvictor.geleia.fragments.library.DownloadsFragment;
import org.adrianvictor.geleia.fragments.library.FavoritesFragment; import org.adrianvictor.geleia.fragments.library.FavoritesFragment;
import org.adrianvictor.geleia.model.Category; import org.adrianvictor.geleia.model.Category;
import org.adrianvictor.geleia.fragments.library.AlbumsFragment; import org.adrianvictor.geleia.fragments.library.AlbumsFragment;
@ -157,7 +160,8 @@ public class MusicLibraryPagerAdapter extends FragmentPagerAdapter {
ARTISTS(ArtistsFragment.class), ARTISTS(ArtistsFragment.class),
GENRES(GenresFragment.class), GENRES(GenresFragment.class),
PLAYLISTS(PlaylistsFragment.class), PLAYLISTS(PlaylistsFragment.class),
FAVORITES(FavoritesFragment.class); FAVORITES(FavoritesFragment.class),
DOWNLOADS(DownloadsFragment.class);
private final Class<? extends Fragment> mFragmentClass; private final Class<? extends Fragment> mFragmentClass;

View file

@ -14,6 +14,9 @@ public interface CacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
void insertCache(Cache cache); void insertCache(Cache cache);
@Query("SELECT * FROM cache")
List<Cache> getAll();
@Query("SELECT * FROM songs LEFT JOIN cache USING(id) WHERE songs.id IN (:ids)") @Query("SELECT * FROM songs LEFT JOIN cache USING(id) WHERE songs.id IN (:ids)")
List<Song> getSongs(List<String> ids); List<Song> getSongs(List<String> ids);

View file

@ -31,7 +31,7 @@ public class SongShareDialog extends DialogFragment {
final String currentlyListening = getString(R.string.currently_listening_to_x_by_x, song.title, song.artistName); final String currentlyListening = getString(R.string.currently_listening_to_x_by_x, song.title, song.artistName);
return new MaterialDialog.Builder(requireActivity()) return new MaterialDialog.Builder(requireActivity())
.title(R.string.what_do_you_want_to_share) .title(R.string.what_do_you_want_to_share)
.items(getString(R.string.the_audio_file), "\u201C" + currentlyListening + "\u201D") .items(getString(R.string.the_audio_file), "" + currentlyListening + "")
.itemsCallback((materialDialog, view, i, charSequence) -> { .itemsCallback((materialDialog, view, i, charSequence) -> {
switch (i) { switch (i) {
case 0: case 0:

View file

@ -0,0 +1,25 @@
package org.adrianvictor.geleia.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import org.adrianvictor.geleia.R;
public class OfflineFragment extends Fragment {
public static OfflineFragment newInstance() {
return new OfflineFragment();
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_offline, container, false);
}
}

View file

@ -0,0 +1,125 @@
package org.adrianvictor.geleia.fragments.library;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.GridLayoutManager;
import org.adrianvictor.geleia.App;
import org.adrianvictor.geleia.adapter.DownloadsAdapter;
import org.adrianvictor.geleia.database.Cache;
import org.adrianvictor.geleia.model.Song;
import org.adrianvictor.geleia.model.SortMethod;
import org.adrianvictor.geleia.model.SortOrder;
import java.util.ArrayList;
import java.util.List;
public class DownloadsFragment extends AbsLibraryPagerRecyclerViewCustomGridSizeFragment<DownloadsAdapter, GridLayoutManager, Void> {
@NonNull
@Override
protected DownloadsAdapter createAdapter() {
return new DownloadsAdapter(getItemLayoutRes());
}
@NonNull
@Override
protected GridLayoutManager createLayoutManager() {
return new GridLayoutManager(getActivity(), getGridSize());
}
@NonNull
@Override
protected Void createQuery() {
return null;
}
@Override
protected void loadItems(int index) {
new Thread(() -> {
List<Cache> cachedEntries = App.getDatabase().cacheDao().getAll();
List<String> songIds = new ArrayList<>();
for (Cache entry : cachedEntries) {
songIds.add(entry.id);
}
final List<Song> downloadedSongs = App.getDatabase().cacheDao().getSongs(songIds);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
getAdapter().swapDataSet(downloadedSongs);
});
}
}).start();
}
@Override
protected int loadGridSize() {
return 1;
}
@Override
protected void saveGridSize(int gridColumns) {
}
@Override
protected int loadGridSizeLand() {
return 1;
}
@Override
protected void saveGridSizeLand(int gridColumns) {
}
@Override
protected void saveUsePalette(boolean usePalette) {
}
@Override
protected boolean loadUsePalette() {
return false;
}
@Override
protected void setUsePalette(boolean usePalette) {
}
@Override
protected void setGridSize(int gridSize) {
}
@Override
protected SortMethod loadSortMethod() {
return SortMethod.ADDED;
}
@Override
protected void saveSortMethod(SortMethod sortMethod) {
}
@Override
protected void setSortMethod(SortMethod sortMethod) {
}
@Override
protected SortOrder loadSortOrder() {
return SortOrder.ASCENDING;
}
@Override
protected void saveSortOrder(SortOrder sortOrder) {
}
@Override
protected void setSortOrder(SortOrder sortOrder) {
}
}

View file

@ -273,7 +273,7 @@ public class MusicPlayerRemote {
public static boolean playNext(Song song) { public static boolean playNext(Song song) {
if (musicService != null && musicService.queueManager != null) { if (musicService != null && musicService.queueManager != null) {
if (getPlayingQueue().size() > 0) { if (!getPlayingQueue().isEmpty()) {
musicService.queueManager.addSong(getPosition() + 1, song); musicService.queueManager.addSong(getPosition() + 1, song);
} else { } else {
List<Song> queue = new ArrayList<>(); List<Song> queue = new ArrayList<>();
@ -290,7 +290,7 @@ public class MusicPlayerRemote {
public static boolean playNext(@NonNull List<Song> songs) { public static boolean playNext(@NonNull List<Song> songs) {
if (musicService != null && musicService.queueManager != null) { if (musicService != null && musicService.queueManager != null) {
if (getPlayingQueue().size() > 0) { if (!getPlayingQueue().isEmpty()) {
musicService.queueManager.addSongs(getPosition() + 1, songs); musicService.queueManager.addSongs(getPosition() + 1, songs);
} else { } else {
openQueue(songs, 0, false); openQueue(songs, 0, false);
@ -306,7 +306,7 @@ public class MusicPlayerRemote {
public static boolean enqueue(Song song) { public static boolean enqueue(Song song) {
if (musicService != null && musicService.queueManager != null) { if (musicService != null && musicService.queueManager != null) {
if (getPlayingQueue().size() > 0) { if (!getPlayingQueue().isEmpty()) {
musicService.queueManager.addSong(song); musicService.queueManager.addSong(song);
} else { } else {
List<Song> queue = new ArrayList<>(); List<Song> queue = new ArrayList<>();
@ -323,7 +323,7 @@ public class MusicPlayerRemote {
public static boolean enqueue(@NonNull List<Song> songs) { public static boolean enqueue(@NonNull List<Song> songs) {
if (musicService != null && musicService.queueManager != null) { if (musicService != null && musicService.queueManager != null) {
if (getPlayingQueue().size() > 0) { if (!getPlayingQueue().isEmpty()) {
musicService.queueManager.addSongs(songs); musicService.queueManager.addSongs(songs);
} else { } else {
openQueue(songs, 0, false); openQueue(songs, 0, false);

View file

@ -29,10 +29,10 @@ public class Album implements Parcelable {
this.title = itemDto.getName(); this.title = itemDto.getName();
this.year = itemDto.getProductionYear() != null ? itemDto.getProductionYear() : 0; this.year = itemDto.getProductionYear() != null ? itemDto.getProductionYear() : 0;
if (itemDto.getAlbumArtists().size() != 0) { if (!itemDto.getAlbumArtists().isEmpty()) {
this.artistId = itemDto.getAlbumArtists().get(0).getId(); this.artistId = itemDto.getAlbumArtists().get(0).getId();
this.artistName = itemDto.getAlbumArtists().get(0).getName(); this.artistName = itemDto.getAlbumArtists().get(0).getName();
} else if (itemDto.getArtistItems().size() != 0) { } else if (!itemDto.getArtistItems().isEmpty()) {
this.artistId = itemDto.getArtistItems().get(0).getId(); this.artistId = itemDto.getArtistItems().get(0).getId();
this.artistName = itemDto.getArtistItems().get(0).getName(); this.artistName = itemDto.getArtistItems().get(0).getName();
} }

View file

@ -10,7 +10,8 @@ public enum Category {
ARTISTS(R.string.artists), ARTISTS(R.string.artists),
GENRES(R.string.genres), GENRES(R.string.genres),
PLAYLISTS(R.string.playlists), PLAYLISTS(R.string.playlists),
FAVORITES(R.string.favorites); FAVORITES(R.string.favorites),
DOWNLOADS(R.string.downloads);
@StringRes @StringRes
public final int title; public final int title;

View file

@ -67,10 +67,10 @@ public class Song implements Parcelable {
this.albumId = itemDto.getAlbumId(); this.albumId = itemDto.getAlbumId();
this.albumName = itemDto.getAlbum(); this.albumName = itemDto.getAlbum();
if (itemDto.getArtistItems().size() != 0) { if (!itemDto.getArtistItems().isEmpty()) {
this.artistId = itemDto.getArtistItems().get(0).getId(); this.artistId = itemDto.getArtistItems().get(0).getId();
this.artistName = itemDto.getArtistItems().get(0).getName(); this.artistName = itemDto.getArtistItems().get(0).getName();
} else if (itemDto.getAlbumArtists().size() != 0) { } else if (!itemDto.getAlbumArtists().isEmpty()) {
this.artistId = itemDto.getAlbumArtists().get(0).getId(); this.artistId = itemDto.getAlbumArtists().get(0).getId();
this.artistName = itemDto.getAlbumArtists().get(0).getName(); this.artistName = itemDto.getAlbumArtists().get(0).getName();
} }
@ -93,7 +93,7 @@ public class Song implements Parcelable {
this.supportsTranscoding = source.getSupportsTranscoding(); this.supportsTranscoding = source.getSupportsTranscoding();
if (source.getMediaStreams() != null && source.getMediaStreams().size() != 0) { if (source.getMediaStreams() != null && !source.getMediaStreams().isEmpty()) {
MediaStream stream = source.getMediaStreams().get(0); MediaStream stream = source.getMediaStreams().get(0);
this.codec = stream.getCodec(); this.codec = stream.getCodec();

View file

@ -4,6 +4,8 @@ import android.app.Service;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.IBinder; import android.os.IBinder;
import android.util.Log;
import androidx.documentfile.provider.DocumentFile; import androidx.documentfile.provider.DocumentFile;
import org.adrianvictor.geleia.App; import org.adrianvictor.geleia.App;
@ -11,6 +13,7 @@ import org.adrianvictor.geleia.BuildConfig;
import org.adrianvictor.geleia.database.Cache; import org.adrianvictor.geleia.database.Cache;
import org.adrianvictor.geleia.model.Song; import org.adrianvictor.geleia.model.Song;
import org.adrianvictor.geleia.service.notifications.DownloadNotification; import org.adrianvictor.geleia.service.notifications.DownloadNotification;
import org.adrianvictor.geleia.service.notifications.ErrorNotification;
import org.adrianvictor.geleia.util.MusicUtil; import org.adrianvictor.geleia.util.MusicUtil;
import org.adrianvictor.geleia.util.PreferenceUtil; import org.adrianvictor.geleia.util.PreferenceUtil;
@ -23,8 +26,8 @@ import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@SuppressWarnings("ResultOfMethodCallIgnored")
public class DownloadService extends Service { public class DownloadService extends Service {
public static final String TAG = DownloadService.class.getSimpleName();
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
public static final String ACTION_START = PACKAGE_NAME + ".action.start"; public static final String ACTION_START = PACKAGE_NAME + ".action.start";
public static final String ACTION_CANCEL = PACKAGE_NAME + ".action.cancel"; public static final String ACTION_CANCEL = PACKAGE_NAME + ".action.cancel";
@ -33,6 +36,8 @@ public class DownloadService extends Service {
private ExecutorService executor; private ExecutorService executor;
private DownloadNotification notification; private DownloadNotification notification;
private static final Object lock = new Object();
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
@ -55,6 +60,7 @@ public class DownloadService extends Service {
break; break;
case DownloadService.ACTION_START: case DownloadService.ACTION_START:
List<Song> songs = intent.getParcelableArrayListExtra(EXTRA_SONGS); List<Song> songs = intent.getParcelableArrayListExtra(EXTRA_SONGS);
assert songs != null;
for (Song song : songs) { for (Song song : songs) {
download(song); download(song);
notification.start(song); notification.start(song);
@ -78,19 +84,24 @@ public class DownloadService extends Service {
String location = PreferenceUtil.getInstance(App.getInstance()).getLocationDownload(); String location = PreferenceUtil.getInstance(App.getInstance()).getLocationDownload();
DocumentFile root; DocumentFile root;
if (location.equals(getApplicationContext().getCacheDir().toString())) { if (location.startsWith("content://")) {
root = DocumentFile.fromFile(new File(location));
} else {
root = DocumentFile.fromTreeUri(this, Uri.parse(location)); root = DocumentFile.fromTreeUri(this, Uri.parse(location));
} else {
root = DocumentFile.fromFile(new File(location));
} }
DocumentFile artist = root.findFile(MusicUtil.ascii(song.artistName)); DocumentFile artist;
if (artist == null) { DocumentFile album;
artist = root.createDirectory(MusicUtil.ascii(song.artistName));
} synchronized (lock) {
DocumentFile album = artist.findFile(MusicUtil.ascii(song.albumName)); artist = root.findFile(MusicUtil.ascii(song.artistName));
if (album == null) { if (artist == null) {
album = artist.createDirectory(MusicUtil.ascii(song.albumName)); artist = root.createDirectory(MusicUtil.ascii(song.artistName));
}
album = artist.findFile(MusicUtil.ascii(song.albumName));
if (album == null) {
album = artist.createDirectory(MusicUtil.ascii(song.albumName));
}
} }
String fileName = song.discNumber + "." + song.trackNumber + " - " + MusicUtil.ascii(song.title) + "." + song.container; String fileName = song.discNumber + "." + song.trackNumber + " - " + MusicUtil.ascii(song.title) + "." + song.container;
@ -116,7 +127,8 @@ public class DownloadService extends Service {
App.getDatabase().cacheDao().insertCache(new Cache(song)); App.getDatabase().cacheDao().insertCache(new Cache(song));
notification.stop(song); notification.stop(song);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); Log.e(TAG, "Failed to download song: " + song.title, e);
ErrorNotification.show(this, e.getMessage());
} }
}); });
} }

View file

@ -46,9 +46,19 @@ public class LoginService extends Service {
if (user == null) { if (user == null) {
Toast.makeText(this, context.getResources().getString(R.string.error_unexpected), Toast.LENGTH_SHORT).show(); Toast.makeText(this, context.getResources().getString(R.string.error_unexpected), Toast.LENGTH_SHORT).show();
sendBroadcast(new Intent(STATE_OFFLINE));
return; return;
} }
if (App.getApiClient() == null) {
try {
App.createApiClient(context);
} catch (Exception e) {
sendBroadcast(new Intent(STATE_OFFLINE));
return;
}
}
App.getApiClient().ChangeServerLocation(user.server); App.getApiClient().ChangeServerLocation(user.server);
App.getApiClient().SetAuthenticationInfo(user.token, user.id); App.getApiClient().SetAuthenticationInfo(user.token, user.id);
App.getApiClient().GetSystemInfoAsync(new Response<SystemInfo>() { App.getApiClient().GetSystemInfoAsync(new Response<SystemInfo>() {

View file

@ -1,114 +1,134 @@
package org.adrianvictor.geleia.service.notifications; package org.adrianvictor.geleia.service.notifications;
import android.app.Notification;
import android.app.NotificationChannel; import android.app.NotificationChannel;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.widget.RemoteViews;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import org.adrianvictor.geleia.R; import org.adrianvictor.geleia.R;
import org.adrianvictor.geleia.activities.MainActivity;
import org.adrianvictor.geleia.model.Song; import org.adrianvictor.geleia.model.Song;
import org.adrianvictor.geleia.service.DownloadService; import org.adrianvictor.geleia.service.DownloadService;
import java.util.ArrayList; import java.util.Collections;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import static android.content.Context.NOTIFICATION_SERVICE;
public class DownloadNotification { public class DownloadNotification {
private static final String CHANNEL_ID = DownloadNotification.class.getSimpleName(); private static final String CHANNEL_ID = "download_channel";
private static final int NOTIFICATION_ID = 2; private final int ID = 2;
private final Context context; private final Context context;
private final NotificationManager notificationManager; private final NotificationManager notificationManager;
private final List<Song> queue = Collections.synchronizedList(new LinkedList<>());
private final List<Song> songs; private long totalSize;
private long downloadedSize;
private int current; private int lastPercentage = -1;
private int maximum;
public DownloadNotification(Context context) { public DownloadNotification(Context context) {
this.notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE);
this.context = context; this.context = context;
this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
this.songs = new ArrayList<>(); createNotificationChannel();
} }
public synchronized void start(Song song) { private void createNotificationChannel() {
this.songs.add(song);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(); NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
context.getString(R.string.download_channel_name),
NotificationManager.IMPORTANCE_LOW
);
notificationManager.createNotificationChannel(channel);
} }
} }
public synchronized void update(int current, int maximum) { public void start(Song song) {
this.current += current; queue.add(song);
this.maximum += maximum; if (queue.size() == 1) { // This is the first song of a new batch
totalSize = 0;
Intent action = new Intent(context, MainActivity.class).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); downloadedSize = 0;
PendingIntent clickIntent = PendingIntent.getActivity(context, 0, action, PendingIntent.FLAG_IMMUTABLE); lastPercentage = -1;
notificationManager.notify(ID, getNotification());
Intent cancel = new Intent(context, DownloadService.class).setAction(DownloadService.ACTION_CANCEL); } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
PendingIntent pendingCancel = PendingIntent.getService(context, 0, cancel, PendingIntent.FLAG_IMMUTABLE); // For KitKat, update for every new song to show correct count.
notificationManager.notify(ID, getNotification());
NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle();
for (Song item : songs.stream().limit(5).collect(Collectors.toList())) {
style.addLine(item.title);
} }
}
public void update(long downloaded, long total) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
return; // No progress updates for KitKat
}
synchronized (this) {
totalSize += total;
downloadedSize += downloaded;
int percentage = 0;
if (totalSize > 0) {
percentage = (int) ((downloadedSize * 100) / totalSize);
}
if (percentage > lastPercentage) {
lastPercentage = percentage;
notificationManager.notify(ID, getNotification());
}
}
}
public void stop(Song song) {
queue.remove(song);
if (queue.isEmpty()) {
notificationManager.cancel(ID);
} else {
// Update notification to show new queue size.
// On KitKat, this is the only update after a download finishes.
notificationManager.notify(ID, getNotification());
}
}
public void cancelAll() {
queue.clear();
notificationManager.cancel(ID);
}
private Notification getNotification() {
Intent intent = new Intent(context, DownloadService.class);
intent.setAction(DownloadService.ACTION_CANCEL);
int flags = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, flags);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentIntent(clickIntent) .setContentTitle(context.getString(R.string.downloading_songs))
.setContentTitle(String.format(context.getString(R.string.downloading_x_songs), songs.size())) .setOngoing(true)
.setProgress(this.maximum, this.current, false) .addAction(android.R.drawable.ic_menu_close_clear_cancel, context.getString(android.R.string.cancel), pendingIntent);
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.ic_close_white_24dp, context.getString(R.string.action_cancel), pendingCancel)
.setStyle(style)
.setShowWhen(false);
notificationManager.notify(NOTIFICATION_ID, builder.build()); String contentText = context.getResources().getQuantityString(R.plurals.downloading_s_songs, queue.size(), queue.size());
}
public synchronized void stop(Song song) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (song != null) { RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.notification_download);
songs.remove(song); remoteViews.setTextViewText(R.id.notification_download_title, contentText);
} else { int progress = lastPercentage > 0 ? lastPercentage : 0;
songs.clear(); remoteViews.setProgressBar(R.id.notification_download_progress, 100, progress, totalSize == 0 && downloadedSize == 0);
builder.setCustomContentView(remoteViews);
} else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
int progress = lastPercentage > 0 ? lastPercentage : 0;
builder.setProgress(100, progress, totalSize == 0 && downloadedSize == 0);
builder.setContentText(contentText);
} else { // KitKat
builder.setContentText(contentText);
} }
if (songs.size() != 0) { return builder.build();
return;
}
current = 0;
maximum = 0;
notificationManager.cancel(NOTIFICATION_ID);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.deleteNotificationChannel(CHANNEL_ID);
}
}
@RequiresApi(Build.VERSION_CODES.O)
private void createNotificationChannel() {
NotificationChannel notificationChannel = notificationManager.getNotificationChannel(CHANNEL_ID);
if (notificationChannel == null) {
notificationChannel = new NotificationChannel(CHANNEL_ID, context.getString(R.string.action_download), NotificationManager.IMPORTANCE_LOW);
notificationChannel.setDescription(context.getString(R.string.playing_notification_description));
notificationChannel.enableLights(false);
notificationChannel.enableVibration(false);
notificationManager.createNotificationChannel(notificationChannel);
}
} }
} }

View file

@ -0,0 +1,49 @@
package org.adrianvictor.geleia.service.notifications;
import static android.content.Context.NOTIFICATION_SERVICE;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import org.adrianvictor.geleia.R;
public class ErrorNotification {
private static final String CHANNEL_ID = ErrorNotification.class.getSimpleName();
private static final int NOTIFICATION_ID = 3;
private ErrorNotification() {}
public static void show(Context context, String error) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE);
createNotificationChannel(notificationManager);
NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle();
style.setBigContentTitle("Error:").bigText(error);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_bug_report_white_24dp)
.setContentTitle(context.getString(R.string.error_notification_title))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(style)
.setContentText("Expand the notification for details.");
}
public static void createNotificationChannel(NotificationManager notificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(CHANNEL_ID);
if (channel == null) {
channel = new NotificationChannel(CHANNEL_ID, "Errors", NotificationManager.IMPORTANCE_LOW);
channel.setDescription("Displays all sorts of errors.");
channel.enableLights(false);
channel.enableVibration(true);
notificationManager.createNotificationChannel(channel);
}
}
}
}

View file

@ -2,6 +2,7 @@ package org.adrianvictor.geleia.service.playback;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
@ -141,11 +142,17 @@ public class LocalPlayer implements Playback {
private List<MediaItem> createMediaItems(List<Song> queue) { private List<MediaItem> createMediaItems(List<Song> queue) {
return queue.stream().map(song -> { return queue.stream().map(song -> {
File audio = new File(MusicUtil.getFileUri(song)); String fileUri = MusicUtil.getFileUri(song);
Uri uri = Uri.fromFile(audio); Uri uri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && fileUri.startsWith("content://")) {
uri = Uri.parse(fileUri);
} else {
File audio = new File(fileUri);
uri = Uri.fromFile(audio);
if (!audio.exists()) { if (!audio.exists()) {
uri = Uri.parse(MusicUtil.getTranscodeUri(song)); uri = Uri.parse(MusicUtil.getTranscodeUri(song));
}
} }
List<String> containers = PreferenceUtil.getInstance(context).getDirectPlayCodecs().stream() List<String> containers = PreferenceUtil.getInstance(context).getDirectPlayCodecs().stream()

View file

@ -14,6 +14,7 @@ import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes; import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.content.res.ResourcesCompat; import androidx.core.content.res.ResourcesCompat;
@ -91,18 +92,22 @@ public class ImageUtil {
} }
public static Drawable getTintedVectorDrawable(@NonNull Context context, @DrawableRes int resId, @ColorInt int color) { public static Drawable getTintedVectorDrawable(@NonNull Context context, @DrawableRes int resId, @ColorInt int color) {
Drawable drawable = getVectorDrawable(context.getResources(), resId, context.getTheme()); final Drawable drawable = AppCompatResources.getDrawable(context, resId);
DrawableCompat.setTintMode(drawable, PorterDuff.Mode.SRC_IN); if (drawable != null) {
DrawableCompat.setTint(drawable, color); Drawable wrappedDrawable = DrawableCompat.wrap(drawable).mutate();
return drawable; DrawableCompat.setTintMode(wrappedDrawable, PorterDuff.Mode.SRC_IN);
DrawableCompat.setTint(wrappedDrawable, color);
return wrappedDrawable;
}
return null;
} }
public static Drawable getVectorDrawable(@NonNull Context context, @DrawableRes int resId) { public static Drawable getVectorDrawable(@NonNull Context context, @DrawableRes int resId) {
return getVectorDrawable(context.getResources(), resId, context.getTheme()); return AppCompatResources.getDrawable(context, resId);
} }
public static Drawable resolveDrawable(@NonNull Context context, @AttrRes int drawableAttr) { public static Drawable resolveDrawable(@NonNull Context context, @AttrRes int drawableAttr) {
TypedArray a = context.obtainStyledAttributes(new int[]{drawableAttr}); TypedArray a = context.obtainStyledAttributes(new int[]{drawableAttr});
Drawable drawable = a.getDrawable(0); Drawable drawable = a.getDrawable(0);

View file

@ -3,6 +3,7 @@ package org.adrianvictor.geleia.util;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
@ -18,6 +19,7 @@ import org.adrianvictor.geleia.model.Codec;
import org.adrianvictor.geleia.model.Genre; import org.adrianvictor.geleia.model.Genre;
import org.adrianvictor.geleia.model.Song; import org.adrianvictor.geleia.model.Song;
import org.adrianvictor.geleia.service.notifications.ErrorNotification;
import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response; import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.UserItemDataDto; import org.jellyfin.apiclient.model.dto.UserItemDataDto;
@ -49,7 +51,7 @@ public class MusicUtil {
List<Codec> codecs = preferenceUtil.getDirectPlayCodecs(); List<Codec> codecs = preferenceUtil.getDirectPlayCodecs();
Stream<String> values = codecs.stream().map(codec -> codec.value); Stream<String> values = codecs.stream().map(codec -> codec.value);
if (codecs.size() != 0) { if (!codecs.isEmpty()) {
builder.append("&Container=").append(values.collect(Collectors.joining(","))); builder.append("&Container=").append(values.collect(Collectors.joining(",")));
} }
@ -81,7 +83,8 @@ public class MusicUtil {
public static String getFileUri(Song song) { public static String getFileUri(Song song) {
String location = PreferenceUtil.getInstance(App.getInstance()).getLocationDownload(); String location = PreferenceUtil.getInstance(App.getInstance()).getLocationDownload();
File root = new File(location, "music"); File root = new File(location, "music");
if (!location.equals(App.getInstance().getCacheDir().toString())) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !location.equals(App.getInstance().getCacheDir().toString())) {
Uri uri = Uri.parse(location); Uri uri = Uri.parse(location);
return new File(uri.getPath()).getAbsolutePath(); return new File(uri.getPath()).getAbsolutePath();
} }
@ -102,7 +105,7 @@ public class MusicUtil {
try { try {
return new Intent(); return new Intent();
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
e.printStackTrace(); ErrorNotification.show(context, e.getMessage());
Toast.makeText(context, R.string.error_share_file, Toast.LENGTH_SHORT).show(); Toast.makeText(context, R.string.error_share_file, Toast.LENGTH_SHORT).show();
return new Intent(); return new Intent();
} }
@ -110,7 +113,7 @@ public class MusicUtil {
@NonNull @NonNull
public static String getArtistInfoString(@NonNull final Context context, @NonNull final Artist artist) { public static String getArtistInfoString(@NonNull final Context context, @NonNull final Artist artist) {
return artist.genres.size() != 0 ? artist.genres.get(0).name : ""; return !artist.genres.isEmpty() ? artist.genres.get(0).name : "";
} }
@NonNull @NonNull

View file

@ -12,6 +12,7 @@ import androidx.core.util.Pair;
import org.adrianvictor.geleia.activities.LoginActivity; import org.adrianvictor.geleia.activities.LoginActivity;
import org.adrianvictor.geleia.activities.MainActivity; import org.adrianvictor.geleia.activities.MainActivity;
import org.adrianvictor.geleia.activities.SelectActivity; import org.adrianvictor.geleia.activities.SelectActivity;
import org.adrianvictor.geleia.activities.UnreachableActivity;
import org.adrianvictor.geleia.model.Album; import org.adrianvictor.geleia.model.Album;
import org.adrianvictor.geleia.model.Artist; import org.adrianvictor.geleia.model.Artist;
import org.adrianvictor.geleia.model.Genre; import org.adrianvictor.geleia.model.Genre;
@ -61,6 +62,13 @@ public class NavigationUtil {
context.startActivity(intent); context.startActivity(intent);
} }
public static void startUnreachable(Context context) {
final Intent intent = new Intent(context, UnreachableActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(intent);
}
public static void startSelect(Context context) { public static void startSelect(Context context) {
final Intent intent = new Intent(context, SelectActivity.class); final Intent intent = new Intent(context, SelectActivity.class);

View file

@ -48,7 +48,7 @@ public class PlaylistUtil {
PlaylistCreationRequest request = new PlaylistCreationRequest(); PlaylistCreationRequest request = new PlaylistCreationRequest();
request.setUserId(App.getApiClient().getCurrentUserId()); request.setUserId(App.getApiClient().getCurrentUserId());
request.setName(name); request.setName(name);
if (ids.size() != 0) request.setItemIdList(ids); if (!ids.isEmpty()) request.setItemIdList(ids);
App.getApiClient().CreatePlaylist(request, new Response<>()); App.getApiClient().CreatePlaylist(request, new Response<>());
} }

View file

@ -448,7 +448,6 @@ public final class PreferenceUtil {
}).collect(Collectors.toList()); }).collect(Collectors.toList());
} }
@SuppressWarnings("SimplifyStreamApiCallChains")
public void setCategories(List<Category> categories) { public void setCategories(List<Category> categories) {
List<String> values = categories.stream().map(category -> { List<String> values = categories.stream().map(category -> {
return category.select ? category.toString() : category.toString().toLowerCase(); return category.select ? category.toString() : category.toString().toLowerCase();

View file

@ -10,23 +10,27 @@ import org.adrianvictor.geleia.util.ThemeUtil;
import org.adrianvictor.geleia.R; import org.adrianvictor.geleia.R;
public class IconImageView extends AppCompatImageView { public class IconImageView extends AppCompatImageView {
public IconImageView(Context context) { public IconImageView(Context context) {
super(context); super(context);
init(context);
} }
public IconImageView(Context context, AttributeSet attrs) { public IconImageView(Context context, AttributeSet attrs) {
super(context, attrs); super(context, attrs);
init(context);
} }
public IconImageView(Context context, AttributeSet attrs, int defStyleAttr) { public IconImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr); super(context, attrs, defStyleAttr);
init(context);
} }
private void init(Context context) { @Override
if (context == null) return; protected void onAttachedToWindow() {
setColorFilter(ThemeUtil.getColorResource(context, R.attr.iconColor), PorterDuff.Mode.SRC_IN); super.onAttachedToWindow();
init();
}
private void init() {
if (getContext() == null) return;
setColorFilter(ThemeUtil.getColorResource(getContext(), R.attr.iconColor), PorterDuff.Mode.SRC_IN);
} }
} }

View file

@ -36,7 +36,7 @@ public class DynamicShortcutManager {
} }
public void initDynamicShortcuts() { public void initDynamicShortcuts() {
if (shortcutManager.getDynamicShortcuts().size() == 0) { if (shortcutManager.getDynamicShortcuts().isEmpty()) {
shortcutManager.setDynamicShortcuts(getDefaultShortcuts()); shortcutManager.setDynamicShortcuts(getDefaultShortcuts());
} }
} }

View file

@ -1,14 +1,4 @@
<vector android:height="192dp" android:viewportHeight="24" <?xml version="1.0" encoding="utf-8"?>
android:viewportWidth="24" android:width="192dp" <selector xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
<path </selector>
android:pathData="m12,9.8425c-1.1866,0 -2.1575,0.9709 -2.1575,2.1575s0.9709,2.1575 2.1575,2.1575 2.1575,-0.9709 2.1575,-2.1575 -0.9709,-2.1575 -2.1575,-2.1575zM12,1c-6.072,0 -11,4.928 -11,11s4.928,11 11,11 11,-4.928 11,-11 -4.928,-11 -11,-11zM12,7.05c2.739,0 4.95,2.211 4.95,4.95s-2.211,4.95 -4.95,4.95 -4.95,-2.211 -4.95,-4.95 2.211,-4.95 4.95,-4.95z" android:strokeWidth="2.1575">
<aapt:attr name="android:fillColor">
<gradient android:endX="25.2" android:endY="25.1996"
android:startX="1" android:startY="0.99960005" android:type="linear">
<item android:color="#FFAA5CC3" android:offset="0"/>
<item android:color="#FF00A4DC" android:offset="1"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -19,134 +19,122 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<ImageView <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="40dp"
app:srcCompat="@drawable/ic_launcher_nodpi"
tools:ignore="ContentDescription"
app:layout_constraintTop_toBottomOf="@id/toolbar" app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/username_layout" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:id="@+id/username_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:textColorHint="?android:textColorHint"
app:boxCornerRadiusBottomEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusTopStart="24dp"
app:errorEnabled="true"
app:endIconMode="clear_text"
app:endIconTint="?android:textColorSecondary"
app:boxStrokeColor="?android:textColorSecondary"
app:hintTextColor="?android:textColorSecondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/password_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions"
android:hint="@string/username" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:id="@+id/password_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:textColorHint="?android:textColorHint"
app:boxCornerRadiusBottomEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusTopStart="24dp"
app:errorEnabled="true"
app:endIconMode="password_toggle"
app:endIconTint="?android:textColorSecondary"
app:boxStrokeColor="?android:textColorSecondary"
app:hintTextColor="?android:textColorSecondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/server_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:hint="@string/password" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:id="@+id/server_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:textColorHint="?android:textColorHint"
app:boxCornerRadiusBottomEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusTopStart="24dp"
app:errorEnabled="true"
app:endIconMode="clear_text"
app:endIconTint="?android:textColorSecondary"
app:boxStrokeColor="?android:textColorSecondary"
app:hintTextColor="?android:textColorSecondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/login">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:hint="@string/server" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/login"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:layout_marginBottom="16dp"
android:text="@string/login"
app:cornerRadius="24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/select" />
<com.google.android.material.button.MaterialButton
android:id="@+id/select"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="32dp"
android:text="@string/select"
android:backgroundTint="?cardBackgroundColor"
app:cornerRadius="24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
tools:ignore="UnusedAttribute" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:textColorHint="?android:textColorHint"
app:boxCornerRadiusBottomEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusTopStart="24dp"
app:boxStrokeColor="?android:textColorSecondary"
app:endIconMode="clear_text"
app:endIconTint="?android:textColorSecondary"
app:errorEnabled="true"
app:hintTextColor="?android:textColorSecondary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:inputType="textNoSuggestions" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:textColorHint="?android:textColorHint"
app:boxCornerRadiusBottomEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusTopStart="24dp"
app:boxStrokeColor="?android:textColorSecondary"
app:endIconMode="password_toggle"
app:endIconTint="?android:textColorSecondary"
app:errorEnabled="true"
app:hintTextColor="?android:textColorSecondary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/password"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/server_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:textColorHint="?android:textColorHint"
app:boxCornerRadiusBottomEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusTopStart="24dp"
app:boxStrokeColor="?android:textColorSecondary"
app:endIconMode="clear_text"
app:endIconTint="?android:textColorSecondary"
app:errorEnabled="true"
app:hintTextColor="?android:textColorSecondary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/login"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:layout_marginBottom="16dp"
android:text="@string/login"
app:cornerRadius="24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/select"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="32dp"
android:backgroundTint="?cardBackgroundColor"
android:text="@string/select"
app:cornerRadius="24dp"
tools:ignore="UnusedAttribute" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="org.adrianvictor.geleia.activities.UnreachableActivity">
<include layout="@layout/status_bar" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="16dp"
android:clipToPadding="false"
android:isScrollContainer="true">
<include layout="@layout/fragment_offline" />
</ScrollView>
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:layout_marginBottom="16dp"
android:onClick="onSelectClick"
android:text="@string/change_server" />
</LinearLayout>

View file

@ -82,6 +82,50 @@
</LinearLayout> </LinearLayout>
<!-- <LinearLayout-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:background="?attr/rectSelector"-->
<!-- android:clickable="true"-->
<!-- android:focusable="true"-->
<!-- android:gravity="center_vertical"-->
<!-- android:minHeight="@dimen/item_height"-->
<!-- android:orientation="horizontal"-->
<!-- android:paddingLeft="16dp"-->
<!-- android:paddingRight="16dp">-->
<!-- <org.adrianvictor.geleia.views.IconImageView-->
<!-- android:id="@+id/icon_license"-->
<!-- android:layout_width="24dp"-->
<!-- android:layout_height="24dp"-->
<!-- app:srcCompat="@android:drawable/ic_lock_idle_lock"-->
<!-- tools:ignore="ContentDescription" />-->
<!-- <LinearLayout-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginStart="32dp"-->
<!-- android:orientation="vertical"-->
<!-- android:paddingBottom="8dp"-->
<!-- android:paddingTop="8dp">-->
<!-- <TextView-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:text="@string/license"-->
<!-- android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />-->
<!-- <TextView-->
<!-- android:id="@+id/app_license"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:textAppearance="@style/TextAppearance.AppCompat.Caption"-->
<!-- tools:text="@string/application_license" />-->
<!-- </LinearLayout>-->
<!-- </LinearLayout>-->
<LinearLayout <LinearLayout
android:id="@+id/app_source" android:id="@+id/app_source"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -113,6 +157,14 @@
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center"
android:text="@string/application_license" />
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View file

@ -5,7 +5,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp" android:layout_height="100dp"
android:layout_margin="20dp" android:layout_margin="20dp"
android:background="@drawable/card_server"
android:elevation="4dp" android:elevation="4dp"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">

View file

@ -27,7 +27,8 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:background="?attr/dividerColor" /> android:layout_marginBottom="10dp"
android:background="?attr/dividerColor"/>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -76,6 +77,12 @@
</LinearLayout> </LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="10dp"
android:background="?attr/dividerColor" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -134,6 +141,7 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginBottom="10dp"
android:background="?attr/dividerColor" /> android:background="?attr/dividerColor" />
<LinearLayout <LinearLayout
@ -186,6 +194,7 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginBottom="10dp"
android:background="?attr/dividerColor" /> android:background="?attr/dividerColor" />
<LinearLayout <LinearLayout
@ -238,6 +247,7 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginBottom="10dp"
android:background="?attr/dividerColor" /> android:background="?attr/dividerColor" />
<LinearLayout <LinearLayout
@ -298,6 +308,7 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginBottom="10dp"
android:background="?attr/dividerColor" /> android:background="?attr/dividerColor" />
<LinearLayout <LinearLayout

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.adrianvictor.geleia.fragments.library.DownloadsFragment">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="409dp"
android:layout_height="729dp"
tools:layout_editor_absoluteX="1dp"
tools:layout_editor_absoluteY="1dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,40 @@
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:layout_margin="32dp"
tools:context=".fragments.OfflineFragment">
<TextView
android:id="@+id/offline_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:text="@string/sad_face"
android:textAppearance="@style/TextAppearance.AppCompat.Display3" />
<TextView
android:id="@+id/error_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/offline_icon"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:text="@string/oops"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/error_title"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:text="@string/server_is_unreachable"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
</RelativeLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/notification_download_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Compat.Notification.Title"
tools:text="Downloading 2 songs" />
<ProgressBar
android:id="@+id/notification_download_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
</LinearLayout>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before After
Before After

View file

@ -235,5 +235,22 @@
<string name="error_empty_username">Please fill in your username.</string> <string name="error_empty_username">Please fill in your username.</string>
<string name="dkanada_summary">Forking Phonograph and making Gelli</string> <string name="dkanada_summary">Forking Phonograph and making Gelli</string>
<string name="adrianvictor">Adrian Victor</string> <string name="adrianvictor">Adrian Victor</string>
<string name="error_notification_title">An error occurred in Jamfish.</string>
<string name="offline">You are offline.</string>
<string name="change_server">Change server</string>
<string name="oops">Oops! I did it again...</string>
<string name="sad_face">;(</string>
<string name="server_is_unreachable">Sorry, but we couldn\'t reach this server right now.</string>
<string name="license">License</string>
<string name="app_license">GNU General Public License v3.0</string>
<string name="application_license">GNU General Public License v3.0</string>
<string name="download_channel_name">Downloads</string>
<string name="downloading_songs">Downloading songs</string>
<string name="downloads">Downloads</string>
<plurals name="downloading_s_songs">
<item quantity="one">Downloading %d song</item>
<item quantity="other">Downloading %d songs</item>
</plurals>
</resources> </resources>

View file

@ -1 +1,16 @@
This is a native music player for Android devices that connects to Jellyfin media servers. The code is based on Gelli's archived repository, which is based on an old version of Phonograph. Jamfish is made for personal use, but contributions are welcome! Please open an issue to discuss larger changes before submitting a pull request. This is a native music player for Android devices that connects to Jellyfin media servers. The code is based on Gelli's archived repository, which is based on an old version of Phonograph. Jamfish is made for personal use, but contributions are welcome! Please open an issue to discuss larger changes before submitting a pull request.
Features
- Basic library navigation
- Download songs to internal storage individually or through batch actions
- Gapless playback
- Sort albums and songs by different fields
- Search media for partial matches
- Media service integration with notification
- Favorites and playlists
- Playback history reporting
- Filter content by library
Requisites:
- A Jellfin server
- Android 4.4 or later

View file

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 650 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Before After
Before After