Skip to content

Commit 34aa38b

Browse files
authored
Merge pull request #141 from eValDoll/release/v1.1.2
release: merge v1.1.2 into main
2 parents a64e36e + 5e5b8ea commit 34aa38b

60 files changed

Lines changed: 4571 additions & 1342 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ android {
7070
applicationId = "com.asmr.player"
7171
minSdk = 24
7272
targetSdk = 34
73-
versionCode = 10102
74-
versionName = "1.1.1"
73+
versionCode = 10103
74+
versionName = "1.1.2"
7575
buildConfigField("String", "UPDATE_REPO_OWNER", "\"eValDoll\"")
7676
buildConfigField("String", "UPDATE_REPO_NAME", "\"EaraAsmrPlayer\"")
7777
buildConfigField("String", "LISTEN_TOGETHER_BASE_URL", "\"$listenTogetherBaseUrl\"")
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

app/src/main/java/com/asmr/player/MainActivity.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import android.content.Context
2525
import android.content.ContextWrapper
2626
import android.content.pm.ActivityInfo
2727
import android.content.res.Configuration
28+
import android.media.AudioManager
2829
import android.net.Uri
2930
import androidx.compose.ui.platform.LocalConfiguration
3031
import androidx.core.view.WindowCompat
@@ -650,4 +651,22 @@ class MainActivity : ComponentActivity() {
650651
}
651652
return true
652653
}
654+
655+
override fun onResume() {
656+
super.onResume()
657+
syncAppVolumePercentFromSystem()
658+
}
659+
660+
private fun syncAppVolumePercentFromSystem() {
661+
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
662+
val maxSystemVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
663+
val currentSystemVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
664+
val currentAppVolumePercent = AppVolume.percentFromSystemVolume(
665+
systemVolume = currentSystemVolume,
666+
maxSystemVolume = maxSystemVolume
667+
)
668+
lifecycleScope.launch {
669+
settingsRepository.syncAppVolumePercentFromSystem(currentAppVolumePercent)
670+
}
671+
}
653672
}

app/src/main/java/com/asmr/player/data/local/datastore/SearchCacheStore.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import androidx.datastore.preferences.core.stringPreferencesKey
77
import androidx.datastore.preferences.preferencesDataStore
88
import com.asmr.player.domain.model.Album
99
import com.google.gson.Gson
10+
import com.google.gson.reflect.TypeToken
1011
import dagger.hilt.android.qualifiers.ApplicationContext
1112
import kotlinx.coroutines.flow.first
1213
import javax.inject.Inject
1314
import javax.inject.Singleton
1415

1516
private val Context.searchCacheDataStore by preferencesDataStore(name = "search_cache")
17+
private const val SearchHistoryLimit = 50
1618

