diff --git a/README.md b/README.md
index 348fabe..a32e44b 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,120 @@
-
LastWave
+# LastWave
+**A beautiful Last.fm companion app for Android.**
-This repo totally belong to [DUXTAMI](https://github.com/duxtami)
+LastWave connects to your Last.fm account to surface your scrobble history, genre analytics, and smart playlist generation β all wrapped in a clean Material You interface.
+
+
+
+
+
+
-# LastWave β Last.fm Playlist Generator
+---
+
+## Features
+
+### π Home Dashboard
+Your listening life at a glance. See your total scrobble count, unique track/artist/album stats, and a live-updating track list sorted by recency or play count. A real-time listening timer shows your cumulative headphone time as it ticks up.
-A Material You Android app that generates playlists from your Last.fm history.
+
+
+
+
---
-## Setup in Android Studio
+### πΈ Genre Analytics
+Discover what your listening habits actually say about you. LastWave analyses the tags across your top artists and builds a ranked breakdown of your genres. Filter by the past 7 days, this month, the last 12 months, or your all-time history.
-### Option A: Copy-paste into existing project
-1. Open Android Studio β New Project β Empty Views Activity
-2. Package name: `com.lastwave.app`
-3. Replace the generated `app/` folder with the one from this zip
-4. Replace `build.gradle` (root) and `settings.gradle` with the ones from this zip
-5. Sync Gradle β Run
+
+
+
+
+
+---
-### Option B: Open as project
-1. Open Android Studio
-2. File β Open β select the `LastWave/` folder
-3. Let Gradle sync
-4. Run on device or emulator
+### β¨ Playlist Generator
+Turn your scrobble data into ready-to-use playlists. Choose from eight generation modes:
+
+| Mode | What it does |
+|---|---|
+| **Top Tracks** | Your most-played tracks of all time |
+| **Recent Tracks** | What you've been listening to lately |
+| **Similar Tracks** | Tracks similar to one you love |
+| **Similar Artists** | Discover artists like your favourites |
+| **By Tag / Genre** | Browse by genre β rock, lo-fi, jazz, and more |
+| **My Mix** | Smart blend of top, recent & similar |
+| **My Recommendations** | 30 fresh tracks picked just for you |
+| **My Library** | Re-discover the sounds of your past |
+
+
+
+
+
+
---
-## Getting a Last.fm API Key
+### πΏ Playlist Manager
+All your generated playlists in one place. Regenerate a fresh shuffle, swap generation modes, or export to **CSV** or **M3U** to take your playlist anywhere.
-1. Go to https://www.last.fm/api/account/create
-2. Fill in App name: `LastWave`, anything for description
-3. Copy the **API key**
-4. Open the app β Settings β paste the key β Save
+
+
+
+
+
---
-## Playlist Modes
+### π Search
+Search across tracks, artists, and albums β all powered by the Last.fm catalogue.
-| Mode | Description |
-|------|-------------|
-| Top Tracks | Your most-played tracks (choose time period) |
-| Recent Tracks | Latest scrobbles |
-| Similar Tracks | Tracks similar to a seed track |
-| Similar Artists | Top tracks from artists similar to a seed artist |
-| By Tag | Top tracks for a genre/tag (rock, lofi, jazzβ¦) |
-| My Mix | Smart blend of all of the above |
+
+
+
+
-## Export Options
+---
+
+### π¨ Themes & Personalisation
+Pick from a curated set of Material You colour palettes, or dial in your own accent colour using the built-in colour wheel.
-- **CSV** β Opens share sheet, save anywhere
-- **M3U** β Standard playlist file for media players
-- **Share** β Text list via any app (WhatsApp, notes, etc.)
-- **You Tube** β Searches the first track on You Tube
+
+
+
+
+
---
-## Build via GitHub Actions
+## Getting Started
+
+LastWave uses the free Last.fm API. You'll need a Last.fm account and a personal API key to get started.
+
+1. **Create a Last.fm API key** at [last.fm/api/account/create](https://www.last.fm/api/account/create) β it's free and takes about a minute.
+2. **Install LastWave** on your Android device.
+3. Open the app and go to **Settings**.
+4. Enter your **Last.fm username**, **API Key**, and **API Secret**.
+5. Head to the **Home** screen β your stats will load automatically.
+
+---
+
+## Requirements
+
+- Android device
+- A [Last.fm](https://www.last.fm) account
+- A free Last.fm API key
+
+---
+
+## Building from Source
+
+```bash
+git clone https://github.com/your-username/lastwave.git
+cd lastwave
+./gradlew assembleDebug
+```
-1. Push this folder to a GitHub repository
-2. Actions tab β "Build LastWave APK" β Run workflow
-3. Download the APK artifact when complete
-4.
+Open the project in Android Studio, or build via the Gradle wrapper. The app targets a standard Android SDK setup with no external library dependencies beyond the Android framework.
\ No newline at end of file
diff --git a/Screenshot/Colour_Wheel.png b/Screenshot/Colour_Wheel.png
new file mode 100644
index 0000000..ce3dec8
Binary files /dev/null and b/Screenshot/Colour_Wheel.png differ
diff --git a/Screenshot/Create.png b/Screenshot/Create.png
new file mode 100644
index 0000000..553b8ae
Binary files /dev/null and b/Screenshot/Create.png differ
diff --git a/Screenshot/Create_2.png b/Screenshot/Create_2.png
new file mode 100644
index 0000000..fd6a0d8
Binary files /dev/null and b/Screenshot/Create_2.png differ
diff --git a/Screenshot/Generator.png b/Screenshot/Generator.png
new file mode 100644
index 0000000..7eff4e5
Binary files /dev/null and b/Screenshot/Generator.png differ
diff --git a/Screenshot/Genres.png b/Screenshot/Genres.png
new file mode 100644
index 0000000..217c555
Binary files /dev/null and b/Screenshot/Genres.png differ
diff --git a/Screenshot/Genres_2.png b/Screenshot/Genres_2.png
new file mode 100644
index 0000000..fd5c71a
Binary files /dev/null and b/Screenshot/Genres_2.png differ
diff --git a/Screenshot/Home.png b/Screenshot/Home.png
new file mode 100644
index 0000000..84a9f3f
Binary files /dev/null and b/Screenshot/Home.png differ
diff --git a/Screenshot/Home_2.png b/Screenshot/Home_2.png
new file mode 100644
index 0000000..76e4ae5
Binary files /dev/null and b/Screenshot/Home_2.png differ
diff --git a/Screenshot/Playlist.png b/Screenshot/Playlist.png
new file mode 100644
index 0000000..6716ee1
Binary files /dev/null and b/Screenshot/Playlist.png differ
diff --git a/Screenshot/Playlist_2.png b/Screenshot/Playlist_2.png
new file mode 100644
index 0000000..4d6b90b
Binary files /dev/null and b/Screenshot/Playlist_2.png differ
diff --git a/Screenshot/Playlist_3.png b/Screenshot/Playlist_3.png
new file mode 100644
index 0000000..237df6c
Binary files /dev/null and b/Screenshot/Playlist_3.png differ
diff --git a/Screenshot/Search.png b/Screenshot/Search.png
new file mode 100644
index 0000000..49145c8
Binary files /dev/null and b/Screenshot/Search.png differ
diff --git a/Screenshot/Search_2.png b/Screenshot/Search_2.png
new file mode 100644
index 0000000..38ec69d
Binary files /dev/null and b/Screenshot/Search_2.png differ
diff --git a/Screenshot/Settings.jpg b/Screenshot/Settings.jpg
new file mode 100644
index 0000000..4caa8ee
Binary files /dev/null and b/Screenshot/Settings.jpg differ
diff --git a/Screenshot/Settings_2.png b/Screenshot/Settings_2.png
new file mode 100644
index 0000000..cc0d156
Binary files /dev/null and b/Screenshot/Settings_2.png differ
diff --git a/app/build.gradle b/app/build.gradle
index 48efd63..eb68bb1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -26,6 +26,8 @@ android {
buildTypes {
release {
minifyEnabled true
+ shrinkResources true
+ debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
@@ -44,4 +46,4 @@ dependencies {
implementation 'androidx.core:core:1.12.0'
implementation 'androidx.webkit:webkit:1.10.0'
implementation 'androidx.browser:browser:1.6.0'
-}
+}
\ No newline at end of file
diff --git a/app/src/main/assets/app.js b/app/src/main/assets/app.js
index b1a148f..1de7c0b 100644
--- a/app/src/main/assets/app.js
+++ b/app/src/main/assets/app.js
@@ -379,8 +379,6 @@ function _md5SelfTest() {
_md5SelfTestPassed = (actual === expected);
if (!_md5SelfTestPassed) {
console.error('[Auth] MD5 self-test FAILED β got:', actual, ' expected:', expected);
- } else {
- console.debug('[Auth] MD5 self-test passed OK');
}
return _md5SelfTestPassed;
}
@@ -420,12 +418,7 @@ function _lfmSig(params, secret) {
// Signature base: key1value1key2value2...SECRET
const base = sortedKeys.map(k => k + String(params[k])).join('') + secret;
- // Debug log (secret masked)
- console.debug('[Auth] Signing keys (sorted):', sortedKeys.join(', '));
- console.debug('[Auth] Sig base string:', base.replace(secret, '**SECRET**'));
-
const hash = _md5(base);
- console.debug('[Auth] api_sig (md5):', hash);
return hash;
}
@@ -485,13 +478,10 @@ async function _buildUserTasteProfile() {
try {
const raw = JSON.parse(localStorage.getItem(_TASTE_PROFILE_KEY) || 'null');
if (raw && raw.username === state.username && Date.now() - raw.ts < _TASTE_PROFILE_TTL) {
- console.debug('[Taste] using cached profile, age:', Math.round((Date.now() - raw.ts) / 60000), 'min');
return raw.profile;
}
} catch {}
- console.debug('[Taste] building fresh profileβ¦');
-
const profile = {
topArtistNames: [], // lowercased
recentArtists: [], // lowercased, no duplicates
@@ -548,12 +538,6 @@ async function _buildUserTasteProfile() {
}));
} catch {}
- console.debug('[Taste] profile built β',
- profile.topArtistNames.length, 'artists,',
- profile.topTags.length, 'tags,',
- profile.topTrackSeeds.length, 'top tracks,',
- profile.recentTrackSeeds.length, 'recent tracks'
- );
return profile;
}
@@ -839,7 +823,6 @@ function _artDiskGet(key) {
delete cache[key];
return null;
}
- console.debug('[ArtCache] disk hit:', key, entry.url ? 'β ' + entry.url.slice(-30) : '(no art)');
return entry.url;
}
@@ -894,7 +877,6 @@ async function _itunesFetchArtwork(nameOrTrack, artist, type) {
const ck = `it:${type}:${(nameOrTrack).toLowerCase().slice(0, 60)}:${(artist || '').toLowerCase().slice(0, 40)}`;
if (_itunesCache.has(ck)) {
- console.debug('[iTunes] mem-cache hit:', ck);
return _itunesCache.get(ck);
}
// Check persistent disk cache before hitting the network
@@ -923,7 +905,6 @@ async function _itunesFetchArtwork(nameOrTrack, artist, type) {
apiUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&media=music&entity=song&limit=1`;
}
- console.debug('[iTunes] fetching:', type, nameOrTrack, artist && `by ${artist}`);
const res = await fetch(apiUrl, { signal: AbortSignal.timeout(6000) });
if (!res.ok) {
@@ -933,15 +914,12 @@ async function _itunesFetchArtwork(nameOrTrack, artist, type) {
const results = data?.results;
if (!Array.isArray(results) || results.length === 0) {
- console.debug('[iTunes] no results for:', nameOrTrack);
+ // no results β imgUrl stays ''
} else {
const item = results[0];
const raw = item?.artworkUrl100 || item?.artworkUrl60 || '';
if (raw) {
imgUrl = _itunesUpscale(raw);
- console.debug('[iTunes] found artwork for:', nameOrTrack, 'β', imgUrl);
- } else {
- console.debug('[iTunes] item has no artworkUrl100 for:', nameOrTrack);
}
}
}
@@ -1134,8 +1112,6 @@ async function lfmCallSigned(params) {
// Do NOT include format, callback, or api_sig at this stage.
const p = { ...params, api_key: keyNorm };
- console.debug('[Auth] Params to sign:', Object.keys(p).sort().join(', '));
-
// ββ Step 4: Compute signature βββββββββββββββββββββββββββββββββββββββββββββ
p.api_sig = _lfmSig(p, secNorm);
@@ -1316,7 +1292,6 @@ function signOut() {
function _setAuthStatus(status, msg) {
state.authState = status;
- if (msg) console.debug('[Auth]', msg);
}
/** Fire the settings screen's refresh function if it's currently loaded */
@@ -1717,16 +1692,6 @@ async function fetchRecommendations(total) {
const fresh = _filterFresh(validatedTracks);
const result = (fresh.length >= Math.min(total, 8) ? fresh : validatedTracks).slice(0, total);
- console.debug('[Recommendations v2] pool:', weighted.length,
- '\u2192 deduped:', dedupedWeighted.length,
- '\u2192 scored (not recent):', scored.filter(s => s.score !== -1).length,
- '\u2192 filtered:', filtered.length,
- '\u2192 balanced:', balanced.length,
- '\u2192 diverse:', diverse.length,
- '\u2192 validated:', validatedTracks.length,
- '\u2192 final:', result.length
- );
-
return deduplicateTracks(result);
}
diff --git a/app/src/main/assets/playlist/playlist.js b/app/src/main/assets/playlist/playlist.js
index 0d54496..c8f09c8 100644
--- a/app/src/main/assets/playlist/playlist.js
+++ b/app/src/main/assets/playlist/playlist.js
@@ -169,7 +169,6 @@ function _plBindCardLongPress(container) {
// avoids any _plLoad() lookup ambiguity that could return meta text.
const name = (header.closest('.pl-card')?.dataset?.plTitle || '').trim();
if (!name) return;
- console.log('Copied:', name);
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(name)
.then(() => showToast('Copied: ' + name, 'success'))
@@ -823,4 +822,4 @@ function _plSetupPullToRefresh() {
showToast('Refreshed');
}
}, { passive: true });
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lastwave/app/MainActivity.java b/app/src/main/java/com/lastwave/app/MainActivity.java
index f38b65d..b805b2b 100644
--- a/app/src/main/java/com/lastwave/app/MainActivity.java
+++ b/app/src/main/java/com/lastwave/app/MainActivity.java
@@ -3,7 +3,6 @@
import android.graphics.Color;
import android.Manifest;
import android.app.WallpaperManager;
-import android.content.pm.ApplicationInfo;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.SharedPreferences;
@@ -125,6 +124,8 @@ protected void onCreate(Bundle savedInstanceState) {
// level, and the setting contradicts the manifest's intent.
webView.setWebChromeClient(new WebChromeClient());
+ // Disable remote debugging in all builds β never expose DevTools to the network
+ WebView.setWebContentsDebuggingEnabled(false);
final WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
.addPathHandler("/assets/", new WebViewAssetLoader.AssetsPathHandler(this))
@@ -147,17 +148,6 @@ public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
@Override
public void onPageFinished(WebView view, String url) {
- boolean isDebug = (getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
- if (isDebug) {
- view.evaluateJavascript(
- "(function(){" +
- " var s = document.createElement('script');" +
- " s.src = 'https://cdn.jsdelivr.net/npm/eruda';" +
- " s.onload = function(){ eruda.init(); };" +
- " document.head.appendChild(s);" +
- "})()", null);
- }
-
// Deliver any deep-link token that arrived before the page finished loading
deliverPendingDeepLink();
}
@@ -527,4 +517,4 @@ private void shareFile(File file, String mimeType) {
}
});
}
-}
+}
\ No newline at end of file
diff --git a/tree.txt b/tree.txt
new file mode 100644
index 0000000..a5e504f
--- /dev/null
+++ b/tree.txt
@@ -0,0 +1,101 @@
+.
+βββ README.md
+βββ Screenshot
+βΒ Β βββ Colour_Wheel.png
+βΒ Β βββ Create.png
+βΒ Β βββ Create_2.png
+βΒ Β βββ Generator.png
+βΒ Β βββ Genres.png
+βΒ Β βββ Genres_2.png
+βΒ Β βββ Home.png
+βΒ Β βββ Home_2.png
+βΒ Β βββ Playlist.png
+βΒ Β βββ Playlist_2.png
+βΒ Β βββ Playlist_3.png
+βΒ Β βββ Search.png
+βΒ Β βββ Search_2.png
+βΒ Β βββ Settings.jpg
+βΒ Β βββ Settings_2.png
+βββ app
+βΒ Β βββ build.gradle
+βΒ Β βββ proguard-rules.pro
+βΒ Β βββ src
+βΒ Β βββ main
+βΒ Β βββ AndroidManifest.xml
+βΒ Β βββ assets
+βΒ Β βΒ Β βββ app.css
+βΒ Β βΒ Β βββ app.js
+βΒ Β βΒ Β βββ bridge.js
+βΒ Β βΒ Β βββ fonts
+βΒ Β βΒ Β βΒ Β βββ MaterialSymbols.woff2
+βΒ Β βΒ Β βββ generator
+βΒ Β βΒ Β βΒ Β βββ generator.css
+βΒ Β βΒ Β βΒ Β βββ generator.html
+βΒ Β βΒ Β βΒ Β βββ generator.js
+βΒ Β βΒ Β βββ genres
+βΒ Β βΒ Β βΒ Β βββ genres.css
+βΒ Β βΒ Β βΒ Β βββ genres.html
+βΒ Β βΒ Β βΒ Β βββ genres.js
+βΒ Β βΒ Β βββ home
+βΒ Β βΒ Β βΒ Β βββ home.css
+βΒ Β βΒ Β βΒ Β βββ home.html
+βΒ Β βΒ Β βΒ Β βββ home.js
+βΒ Β βΒ Β βββ index.html
+βΒ Β βΒ Β βββ nav.js
+βΒ Β βΒ Β βββ playlist
+βΒ Β βΒ Β βΒ Β βββ playlist.css
+βΒ Β βΒ Β βΒ Β βββ playlist.html
+βΒ Β βΒ Β βΒ Β βββ playlist.js
+βΒ Β βΒ Β βββ results
+βΒ Β βΒ Β βΒ Β βββ results.css
+βΒ Β βΒ Β βΒ Β βββ results.html
+βΒ Β βΒ Β βΒ Β βββ results.js
+βΒ Β βΒ Β βββ search
+βΒ Β βΒ Β βΒ Β βββ search.css
+βΒ Β βΒ Β βΒ Β βββ search.html
+βΒ Β βΒ Β βΒ Β βββ search.js
+βΒ Β βΒ Β βββ settings
+βΒ Β βΒ Β βββ settings.css
+βΒ Β βΒ Β βββ settings.html
+βΒ Β βΒ Β βββ settings.js
+βΒ Β βββ java
+βΒ Β βΒ Β βββ com
+βΒ Β βΒ Β βββ lastwave
+βΒ Β βΒ Β βββ app
+βΒ Β βΒ Β βββ MainActivity.java
+βΒ Β βββ res
+βΒ Β βββ layout
+βΒ Β βΒ Β βββ activity_main.xml
+βΒ Β βββ mipmap-hdpi
+βΒ Β βΒ Β βββ ic_launcher.png
+βΒ Β βΒ Β βββ ic_launcher_round.png
+βΒ Β βββ mipmap-mdpi
+βΒ Β βΒ Β βββ ic_launcher.png
+βΒ Β βΒ Β βββ ic_launcher_round.png
+βΒ Β βββ mipmap-xhdpi
+βΒ Β βΒ Β βββ ic_launcher.png
+βΒ Β βΒ Β βββ ic_launcher_round.png
+βΒ Β βββ mipmap-xxhdpi
+βΒ Β βΒ Β βββ ic_launcher.png
+βΒ Β βΒ Β βββ ic_launcher_round.png
+βΒ Β βββ mipmap-xxxhdpi
+βΒ Β βΒ Β βββ ic_launcher.png
+βΒ Β βΒ Β βββ ic_launcher_round.png
+βΒ Β βββ values
+βΒ Β βΒ Β βββ strings.xml
+βΒ Β βΒ Β βββ themes.xml
+βΒ Β βββ xml
+βΒ Β βββ file_paths.xml
+βββ build.gradle
+βββ gradle
+βΒ Β βββ wrapper
+βΒ Β βββ gradle-wrapper.jar
+βΒ Β βββ gradle-wrapper.properties
+βββ gradle.properties
+βββ gradlew
+βββ gradlew.bat
+βββ local.properties
+βββ settings.gradle
+βββ tree.txt
+
+29 directories, 70 files