diff --git a/README.md b/README.md
index d37c68a..b89b72f 100644
--- a/README.md
+++ b/README.md
@@ -189,20 +189,19 @@ HappyView joins user records through the catalog for social queries like friends
- [x] Parse
- [x] UI
- [ ] ATProto
-- [ ] Album
- - [ ] Parse
- - [ ] UI
- - [ ] ATProto
+- [x] Album
+ - [x] Parse
+ - [x] UI
+ - [ ] ATProto (publish image as blob + backlink to record?)
- [x] Friends
- [x] Parse
- [x] UI
- [x] Favorite/unfavorite, rival register/deregister, friend detail, Friend VS
- [x] Remove/drop friend
- [x] ATProto
-- [ ] Friend VS (score comparison page)
+- [ ] Friend VS
- [ ] Parse
- [ ] UI
- - [ ] ATProto
- [x] Search Friends
- [x] Parse
- [x] UI
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index dd64186..6e8697d 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -22,8 +22,7 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
- android:theme="@android:style/Theme.Material.Light.NoActionBar"
- tools:replace="android:name">
+ android:theme="@android:style/Theme.Material.Light.NoActionBar">
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/com/derakkuma/share/ImageActions.android.kt b/composeApp/src/androidMain/kotlin/com/derakkuma/share/ImageActions.android.kt
new file mode 100644
index 0000000..d7f0e48
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/derakkuma/share/ImageActions.android.kt
@@ -0,0 +1,48 @@
+package com.derakkuma.share
+
+import android.content.ContentValues
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import androidx.core.content.FileProvider
+import com.derakkuma.DerakkumaApp
+import java.io.File
+
+actual suspend fun saveImageToGallery(bytes: ByteArray, fileName: String): Boolean = runCatching {
+ val context = DerakkumaApp.context
+ val resolver = context.contentResolver
+ val values = ContentValues().apply {
+ put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
+ put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Derakkuma")
+ put(MediaStore.Images.Media.IS_PENDING, 1)
+ }
+ }
+ val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false
+ resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ values.clear()
+ values.put(MediaStore.Images.Media.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ }
+ true
+}.getOrDefault(false)
+
+actual suspend fun shareImage(bytes: ByteArray, fileName: String, text: String): Boolean = runCatching {
+ val context = DerakkumaApp.context
+ val dir = File(context.cacheDir, "shared_images").apply { mkdirs() }
+ val file = File(dir, fileName)
+ file.writeBytes(bytes)
+ val uri: Uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "image/jpeg"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_TEXT, text)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Share score photo").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ true
+}.getOrDefault(false)
diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt
index 592b4a4..e5ba5a6 100644
--- a/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt
@@ -102,6 +102,7 @@ object Routes {
const val PLAYER_DATA = "more/playerData"
const val COLLECTION = "more/collection"
const val STAMP_CARD = "more/stampCard"
+ const val ALBUM = "more/album"
const val SETTINGS = "more/settings"
const val FAVORITES = "more/favorites"
const val PLAYER_NAME = "more/playerName"
@@ -486,6 +487,7 @@ fun App(authStore: AuthStore, cacheStore: CacheStore, playStore: PlayStore) {
onPlayerData = { navController.navigate(Routes.PLAYER_DATA) },
onCollection = { navController.navigate(Routes.COLLECTION) },
onStampCard = { navController.navigate(Routes.STAMP_CARD) },
+ onAlbum = { navController.navigate(Routes.ALBUM) },
onSettings = { navController.navigate(Routes.SETTINGS) },
onFavorites = { navController.navigate(Routes.FAVORITES) },
onPlayerName = { navController.navigate(Routes.PLAYER_NAME) },
@@ -508,6 +510,9 @@ fun App(authStore: AuthStore, cacheStore: CacheStore, playStore: PlayStore) {
composable(Routes.STAMP_CARD) {
StampCardTab(client = client, cookie = cookie, maintenanceMode = maintenanceMode)
}
+ composable(Routes.ALBUM) {
+ AlbumTab(client = client, cookie = cookie, maintenanceMode = maintenanceMode)
+ }
composable(Routes.SETTINGS) {
SettingsTab(client = client, cookie = cookie, maintenanceMode = maintenanceMode)
}
diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt
index f7aafc2..997041e 100644
--- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt
+++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt
@@ -428,6 +428,25 @@ class MaimaiClient(
suspend fun fetchStampCards(cookie: String = currentCookie()): StampCardPage = parseStampCardPage(fetchPage("/playerData/stampCard/", cookie))
+ suspend fun fetchPhotoAlbum(cookie: String = currentCookie()): PhotoAlbumPage = parsePhotoAlbumPage(fetchPage("/playerData/photo/", cookie))
+
+ suspend fun fetchImageBytes(url: String, cookie: String = currentCookie()): ByteArray {
+ val resp = client.get(url) {
+ header(HttpHeaders.Cookie, cookie)
+ header(HttpHeaders.UserAgent, UA)
+ header(HttpHeaders.Accept, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
+ header(HttpHeaders.AcceptLanguage, "en-US,en;q=0.9")
+ header(HttpHeaders.Referrer, "$BASE/playerData/photo/")
+ header(HttpHeaders.CacheControl, "no-cache")
+ header(HttpHeaders.Pragma, "no-cache")
+ header("Sec-Fetch-Dest", "document")
+ header("Sec-Fetch-Mode", "navigate")
+ header("Sec-Fetch-Site", "same-origin")
+ header("Upgrade-Insecure-Requests", "1")
+ }
+ return resp.body()
+ }
+
suspend fun setStampCard(item: StampCardItem, cookie: String = currentCookie()): Boolean {
if (item.idx.isBlank() || item.token.isBlank()) return false
val path = item.setAction.ifBlank { "/playerData/stampCard/set/" }
diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt
index 6ea6146..5c1578a 100644
--- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt
+++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt
@@ -644,6 +644,26 @@ data class CircleFestaHistory(
val idx: String = "",
)
+@Serializable
+data class PhotoAlbumPage(
+ val items: List = emptyList(),
+)
+
+@Serializable
+data class PhotoAlbumItem(
+ val songName: String = "",
+ val difficulty: String = "",
+ val edition: String = "",
+ val musicKind: String = "",
+ val musicKindImageUrl: String? = null,
+ val date: String = "",
+ val location: String = "",
+ val imageUrl: String = "",
+) {
+ val shareText: String
+ get() = "Check out my maimai DX score on $songName (${difficulty.replaceFirstChar { it.uppercase() }})!\nShared via derakkuma.com\n\n#maimai思い出写真 #maimaidx"
+}
+
// ---- Shared helpers ----
// Cover art: prefer CDN, fall back to scraped SEGA URL
diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt
index e90bdc1..b658021 100644
--- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt
+++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt
@@ -1096,6 +1096,30 @@ fun parseStampCardPage(html: String): StampCardPage {
return StampCardPage(items)
}
+fun parsePhotoAlbumPage(html: String): PhotoAlbumPage {
+ val items = Regex("""