Skip to content

Commit

Permalink
Transcripts - Update html format transcript display (#2910)
Browse files Browse the repository at this point in the history
Co-authored-by: Michał Sikora <[email protected]>
  • Loading branch information
ashiagr and MiSikora authored Oct 7, 2024
1 parent c29c4ee commit 7d256cd
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 42 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
([#2979](https://github.com/Automattic/pocket-casts-android/pull/2979))
* Fix search podcast results scroll back to the start after subscribing
([#2923](https://github.com/Automattic/pocket-casts-android/pull/2923))
* Display web-page based HTML transcripts in web view
([#2910](https://github.com/Automattic/pocket-casts-android/pull/2910))

7.74
-----
Expand Down
28 changes: 3 additions & 25 deletions app/lint-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@

<issue
id="PrivateResource"
message="The resource `@dimen/design_bottom_navigation_height` is marked as private in com.google.android.material:material:1.9.0"
message="The resource `@dimen/design_bottom_navigation_height` is marked as private in com.google.android.material:material:1.11.0"
errorLine1=" val padding = resources.getDimension(MR.dimen.design_bottom_navigation_height).toInt()"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
Expand All @@ -201,7 +201,7 @@

<issue
id="PrivateResource"
message="The resource `@dimen/design_bottom_navigation_height` is marked as private in com.google.android.material:material:1.9.0"
message="The resource `@dimen/design_bottom_navigation_height` is marked as private in com.google.android.material:material:1.11.0"
errorLine1=" val padding = resources.getDimension(MR.dimen.design_bottom_navigation_height).toInt() + miniPlayerHeight"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
Expand All @@ -212,7 +212,7 @@

<issue
id="PrivateResource"
message="The resource `@dimen/design_bottom_navigation_height` is marked as private in com.google.android.material:material:1.9.0"
message="The resource `@dimen/design_bottom_navigation_height` is marked as private in com.google.android.material:material:1.11.0"
errorLine1=" android:paddingBottom=&quot;@dimen/design_bottom_navigation_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
Expand Down Expand Up @@ -7381,17 +7381,6 @@
column="13"/>
</issue>

<issue
id="UnusedIds"
message="The resource `R.id.recycler_view` appears to be unused"
errorLine1=" android:id=&quot;@+id/recycler_view&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="../modules/services/views/src/main/res/layout/fragment_recyclerview.xml"
line="3"
column="5"/>
</issue>

<issue
id="UnusedIds"
message="The resource `R.id.sharePodcast` appears to be unused"
Expand Down Expand Up @@ -7656,17 +7645,6 @@
column="17"/>
</issue>

<issue
id="UnusedIds"
message="The resource `R.id.carousel` appears to be unused"
errorLine1=" android:id=&quot;@+id/carousel&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="../modules/features/discover/src/main/res/layout/row_carousel_list.xml"
line="3"
column="5"/>
</issue>

<issue
id="UnusedIds"
message="The resource `R.id.share_podcasts` appears to be unused"
Expand Down
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ tasker-plugin = "0.4.10"
test = "1.6.1"
tracks = "6.0.2"
wear-compose = "1.3.1"
webkit = "1.12.0"
work = "2.9.1"

[libraries]
Expand Down Expand Up @@ -238,6 +239,9 @@ wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation",
wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wear-compose" }
wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "wear-compose" }

# Webkit
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }

# Work Manager
work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
work-rx2 = { module = "androidx.work:work-rxjava2", version.ref = "work" }
Expand All @@ -260,6 +264,7 @@ androidx-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"
androidx-viewpager = "androidx.viewpager2:viewpager2:1.1.0"
barista = "com.adevinta.android:barista:4.3.0"
capturable = "dev.shreyaspatil:capturable:2.1.0"
compose-webview = "io.github.kevinnzou:compose-webview:0.33.6"
desugar-jdk = "com.android.tools:desugar_jdk_libs:2.1.2"
device-names = "com.jaredrummler:android-device-names:2.1.1"
encryptedlogging = "com.automattic:encryptedlogging:0.0.1"
Expand Down
2 changes: 2 additions & 0 deletions modules/features/player/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.webkit)
implementation(libs.compose.animation)
implementation(libs.compose.material)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.webview)
implementation(libs.coroutines.core)
implementation(libs.coroutines.rx2)
implementation(libs.fragment.compose)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package au.com.shiftyjelly.pocketcasts.player.view.transcripts

import android.os.Build
import android.view.KeyEvent
import android.view.View
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -38,10 +40,13 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.text.Cue
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.text.CuesWithTiming
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
import au.com.shiftyjelly.pocketcasts.compose.Devices
import au.com.shiftyjelly.pocketcasts.compose.components.TextP40
Expand All @@ -67,6 +72,10 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.TranscriptFormat
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
import au.com.shiftyjelly.pocketcasts.utils.Util
import com.google.common.collect.ImmutableList
import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewNavigator
import com.kevinnzou.web.rememberWebViewState
import au.com.shiftyjelly.pocketcasts.localization.R as LR

@kotlin.OptIn(ExperimentalMaterialApi::class)
Expand Down Expand Up @@ -112,6 +121,7 @@ fun TranscriptPage(
searchState = searchState.value,
colors = colors,
modifier = modifier,
transitionState = transitionState.value,
)

PullRefreshIndicator(
Expand Down Expand Up @@ -165,6 +175,7 @@ private fun TranscriptContent(
searchState: SearchUiState,
colors: TranscriptColors,
modifier: Modifier,
transitionState: TransitionState?,
) {
Box(
modifier = modifier
Expand All @@ -183,6 +194,7 @@ private fun TranscriptContent(
ScrollableTranscriptView(
state = state,
searchState = searchState,
transitionState = transitionState,
)
}

Expand All @@ -208,6 +220,7 @@ private fun TranscriptContent(
private fun ScrollableTranscriptView(
state: UiState.TranscriptLoaded,
searchState: SearchUiState,
transitionState: TransitionState?,
) {
val screenWidthDp = LocalConfiguration.current.screenWidthDp
val displayWidthPercent = if (Util.isTablet(LocalContext.current)) 0.8f else 1f
Expand Down Expand Up @@ -235,21 +248,25 @@ private fun ScrollableTranscriptView(
),
) {
SelectionContainer {
LazyColumn(
state = scrollState,
modifier = scrollableContentModifier,
contentPadding = PaddingValues(
start = horizontalContentPadding,
end = horizontalContentPadding,
top = 64.dp,
bottom = 80.dp,
),
) {
items(state.displayInfo.items) { item ->
TranscriptItem(
item = item,
searchState = searchState,
)
if (state.showAsWebPage) {
TranscriptWebView(state, transitionState)
} else {
LazyColumn(
state = scrollState,
modifier = scrollableContentModifier,
contentPadding = PaddingValues(
start = horizontalContentPadding,
end = horizontalContentPadding,
top = 64.dp,
bottom = 80.dp,
),
) {
items(state.displayInfo.items) { item ->
TranscriptItem(
item = item,
searchState = searchState,
)
}
}
}
}
Expand All @@ -274,6 +291,53 @@ private fun ScrollableTranscriptView(
}
}

@Composable
private fun TranscriptWebView(
state: UiState.TranscriptLoaded,
transitionState: TransitionState?,
) {
val webViewState = rememberWebViewState(state.transcript.url)
val navigator = rememberWebViewNavigator()
val lastLoadedUri = webViewState.lastLoadedUrl?.toUri()
val transcriptUri = state.transcript.url.toUri()
val isRootUrl = "${lastLoadedUri?.host}${lastLoadedUri?.path}" == "${transcriptUri.host}${transcriptUri.path}" // Ignore scheme http or https
WebView(
state = webViewState,
navigator = navigator,
onCreated = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
it.settings.isAlgorithmicDarkeningAllowed = true
} else {
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
@Suppress("DEPRECATION")
WebSettingsCompat.setForceDark(it.settings, WebSettingsCompat.FORCE_DARK_ON)
}
}
it.setBackgroundColor(android.graphics.Color.TRANSPARENT)
it.setOnKeyListener(
View.OnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
if (keyCode == KeyEvent.KEYCODE_BACK && it.canGoBack() && !isRootUrl) {
it.goBack()
return@OnKeyListener true
}
}
false
},
)
},
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomPadding()),
)
if (webViewState.loadingState is LoadingState.Loading) {
LoadingView(color = TranscriptColors.textColor())
}
LaunchedEffect(transitionState, webViewState.viewState) {
if (!isRootUrl) navigator.navigateBack()
}
}

@Composable
private fun TranscriptItem(
item: DisplayItem,
Expand Down Expand Up @@ -411,6 +475,7 @@ private fun TranscriptContentPreview(
),
),
searchState = searchState,
transitionState = null,
colors = TranscriptColors(Color.Black),
modifier = Modifier.fillMaxSize(),
)
Expand All @@ -437,6 +502,7 @@ private fun TranscriptEmptyContentPreview() {
),
),
searchState = SearchUiState(),
transitionState = null,
colors = TranscriptColors(Color.Black),
modifier = Modifier.fillMaxSize(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ fun TranscriptPageWrapper(
}

LaunchedEffect(transcriptUiState.value) {
showSearch = transcriptUiState.value is TranscriptViewModel.UiState.TranscriptLoaded
showSearch = (transcriptUiState.value as? TranscriptViewModel.UiState.TranscriptLoaded)?.showSearch == true
if (!showSearch) expandSearch = false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ class TranscriptViewModel @Inject constructor(
cuesInfo.isNotEmpty() && cuesInfo[0].cuesWithTiming.cues.any {
it.text?.contains("<script type=\"text/javascript\">") ?: false
}

val showSearch = !showAsWebPage
}

data class Error(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package au.com.shiftyjelly.pocketcasts.player.view.transcripts

import androidx.media3.common.text.Cue
import androidx.media3.extractor.text.CuesWithTiming
import app.cash.turbine.test
import au.com.shiftyjelly.pocketcasts.models.entity.Podcast
import au.com.shiftyjelly.pocketcasts.models.to.Transcript
import au.com.shiftyjelly.pocketcasts.models.to.TranscriptCuesInfo
import au.com.shiftyjelly.pocketcasts.player.view.transcripts.TranscriptViewModel.TranscriptError
import au.com.shiftyjelly.pocketcasts.player.view.transcripts.TranscriptViewModel.UiState
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
Expand All @@ -18,6 +21,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -143,6 +147,58 @@ class TranscriptViewModelTest {
verify(transcriptsManager).loadTranscriptCuesInfo(podcastId, transcript, forceRefresh = false)
}

@Test
fun `given html transcript with javascript, when transcript load invoked, then transcript is shown in webview`() = runTest {
whenever(transcriptsManager.observerTranscriptForEpisode(any())).thenReturn(flowOf(transcript.copy(type = "text/html")))
initViewModel(content = "<html><script type=\"text/javascript\"></html>")

viewModel.parseAndLoadTranscript(isTranscriptViewOpen = true)

viewModel.uiState.test {
assertTrue((awaitItem() as UiState.TranscriptLoaded).showAsWebPage)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun `given html transcript without javascript, when transcript load invoked, then transcript is not shown in webview`() = runTest {
whenever(transcriptsManager.observerTranscriptForEpisode(any())).thenReturn(flowOf(transcript.copy(type = "text/html")))
initViewModel(content = "<html></html>")

viewModel.parseAndLoadTranscript(isTranscriptViewOpen = true)

viewModel.uiState.test {
assertFalse((awaitItem() as UiState.TranscriptLoaded).showAsWebPage)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun `given html transcript with javascript, when transcript load invoked, then search is not shown`() = runTest {
whenever(transcriptsManager.observerTranscriptForEpisode(any())).thenReturn(flowOf(transcript.copy(type = "text/html")))
initViewModel(content = "<html><script type=\"text/javascript\"></html>")

viewModel.parseAndLoadTranscript(isTranscriptViewOpen = true)

viewModel.uiState.test {
assertFalse((awaitItem() as UiState.TranscriptLoaded).showSearch)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun `given html transcript without javascript, when transcript load invoked, then search is shown`() = runTest {
whenever(transcriptsManager.observerTranscriptForEpisode(any())).thenReturn(flowOf(transcript.copy(type = "text/html")))
initViewModel(content = "<html></html>")

viewModel.parseAndLoadTranscript(isTranscriptViewOpen = true)

viewModel.uiState.test {
assertTrue((awaitItem() as UiState.TranscriptLoaded).showSearch)
cancelAndConsumeRemainingEvents()
}
}

@OptIn(ExperimentalCoroutinesApi::class)
private fun initViewModel(
content: String? = null,
Expand All @@ -158,7 +214,12 @@ class TranscriptViewModelTest {
} else {
whenever(response.bytes()).thenReturn(byteArrayOf())
}
whenever(transcriptsManager.loadTranscriptCuesInfo(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(emptyList())
val cuesInfo = if (content == null) {
emptyList()
} else {
listOf(TranscriptCuesInfo(CuesWithTiming(listOf(Cue.Builder().setText(content).build()), 0, 0), null))
}
whenever(transcriptsManager.loadTranscriptCuesInfo(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(cuesInfo)
}

viewModel = TranscriptViewModel(
Expand Down
Loading

0 comments on commit 7d256cd

Please sign in to comment.