Skip to content

Commit

Permalink
Show warning when video playing in YouTube is probably different
Browse files Browse the repository at this point in the history
  • Loading branch information
arkon committed May 19, 2024
1 parent 8c6bf90 commit 830ba4f
Show file tree
Hide file tree
Showing 7 changed files with 68 additions and 46 deletions.
30 changes: 15 additions & 15 deletions app/src/main/kotlin/com/livetl/android/data/chat/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.livetl.android.util.readAssetFile
import com.livetl.android.util.runJS
import com.livetl.android.util.setDefaultSettings
import com.livetl.android.util.toDebugTimestampString
import com.livetl.android.util.withUIContext
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.request.get
Expand All @@ -28,7 +29,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
Expand Down Expand Up @@ -75,19 +75,19 @@ class ChatService @Inject constructor(
}

suspend fun seekTo(videoId: String, second: Long) {
withContext(Dispatchers.Main) {
if (second != currentSecond) {
Timber.d("$videoId: seeking to $second")
if (second != currentSecond) {
Timber.d("Seeking to $second")
withUIContext {
webview.runJS("window.postMessage({ 'yt-player-video-progress': $second, video: '$videoId'}, '*');")
}

// Clear out messages if we seem to be manually seeking
if (currentSecond - 10 > second || second > currentSecond + 10) {
Timber.d("$videoId: manual seek")
clearMessages()
}

currentSecond = second
// Clear out messages if we seem to be manually seeking
if (currentSecond - 10 > second || second > currentSecond + 10) {
Timber.d("Manual seek")
clearMessages()
}

currentSecond = second
}
}

Expand Down Expand Up @@ -148,12 +148,12 @@ class ChatService @Inject constructor(
}
}
val matches = CHAT_CONTINUATION_PATTERN.matcher(result.bodyAsText())
if (matches.find()) {
val continuation = matches.group(1)
return "${urlPrefix}_replay?continuation=$continuation&embed_domain=www.livetl.app"
} else {
if (!matches.find()) {
throw NoChatContinuationFoundException(videoId)
}

val continuation = matches.group(1)
return "${urlPrefix}_replay?continuation=$continuation&embed_domain=www.livetl.app"
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class FeedService @Inject constructor(private val client: HttpClient, private va
}
}

// TODO: fallback to YouTube API for things HoloDex doesn't have?
suspend fun getVideoInfo(videoId: String): Stream = withContext(SupervisorJob() + Dispatchers.IO) {
val result = client.get {
url {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
Expand All @@ -39,17 +40,21 @@ class YouTubeSessionService @Inject constructor(
private val mediaControllerCallback = object : MediaController.Callback() {
override fun onPlaybackStateChanged(state: PlaybackState?) {
scope.launch {
session.value = getYouTubeSession()
val currentSession = getYouTubeSession()
session.update { currentSession }

// We don't really get progress updates, so we simulate per-second updates
// while it's playing
if (state?.state == PlaybackState.STATE_PLAYING && session.value?.isLive == false) {
if (state?.state == PlaybackState.STATE_PLAYING && currentSession?.isLive == false) {
progressJob = launch {
while (true) {
Timber.d("Updating playback position")
session.update {
it?.copy(
positionInMs = (it.positionInMs ?: 0L) + 2000L,
)
}
delay(2.seconds)
session.value = session.value?.copy(
positionInMs = (session.value?.positionInMs ?: 0L) + 2000L,
)
}
}
} else {
Expand All @@ -61,7 +66,8 @@ class YouTubeSessionService @Inject constructor(

override fun onMetadataChanged(metadata: MediaMetadata?) {
scope.launch {
session.value = getYouTubeSession()
val currentSession = getYouTubeSession()
session.update { currentSession }
}
}

Expand Down Expand Up @@ -131,23 +137,23 @@ class YouTubeSessionService @Inject constructor(
val streamInfo = streamService.findStreamInfo(title, channelName)

return YouTubeSession(
title = title,
videoId = streamInfo?.videoId,
videoTitle = title,
channelName = channelName,
playbackState = state,
isLive = position == 0L || streamInfo?.isLive == true,
playbackState = state,
positionInMs = position,
videoId = streamInfo?.videoId,
)
}
}

data class YouTubeSession(
val title: String,
val videoId: String?,
val videoTitle: String,
val channelName: String,
val playbackState: YouTubeVideoPlaybackState,
val isLive: Boolean,
val playbackState: YouTubeVideoPlaybackState,
val positionInMs: Long?,
val videoId: String?,
)

enum class YouTubeVideoPlaybackState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.livetl.android.data.chat.ChatFilterService
import com.livetl.android.data.chat.ChatMessage
import com.livetl.android.data.media.YouTubeSession
import com.livetl.android.data.media.YouTubeSessionService
import com.livetl.android.data.stream.StreamInfo
import com.livetl.android.data.stream.StreamService
Expand All @@ -15,16 +14,13 @@ import com.livetl.android.util.AppPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

@HiltViewModel
class PlayerViewModel @Inject constructor(
Expand Down Expand Up @@ -53,15 +49,22 @@ class PlayerViewModel @Inject constructor(
}

youTubeSessionService.attach()
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch {
youTubeSessionService.session
.filterNotNull()
.debounce(2.seconds)
.collectLatest { session ->
Timber.d(
"Current YouTube video: ${session.videoId} / ${session.title} / ${session.positionInMs} / ${session.playbackState}",
"Current YouTube video: ${session.videoId} / ${session.videoTitle} / ${session.positionInMs} / ${session.playbackState}",
)
state.update { it.copy(youTubeSession = session) }
val isDifferentVideo =
(session.videoId != null && session.videoId != state.value.streamInfo?.videoId) ||
(session.videoTitle != state.value.streamInfo?.title) ||
(session.channelName != state.value.streamInfo?.author)
state.update {
it.copy(
isDifferentVideo = isDifferentVideo,
)
}

// Update chat progress based on playback state
if (!session.isLive && session.videoId != null && session.positionInMs != null) {
Expand Down Expand Up @@ -98,8 +101,7 @@ class PlayerViewModel @Inject constructor(
val filteredMessages: ImmutableList<ChatMessage> = persistentListOf(),
val fontScale: Float = 1f,
val streamInfo: StreamInfo? = null,
// TODO: show message if playing video seems to have changed
val youTubeSession: YouTubeSession? = null,
val isDifferentVideo: Boolean = false,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package com.livetl.android.ui.screen.player.composable

import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.safeDrawingPadding
Expand All @@ -20,6 +22,7 @@ import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
Expand Down Expand Up @@ -83,6 +86,7 @@ fun PlayerTabs(
filteredMessages = state.filteredMessages,
fontScale = state.fontScale,
chatState = chatState,
showDifferentVideoWarning = state.isDifferentVideo,
modifier = modifier,
)
}
Expand All @@ -93,6 +97,7 @@ private fun FullPlayerTab(
filteredMessages: ImmutableList<ChatMessage>,
fontScale: Float,
chatState: ChatState,
showDifferentVideoWarning: Boolean,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
Expand All @@ -111,6 +116,19 @@ private fun FullPlayerTab(
.fillMaxSize()
.safeDrawingPadding(),
) {
if (showDifferentVideoWarning) {
Text(
text = stringResource(R.string.different_video_warning),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.error)
.padding(16.dp),
color = MaterialTheme.colorScheme.onError,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}

AnimatedVisibility(
visible = showStreamInfo,
) {
Expand Down
12 changes: 3 additions & 9 deletions app/src/main/kotlin/com/livetl/android/util/CoroutineUtils.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
package com.livetl.android.util

import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

@DelicateCoroutinesApi
fun runOnMainThread(block: () -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
block()
}
}
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.Main, block)

suspend fun waitUntil(
predicate: () -> Boolean,
Expand Down
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 @@ -34,6 +34,7 @@
<string name="new_member">New member</string>
<string name="error_no_notification_access">To sync progress with YouTube, %s needs notification access.</string>
<string name="action_grant_notification_access">Grant notification access</string>
<string name="different_video_warning">The video playing in YouTube might be different than the one open here!</string>

<string name="action_open_youtube">Open YouTube</string>

Expand Down

0 comments on commit 830ba4f

Please sign in to comment.