1719
data class LastSearchStateV1(
1820
val savedAtMs: Long,
@@ -21,6 +23,8 @@ data class LastSearchStateV1(
2123
val purchasedOnly: Boolean,
2224
val presaleOnly: Boolean = false,
2325
val chineseTranslatedOnly: Boolean = false,
26+
val collectedOnly: Boolean = false,
27+
val collectedSortName: String = "",
2428
val locale: String?,
2529
val page: Int,
2630
val canGoNext: Boolean,
@@ -32,7 +36,9 @@ class SearchCacheStore @Inject constructor(
3236
@ApplicationContext private val context: Context
3337
) {
3438
private val key: Preferences.Key<String> = stringPreferencesKey("last_search_state_v1")
39+
private val historyKey: Preferences.Key<String> = stringPreferencesKey("search_history_keywords_v1")
3540
private val gson = Gson()
41+
private val historyListType = object : TypeToken<List<String>>() {}.type
3642

3743
suspend fun readLast(): LastSearchStateV1? {
3844
val raw = context.searchCacheDataStore.data.first()[key].orEmpty()
@@ -48,9 +54,50 @@ class SearchCacheStore @Inject constructor(
4854
}
4955
}
5056

57+
suspend fun readHistory(): List<String> {
58+
val raw = context.searchCacheDataStore.data.first()[historyKey].orEmpty()
59+
if (raw.isBlank()) return emptyList()
60+
return runCatching {
61+
val parsed: List<String> = gson.fromJson(raw, historyListType)
62+
mergeSearchHistory("", parsed)
63+
}.getOrDefault(emptyList())
64+
}
65+
66+
suspend fun addHistory(keyword: String) {
67+
val current = readHistory()
68+
val next = mergeSearchHistory(keyword, current)
69+
context.searchCacheDataStore.edit { prefs ->
70+
prefs[historyKey] = gson.toJson(next)
71+
}
72+
}
73+
74+
suspend fun clearHistory() {
75+
context.searchCacheDataStore.edit { prefs ->
76+
prefs.remove(historyKey)
77+
}
78+
}
79+
5180
suspend fun clear() {
5281
context.searchCacheDataStore.edit { prefs ->
5382
prefs.remove(key)
5483
}
5584
}
5685
}
86+
87+
internal fun mergeSearchHistory(
88+
keyword: String,
89+
existing: List<String>,
90+
limit: Int = SearchHistoryLimit
91+
): List<String> {
92+
val normalizedKeyword = keyword.trim()
93+
val merged = buildList {
94+
if (normalizedKeyword.isNotBlank()) add(normalizedKeyword)
95+
existing.forEach { item ->
96+
val normalized = item.trim()
97+
if (normalized.isNotBlank()) add(normalized)
98+
}
99+
}
100+
return merged
101+
.distinctBy { it.lowercase() }
102+
.take(limit.coerceAtLeast(0))
103+
}

app/src/main/java/com/asmr/player/data/lyrics/LyricsLoader.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,14 @@ class LyricsLoader @Inject constructor(
354354
"Subtitle" -> TreeFileType.Subtitle
355355
"Text" -> TreeFileType.Text
356356
"Pdf" -> TreeFileType.Pdf
357+
"Archive" -> TreeFileType.Archive
358+
"Document" -> TreeFileType.Document
359+
"Spreadsheet" -> TreeFileType.Spreadsheet
360+
"Presentation" -> TreeFileType.Presentation
361+
"Code" -> TreeFileType.Code
362+
"Ebook" -> TreeFileType.Ebook
363+
"Font" -> TreeFileType.Font
364+
"AppPackage" -> TreeFileType.AppPackage
357365
else -> TreeFileType.Other
358366
}
359367
}

app/src/main/java/com/asmr/player/data/remote/api/AsmrOneApi.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ data class Pagination(val totalCount: Int, val pageSize: Int, val page: Int)
7979
data class AsmrOneTrackNodeResponse(
8080
@SerializedName(value = "title", alternate = ["name", "fileName"])
8181
val title: String? = null,
82+
@SerializedName(value = "type", alternate = ["fileType", "mimeType"])
83+
val type: String? = null,
8284
@SerializedName(value = "children", alternate = ["child", "items", "tracks"])
8385
val children: List<AsmrOneTrackNodeResponse>? = null,
8486
val duration: Double? = null,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package com.asmr.player.data.remote.api
2+
3+
import android.os.Build
4+
import com.asmr.player.BuildConfig
5+
import com.asmr.player.data.remote.NetworkHeaders
6+
import com.asmr.player.listentogether.XxHash64
7+
import com.google.gson.Gson
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.withContext
10+
import okhttp3.MediaType.Companion.toMediaType
11+
import okhttp3.OkHttpClient
12+
import okhttp3.Request
13+
import okhttp3.RequestBody.Companion.toRequestBody
14+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
15+
import java.io.IOException
16+
import java.util.UUID
17+
import javax.inject.Inject
18+
import javax.inject.Singleton
19+
import kotlin.text.Charsets.UTF_8
20+
21+
data class AsmrOneAvailabilityRequest(
22+
val rjs: List<String>
23+
)
24+
25+
data class AsmrOneAvailabilityResponse(
26+
val items: List<AsmrOneAvailabilityItem> = emptyList(),
27+
val serverTimeEpochMs: Long = 0L
28+
)
29+
30+
data class AsmrOneAvailabilityItem(
31+
val rj: String = "",
32+
val collected: Boolean = false,
33+
val workId: Int = 0,
34+
val matchedRjs: List<String> = emptyList(),
35+
val originalWorkno: String = "",
36+
val title: String = ""
37+
)
38+
39+
data class AsmrOneCollectedSearchResponse(
40+
val items: List<AsmrOneCollectedSearchItem>? = emptyList(),
41+
val total: Int = 0,
42+
val limit: Int = 0,
43+
val offset: Int = 0,
44+
val sort: String = "",
45+
val serverTimeEpochMs: Long = 0L
46+
)
47+
48+
data class AsmrOneCollectedSearchItem(
49+
val workId: Int = 0,
50+
val rj: String = "",
51+
val title: String = "",
52+
val circle: String = "",
53+
val cvs: List<String>? = emptyList(),
54+
val tags: List<String>? = emptyList(),
55+
val matchedRjs: List<String>? = emptyList(),
56+
val originalWorkno: String = "",
57+
val releaseDate: String = "",
58+
val createDate: String = "",
59+
val mainCoverUrl: String = "",
60+
val dlCount: Int? = null,
61+
val price: Int? = null,
62+
val reviewCount: Int? = null,
63+
val rateCount: Int? = null,
64+
val rateAverage2dp: Double? = null
65+
)
66+
67+
@Singleton
68+
class AsmrOneAvailabilityApi @Inject constructor(
69+
private val okHttpClient: OkHttpClient,
70+
private val gson: Gson
71+
) {
72+
private val clientSessionId = UUID.randomUUID().toString()
73+
private val appHeaderValue = "com.asmr.player/${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
74+
private val deviceFingerprint = buildDeviceFingerprint()
75+
private val userAgent = buildUserAgent()
76+
77+
suspend fun check(rjs: List<String>): Map<String, Boolean> {
78+
val normalized = rjs
79+
.asSequence()
80+
.map { it.trim().uppercase() }
81+
.filter { RJ_CODE_REGEX.matches(it) }
82+
.distinct()
83+
.take(MAX_RJS)
84+
.toList()
85+
if (normalized.isEmpty() || baseUrl.isBlank()) return emptyMap()
86+
87+
return runCatching {
88+
withContext(Dispatchers.IO) {
89+
val request = Request.Builder()
90+
.url(resolveUrl("api/asmr-one/availability"))
91+
.header("User-Agent", userAgent)
92+
.header("X-Listen-Together-App", appHeaderValue)
93+
.header("X-Listen-Together-Client-Session-Id", clientSessionId)
94+
.header("X-Listen-Together-Device-Fingerprint", deviceFingerprint)
95+
.header(NetworkHeaders.HEADER_SILENT_IO_ERROR, NetworkHeaders.SILENT_IO_ERROR_ON)
96+
.post(gson.toJson(AsmrOneAvailabilityRequest(normalized)).toRequestBody(JSON_MEDIA_TYPE))
97+
.build()
98+
okHttpClient.newCall(request).execute().use { response ->
99+
if (!response.isSuccessful) return@withContext emptyMap()
100+
val raw = response.body?.string().orEmpty()
101+
if (raw.isBlank()) return@withContext emptyMap()
102+
val parsed = gson.fromJson(raw, AsmrOneAvailabilityResponse::class.java)
103+
parsed.items.associate { it.rj.trim().uppercase() to it.collected }
104+
}
105+
}
106+
}.getOrDefault(emptyMap())
107+
}
108+
109+
suspend fun search(keyword: String, limit: Int, offset: Int, sort: String): AsmrOneCollectedSearchResponse {
110+
if (baseUrl.isBlank()) throw IOException("asmr.one backend is not configured")
111+
return withContext(Dispatchers.IO) {
112+
val url = resolveUrl("api/asmr-one/search")
113+
.toHttpUrlOrNull()
114+
?.newBuilder()
115+
?.addQueryParameter("q", keyword.trim())
116+
?.addQueryParameter("limit", limit.coerceIn(1, 100).toString())
117+
?.addQueryParameter("offset", offset.coerceAtLeast(0).toString())
118+
?.addQueryParameter("sort", sort.trim().ifBlank { "release" })
119+
?.build()
120+
?: throw IOException("invalid asmr.one backend url")
121+
val request = Request.Builder()
122+
.url(url)
123+
.header("User-Agent", userAgent)
124+
.header("X-Listen-Together-App", appHeaderValue)
125+
.header("X-Listen-Together-Client-Session-Id", clientSessionId)
126+
.header("X-Listen-Together-Device-Fingerprint", deviceFingerprint)
127+
.header(NetworkHeaders.HEADER_SILENT_IO_ERROR, NetworkHeaders.SILENT_IO_ERROR_ON)
128+
.get()
129+
.build()
130+
okHttpClient.newCall(request).execute().use { response ->
131+
if (!response.isSuccessful) {
132+
throw IOException("asmr.one search failed: HTTP ${response.code}")
133+
}
134+
val raw = response.body?.string().orEmpty()
135+
if (raw.isBlank()) return@withContext AsmrOneCollectedSearchResponse()
136+
gson.fromJson(raw, AsmrOneCollectedSearchResponse::class.java)
137+
?: AsmrOneCollectedSearchResponse()
138+
}
139+
}
140+
}
141+
142+
private fun resolveUrl(path: String): String {
143+
val root = baseUrl.trimEnd('/')
144+
val normalizedPath = path.trimStart('/')
145+
return "$root/$normalizedPath"
146+
}
147+
148+
private fun buildDeviceFingerprint(): String {
149+
val source = listOf(
150+
Build.BRAND,
151+
Build.MANUFACTURER,
152+
Build.MODEL,
153+
Build.DEVICE,
154+
Build.PRODUCT,
155+
Build.VERSION.SDK_INT.toString(),
156+
BuildConfig.APPLICATION_ID,
157+
BuildConfig.VERSION_NAME,
158+
).joinToString(separator = "|") { it.trim() }
159+
return XxHash64.hashHex(source.toByteArray(UTF_8))
160+
}
161+
162+
private fun buildUserAgent(): String {
163+
val deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
164+
.map { it.trim() }
165+
.filter { it.isNotBlank() }
166+
.joinToString(separator = " ")
167+
.ifBlank { "Android" }
168+
return "EaraAsmrOneAvailability/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.SDK_INT}; $deviceModel)"
169+
}
170+
171+
private companion object {
172+
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
173+
private val RJ_CODE_REGEX = Regex("""RJ\d{6,}""")
174+
private const val MAX_RJS = 100
175+
private val baseUrl: String
176+
get() = BuildConfig.LISTEN_TOGETHER_BASE_URL
177+
}
178+
}

app/src/main/java/com/asmr/player/data/settings/SettingsDataStore.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ object SettingsKeys {
1919
val LIBRARY_VIEW_MODE = intPreferencesKey("library_view_mode")
2020
val SEARCH_VIEW_MODE = intPreferencesKey("search_view_mode")
2121
val HOT_LISTENING_VIEW_MODE = intPreferencesKey("hot_listening_view_mode")
22+
val HOT_LISTENING_SORT_MODE = stringPreferencesKey("hot_listening_sort_mode")
2223

2324
val PLAY_MODE = intPreferencesKey("play_mode")
2425

0 commit comments

Comments
 (0)