Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 96 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,120 @@
<h1 align="center">LastWave</h1><p align="center">
# 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.

<p align="center">
<img src="Screenshot/Home.png" width="23%" />
<img src="Screenshot/Genres.png" width="23%" />
<img src="Screenshot/Generator.png" width="23%" />
<img src="Screenshot/Playlist.png" width="23%" />
</p>

# 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.
<p align="center">
<img src="Screenshot/Home.png" width="30%" />
<img src="Screenshot/Home_2.png" width="30%" />
</p>

---

## 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
<p align="center">
<img src="Screenshot/Genres.png" width="30%" />
<img src="Screenshot/Genres_2.png" width="30%" />
</p>

---

### 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 |

<p align="center">
<img src="Screenshot/Generator.png" width="30%" />
<img src="Screenshot/Create.png" width="30%" />
<img src="Screenshot/Create_2.png" width="30%" />
</p>

---

## 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
<p align="center">
<img src="Screenshot/Playlist.png" width="30%" />
<img src="Screenshot/Playlist_2.png" width="30%" />
<img src="Screenshot/Playlist_3.png" width="30%" />
</p>

---

## 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 |
<p align="center">
<img src="Screenshot/Search.png" width="30%" />
<img src="Screenshot/Search_2.png" width="30%" />
</p>

## 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
<p align="center">
<img src="Screenshot/Settings.jpg" width="30%" />
<img src="Screenshot/Settings_2.png" width="30%" />
<img src="Screenshot/Colour_Wheel.png" width="30%" />
</p>

---

## 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.
Binary file added Screenshot/Colour_Wheel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Create.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Create_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Generator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Genres.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Genres_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Home_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Playlist.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Playlist_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Playlist_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Search_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Settings.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Screenshot/Settings_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
Expand All @@ -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'
}
}
37 changes: 1 addition & 36 deletions app/src/main/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
}

Expand Down
3 changes: 1 addition & 2 deletions app/src/main/assets/playlist/playlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -823,4 +822,4 @@ function _plSetupPullToRefresh() {
showToast('Refreshed');
}
}, { passive: true });
}
}
16 changes: 3 additions & 13 deletions app/src/main/java/com/lastwave/app/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand All @@ -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();
}
Expand Down Expand Up @@ -527,4 +517,4 @@ private void shareFile(File file, String mimeType) {
}
});
}
}
}
Loading
Loading