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