Compare commits
6 Commits
f7c5b932e9
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| ab07086fcb | |||
| 096c676ce2 | |||
| 922cc5d82a | |||
| 88db4ad18d | |||
| a10102c10a | |||
| 19bbccf244 |
2
.gitignore
vendored
@@ -10,3 +10,5 @@ captures/
|
||||
*.apk
|
||||
*.ap_
|
||||
*.aab
|
||||
# David
|
||||
app/src/main/jniLibs
|
||||
|
||||
4
BUGS.md
@@ -1,6 +1,6 @@
|
||||
# Bugs
|
||||
|
||||
- annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||
X> annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||
|
||||
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
## Nice-to
|
||||
|
||||
- beat metadata is handled by the app, currently. The handling should move into "jukebox".
|
||||
|
||||
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
|
||||
|
||||
- TrackFileMatching, scoreAgainstLocalHints
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LockstepPlayer">
|
||||
<activity
|
||||
|
||||
@@ -192,7 +192,7 @@ class LockstepViewModel(
|
||||
context = appContext,
|
||||
sessionFolder = sessionFolder,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
trackQueueIndex0Based = event.playlistPosition,
|
||||
contentId = contentId,
|
||||
title = event.title,
|
||||
artist = event.artist,
|
||||
@@ -207,7 +207,7 @@ class LockstepViewModel(
|
||||
BeatAnnotationStorage.writeBeatsFile(
|
||||
context = appContext,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
trackQueueIndex0Based = event.playlistPosition,
|
||||
contentId = contentId,
|
||||
title = event.title,
|
||||
artist = event.artist,
|
||||
@@ -278,7 +278,7 @@ class LockstepViewModel(
|
||||
writeRunDataAndRecordMetadata(
|
||||
runSessionFolder = runSessionFolder,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
trackQueueIndex0Based = event.playlistPosition,
|
||||
trackId = event.trackId,
|
||||
metaContentUri = meta,
|
||||
title = event.title,
|
||||
@@ -296,7 +296,7 @@ class LockstepViewModel(
|
||||
trackId: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
queueIndex: Int,
|
||||
playlistPosition: Int,
|
||||
snapshot: RunTrackDataSnapshot,
|
||||
) {
|
||||
if (snapshot.isEmpty()) {
|
||||
@@ -308,7 +308,7 @@ class LockstepViewModel(
|
||||
writeRunDataAndRecordMetadata(
|
||||
runSessionFolder = runSessionFolder,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = queueIndex,
|
||||
trackQueueIndex0Based = playlistPosition,
|
||||
trackId = trackId,
|
||||
metaContentUri = meta,
|
||||
title = title,
|
||||
@@ -453,6 +453,22 @@ class LockstepViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Set before popping Now Playing; [consumeSuppressNextLibraryMetadataSync] skips one Library batch sync. */
|
||||
@Volatile
|
||||
private var suppressNextLibraryMetadataSync = false
|
||||
|
||||
fun suppressNextLibraryMetadataSync() {
|
||||
suppressNextLibraryMetadataSync = true
|
||||
}
|
||||
|
||||
fun consumeSuppressNextLibraryMetadataSync(): Boolean {
|
||||
if (!suppressNextLibraryMetadataSync) {
|
||||
return false
|
||||
}
|
||||
suppressNextLibraryMetadataSync = false
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun syncPendingMetadata(): String? {
|
||||
val token = spotifyAccessToken.value
|
||||
if (token.isNullOrBlank()) {
|
||||
@@ -546,13 +562,34 @@ class LockstepViewModel(
|
||||
app.playlistRepository.syncPlaylistDetail(playlistId)
|
||||
null
|
||||
} catch (e: LockstepApiException) {
|
||||
e.message ?: "Load failed"
|
||||
syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
|
||||
} catch (e: IOException) {
|
||||
e.message ?: "Load failed"
|
||||
syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playlist detail fetch is best-effort when opening from the library. Unauthorized responses mean
|
||||
* the token is stale; cached jukebox rows (if any) are still valid for pairing and playback.
|
||||
*/
|
||||
private fun syncPlaylistDetailErrorForLibraryOpen(playlistId: String, e: Exception): String? {
|
||||
if (isPlaylistDetailHttp401(e)) {
|
||||
Log.w(TAG, "syncPlaylistDetail unauthorized for $playlistId, opening from jukebox cache", e)
|
||||
return null
|
||||
}
|
||||
if (app.playlistRepository.getTracks(playlistId).isNotEmpty()) {
|
||||
Log.w(TAG, "syncPlaylistDetail failed for $playlistId, using cached tracks", e)
|
||||
return null
|
||||
}
|
||||
return e.message ?: "Load failed"
|
||||
}
|
||||
|
||||
private fun isPlaylistDetailHttp401(e: Exception): Boolean {
|
||||
val msg = e.message ?: return false
|
||||
return msg.contains("HTTP 401", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingComplete(true)
|
||||
|
||||
@@ -21,7 +21,7 @@ public final class LibPasada {
|
||||
if (loaded) {
|
||||
return;
|
||||
}
|
||||
System.loadLibrary("pasada");
|
||||
System.loadLibrary("lockstep-native");
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
@@ -72,4 +72,7 @@ public final class LibPasada {
|
||||
|
||||
/** Register listener for async events raised from the audio/native thread. */
|
||||
public static native void setPlaybackListener(PasadaPlaybackListener listener);
|
||||
|
||||
/** native version string */
|
||||
public static native String getVersion();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public interface PasadaPlaybackListener {
|
||||
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
|
||||
void onTrackFinished();
|
||||
|
||||
void onTrackClosed(int fd);
|
||||
|
||||
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
|
||||
void onError(int errorCode, String message);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import at.lockstep.player.LockstepApplication
|
||||
import at.lockstep.player.MainActivity
|
||||
import at.lockstep.player.R
|
||||
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
|
||||
import at.lockstep.player.playback.engine.PasadaMusicPlayerEngine
|
||||
import at.lockstep.player.playback.engine.MusicPlayerEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -110,6 +111,7 @@ class PlaybackService : Service() {
|
||||
title = item.title,
|
||||
artist = item.artist,
|
||||
queueIndex = i,
|
||||
playlistPosition = item.playlistPosition,
|
||||
queueSize = queue.size,
|
||||
reason = reason,
|
||||
),
|
||||
@@ -203,7 +205,8 @@ class PlaybackService : Service() {
|
||||
engine?.let {
|
||||
return it
|
||||
}
|
||||
return ExoPlayerMusicPlayerEngine(this)
|
||||
//return ExoPlayerMusicPlayerEngine(this)
|
||||
return PasadaMusicPlayerEngine(this)
|
||||
.also {
|
||||
it.setListener(engineListener)
|
||||
it.initSession()
|
||||
@@ -265,6 +268,7 @@ class PlaybackService : Service() {
|
||||
artist = row.artistName ?: "—",
|
||||
localUri = Uri.parse(uriStr),
|
||||
durationMsHint = hint,
|
||||
playlistPosition = row.position,
|
||||
)
|
||||
}
|
||||
index = 0
|
||||
@@ -300,6 +304,7 @@ class PlaybackService : Service() {
|
||||
isPlaying = _uiState.value.isPlaying,
|
||||
currentTrackId = item.id,
|
||||
currentQueueIndex = index,
|
||||
currentPlaylistPosition = item.playlistPosition,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updateProgressFromEngine()
|
||||
@@ -367,6 +372,8 @@ class PlaybackService : Service() {
|
||||
durationSeconds = durationSec,
|
||||
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
||||
currentQueueIndex = index,
|
||||
currentPlaylistPosition =
|
||||
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updatePlaybackStateFromEngine()
|
||||
@@ -566,6 +573,8 @@ class PlaybackService : Service() {
|
||||
val isPlaying: Boolean,
|
||||
val currentTrackId: String?,
|
||||
val currentQueueIndex: Int,
|
||||
/** 0-based position in the full Spotify playlist for the current track. */
|
||||
val currentPlaylistPosition: Int,
|
||||
val queueSize: Int,
|
||||
) {
|
||||
companion object {
|
||||
@@ -578,6 +587,7 @@ class PlaybackService : Service() {
|
||||
isPlaying = false,
|
||||
currentTrackId = null,
|
||||
currentQueueIndex = 0,
|
||||
currentPlaylistPosition = 0,
|
||||
queueSize = 0,
|
||||
)
|
||||
}
|
||||
@@ -590,6 +600,8 @@ class PlaybackService : Service() {
|
||||
val localUri: Uri?,
|
||||
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
||||
val durationMsHint: Int,
|
||||
/** 0-based position in the full Spotify playlist. */
|
||||
val playlistPosition: Int,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -8,8 +8,10 @@ data class TrackBoundaryEvent(
|
||||
val trackId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
/** 0-based index in the current play queue when this track was current. */
|
||||
/** 0-based index in the paired-only play queue when this track was current. */
|
||||
val queueIndex: Int,
|
||||
/** 0-based position in the full Spotify playlist (used for beat/run-data file slots). */
|
||||
val playlistPosition: Int,
|
||||
val queueSize: Int,
|
||||
val reason: TrackBoundaryReason,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import at.lockstep.player.pasada.LibPasada
|
||||
import at.lockstep.player.pasada.PasadaPlaybackListener
|
||||
import java.io.IOException
|
||||
@@ -35,12 +36,18 @@ class PasadaMusicPlayerEngine(
|
||||
private val nativeListener =
|
||||
object : PasadaPlaybackListener {
|
||||
override fun onTrackFinished() {
|
||||
Log.i(TAG, "PasadaPlaybackListener.onTrackFinished()")
|
||||
pendingStart = false
|
||||
mainHandler.post {
|
||||
listener?.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTrackClosed(fd: Int) {
|
||||
Log.i(TAG, "onTrackClosed: native returned fd=$fd")
|
||||
// we handle parcel close() separately
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
errorCode: Int,
|
||||
message: String,
|
||||
@@ -51,13 +58,18 @@ class PasadaMusicPlayerEngine(
|
||||
}
|
||||
}
|
||||
|
||||
val TAG = "PasadaMusicPlayerEngine"
|
||||
|
||||
override fun initSession() {
|
||||
if (sessionInitialized) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "LibPasada.loadNative() ...")
|
||||
LibPasada.loadNative()
|
||||
LibPasada.setPlaybackListener(nativeListener)
|
||||
Log.i(TAG, "LibPasada.init() ...")
|
||||
LibPasada.init()
|
||||
Log.i(TAG, "LibPasada.init() done.")
|
||||
sessionInitialized = true
|
||||
}
|
||||
|
||||
@@ -131,7 +143,9 @@ class PasadaMusicPlayerEngine(
|
||||
|
||||
private fun startPendingTrack() {
|
||||
val fd = trackFd ?: return
|
||||
Log.i(TAG, "LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength) ...")
|
||||
LibPasada.play(fd, trackOffset, trackLength)
|
||||
Log.i(TAG, "LibPasada.play() done.")
|
||||
pendingStart = false
|
||||
}
|
||||
|
||||
|
||||
@@ -91,17 +91,21 @@ fun AnnotationRoute(
|
||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
|
||||
LaunchedEffect(playback, playlistId, annotationSessionFolder) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
service.trackBoundaryEvents.collect { event ->
|
||||
val snapshot = beatTimesMs.toList()
|
||||
beatTimesMs.clear()
|
||||
viewModel.persistBeatAnnotation(
|
||||
playlistId,
|
||||
playlistDisplayName,
|
||||
name,
|
||||
annotationSessionFolder,
|
||||
event,
|
||||
snapshot,
|
||||
|
||||
@@ -116,7 +116,10 @@ fun LockstepAppNavHost(
|
||||
playlistId = playlistId,
|
||||
playback = playback,
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
viewModel.suppressNextLibraryMetadataSync()
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
||||
@@ -2,6 +2,7 @@ package at.lockstep.player.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -169,6 +170,7 @@ fun NowPlayingRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
BackHandler(onBack = onBack)
|
||||
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||
val collector = remember { RunDataCollector(context) }
|
||||
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||
@@ -194,7 +196,7 @@ fun NowPlayingRoute(
|
||||
}
|
||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
||||
var currentQueueIndex by remember { mutableIntStateOf(0) }
|
||||
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
|
||||
|
||||
var ui by remember {
|
||||
mutableStateOf(
|
||||
@@ -210,18 +212,16 @@ fun NowPlayingRoute(
|
||||
}
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
}
|
||||
|
||||
LaunchedEffect(playlistId, playlistDisplayName) {
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName)
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.uiState.collect { p ->
|
||||
currentTrackId = p.currentTrackId
|
||||
currentQueueIndex = p.currentQueueIndex
|
||||
currentPlaylistPosition = p.currentPlaylistPosition
|
||||
ui =
|
||||
NowPlayingUiState(
|
||||
title = p.title,
|
||||
@@ -255,12 +255,14 @@ fun NowPlayingRoute(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) {
|
||||
LaunchedEffect(collectRunData, playback, playlistId) {
|
||||
if (!collectRunData) return@LaunchedEffect
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
service.trackBoundaryEvents.collect { event ->
|
||||
val snapshot = collector.snapshotAndClear()
|
||||
viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot)
|
||||
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +285,7 @@ fun NowPlayingRoute(
|
||||
trackId = trackId,
|
||||
title = ui.title,
|
||||
artist = ui.artist,
|
||||
queueIndex = currentQueueIndex,
|
||||
playlistPosition = currentPlaylistPosition,
|
||||
snapshot = snapshot,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,10 +49,11 @@ fun LibraryScreen(
|
||||
|
||||
LaunchedEffect(token) {
|
||||
if (token.isNullOrBlank()) return@LaunchedEffect
|
||||
val skipMetadataSync = viewModel.consumeSuppressNextLibraryMetadataSync()
|
||||
val errors =
|
||||
listOfNotNull(
|
||||
viewModel.syncJukeboxIfToken(),
|
||||
viewModel.syncPendingMetadata(),
|
||||
if (skipMetadataSync) null else viewModel.syncPendingMetadata(),
|
||||
)
|
||||
if (errors.isNotEmpty()) {
|
||||
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
||||
|
||||
64
app/src/main/java/com/rex/logger/NdkLogger.java
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.rex.logger;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
//import proguard.annotation.KeepName;
|
||||
|
||||
//@KeepName
|
||||
|
||||
/**
|
||||
* Gather native log into logback, for easy control save log into SDCard
|
||||
*/
|
||||
public class NdkLogger {
|
||||
private static final String TAG = "NDK";
|
||||
|
||||
//@KeepName
|
||||
public static void logWrite(int level, String message) {
|
||||
//sLogger.trace("level:{} message:{}", level, message);
|
||||
switch (level) {
|
||||
case Log.VERBOSE:
|
||||
Log.v(TAG, message);
|
||||
break;
|
||||
case Log.DEBUG:
|
||||
Log.d(TAG, message);
|
||||
break;
|
||||
case Log.INFO:
|
||||
Log.i(TAG, message);
|
||||
break;
|
||||
case Log.WARN:
|
||||
Log.w(TAG, message);
|
||||
break;
|
||||
case Log.ERROR:
|
||||
Log.e(TAG, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
try {
|
||||
System.loadLibrary("ndk-logger");
|
||||
} catch (UnsatisfiedLinkError ex) {
|
||||
Log.e(TAG, "Failed to load library.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getABI() { return native_getABI(); }
|
||||
private static native String native_getABI();
|
||||
}
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
66
media/shoe_logo4.svg
Normal file
|
After Width: | Height: | Size: 51 KiB |
53
scripts/generate_launcher_icons.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Downscale launcher icon PNGs from mipmap-xxxhdpi masters to lower densities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RES = ROOT / "app" / "src" / "main" / "res"
|
||||
SOURCE_FOLDER = "mipmap-xxxhdpi"
|
||||
|
||||
SOURCES = {
|
||||
"ic_launcher.png": {
|
||||
"mipmap-xxxhdpi": 192,
|
||||
"mipmap-xxhdpi": 144,
|
||||
"mipmap-xhdpi": 96,
|
||||
"mipmap-hdpi": 72,
|
||||
"mipmap-mdpi": 48,
|
||||
},
|
||||
"ic_launcher_foreground.png": {
|
||||
"mipmap-xxxhdpi": 432,
|
||||
"mipmap-xxhdpi": 324,
|
||||
"mipmap-xhdpi": 216,
|
||||
"mipmap-hdpi": 162,
|
||||
"mipmap-mdpi": 108,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for filename, sizes in SOURCES.items():
|
||||
source_path = RES / SOURCE_FOLDER / filename
|
||||
source = Image.open(source_path)
|
||||
expected = sizes[SOURCE_FOLDER]
|
||||
if source.size != (expected, expected):
|
||||
raise RuntimeError(
|
||||
f"{source_path} is {source.size[0]}x{source.size[1]}, expected {expected}x{expected}"
|
||||
)
|
||||
|
||||
for folder, size in sizes.items():
|
||||
if folder == SOURCE_FOLDER:
|
||||
continue
|
||||
out_dir = RES / folder
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
resized = source.resize((size, size), Image.Resampling.LANCZOS)
|
||||
resized.save(out_dir / filename)
|
||||
print(f"Wrote {folder}/{filename} ({size}x{size})")
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||