diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..4579e35 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13cae44..ebf27e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,16 +4,18 @@ plugins { android { namespace = "me.arimelody.aridroid" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "me.arimelody.aridroid" minSdk = 28 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" + targetSdk = 35 + versionCode = 2 + versionName = "1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + resourceConfigurations += setOf() + setProperty("archivesBaseName", applicationId + "-v" + versionCode + "(" + versionName + ")") } buildTypes { diff --git a/app/release/baselineProfiles/0/me.arimelody.aridroid-v2(1.1)-release.dm b/app/release/baselineProfiles/0/me.arimelody.aridroid-v2(1.1)-release.dm new file mode 100644 index 0000000..328c4be Binary files /dev/null and b/app/release/baselineProfiles/0/me.arimelody.aridroid-v2(1.1)-release.dm differ diff --git a/app/release/baselineProfiles/1/me.arimelody.aridroid-v2(1.1)-release.dm b/app/release/baselineProfiles/1/me.arimelody.aridroid-v2(1.1)-release.dm new file mode 100644 index 0000000..aea7f86 Binary files /dev/null and b/app/release/baselineProfiles/1/me.arimelody.aridroid-v2(1.1)-release.dm differ diff --git a/app/release/me.arimelody.aridroid-v2(1.1)-release.apk b/app/release/me.arimelody.aridroid-v2(1.1)-release.apk new file mode 100644 index 0000000..b7e5443 Binary files /dev/null and b/app/release/me.arimelody.aridroid-v2(1.1)-release.apk differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..67c29ec --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "me.arimelody.aridroid", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 2, + "versionName": "1.1", + "outputFile": "me.arimelody.aridroid-v2(1.1)-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/me.arimelody.aridroid-v2(1.1)-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/me.arimelody.aridroid-v2(1.1)-release.dm" + ] + } + ], + "minSdkVersionForDexing": 28 +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 37bf20b..1f49721 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.AriDroid"> + android:theme="@style/AriDroid.Base"> @@ -21,6 +21,9 @@ + diff --git a/app/src/main/java/me/arimelody/aridroid/ArtistListAdapter.java b/app/src/main/java/me/arimelody/aridroid/ArtistListAdapter.java new file mode 100644 index 0000000..7a86baa --- /dev/null +++ b/app/src/main/java/me/arimelody/aridroid/ArtistListAdapter.java @@ -0,0 +1,127 @@ +package me.arimelody.aridroid; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.LruCache; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; + +public class ArtistListAdapter extends RecyclerView.Adapter { + Context context; + Handler handler; + ArrayList artists; + LruCache avatarCache; + + public ArtistListAdapter(@NonNull Context context) { + this.context = context; + handler = new Handler(Looper.getMainLooper()); + artists = new ArrayList<>(); + + avatarCache = new LruCache<>(32); + } + + public void addArtist(ArtistModel artist) { + artists.add(artist); + handler.post(() -> notifyItemInserted(artists.size())); + } + + public void removeArtist(int position) { + artists.remove(position); + handler.post(() -> notifyItemRemoved(position)); + } + + public void clearArtists() { + int size = artists.size(); + artists.clear(); + handler.post(() -> notifyItemRangeRemoved(0, size)); + } + + @NonNull + @Override + public ArtistViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(context); + View view = inflater.inflate(R.layout.artist_list_item, parent, false); + return new ArtistViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ArtistViewHolder holder, int position) { + ArtistModel artist = artists.get(position); + + holder.website.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(artist.getWebsite())); + context.startActivity(browserIntent); + } + }); + holder.name.setText(artist.getName()); + if (!artist.getAvatarURL().isEmpty()) + holder.fetchAvatar(artist.getAvatarURL(), avatarCache); + } + + @Override + public int getItemCount() { + return artists.size(); + } + + public static class ArtistViewHolder extends RecyclerView.ViewHolder { + ImageView avatar; + TextView name; + Button website; + + public ArtistViewHolder(@NonNull View itemView) { + super(itemView); + + avatar = itemView.findViewById(R.id.artistAvatar); + name = itemView.findViewById(R.id.artistName); + website = itemView.findViewById(R.id.artistWebsite); + } + + void fetchAvatar(String avatarURL, LruCache avatarCache) { + Thread thread = new Thread(() -> { + try { + URL url = new URL(MainActivity.BASE_URL + avatarURL); + + // check cache + Bitmap cachedImage = avatarCache.get(url); + if (cachedImage != null) { + avatar.post(() -> avatar.setImageBitmap(cachedImage)); + return; + } + + System.out.printf("Fetching %s...\n", url); + HttpURLConnection http = (HttpURLConnection) url.openConnection(); + http.setRequestMethod("GET"); + http.setRequestProperty("User-Agent", "aridroid/" + MainActivity.VERSION); + http.setRequestProperty("Accept", "image/*"); + + InputStream stream = http.getInputStream(); + Bitmap img = BitmapFactory.decodeStream(stream); + // store image in memory cache + avatarCache.put(url, img); + avatar.post(() -> avatar.setImageBitmap(img)); + } catch (Exception e) { + System.err.printf("FATAL: Failed to fetch release artwork: %s\n", e); + } + }); + thread.start(); + } + } +} diff --git a/app/src/main/java/me/arimelody/aridroid/ArtistModel.java b/app/src/main/java/me/arimelody/aridroid/ArtistModel.java new file mode 100644 index 0000000..0e97d7e --- /dev/null +++ b/app/src/main/java/me/arimelody/aridroid/ArtistModel.java @@ -0,0 +1,31 @@ +package me.arimelody.aridroid; + +public class ArtistModel { + private final String id; + private final String name; + private final String website; + private final String avatarURL; + + public ArtistModel(String id, String name, String website, String avatarURL) { + this.id = id; + this.name = name; + this.website = website; + this.avatarURL = avatarURL; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getWebsite() { + return website; + } + + public String getAvatarURL() { + return avatarURL; + } +} diff --git a/app/src/main/java/me/arimelody/aridroid/MainActivity.java b/app/src/main/java/me/arimelody/aridroid/MainActivity.java index c479504..caa1d12 100644 --- a/app/src/main/java/me/arimelody/aridroid/MainActivity.java +++ b/app/src/main/java/me/arimelody/aridroid/MainActivity.java @@ -1,9 +1,8 @@ package me.arimelody.aridroid; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.os.Bundle; -import android.util.JsonReader; import androidx.activity.EdgeToEdge; import androidx.appcompat.app.AppCompatActivity; @@ -18,12 +17,9 @@ import org.json.JSONException; import org.json.JSONObject; import java.io.ByteArrayOutputStream; -import java.io.InputStream; import java.net.HttpURLConnection; -import java.net.MalformedURLException; import java.net.URI; import java.net.URL; -import java.nio.channels.ClosedByInterruptException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -31,11 +27,8 @@ import java.util.Date; import java.util.Locale; public class MainActivity extends AppCompatActivity { - public static String BASE_URL; - - RecyclerView musicList; - MusicListAdapter musicListAdapter; + public static String VERSION; @Override protected void onCreate(Bundle savedInstanceState) { @@ -48,55 +41,61 @@ public class MainActivity extends AppCompatActivity { return insets; }); + try { + PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0); + VERSION = pInfo.versionName; + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } + BASE_URL = getResources().getString(R.string.base_url); - musicList = findViewById(R.id.musicList); + ReleaseListAdapter releaseListAdapter = new ReleaseListAdapter(this); + fetchReleaseData(releaseListAdapter); + ArtistListAdapter artistListAdapter = new ArtistListAdapter(this); + fetchArtistData(artistListAdapter); - ArrayList items = new ArrayList<>(); - fetchMusicData(items); + RecyclerView releaseList = findViewById(R.id.musicList); + releaseList.setAdapter(releaseListAdapter); + releaseList.setLayoutManager(new LinearLayoutManager(this)); - musicListAdapter = new MusicListAdapter(this, items); - - musicList.setAdapter(musicListAdapter); - musicList.setLayoutManager(new LinearLayoutManager(this)); + RecyclerView artistList = findViewById(R.id.artistList); + artistList.setAdapter(artistListAdapter); + artistList.setLayoutManager(new LinearLayoutManager(this)); } - void fetchMusicData(ArrayList items) { + void fetchReleaseData(ReleaseListAdapter releaseListAdapter) { Thread thread = new Thread(() -> { try { URL url = URI.create(BASE_URL + "/api/v1/music").toURL(); System.out.printf("Fetching %s...\n", url); HttpURLConnection http = (HttpURLConnection) url.openConnection(); http.setRequestMethod("GET"); - http.setRequestProperty("User-Agent", "aridroid"); + http.setRequestProperty("User-Agent", "aridroid/" + MainActivity.VERSION); http.setRequestProperty("Accept", "application/json"); ByteArrayOutputStream stream = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; - int length = 0; + int length; while ((length = http.getInputStream().read(buffer)) != -1) { stream.write(buffer, 0, length); } - updateReleaseList(stream.toString(), items); + updateReleaseList(stream.toString(), releaseListAdapter); } catch (Exception e) { - System.err.printf("Failed to fetch music data: %s\n", e.toString()); + System.err.printf("Failed to fetch music data: %s\n", e); } }); thread.start(); } - void updateReleaseList(String res, ArrayList items) { + void updateReleaseList(String res, ReleaseListAdapter releaseListAdapter) { try { JSONArray json = new JSONArray(res); - items.clear(); - musicList.post(() -> { - musicListAdapter.notifyItemRangeRemoved(0, items.size()); - }); + releaseListAdapter.clearReleases(); for (int i = 0; i < json.length(); i++) { JSONObject obj = json.getJSONObject(i); - String id = obj.getString("id"); ArrayList credits = new ArrayList<>(); JSONArray jsonArtists = obj.getJSONArray("artists"); @@ -109,12 +108,12 @@ public class MainActivity extends AppCompatActivity { try { date = df.parse(obj.getString("releaseDate")); } catch (ParseException e) { - System.err.printf("Failed to parse date for release %s: %s\n", id, e.toString()); + System.err.printf("Failed to parse date for release %s: %s\n", obj.getString("id"), e); continue; } - MusicItemModel item = new MusicItemModel( - id, + releaseListAdapter.addRelease(new ReleaseModel( + obj.getString("id"), obj.getString("title"), "", obj.getString("type"), @@ -124,15 +123,53 @@ public class MainActivity extends AppCompatActivity { buylink, obj.getString("copyright"), null, - credits); - - items.add(item); - musicList.post(() -> { - musicListAdapter.notifyItemInserted(items.size()); - }); + credits)); } } catch (JSONException e) { - System.err.printf("Failed to parse JSON response: %s\n", e.toString()); + System.err.printf("Failed to parse JSON response: %s\n", e); + } + } + + void fetchArtistData(ArtistListAdapter artistListAdapter) { + Thread thread = new Thread(() -> { + try { + URL url = URI.create(BASE_URL + "/api/v1/artist").toURL(); + System.out.printf("Fetching %s...\n", url); + HttpURLConnection http = (HttpURLConnection) url.openConnection(); + http.setRequestMethod("GET"); + http.setRequestProperty("User-Agent", "aridroid/" + MainActivity.VERSION); + http.setRequestProperty("Accept", "application/json"); + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = http.getInputStream().read(buffer)) != -1) { + stream.write(buffer, 0, length); + } + updateArtistList(stream.toString(), artistListAdapter); + } catch (Exception e) { + System.err.printf("Failed to fetch artist data: %s\n", e); + } + }); + thread.start(); + } + + void updateArtistList(String res, ArtistListAdapter artistListAdapter) { + try { + JSONArray json = new JSONArray(res); + artistListAdapter.clearArtists(); + + for (int i = 0; i < json.length(); i++) { + JSONObject obj = json.getJSONObject(i); + + artistListAdapter.addArtist(new ArtistModel( + obj.getString("id"), + obj.getString("name"), + obj.getString("website"), + obj.getString("avatar"))); + } + } catch (JSONException e) { + System.err.printf("Failed to parse JSON response: %s\n", e); } } } diff --git a/app/src/main/java/me/arimelody/aridroid/MusicListAdapter.java b/app/src/main/java/me/arimelody/aridroid/ReleaseListAdapter.java similarity index 62% rename from app/src/main/java/me/arimelody/aridroid/MusicListAdapter.java rename to app/src/main/java/me/arimelody/aridroid/ReleaseListAdapter.java index 81f64fe..3fb3656 100644 --- a/app/src/main/java/me/arimelody/aridroid/MusicListAdapter.java +++ b/app/src/main/java/me/arimelody/aridroid/ReleaseListAdapter.java @@ -3,6 +3,9 @@ package me.arimelody.aridroid; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; +import android.util.LruCache; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -10,7 +13,6 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import java.io.InputStream; @@ -20,17 +22,19 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.HashMap; import java.util.Locale; -import java.util.Map; -public class MusicListAdapter extends RecyclerView.Adapter { +public class ReleaseListAdapter extends RecyclerView.Adapter { Context context; - ArrayList data; + Handler handler; + ArrayList releases; + LruCache artworkCache; - public HashMap ReleaseTypes = new HashMap(); + public static HashMap ReleaseTypes = new HashMap<>(); - public MusicListAdapter(Context context, ArrayList data) { + public ReleaseListAdapter(@NonNull Context context) { this.context = context; - this.data = data; + handler = new Handler(Looper.getMainLooper()); + releases = new ArrayList<>(); ReleaseTypes.put("single", R.string.release_type_single); ReleaseTypes.put("album", R.string.release_type_album); @@ -38,21 +42,40 @@ public class MusicListAdapter extends RecyclerView.Adapter(32); + } + + public void addRelease(ReleaseModel release) { + releases.add(release); + handler.post(() -> notifyItemInserted(releases.size())); + } + + public void removeRelease(int position) { + releases.remove(position); + handler.post(() -> notifyItemRemoved(position)); + } + + public void clearReleases() { + int size = releases.size(); + releases.clear(); + handler.post(() -> notifyItemRangeRemoved(0, size)); } @NonNull @Override - public MusicListAdapter.MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + public ReleaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(context); - View view = inflater.inflate(R.layout.music_item, parent, false); - return new MusicListAdapter.MyViewHolder(view); + View view = inflater.inflate(R.layout.release_list_item, parent, false); + return new ReleaseViewHolder(view); } @Override - public void onBindViewHolder(@NonNull MusicListAdapter.MyViewHolder holder, int position) { - MusicItemModel release = data.get(position); + public void onBindViewHolder(@NonNull ReleaseViewHolder holder, int position) { + ReleaseModel release = releases.get(position); - holder.fetchReleaseArtwork(release.getArtwork()); + if (!release.getArtworkURL().isEmpty()) + holder.fetchReleaseArtwork(release.getArtworkURL(), artworkCache); holder.artwork.setContentDescription(release.getTitle() + " artwork"); holder.title.setText(release.getTitle()); @@ -86,43 +109,51 @@ public class MusicListAdapter extends RecyclerView.Adapter artworkCache) { Thread thread = new Thread(() -> { try { URL url = new URL(MainActivity.BASE_URL + artworkURL); + + // check cache + Bitmap cachedImage = artworkCache.get(url); + if (cachedImage != null) { + artwork.post(() -> artwork.setImageBitmap(cachedImage)); + return; + } + System.out.printf("Fetching %s...\n", url); HttpURLConnection http = (HttpURLConnection) url.openConnection(); http.setRequestMethod("GET"); - http.setRequestProperty("User-Agent", "aridroid"); + http.setRequestProperty("User-Agent", "aridroid/" + MainActivity.VERSION); http.setRequestProperty("Accept", "image/*"); InputStream stream = http.getInputStream(); Bitmap img = BitmapFactory.decodeStream(stream); - // TODO: image caching - // https://developer.android.com/topic/performance/graphics/cache-bitmap#java + // store image in memory cache + artworkCache.put(url, img); artwork.post(() -> artwork.setImageBitmap(img)); } catch (Exception e) { - System.err.printf("FATAL: Failed to fetch release artwork: %s\n", e.toString()); + System.err.printf("FATAL: Failed to fetch release artwork: %s\n", e); } }); thread.start(); diff --git a/app/src/main/java/me/arimelody/aridroid/MusicItemModel.java b/app/src/main/java/me/arimelody/aridroid/ReleaseModel.java similarity index 79% rename from app/src/main/java/me/arimelody/aridroid/MusicItemModel.java rename to app/src/main/java/me/arimelody/aridroid/ReleaseModel.java index 77866fd..44dae2b 100644 --- a/app/src/main/java/me/arimelody/aridroid/MusicItemModel.java +++ b/app/src/main/java/me/arimelody/aridroid/ReleaseModel.java @@ -4,26 +4,26 @@ import java.net.URL; import java.util.ArrayList; import java.util.Date; -public class MusicItemModel { +public class ReleaseModel { private final String id; private final String title; private final String description; private final String type; private final Date releaseDate; - private final String artwork; + private final String artworkURL; private final String buyname; private final String buylink; private final String copyright; private final URL copyrightURL; private final ArrayList credits; - public MusicItemModel(String id, String title, String description, String type, Date releaseDate, String artwork, String buyname, String buylink, String copyright, URL copyrightURL, ArrayList credits) { + public ReleaseModel(String id, String title, String description, String type, Date releaseDate, String artwork, String buyname, String buylink, String copyright, URL copyrightURL, ArrayList credits) { this.id = id; this.title = title; this.description = description; this.type = type; this.releaseDate = releaseDate; - this.artwork = artwork; + this.artworkURL = artwork; this.buyname = buyname; this.buylink = buylink; this.copyright = copyright; @@ -51,8 +51,8 @@ public class MusicItemModel { return releaseDate; } - public String getArtwork() { - return artwork; + public String getArtworkURL() { + return artworkURL; } public String getBuyname() { diff --git a/app/src/main/res/color/on_surface_dim.xml b/app/src/main/res/color/on_surface_dim.xml new file mode 100644 index 0000000..4d7ea1b --- /dev/null +++ b/app/src/main/res/color/on_surface_dim.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/font/inter.xml b/app/src/main/res/font/inter.xml new file mode 100644 index 0000000..71311f0 --- /dev/null +++ b/app/src/main/res/font/inter.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/font/inter_black.xml b/app/src/main/res/font/inter_black.xml new file mode 100644 index 0000000..9995dba --- /dev/null +++ b/app/src/main/res/font/inter_black.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/font/inter_bold.xml b/app/src/main/res/font/inter_bold.xml new file mode 100644 index 0000000..fc40af7 --- /dev/null +++ b/app/src/main/res/font/inter_bold.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/font/monaspace.xml b/app/src/main/res/font/monaspace.xml new file mode 100644 index 0000000..fe1090f --- /dev/null +++ b/app/src/main/res/font/monaspace.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/monaspace_bold.otf b/app/src/main/res/font/monaspace_bold.otf new file mode 100644 index 0000000..75d618a Binary files /dev/null and b/app/src/main/res/font/monaspace_bold.otf differ diff --git a/app/src/main/res/font/monaspace_regular.otf b/app/src/main/res/font/monaspace_regular.otf new file mode 100644 index 0000000..e7159da Binary files /dev/null and b/app/src/main/res/font/monaspace_regular.otf differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ce14471..0d846d8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,60 @@ - - + app:layout_constraintLeft_toLeftOf="parent"> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/artist_list_item.xml b/app/src/main/res/layout/artist_list_item.xml new file mode 100644 index 0000000..3dfd357 --- /dev/null +++ b/app/src/main/res/layout/artist_list_item.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + +