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
1 change: 1 addition & 0 deletions app/src/main/assets/changelog/v3.0.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ PLAYBACK AND NOTIFICATIONS
- The video notification has repeat, and shuffle while a queue is playing
- DeArrow shows a frame from the video when nobody has submitted a thumbnail, as DeArrow itself does, with a switch to turn that off
- DeArrow titles also appear on Shorts cards
- Lyrics add lrc.red and Unison as sources, find word-by-word timing more often, and no longer give live or remixed songs the studio version's timing

FIXES
- Fix a paused video not playing again after the screen was off for a while
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ class PlayerPreferences(
val LYRICS_PROVIDER_ENABLED_KUGOU = booleanPreferencesKey("lyrics_provider_enabled_kugou")
val LYRICS_PROVIDER_ENABLED_PAXSENIX = booleanPreferencesKey("lyrics_provider_enabled_paxsenix")
val LYRICS_PROVIDER_ENABLED_YOUTUBESUBTITLE = booleanPreferencesKey("lyrics_provider_enabled_youtubesubtitle")
val LYRICS_PROVIDER_ENABLED_LRCRED = booleanPreferencesKey("lyrics_provider_enabled_lrcred")
val LYRICS_PROVIDER_ENABLED_UNISON = booleanPreferencesKey("lyrics_provider_enabled_unison")
val SWIPE_GESTURES_ENABLED = booleanPreferencesKey("swipe_gestures_enabled")
val BRIGHTNESS_SWIPE_GESTURES_ENABLED = booleanPreferencesKey("brightness_swipe_gestures_enabled")
val REMEMBER_BRIGHTNESS_ENABLED = booleanPreferencesKey("remember_brightness_enabled")
Expand Down Expand Up @@ -3448,6 +3450,8 @@ class PlayerPreferences(
"KuGou" to Keys.LYRICS_PROVIDER_ENABLED_KUGOU,
"Paxsenix" to Keys.LYRICS_PROVIDER_ENABLED_PAXSENIX,
"YouTubeSubtitle" to Keys.LYRICS_PROVIDER_ENABLED_YOUTUBESUBTITLE,
"LrcRed" to Keys.LYRICS_PROVIDER_ENABLED_LRCRED,
"Unison" to Keys.LYRICS_PROVIDER_ENABLED_UNISON,
)

fun isLyricsProviderEnabled(providerName: String): Flow<Boolean> =
Expand Down
163 changes: 84 additions & 79 deletions app/src/main/java/io/github/aedev/flow/data/lyrics/BetterLyrics.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,23 @@ package io.github.aedev.flow.data.lyrics
import android.util.Log
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import io.github.aedev.flow.network.AppProxyManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.util.concurrent.TimeUnit

/**
* Service for fetching lyrics from the BetterLyrics API (lyrics-api.boidu.dev).
* Returns structured LyricsEntry objects with word-level timestamps from TTML.
*/
object BetterLyrics {
private const val TAG = "BetterLyrics"

private val client: OkHttpClient
get() = AppProxyManager.applyTo(OkHttpClient.Builder())
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()

class BetterLyrics(
private val client: OkHttpClient,
) {
private val gson = Gson()

private const val BASE_URL = "https://lyrics-api.boidu.dev"

private data class TTMLResponse(
@SerializedName("ttml") val ttml: String? = null
@SerializedName("ttml") val ttml: String? = null,
)

/**
Expand All @@ -48,73 +38,88 @@ object BetterLyrics {
title: String,
artist: String,
duration: Int,
album: String? = null
): Result<List<LyricsEntry>> = withContext(Dispatchers.IO) {
try {
val encodedTitle = URLEncoder.encode(title, "UTF-8")
val encodedArtist = URLEncoder.encode(artist, "UTF-8")

val url = buildString {
append("$BASE_URL/getLyrics")
append("?s=$encodedTitle")
append("&a=$encodedArtist")
if (!album.isNullOrBlank()) {
append("&al=${URLEncoder.encode(album, "UTF-8")}")
}
if (duration > 0) {
append("&d=$duration")
album: String? = null,
): Result<List<LyricsEntry>> =
withContext(Dispatchers.IO) {
try {
val encodedTitle = URLEncoder.encode(title, "UTF-8")
val encodedArtist = URLEncoder.encode(artist, "UTF-8")

val url =
buildString {
append("$BASE_URL/getLyrics")
append("?s=$encodedTitle")
append("&a=$encodedArtist")
if (!album.isNullOrBlank()) {
append("&al=${URLEncoder.encode(album, "UTF-8")}")
}
if (duration > 0) {
append("&d=$duration")
}
}

Log.d(TAG, "Fetching: $url")

val request =
Request
.Builder()
.url(url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
).header("Accept", "application/json")
.build()

client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
// Songs nobody has looked up yet need an API key (401). That is a miss for
// this song, not a refused client, so it must not bench the provider.
val uncached = response.code == 401 && response.body.string().contains("Uncached", ignoreCase = true)
val reason = if (uncached) "no cached lyrics for this song" else "API returned ${response.code}"
return@withContext Result.failure(Exception("BetterLyrics $reason"))
}

val body = response.body?.string()
if (body.isNullOrBlank()) {
return@withContext Result.failure(Exception("Empty response from BetterLyrics"))
}

val ttml =
try {
val ttmlResponse = gson.fromJson(body, TTMLResponse::class.java)
ttmlResponse?.ttml
} catch (e: Exception) {
Log.d(TAG, "JSON parse failed, trying raw TTML")
if (body.contains("<tt") || body.contains("<p ")) body else null
}

if (ttml.isNullOrBlank()) {
return@withContext Result.failure(Exception("No TTML in BetterLyrics response"))
}

val ttmlLines =
try {
TTMLParser.parseTTMLToLyricsEntries(ttml)
} catch (e: Exception) {
Log.e(TAG, "TTML parse failed", e)
emptyList()
}

if (ttmlLines.isNotEmpty()) {
Log.d(TAG, "Parsed ${ttmlLines.size} lines, ${ttmlLines.count { it.words != null }} with word sync")
return@withContext Result.success(ttmlLines)
}

Result.failure(Exception("Failed to parse BetterLyrics TTML"))
}
} catch (e: Exception) {
Log.e(TAG, "BetterLyrics fetch failed", e)
Result.failure(e)
}

Log.d(TAG, "Fetching: $url")

val request = Request.Builder()
.url(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36")
.header("Accept", "application/json")
.build()

client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
Exception("BetterLyrics API returned ${response.code}")
)
}

val body = response.body?.string()
if (body.isNullOrBlank()) {
return@withContext Result.failure(Exception("Empty response from BetterLyrics"))
}

val ttml = try {
val ttmlResponse = gson.fromJson(body, TTMLResponse::class.java)
ttmlResponse?.ttml
} catch (e: Exception) {
Log.d(TAG, "JSON parse failed, trying raw TTML")
if (body.contains("<tt") || body.contains("<p ")) body else null
}

if (ttml.isNullOrBlank()) {
return@withContext Result.failure(Exception("No TTML in BetterLyrics response"))
}

val ttmlLines = try {
TTMLParser.parseTTMLToLyricsEntries(ttml)
} catch (e: Exception) {
Log.e(TAG, "TTML parse failed", e)
emptyList()
}

if (ttmlLines.isNotEmpty()) {
Log.d(TAG, "Parsed ${ttmlLines.size} lines, ${ttmlLines.count { it.words != null }} with word sync")
return@withContext Result.success(ttmlLines)
}

Result.failure(Exception("Failed to parse BetterLyrics TTML"))
}
} catch (e: Exception) {
Log.e(TAG, "BetterLyrics fetch failed", e)
Result.failure(e)
}

private companion object {
const val TAG = "BetterLyrics"
const val BASE_URL = "https://lyrics-api.boidu.dev"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@

package io.github.aedev.flow.data.lyrics

import okhttp3.OkHttpClient

/**
* LyricsProvider implementation for the BetterLyrics service.
* Delegates to BetterLyrics service which returns structured LyricsEntry with word timestamps.
*/
class BetterLyricsProvider : LyricsProvider {
class BetterLyricsProvider(
client: OkHttpClient,
) : LyricsProvider {
override val name = "BetterLyrics"

private val betterLyrics = BetterLyrics(client)

override suspend fun getLyrics(
id: String,
title: String,
artist: String,
duration: Int,
album: String?
): Result<List<LyricsEntry>> {
return BetterLyrics.getLyrics(title, artist, duration, album)
}
album: String?,
): Result<List<LyricsEntry>> = betterLyrics.getLyrics(title, artist, duration, album)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.github.aedev.flow.data.lyrics

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async

/**
* One run per key. A caller asking for a key that is already running waits for that run instead
* of starting another; the run is cancelled only once every caller waiting on it has gone.
*/
internal class InFlightRuns<K : Any, V>(
private val scope: CoroutineScope,
) {
private class Run<V>(
val result: Deferred<V>,
) {
var waiters = 0
}

private val runs = HashMap<K, Run<V>>()

suspend fun join(
key: K,
block: suspend () -> V,
): V {
val run =
synchronized(runs) {
val running =
runs[key]?.takeIf { it.result.isActive }
?: Run(scope.async { block() }).also { started ->
runs[key] = started
started.result.invokeOnCompletion {
synchronized(runs) { if (runs[key] === started) runs.remove(key) }
}
}
running.also { it.waiters++ }
}
try {
return run.result.await()
} finally {
val abandoned = synchronized(runs) { --run.waiters == 0 && run.result.isActive }
if (abandoned) run.result.cancel()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
//==================================================================================================
//This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist)
//==================================================================================================
// ==================================================================================================
// This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist)
// ==================================================================================================

package io.github.aedev.flow.data.lyrics

import io.github.aedev.flow.data.lyrics.kugou.KuGou
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient

class KuGouLyricsProvider : LyricsProvider {
class KuGouLyricsProvider(
client: OkHttpClient,
) : LyricsProvider {
override val name = "KuGou"

private val kuGou = KuGou(client)

override suspend fun getLyrics(
id: String,
title: String,
artist: String,
duration: Int,
album: String?
): Result<List<LyricsEntry>> = withContext(Dispatchers.IO) {
runCatching {
val lrc = KuGou.getLyrics(title, artist, duration, album).getOrThrow()
LyricsUtils.parseLyrics(lrc)
album: String?,
): Result<List<LyricsEntry>> =
withContext(Dispatchers.IO) {
runCatching {
val lrc = kuGou.getLyrics(title, artist, duration, album).getOrThrow()
LyricsUtils.parseLyrics(lrc)
}
}
}
}
Loading
Loading