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
41 changes: 24 additions & 17 deletions app/src/main/kotlin/com/miruplay/tv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1259,23 +1259,12 @@ private fun MiruPlayNavigation(
}
)
) { backStackEntry ->
val uri = backStackEntry.arguments?.getString("uri") ?: return@composable
val decodedUri = Uri.decode(uri)
val mediaSourceId = backStackEntry.arguments?.getString("mediaSourceId") ?: "media"
val startPosition = backStackEntry.arguments?.getLong("startPosition") ?: 0L
val episodeId = backStackEntry.arguments?.getString("episodeId")
?.let(Uri::decode)
?.takeIf { it.isNotBlank() }
val progressId = backStackEntry.arguments?.getString("progressId")
?.let(Uri::decode)
?.takeIf { it.isNotBlank() }
val source = PlaybackSource(
uri = decodedUri,
mediaSourceId = mediaSourceId,
startPosition = startPosition,
subtitleTracks = emptyList(),
episodeId = episodeId,
progressId = progressId ?: episodeId,
val source = playbackSourceFromPlayerRouteArguments(
uri = backStackEntry.arguments?.getString("uri") ?: return@composable,
mediaSourceId = backStackEntry.arguments?.getString("mediaSourceId") ?: "media",
startPosition = backStackEntry.arguments?.getLong("startPosition") ?: 0L,
episodeId = backStackEntry.arguments?.getString("episodeId"),
progressId = backStackEntry.arguments?.getString("progressId"),
)
PlayerScreen(
playbackSource = source,
Expand All @@ -1285,6 +1274,24 @@ private fun MiruPlayNavigation(
}
}

internal fun playbackSourceFromPlayerRouteArguments(
uri: String,
mediaSourceId: String,
startPosition: Long,
episodeId: String?,
progressId: String?,
): PlaybackSource {
val resolvedEpisodeId = episodeId?.takeIf(String::isNotBlank)
return PlaybackSource(
uri = uri,
mediaSourceId = mediaSourceId,
startPosition = startPosition,
subtitleTracks = emptyList(),
episodeId = resolvedEpisodeId,
progressId = progressId?.takeIf(String::isNotBlank) ?: resolvedEpisodeId,
)
}

internal fun navigateToPlayerRoute(
navController: androidx.navigation.NavHostController,
route: String,
Expand Down
18 changes: 18 additions & 0 deletions app/src/test/kotlin/com/miruplay/tv/PlayerRouteNavigationTest.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
package com.miruplay.tv

import com.miruplay.tv.navigation.NavRoutes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class PlayerRouteNavigationTest {
@Test
fun `player route arguments keep canonical WebDAV escapes`() {
val canonicalUri = "http://127.0.0.1:19798/dav/115open/%E5%BD%B1%E9%9F%B3/%E5%8A%A8%E6%BC%AB/Season%201/%5BANi%5D%2003.mp4"

val source = playbackSourceFromPlayerRouteArguments(
uri = canonicalUri,
mediaSourceId = "anime",
startPosition = 0L,
episodeId = "1:/Show%20Name/03.mp4",
progressId = "show#S1E3",
)

assertEquals(canonicalUri, source.uri)
assertEquals("1:/Show%20Name/03.mp4", source.episodeId)
assertEquals("show#S1E3", source.progressId)
}

@Test
fun `player route should be replaced when already on player destination`() {
assertTrue(shouldReplaceExistingPlayerRoute(NavRoutes.PLAYER_WITH_OPTIONS))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class ExoPlaybackController @Inject constructor(
private var embeddedMpvPositionMs: Long = 0L
private var embeddedMpvDurationMs: Long = 0L
private var embeddedMpvSource: PlaybackSource? = null
private var embeddedMpvPlaybackUri: String? = null
private var embeddedMpvHostView: ViewGroup? = null
private var embeddedMpvView: MiruMpvSurfaceView? = null
private var embeddedMpvPendingLoad: Boolean = false
Expand Down Expand Up @@ -207,7 +208,7 @@ class ExoPlaybackController @Inject constructor(
ensureMediaSessionService()
applyVideoEffectsForCurrentConfig()
if (_activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED) {
playWithEmbeddedMpv(source)
playWithEmbeddedMpv(source, httpConfig)
return@withContext
}
val player = activeExoPlayer()
Expand Down Expand Up @@ -373,6 +374,7 @@ class ExoPlaybackController @Inject constructor(
embeddedMpvPositionMs = 0L
embeddedMpvDurationMs = 0L
embeddedMpvSource = null
embeddedMpvPlaybackUri = null
embeddedMpvPendingLoad = false
embeddedMpvPlaybackSpeed = 1.0f
playbackClockSamples.set(emptyList())
Expand Down Expand Up @@ -497,7 +499,7 @@ class ExoPlaybackController @Inject constructor(
),
)
mpvView.loadMedia(
path = source.uri,
path = embeddedMpvPlaybackUri ?: source.uri,
startPositionMs = embeddedMpvPositionMs,
externalSubtitlePaths = source.subtitleTracks.map { it.path },
)
Expand Down Expand Up @@ -1207,9 +1209,13 @@ class ExoPlaybackController @Inject constructor(
}
}

private fun playWithEmbeddedMpv(source: PlaybackSource) {
private fun playWithEmbeddedMpv(
source: PlaybackSource,
httpConfig: PlaybackHttpRequestConfig,
) {
currentSource = source
embeddedMpvSource = source
embeddedMpvPlaybackUri = httpConfig.libVlcUriFor(source.uri)
embeddedMpvPositionMs = source.startPosition.coerceAtLeast(0L)
embeddedMpvDurationMs = 0L
embeddedMpvPlaying = false
Expand Down Expand Up @@ -1241,7 +1247,7 @@ class ExoPlaybackController @Inject constructor(
),
)
mpvView.loadMedia(
path = source.uri,
path = embeddedMpvPlaybackUri ?: source.uri,
startPositionMs = embeddedMpvPositionMs,
externalSubtitlePaths = source.subtitleTracks.map { it.path },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package com.miruplay.tv.player

import android.content.Context
import android.net.Uri
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
Expand Down Expand Up @@ -42,6 +43,13 @@ class PlaybackDataSourceFactory @Inject constructor(
}
}

internal fun canonicalPlaybackUri(uri: String): String =
if (uri.startsWith("http://", ignoreCase = true) || uri.startsWith("https://", ignoreCase = true)) {
MediaPathConventions.canonicalizeRemoteUrl(uri)
} else {
uri
}

data class PlaybackHttpRequestConfig(
private val baseUrl: String,
private val headers: Map<String, String>,
Expand All @@ -50,10 +58,17 @@ data class PlaybackHttpRequestConfig(
private val decodedBaseUrl = MediaPathConventions.decodePath(normalizedBaseUrl)
private val baseOrigin = normalizedBaseUrl.originOrNull()

fun applyTo(dataSpec: DataSpec): DataSpec =
headersFor(dataSpec.uri.toString()).let { requestHeaders ->
if (requestHeaders.isEmpty()) dataSpec else dataSpec.withAdditionalHeaders(requestHeaders)
fun applyTo(dataSpec: DataSpec): DataSpec {
val canonicalUri = canonicalPlaybackUri(dataSpec.uri.toString())
val normalizedDataSpec = if (canonicalUri == dataSpec.uri.toString()) {
dataSpec
} else {
dataSpec.withUri(Uri.parse(canonicalUri))
}
return headersFor(canonicalUri).let { requestHeaders ->
if (requestHeaders.isEmpty()) normalizedDataSpec else normalizedDataSpec.withAdditionalHeaders(requestHeaders)
}
}

fun libVlcUriFor(uri: String): String {
val normalizedUri = normalizeVlcUri(uri)
Expand Down Expand Up @@ -142,7 +157,11 @@ data class PlaybackHttpRequestConfig(
if (trimmed.isBlank()) return uri
if (
trimmed.startsWith("http://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true)
) {
return canonicalPlaybackUri(trimmed)
}
if (
trimmed.startsWith("content://", ignoreCase = true) ||
trimmed.startsWith("file://", ignoreCase = true)
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.miruplay.tv.player

import com.miruplay.tv.mediasource.MediaSourceFactory
import com.miruplay.tv.model.DEFAULT_CLOUD_DRIVE_ENDPOINT_URL
import com.miruplay.tv.model.MediaSourceInfo
import com.miruplay.tv.model.MediaPathConventions
import com.miruplay.tv.model.MediaSourceType
Expand All @@ -8,13 +10,15 @@ import com.miruplay.tv.model.connectionPassword
import com.miruplay.tv.model.connectionUsername
import com.miruplay.tv.model.remoteUrl
import com.miruplay.tv.repository.MediaSourceRepository
import java.net.URI
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PlaybackHttpRequestResolver @Inject constructor(
private val mediaSources: MediaSourceRepository,
private val mediaSourceFactory: MediaSourceFactory? = null,
) {
suspend fun configFor(source: PlaybackSource): PlaybackHttpRequestConfig {
val mediaSource = findMediaSource(source) ?: return PlaybackHttpRequestConfig.Empty
Expand All @@ -23,12 +27,29 @@ class PlaybackHttpRequestResolver @Inject constructor(
val remoteUrl = mediaSource.remoteUrl().orEmpty()
if (remoteUrl.isBlank()) return PlaybackHttpRequestConfig.Empty

if (remoteUrl.isDefaultCloudDriveWebDavEndpoint()) {
warmWebDavParentDirectory(mediaSource, source.uri, remoteUrl)
}
return PlaybackHttpRequestConfig(
baseUrl = remoteUrl,
headers = mapOf(AUTHORIZATION_HEADER to mediaSource.playbackAuthorizationHeader()),
)
}

private suspend fun warmWebDavParentDirectory(
source: MediaSourceInfo,
uri: String,
remoteUrl: String,
) {
val parentPath = webDavParentDirectoryForPlayback(uri, remoteUrl) ?: return
val mediaSource = mediaSourceFactory?.create(source)?.getOrNull() ?: return
try {
runCatching { mediaSource.listFiles(parentPath) }
} finally {
runCatching { mediaSource.close() }
}
}

private suspend fun findMediaSource(source: PlaybackSource): MediaSourceInfo? {
source.sourceIdHint()?.let { sourceId ->
mediaSources.getSourceById(sourceId)
Expand All @@ -43,10 +64,13 @@ class PlaybackHttpRequestResolver @Inject constructor(
return mediaSources.getSources()
.getOrNull()
.orEmpty()
.firstOrNull { mediaSource ->
.filter { mediaSource ->
mediaSource.type == MediaSourceType.WEBDAV &&
source.uri.isAtOrBelowRemoteUrl(mediaSource.remoteUrl().orEmpty())
}
.maxByOrNull { mediaSource ->
MediaPathConventions.decodePath(mediaSource.remoteUrl().orEmpty()).trimEnd('/').length
}
}

private fun PlaybackSource.sourceIdHint(): Long? =
Expand Down Expand Up @@ -94,3 +118,17 @@ class PlaybackHttpRequestResolver @Inject constructor(
private const val AUTHORIZATION_HEADER = "Authorization"
}
}

private fun String.isDefaultCloudDriveWebDavEndpoint(): Boolean =
runCatching {
URI(MediaPathConventions.canonicalizeRemoteUrl(this)).port == URI(DEFAULT_CLOUD_DRIVE_ENDPOINT_URL).port
}.getOrDefault(false)

internal fun webDavParentDirectoryForPlayback(uri: String, remoteUrl: String): String? {
val decodedUri = MediaPathConventions.decodePath(uri.substringBefore('?').substringBefore('#'))
val decodedBase = MediaPathConventions.decodePath(remoteUrl).trimEnd('/')
if (decodedBase.isBlank() || (decodedUri != decodedBase && !decodedUri.startsWith("$decodedBase/"))) {
return null
}
return decodedUri.removePrefix(decodedBase).substringBeforeLast('/', "")
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
package com.miruplay.tv.player

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class PlaybackDataSourceFactoryTest {
@Test
fun `canonicalPlaybackUri encodes CloudDrive unicode and brackets`() {
val uri = canonicalPlaybackUri(
"http://127.0.0.1:19798/dav/115open/影音/动漫/從 0 位居民開始的邊境領主大人/Season 1/[ANi] 從 0 位居民開始的邊境領主大人 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4",
)

assertTrue(uri.contains("/Season%201/%5BANi%5D%20"))
assertTrue(uri.endsWith("%5BCHT%5D.mp4"))
assertFalse(uri.contains("從"))
assertFalse(uri.contains(' '))
}

@Test
fun `headersFor applies auth when uri stays on same WebDAV origin`() {
val config = PlaybackHttpRequestConfig(
Expand Down Expand Up @@ -48,6 +61,23 @@ class PlaybackDataSourceFactoryTest {
)
}

@Test
fun `libVlcUriFor canonicalizes raw CloudDrive path before embedding credentials`() {
val config = PlaybackHttpRequestConfig(
baseUrl = "http://127.0.0.1:19798/dav",
headers = mapOf("Authorization" to "Basic YW5vbnltb3VzOg=="),
)

val uri = config.libVlcUriFor(
"http://127.0.0.1:19798/dav/Show Name/[ANi] 03.mp4",
)

assertEquals(
"http://anonymous:@127.0.0.1:19798/dav/Show%20Name/%5BANi%5D%2003.mp4",
uri,
)
}

@Test
fun `libVlcUriFor normalizes absolute local path into file uri`() {
val config = PlaybackHttpRequestConfig.Empty
Expand Down
Loading
Loading