Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/offset, closes #90 #92

Merged
merged 5 commits into from
Oct 24, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,16 @@ fun SharedTransitionScope.LyricsFetchScreen(

is QueryStatus.Success -> SuccessContent(
result = queryState.song,
onTryAgain = { viewModel.loadSongInfo(context, true) },
onEdit = { viewModel.queryState = QueryStatus.NotSubmitted },
onTryAgain = {
viewModel.lrcOffset = 0
viewModel.loadSongInfo(context, true)
},
onEdit = {
viewModel.lrcOffset = 0
viewModel.queryState = QueryStatus.NotSubmitted
},
offset = viewModel.lrcOffset,
onSetOffset = { viewModel.lrcOffset = it },
onSaveLyrics = {
viewModel.saveLyricsToFile(
it,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class LyricsFetchViewModel(
if (source == null) QueryStatus.NotSubmitted else QueryStatus.Pending
)
private var queryOffset by mutableIntStateOf(0)
var lrcOffset by mutableIntStateOf(0)

var lyricsFetchState by mutableStateOf<LyricsFetchState>(LyricsFetchState.NotSubmitted)

Expand Down Expand Up @@ -85,7 +86,7 @@ class LyricsFetchViewModel(
context: Context,
generatedUsingString: String
) {
val lrcContent = generateLrcContent(song, lyrics, generatedUsingString)
val lrcContent = generateLrcContent(song, lyrics, generatedUsingString, lrcOffset)
val file = newLyricsFilePath(filePath, song)

if (!isLegacyFileAccessRequired(filePath)) {
Expand Down Expand Up @@ -126,7 +127,7 @@ class LyricsFetchViewModel(
context: Context,
song: SongInfo
) {
val lrcContent = generateLrcContent(song, lyrics, context.getString(R.string.generated_using))
val lrcContent = generateLrcContent(song, lyrics, context.getString(R.string.generated_using), lrcOffset)

runCatching {
embedLyricsInFile(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package pl.lambada.songsync.ui.screens.lyricsFetch.components

import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -13,32 +20,97 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Exposure
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import pl.lambada.songsync.R
import pl.lambada.songsync.util.ext.repeatingClickable


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LyricsSuccessContent(
lyrics: String,
offset: Int,
onSetOffset: (Int) -> Unit,
onSaveLyrics: () -> Unit,
onEmbedLyrics: () -> Unit,
onCopyLyrics: () -> Unit
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Exposure,
contentDescription = null,
modifier = Modifier.size(32.dp)
)
Text(
text = stringResource(R.string.offset),
modifier = Modifier.padding(start = 6.dp)
)
Spacer(modifier = Modifier.weight(1f))
OutlinedButton(
onClick = { /* handled by repeatingClickable */ },
modifier = Modifier
.clip(RoundedCornerShape(20.dp)) // otherwise square ripple
.repeatingClickable(
interactionSource = remember { MutableInteractionSource() },
enabled = true,
maxDelayMillis = 500,
onClick = { onSetOffset(offset - 100) }
)
) {
Text(text = "-0.1s")
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = (if (offset >= 0) "+" else "") +
"${offset / 1000.0}s",
)
Spacer(modifier = Modifier.width(10.dp))
OutlinedButton(
onClick = { /* handled by repeatingClickable */ },
modifier = Modifier
.clip(RoundedCornerShape(20.dp)) // otherwise square ripple
.repeatingClickable(
interactionSource = remember { MutableInteractionSource() },
enabled = true,
maxDelayMillis = 500,
onClick = { onSetOffset(offset + 100) }
)
) {
Text(text = "+0.1s")
}
}

Spacer(modifier = Modifier.height(6.dp))

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import pl.lambada.songsync.R
import pl.lambada.songsync.domain.model.SongInfo
import pl.lambada.songsync.ui.components.SongCard
import pl.lambada.songsync.ui.screens.lyricsFetch.LyricsFetchState
import pl.lambada.songsync.util.applyOffsetToLyrics


@OptIn(ExperimentalSharedTransitionApi::class)
Expand All @@ -36,6 +38,8 @@ fun SharedTransitionScope.SuccessContent(
result: SongInfo,
onTryAgain: () -> Unit,
onEdit: () -> Unit,
offset: Int,
onSetOffset: (Int) -> Unit,
onSaveLyrics: (String) -> Unit,
onEmbedLyrics: (String) -> Unit,
onCopyLyrics: (String) -> Unit,
Expand Down Expand Up @@ -99,7 +103,9 @@ fun SharedTransitionScope.SuccessContent(
LyricsFetchState.NotSubmitted -> { /* nothing */ }

is LyricsFetchState.Success -> LyricsSuccessContent(
lyrics = it.lyrics,
lyrics = applyOffsetToLyrics(it.lyrics, offset),
offset = offset,
onSetOffset = onSetOffset,
onSaveLyrics = { onSaveLyrics(it.lyrics) },
onEmbedLyrics = { onEmbedLyrics(it.lyrics) },
onCopyLyrics = { onCopyLyrics(it.lyrics) }
Expand Down
45 changes: 43 additions & 2 deletions app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ import java.io.FileNotFoundException
fun generateLrcContent(
song: SongInfo,
lyrics: String,
generatedUsingString: String
generatedUsingString: String,
offset: Int = 0,
): String {
return "[ti:${song.songName}]\n[ar:${song.artistName}]\n[by:$generatedUsingString]\n$lyrics"
return ("[ti:${song.songName}]\n" +
"[ar:${song.artistName}]\n" +
"[by:$generatedUsingString]\n" + lyrics).let {
if (offset != 0) applyOffsetToLyrics(it, offset) else it
}
}

fun newLyricsFilePath(filePath: String?, song: SongInfo): File {
Expand Down Expand Up @@ -297,4 +302,40 @@ fun saveToExternalPath(
outputStream.write(lrc.toByteArray())
}
}
}

/**
* "Legacy" way to apply an offset to lyrics, modifies the lyrics string directly
* as most players do not support the offset tag in LRC files
* @param lyrics the lyrics to apply the offset to
* @param offset the offset to apply to the lyrics
* @return the lyrics with the offset applied
*/
fun applyOffsetToLyrics(lyrics: String, offset: Int): String {
val timestampRegex = Regex("""[\[<](\d+):(\d+)\.(\d+)[]>]""")

fun applyOffset(minute: Int, second: Int, millisecond: Int): String {
val totalMilliseconds = (minute * 60 * 1000) + (second * 1000) + (millisecond * 10) + offset
if (totalMilliseconds < 0) return "[00:00.000]" // Prevent negative times

val newMinutes = (totalMilliseconds / 60000) % 60
val newSeconds = (totalMilliseconds / 1000) % 60
val newMilliseconds = (totalMilliseconds % 1000)

return "${newMinutes.toString().padStart(2, '0')}:" +
"${newSeconds.toString().padStart(2, '0')}." +
newMilliseconds.toString().padStart(3, '0')
}

return lyrics.replace(timestampRegex) { matchResult ->
val (minuteStr, secondStr, millisecondStr) = matchResult.destructured
val minute = minuteStr.toInt()
val second = secondStr.toInt()
val millisecond = millisecondStr.toInt()

val startChar = matchResult.value[0]
val endChar = if (startChar == '[') ']' else '>'

"${startChar}${applyOffset(minute, second, millisecond)}$endChar"
}
}
62 changes: 62 additions & 0 deletions app/src/main/java/pl/lambada/songsync/util/ext/ComposeExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@ package pl.lambada.songsync.util.ext
import androidx.activity.OnBackPressedCallback
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@OptIn(ExperimentalLayoutApi::class)
@Composable
Expand Down Expand Up @@ -39,3 +52,52 @@ fun BackPressHandler(
}
}
}

fun Modifier.repeatingClickable(
interactionSource: MutableInteractionSource,
enabled: Boolean,
maxDelayMillis: Long = 1000,
minDelayMillis: Long = 5,
delayDecayFactor: Float = .20f,
onClick: () -> Unit
): Modifier = this.then(
composed {
val currentClickListener by rememberUpdatedState(onClick)
val scope = rememberCoroutineScope()

pointerInput(interactionSource, enabled) {
scope.launch {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// Create a down press interaction
val downPress = PressInteraction.Press(down.position)
val heldButtonJob = launch {
// Send the press through the interaction source
interactionSource.emit(downPress)
var currentDelayMillis = maxDelayMillis
while (enabled && down.pressed) {
currentClickListener()
delay(currentDelayMillis)
val nextMillis = currentDelayMillis - (currentDelayMillis * delayDecayFactor)
currentDelayMillis = nextMillis.toLong().coerceAtLeast(minDelayMillis)
}
}
val up = waitForUpOrCancellation()
heldButtonJob.cancel()
// Determine whether a cancel or release occurred, and create the interaction
val releaseOrCancel = when (up) {
null -> PressInteraction.Cancel(downPress)
else -> PressInteraction.Release(downPress)
}
launch {
// Send the result through the interaction source
interactionSource.emit(releaseOrCancel)
}
}
}
}.indication(
interactionSource = interactionSource,
indication = rememberRipple()
)
}
)
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
<string name="embed_non_local_song_error">You tried to embed the lyrics to a non-local file. Aborting operation.</string>
<string name="multi_person_word_by_word">Multi-person word by word lyrics</string>
<string name="multi_person_word_by_word_summary">Use multi-person lyrics format when getting lyrics from Apple Music</string>
<string name="offset">Offset</string>
<string name="song_path">Show song path</string>
<string name="song_path_description">Show the path of a song file on song list</string>
<string name="synced_lyrics">Get Synced Lyrics</string>
Expand Down
Loading