diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 406098c2..19525e56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,10 +8,11 @@ kermit = "2.0.8" kotlin = "2.3.0" agp = "8.12.3" kotlinx-coroutines = "1.10.2" -kotlinxBrowserWasmJs = "0.3" +kotlinxBrowserWasmJs = "0.5.0" kotlinxDatetime = "0.7.1-0.6.x-compat" compose = "1.9.3" androidx-activityCompose = "1.12.2" +androidx-core = "1.17.0" media3Exoplayer = "1.9.0" jna = "5.18.1" platformtoolsDarkmodedetector = "0.7.4" @@ -28,12 +29,14 @@ filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose gst1-java-core = { module = "org.freedesktop.gstreamer:gst1-java-core", version.ref = "gst1JavaCore" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlinx-browser-wasm-js = { module = "org.jetbrains.kotlinx:kotlinx-browser-wasm-js", version.ref = "kotlinxBrowserWasmJs" } +kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowserWasmJs" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-jpms = { module = "net.java.dev.jna:jna-jpms", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } diff --git a/mediaplayer/ComposeMediaPlayer.podspec b/mediaplayer/ComposeMediaPlayer.podspec index e4b02e32..d6cec2ef 100644 --- a/mediaplayer/ComposeMediaPlayer.podspec +++ b/mediaplayer/ComposeMediaPlayer.podspec @@ -8,29 +8,20 @@ Pod::Spec.new do |spec| spec.summary = 'A multiplatform video player library for Compose applications' spec.vendored_frameworks = 'build/cocoapods/framework/ComposeMediaPlayer.framework' spec.libraries = 'c++' - - - if !Dir.exist?('build/cocoapods/framework/ComposeMediaPlayer.framework') || Dir.empty?('build/cocoapods/framework/ComposeMediaPlayer.framework') raise " - Kotlin framework 'ComposeMediaPlayer' doesn't exist yet, so a proper Xcode project can't be generated. 'pod install' should be executed after running ':generateDummyFramework' Gradle task: - ./gradlew :mediaplayer:generateDummyFramework - Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" end - spec.xcconfig = { 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO', } - spec.pod_target_xcconfig = { 'KOTLIN_PROJECT_PATH' => ':mediaplayer', 'PRODUCT_MODULE_NAME' => 'ComposeMediaPlayer', } - spec.script_phases = [ { :name => 'Build ComposeMediaPlayer', @@ -38,8 +29,8 @@ Pod::Spec.new do |spec| :shell_path => '/bin/sh', :script => <<-SCRIPT if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then - echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" - exit 0 + echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" + exit 0 fi set -ev REPO_ROOT="$PODS_TARGET_SRCROOT" @@ -51,4 +42,4 @@ Pod::Spec.new do |spec| } ] spec.resources = ['build/compose/cocoapods/compose-resources'] -end \ No newline at end of file +end diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index 9f8adcea..c0b7697f 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -34,10 +34,17 @@ kotlin { jvmToolchain(17) androidTarget { publishLibraryVariants("release") } jvm() + js { + browser() + binaries.executable() + } + + @OptIn(ExperimentalWasmDsl::class) wasmJs { browser() binaries.executable() } + iosX64() iosArm64() iosSimulatorArm64() @@ -81,6 +88,7 @@ kotlin { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.androidx.activityCompose) + implementation(libs.androidx.core) } androidUnitTest.dependencies { @@ -111,8 +119,10 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } - wasmJsMain.dependencies { - implementation(libs.kotlinx.browser.wasm.js) + webMain.dependencies { + implementation(libs.kotlinx.browser) + implementation(compose.ui) + } wasmJsTest.dependencies { @@ -138,28 +148,32 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 } } val buildMacArm: TaskProvider = tasks.register("buildNativeMacArm") { onlyIf { System.getProperty("os.name").startsWith("Mac") } workingDir(rootDir) - commandLine("swiftc", "-emit-library", "-emit-module", "-module-name", "NativeVideoPlayer", + commandLine( + "swiftc", "-emit-library", "-emit-module", "-module-name", "NativeVideoPlayer", "-target", "arm64-apple-macosx14.0", "-o", "mediaplayer/src/jvmMain/resources/darwin-aarch64/libNativeVideoPlayer.dylib", "mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/native/NativeVideoPlayer.swift", - "-O", "-whole-module-optimization") + "-O", "-whole-module-optimization" + ) } val buildMacX64: TaskProvider = tasks.register("buildNativeMacX64") { onlyIf { System.getProperty("os.name").startsWith("Mac") } workingDir(rootDir) - commandLine("swiftc", "-emit-library", "-emit-module", "-module-name", "NativeVideoPlayer", + commandLine( + "swiftc", "-emit-library", "-emit-module", "-module-name", "NativeVideoPlayer", "-target", "x86_64-apple-macosx14.0", "-o", "mediaplayer/src/jvmMain/resources/darwin-x86-64/libNativeVideoPlayer.dylib", "mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/native/NativeVideoPlayer.swift", - "-O", "-whole-module-optimization") + "-O", "-whole-module-optimization" + ) } val buildWin: TaskProvider = tasks.register("buildNativeWin") { diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index cfd9d7a5..c1fe610c 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri +import androidx.annotation.OptIn import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf @@ -798,5 +799,6 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } } +@OptIn(UnstableApi::class) internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState = VideoPlayerState(isInPreview) diff --git a/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.js.kt b/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.js.kt new file mode 100644 index 00000000..91175a69 --- /dev/null +++ b/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.js.kt @@ -0,0 +1,89 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package io.github.kdroidfilter.composemediaplayer + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.viewinterop.WebElementView +import org.w3c.dom.HTMLVideoElement + +@Composable +actual fun VideoPlayerSurface( + playerState: VideoPlayerState, + modifier: Modifier, + contentScale: ContentScale, + overlay: @Composable () -> Unit +) { + if (playerState.hasMedia) { + var videoElement by remember { mutableStateOf(null) } + var videoRatio by remember { mutableStateOf(null) } + var useCors by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + // State for CORS mode changes + var lastPosition by remember { mutableStateOf(0.0) } + var wasPlaying by remember { mutableStateOf(false) } + var lastPlaybackSpeed by remember { mutableStateOf(1.0f) } + + // Shared effects + VideoPlayerEffects( + playerState = playerState, + videoElement = videoElement, + scope = scope, + useCors = useCors, + onLastPositionChange = { lastPosition = it }, + onWasPlayingChange = { wasPlaying = it }, + onLastPlaybackSpeedChange = { lastPlaybackSpeed = it }, + lastPosition = lastPosition, + wasPlaying = wasPlaying, + lastPlaybackSpeed = lastPlaybackSpeed + ) + + VideoVolumeAndSpeedEffects( + playerState = playerState, + videoElement = videoElement + ) + + // Video content layout with WebElementView + VideoContentLayout( + playerState = playerState, + modifier = modifier, + videoRatio = videoRatio, + contentScale = contentScale, + overlay = overlay + ) { + key(useCors) { + WebElementView( + factory = { + createVideoElement(useCors).apply { + setupMetadataListener(playerState) { ratio -> + videoRatio = ratio + } + setupVideoElement( + video = this, + playerState = playerState, + scope = scope, + enableAudioDetection = true, + useCors = useCors, + onCorsError = { useCors = false } + ) + } + }, + modifier = if (playerState.isFullscreen) Modifier.fillMaxSize() else modifier, + update = { video -> + videoElement = video + video.applyInteropBehindCanvas() + video.applyContentScale(contentScale, videoRatio) + }, + onRelease = { video -> + video.safePause() + videoElement = null + } + ) + } + } + } +} diff --git a/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt b/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt new file mode 100644 index 00000000..c91e1627 --- /dev/null +++ b/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt @@ -0,0 +1,8 @@ +package io.github.kdroidfilter.composemediaplayer.util + +import io.github.vinceglb.filekit.PlatformFile +import org.w3c.dom.url.URL + +actual fun PlatformFile.getUri(): String { + return URL.createObjectURL(this.file) +} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasm.kt b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasm.kt new file mode 100644 index 00000000..91175a69 --- /dev/null +++ b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasm.kt @@ -0,0 +1,89 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package io.github.kdroidfilter.composemediaplayer + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.viewinterop.WebElementView +import org.w3c.dom.HTMLVideoElement + +@Composable +actual fun VideoPlayerSurface( + playerState: VideoPlayerState, + modifier: Modifier, + contentScale: ContentScale, + overlay: @Composable () -> Unit +) { + if (playerState.hasMedia) { + var videoElement by remember { mutableStateOf(null) } + var videoRatio by remember { mutableStateOf(null) } + var useCors by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + // State for CORS mode changes + var lastPosition by remember { mutableStateOf(0.0) } + var wasPlaying by remember { mutableStateOf(false) } + var lastPlaybackSpeed by remember { mutableStateOf(1.0f) } + + // Shared effects + VideoPlayerEffects( + playerState = playerState, + videoElement = videoElement, + scope = scope, + useCors = useCors, + onLastPositionChange = { lastPosition = it }, + onWasPlayingChange = { wasPlaying = it }, + onLastPlaybackSpeedChange = { lastPlaybackSpeed = it }, + lastPosition = lastPosition, + wasPlaying = wasPlaying, + lastPlaybackSpeed = lastPlaybackSpeed + ) + + VideoVolumeAndSpeedEffects( + playerState = playerState, + videoElement = videoElement + ) + + // Video content layout with WebElementView + VideoContentLayout( + playerState = playerState, + modifier = modifier, + videoRatio = videoRatio, + contentScale = contentScale, + overlay = overlay + ) { + key(useCors) { + WebElementView( + factory = { + createVideoElement(useCors).apply { + setupMetadataListener(playerState) { ratio -> + videoRatio = ratio + } + setupVideoElement( + video = this, + playerState = playerState, + scope = scope, + enableAudioDetection = true, + useCors = useCors, + onCorsError = { useCors = false } + ) + } + }, + modifier = if (playerState.isFullscreen) Modifier.fillMaxSize() else modifier, + update = { video -> + videoElement = video + video.applyInteropBehindCanvas() + video.applyContentScale(contentScale, videoRatio) + }, + onRelease = { video -> + video.safePause() + videoElement = null + } + ) + } + } + } +} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasmjs.kt b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasmjs.kt deleted file mode 100644 index cdf19598..00000000 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasmjs.kt +++ /dev/null @@ -1,787 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.DrawScope -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import io.github.kdroidfilter.composemediaplayer.htmlinterop.HtmlView -import io.github.kdroidfilter.composemediaplayer.jsinterop.MediaError -import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer -import io.github.kdroidfilter.composemediaplayer.util.FullScreenLayout -import io.github.kdroidfilter.composemediaplayer.util.toTimeMs -import kotlinx.browser.document -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.w3c.dom.HTMLVideoElement -import org.w3c.dom.events.Event -import kotlin.math.abs - -internal val wasmVideoLogger = Logger.withTag("WasmVideoPlayerSurface").apply { Logger.setMinSeverity(Severity.Warn) } - -// Cache mime type mappings for better performance -private val EXTENSION_TO_MIME_TYPE = mapOf( - "mp4" to "video/mp4", - "webm" to "video/webm", - "ogg" to "video/ogg", - "mov" to "video/quicktime", - "avi" to "video/x-msvideo", - "mkv" to "video/x-matroska" -) - -// Helper functions for common operations -private fun HTMLVideoElement.safePlay() { - try { - play() - } catch (e: Exception) { - wasmVideoLogger.e { "Error playing video: ${e.message}" } - } -} - -private fun HTMLVideoElement.safePause() { - try { - pause() - } catch (e: Exception) { - wasmVideoLogger.e { "Error pausing video: ${e.message}" } - } -} - -private fun HTMLVideoElement.safeSetPlaybackRate(rate: Float) { - try { - playbackRate = rate.toDouble() - } catch (e: Exception) { - wasmVideoLogger.e { "Error setting playback rate: ${e.message}" } - } -} - -private fun HTMLVideoElement.safeSetCurrentTime(time: Double) { - try { - currentTime = time - } catch (e: Exception) { - wasmVideoLogger.e { "Error seeking to ${time}s: ${e.message}" } - } -} - -private fun HTMLVideoElement.addEventListeners( - scope: CoroutineScope, - playerState: VideoPlayerState, - events: Map Unit>, - loadingEvents: Map = emptyMap() -) { - events.forEach { (event, handler) -> - addEventListener(event, handler) - } - - loadingEvents.forEach { (event, isLoading) -> - addEventListener(event) { - scope.launch { playerState._isLoading = isLoading } - } - } -} - -fun Modifier.videoRatioClip(videoRatio: Float?, contentScale: ContentScale = ContentScale.Fit): Modifier = - drawBehind { videoRatio?.let { drawVideoRatioRect(it, contentScale) } } - -// Optimized drawing function to reduce calculations during rendering -private fun DrawScope.drawVideoRatioRect(ratio: Float, contentScale: ContentScale) { - val containerWidth = size.width - val containerHeight = size.height - val containerRatio = containerWidth / containerHeight - - when (contentScale) { - ContentScale.Fit, ContentScale.Inside -> { - // Fit behavior - maintain aspect ratio and fit within container - val (rectWidth, rectHeight) = if (containerRatio > ratio) { - // Container is wider than video - val height = containerHeight - val width = height * ratio - width to height - } else { - // Container is taller than or equal to video - val width = containerWidth - val height = width / ratio - width to height - } - - // Calculate offset only once - val xOffset = (containerWidth - rectWidth) / 2f - val yOffset = (containerHeight - rectHeight) / 2f - - // Use pre-calculated values - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear, - topLeft = Offset(xOffset, yOffset), - size = Size(rectWidth, rectHeight) - ) - } - ContentScale.Crop -> { - // Crop behavior - maintain aspect ratio and fill container - val (rectWidth, rectHeight) = if (containerRatio < ratio) { - // Container is taller than video - val height = containerHeight - val width = height * ratio - width to height - } else { - // Container is wider than or equal to video - val width = containerWidth - val height = width / ratio - width to height - } - - // Calculate offset only once - val xOffset = (containerWidth - rectWidth) / 2f - val yOffset = (containerHeight - rectHeight) / 2f - - // Use pre-calculated values - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear, - topLeft = Offset(xOffset, yOffset), - size = Size(rectWidth, rectHeight) - ) - } - ContentScale.FillWidth -> { - // Fill width behavior - maintain aspect ratio and fill width - val width = containerWidth - val height = width / ratio - - val yOffset = (containerHeight - height) / 2f - - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear, - topLeft = Offset(0f, yOffset), - size = Size(width, height) - ) - } - ContentScale.FillHeight -> { - // Fill height behavior - maintain aspect ratio and fill height - val height = containerHeight - val width = height * ratio - - val xOffset = (containerWidth - width) / 2f - - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear, - topLeft = Offset(xOffset, 0f), - size = Size(width, height) - ) - } - ContentScale.FillBounds -> { - // Fill bounds behavior - fill entire container without maintaining aspect ratio - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear, - topLeft = Offset(0f, 0f), - size = Size(containerWidth, containerHeight) - ) - } - else -> { - // Default to Fit behavior - val (rectWidth, rectHeight) = if (containerRatio > ratio) { - // Container is wider than video - val height = containerHeight - val width = height * ratio - width to height - } else { - // Container is taller than or equal to video - val width = containerWidth - val height = width / ratio - width to height - } - - // Calculate offset only once - val xOffset = (containerWidth - rectWidth) / 2f - val yOffset = (containerHeight - rectHeight) / 2f - - // Use pre-calculated values - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear, - topLeft = Offset(xOffset, yOffset), - size = Size(rectWidth, rectHeight) - ) - } - } -} - -@Composable -private fun SubtitleOverlay(playerState: VideoPlayerState) { - // Early return if subtitles are disabled or no track is selected - if (!playerState.subtitlesEnabled || playerState.currentSubtitleTrack == null) { - return - } - - // Cache duration calculation to avoid repeated conversions - val durationMs = remember(playerState.durationText) { - playerState.durationText.toTimeMs() - } - - // Calculate current time only once per composition - val currentTimeMs = remember(playerState.sliderPos, durationMs) { - ((playerState.sliderPos / 1000f) * durationMs).toLong() - } - - ComposeSubtitleLayer( - currentTimeMs = currentTimeMs, - durationMs = durationMs, - isPlaying = playerState.isPlaying, - subtitleTrack = playerState.currentSubtitleTrack, - subtitlesEnabled = true, // We already checked this above - textStyle = playerState.subtitleTextStyle, - backgroundColor = playerState.subtitleBackgroundColor - ) -} - - -@Composable -actual fun VideoPlayerSurface( - playerState: VideoPlayerState, - modifier: Modifier, - contentScale: ContentScale, - overlay: @Composable () -> Unit -) { - if (playerState.hasMedia) { - - var videoElement by remember { mutableStateOf(null) } - var videoRatio by remember { mutableStateOf(null) } - // Track if we're using CORS mode (initially true, will be set to false if CORS errors occur) - var useCors by remember { mutableStateOf(true) } - val scope = rememberCoroutineScope() - - - // Use VideoContent composable for video display logic - VideoContent( - playerState = playerState, - modifier = modifier, - videoRatio = videoRatio, - useCors = useCors, - onVideoElementChange = { videoElement = it }, - onVideoRatioChange = { videoRatio = it }, - onCorsChange = { useCors = it }, - scope = scope, - contentScale = contentScale, - overlay = overlay - ) - - // Handle fullscreen - LaunchedEffect(playerState.isFullscreen) { - try { - if (!playerState.isFullscreen) { - FullscreenManager.exitFullscreen() - } - } catch (e: Exception) { - wasmVideoLogger.e { "Error handling fullscreen: ${e.message}" } - } - } - - // Listen for fullscreen change events - DisposableEffect(Unit) { - val fullscreenChangeListener: (Event) -> Unit = { - videoElement?.let { video -> - val isDocumentFullscreen = video.ownerDocument?.fullscreenElement != null - if (!isDocumentFullscreen && playerState.isFullscreen) { - playerState.isFullscreen = false - } - } - } - - val fullscreenEvents = listOf( - "fullscreenchange", "webkitfullscreenchange", - "mozfullscreenchange", "MSFullscreenChange" - ) - - fullscreenEvents.forEach { event -> - document.addEventListener(event, fullscreenChangeListener) - } - - onDispose { - fullscreenEvents.forEach { event -> - document.removeEventListener(event, fullscreenChangeListener) - } - } - } - // Handle source change effect - LaunchedEffect(playerState.sourceUri) { - videoElement?.let { video -> - val sourceUri = playerState.sourceUri ?: "" - if (sourceUri.isNotEmpty()) { - playerState.clearError() - video.src = sourceUri - video.load() - - // Don't set playback speed directly, it will be handled by the applyPlaybackSpeedCallback - - if (playerState.isPlaying) video.safePlay() else video.safePause() - } - } - } - - // Handle play/pause - LaunchedEffect(playerState.isPlaying) { - videoElement?.let { video -> - if (playerState.isPlaying) video.safePlay() else video.safePause() - } - } - - // Handle property updates - combined for better performance - // Store pending changes to apply after seeking is complete - var pendingVolumeChange by remember { mutableStateOf(null) } - var pendingPlaybackSpeedChange by remember { mutableStateOf(null) } - - LaunchedEffect(playerState.loop) { - videoElement?.let { video -> - // Always update loop property immediately - video.loop = playerState.loop - } - } - - // Apply pending volume change when seeking is complete - DisposableEffect(videoElement) { - val video = videoElement ?: return@DisposableEffect onDispose {} - - // Set the volume callback to respect seeking state - playerState.applyVolumeCallback = { value -> - if (playerState._isLoading) { - // Store the volume change to apply after seeking is complete - pendingVolumeChange = value.toDouble() - } else { - // Apply volume change immediately if not seeking - video.volume = value.toDouble() - pendingVolumeChange = null - } - } - - // Apply current volume immediately if needed - if (!playerState._isLoading) { - video.volume = playerState.volume.toDouble() - } else { - pendingVolumeChange = playerState.volume.toDouble() - } - - // Set the playback speed callback to respect seeking state - playerState.applyPlaybackSpeedCallback = { value -> - if (playerState._isLoading) { - // Store the playback speed change to apply after seeking is complete - pendingPlaybackSpeedChange = value - } else { - // Apply playback speed change immediately if not seeking - video.safeSetPlaybackRate(value) - pendingPlaybackSpeedChange = null - } - } - - // Apply current playback speed immediately if needed - if (!playerState._isLoading) { - video.safeSetPlaybackRate(playerState.playbackSpeed) - } else { - pendingPlaybackSpeedChange = playerState.playbackSpeed - } - - val seekedListener: (Event) -> Unit = { - pendingVolumeChange?.let { volume -> - video.volume = volume - pendingVolumeChange = null - } - pendingPlaybackSpeedChange?.let { speed -> - video.safeSetPlaybackRate(speed) - pendingPlaybackSpeedChange = null - } - } - - video.addEventListener("seeked", seekedListener) - - onDispose { - video.removeEventListener("seeked", seekedListener) - playerState.applyVolumeCallback = null - playerState.applyPlaybackSpeedCallback = null - } - } - - // State for CORS mode changes - var lastPosition by remember { mutableStateOf(0.0) } - var wasPlaying by remember { mutableStateOf(false) } - var lastPlaybackSpeed by remember { mutableStateOf(1.0f) } - - // Store state before video element recreation - LaunchedEffect(useCors) { - videoElement?.let { - lastPosition = it.currentTime - wasPlaying = playerState.isPlaying - lastPlaybackSpeed = playerState.playbackSpeed - } - } - - // Restore state after video element recreation - LaunchedEffect(videoElement, useCors) { - videoElement?.let { video -> - if (lastPosition > 0) { - video.safeSetCurrentTime(lastPosition) - lastPosition = 0.0 - } - - if (lastPlaybackSpeed != 1.0f) { - video.safeSetPlaybackRate(lastPlaybackSpeed) - lastPlaybackSpeed = 1.0f - } - - if (wasPlaying) { - video.safePlay() - wasPlaying = false - } - } - } - - // Handle seeking - optimized to reduce job creation - LaunchedEffect(playerState.sliderPos) { - if (!playerState.userDragging && playerState.hasMedia) { - // Cancel previous seek job if it exists - playerState.seekJob?.cancel() - - // Create a new seek job only if needed - videoElement?.let { video -> - val duration = video.duration.toFloat() - if (duration > 0f) { - val newTime = (playerState.sliderPos / VideoPlayerState.PERCENTAGE_MULTIPLIER) * duration - val currentTime = video.currentTime - - // Only seek if the difference is significant (> 0.5 seconds) - if (abs(currentTime - newTime) > 0.5) { - playerState.seekJob = scope.launch { - video.safeSetCurrentTime(newTime.toDouble()) - } - } - } - } - } - } - - // Listen for external play/pause events - DisposableEffect(videoElement) { - val video = videoElement ?: return@DisposableEffect onDispose {} - - val playListener: (Event) -> Unit = { - if (!playerState.isPlaying) scope.launch { playerState.play() } - } - - val pauseListener: (Event) -> Unit = { - if (playerState.isPlaying) scope.launch { playerState.pause() } - } - - video.addEventListener("play", playListener) - video.addEventListener("pause", pauseListener) - - onDispose { - video.removeEventListener("play", playListener) - video.removeEventListener("pause", pauseListener) - } - } - } -} - -@Composable -private fun VideoContent( - playerState: VideoPlayerState, - modifier: Modifier, - videoRatio: Float?, - useCors: Boolean, - onVideoElementChange: (HTMLVideoElement?) -> Unit, - onVideoRatioChange: (Float?) -> Unit, - onCorsChange: (Boolean) -> Unit, - scope: CoroutineScope, - contentScale: ContentScale, - overlay: @Composable () -> Unit = {} -) { - @Composable - fun VideoBox(isFullscreenMode: Boolean = false) { - Box( - modifier = Modifier - .fillMaxSize() - .background(if (isFullscreenMode) Color.Black else Color.Transparent) - .videoRatioClip(videoRatio, contentScale) - ) { - SubtitleOverlay(playerState) - Box(modifier = Modifier.fillMaxSize()) { - overlay() - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - VideoBox(isFullscreenMode = false) - - if (playerState.isFullscreen) { - FullScreenLayout(onDismissRequest = { playerState.isFullscreen = false }) { - Box( - modifier = Modifier.fillMaxSize().background(Color.Black), - contentAlignment = Alignment.Center - ) { - VideoBox(isFullscreenMode = true) - } - } - } - - // Create HTML video element - key(useCors) { - HtmlView( - factory = { createVideoElement(useCors) }, - modifier = modifier, - update = { video -> - onVideoElementChange(video) - - video.addEventListener("loadedmetadata") { - val width = video.videoWidth - val height = video.videoHeight - if (height != 0) { - onVideoRatioChange(width.toFloat() / height.toFloat()) - - // Update metadata properties - with(playerState.metadata) { - this.width = width - this.height = height - duration = (video.duration * 1000).toLong() - - // Try to get mimeType and title from the video source - optimized - val src = video.src - if (src.isNotEmpty()) { - // Optimize extension extraction - val lastDotIndex = src.lastIndexOf('.') - if (lastDotIndex > 0 && lastDotIndex < src.length - 1) { - val extension = src.substring(lastDotIndex + 1).lowercase() - // Use map for faster lookup - mimeType = EXTENSION_TO_MIME_TYPE[extension] - } - - // Extract title from filename - optimized - try { - val lastSlashIndex = src.lastIndexOf('/') - val lastBackslashIndex = src.lastIndexOf('\\') - val startIndex = maxOf(lastSlashIndex, lastBackslashIndex) + 1 - - if (startIndex > 0 && startIndex < src.length) { - val endIndex = if (lastDotIndex > startIndex) lastDotIndex else src.length - val filename = src.substring(startIndex, endIndex) - if (filename.isNotEmpty()) { - title = filename - } - } - } catch (e: Exception) { - wasmVideoLogger.w { "Failed to extract title from filename: ${e.message}" } - } - } - } - } - } - - setupVideoElement( - video, - playerState, - scope, - enableAudioDetection = true, - useCors = useCors, - onCorsError = { onCorsChange(false) } - ) - }, - isFullscreen = playerState.isFullscreen, - contentScale = contentScale, - videoRatio = videoRatio - ) - } - } -} - -private fun createVideoElement(useCors: Boolean = true): HTMLVideoElement { - return (document.createElement("video") as HTMLVideoElement).apply { - controls = false - style.position = "absolute" - style.zIndex = "-1" - style.width = "100%" - style.height = "100%" - style.backgroundColor = "black" // Always set background color to black - // Don't set objectFit here, it will be set by setElementPosition based on contentScale - - // Handle CORS mode - if (useCors) { - crossOrigin = "anonymous" - } else { - removeAttribute("crossorigin") - } - - // Set attributes for better compatibility - setAttribute("playsinline", "") - setAttribute("webkit-playsinline", "") - setAttribute("preload", "auto") - setAttribute("x-webkit-airplay", "allow") - } -} - - -fun setupVideoElement( - video: HTMLVideoElement, - playerState: VideoPlayerState, - scope: CoroutineScope, - enableAudioDetection: Boolean = true, - useCors: Boolean = true, - onCorsError: () -> Unit = {}, -) { - val audioAnalyzer = if (enableAudioDetection) AudioLevelProcessor(video) else null - var initializationJob: Job? = null - var corsErrorDetected = false - - // Reset state - playerState.clearError() - playerState.metadata.audioChannels = null - playerState.metadata.audioSampleRate = null - - // Initialize audio analyzer - fun initAudioAnalyzer() { - if (!enableAudioDetection || corsErrorDetected) return - initializationJob?.cancel() - initializationJob = scope.launch { - val success = audioAnalyzer?.initialize() ?: false - if (!success) { - corsErrorDetected = true - } else { - audioAnalyzer.let { analyzer -> - playerState.metadata.audioChannels = analyzer.audioChannels - playerState.metadata.audioSampleRate = analyzer.audioSampleRate - } - } - } - } - - // Setup loading state events - video.addEventListeners( - scope = scope, - playerState = playerState, - events = mapOf( - "timeupdate" to playerState::onTimeUpdateEvent, - "ended" to { scope.launch { playerState.pause() } } - ), - loadingEvents = mapOf( - "seeking" to true, - "waiting" to true, - "playing" to false, - "seeked" to false, - "canplaythrough" to false, - "canplay" to false - ) - ) - - // Optimize event handling by combining related events - // Map of events that can set loading state to false based on conditions - val conditionalLoadingEvents = mapOf( - "suspend" to { video.readyState >= 3 }, - "loadedmetadata" to { true } - ) - - // Add conditional loading event listeners - conditionalLoadingEvents.forEach { (event, condition) -> - video.addEventListener(event) { - // For loadedmetadata, also initialize audio analyzer - if (event == "loadedmetadata") { - initAudioAnalyzer() - } - - // Single coroutine launch for all events - scope.launch { - if (condition()) { - playerState._isLoading = false - } - - // Additional actions for loadedmetadata - if (event == "loadedmetadata") { - // Don't set playback speed directly, it will be handled by the applyPlaybackSpeedCallback - if (playerState.isPlaying) { - video.safePlay() - } - } - } - } - } - - // Handle play event and audio level updates - var audioLevelJob: Job? = null - - video.addEventListener("play") { - if (enableAudioDetection && !corsErrorDetected && initializationJob?.isActive != true) { - initAudioAnalyzer() - } - - if (enableAudioDetection && audioLevelJob?.isActive != true) { - audioLevelJob = scope.launch { - while (video.paused.not()) { - val (left, right) = if (!corsErrorDetected) { - audioAnalyzer?.getAudioLevels() ?: (0f to 0f) - } else { - 0f to 0f - } - playerState.updateAudioLevels(left, right) - delay(100) - } - } - } - } - - // Cancel audio level job when paused - video.addEventListener("pause") { - audioLevelJob?.cancel() - audioLevelJob = null - } - - // Handle errors - video.addEventListener("error") { - scope.launch { - playerState._isLoading = false - corsErrorDetected = true - - val error = video.error - if (error != null) { - val isCorsRelatedError = error.code == MediaError.MEDIA_ERR_NETWORK || - (error.code == MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && useCors) - - if (useCors) { - playerState.clearError() - onCorsError() - } else { - val errorMsg = if (error.code == MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) { - "Failed to load because the video format is not supported" - } else { - "Failed to load because no supported source was found" - } - playerState.setError(VideoPlayerError.SourceError(errorMsg)) - } - } - } - } - - // Set initial properties - // Don't set volume or playback speed here, they will be handled by the callbacks in the DisposableEffect - // This avoids potential race conditions with the seeking state - video.loop = playerState.loop - - // Play if needed - if (video.src.isNotEmpty() && playerState.isPlaying) { - video.safePlay() - } -} - -private fun VideoPlayerState.onTimeUpdateEvent(event: Event) { - (event.target as? HTMLVideoElement)?.let { - onTimeUpdate(it.currentTime.toFloat(), it.duration.toFloat()) - } -} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/htmlinterop/HtmlView.kt b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/htmlinterop/HtmlView.kt deleted file mode 100644 index 3d4c55b0..00000000 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/htmlinterop/HtmlView.kt +++ /dev/null @@ -1,264 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer.htmlinterop - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateObserver -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.* -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.unit.round -import kotlinx.browser.document -import org.w3c.dom.Document -import org.w3c.dom.Element -import org.w3c.dom.HTMLElement - -val LocalLayerContainer = staticCompositionLocalOf { document.body ?: error("Document body unavailable") } -val NoOpUpdate: Element.() -> Unit = {} - -private class ComponentInfo { - lateinit var container: Element - lateinit var component: T - lateinit var updater: Updater -} - -private class FocusSwitcher(private val info: ComponentInfo, private val focusManager: FocusManager) { - private val backwardRequester = FocusRequester() - private val forwardRequester = FocusRequester() - private var isRequesting = false - - private fun requestFocusAndMove(requester: FocusRequester, direction: FocusDirection) { - try { - isRequesting = true - requester.requestFocus() - } finally { - isRequesting = false - } - focusManager.moveFocus(direction) - } - - fun moveBackward() = requestFocusAndMove(backwardRequester, FocusDirection.Previous) - fun moveForward() = requestFocusAndMove(forwardRequester, FocusDirection.Next) - - @Composable - fun Content() { - Box(Modifier - .focusRequester(backwardRequester) - .onFocusChanged { - if (it.isFocused && !isRequesting) { - focusManager.clearFocus(force = true) - info.container.firstElementChild?.let { (it as HTMLElement).focus() } ?: moveForward() - } - } - .focusTarget() - ) - Box(Modifier - .focusRequester(forwardRequester) - .onFocusChanged { - if (it.isFocused && !isRequesting) { - focusManager.clearFocus(force = true) - info.container.lastElementChild?.let { (it as HTMLElement).focus() } ?: moveBackward() - } - } - .focusTarget() - ) - } -} - -private fun setElementPosition( - element: HTMLElement, - width: Float, - height: Float, - x: Float, - y: Float, - isFullscreen: Boolean = false, - contentScale: ContentScale = ContentScale.Fit, - videoRatio: Float? = null -) { - element.style.apply { - position = "absolute" - margin = "0px" - backgroundColor = "black" // Always set background color to black - - if (isFullscreen) { - // In fullscreen mode, make the element fill the entire screen - this.width = "100%" - this.height = "100%" - left = "0" - top = "0" - - // Set object-fit based on contentScale even in fullscreen mode - when (contentScale) { - ContentScale.FillWidth -> objectFit = "contain" - ContentScale.FillHeight -> objectFit = "contain" - ContentScale.FillBounds -> objectFit = "fill" - ContentScale.Crop -> objectFit = "cover" - else -> objectFit = "contain" - } - } else { - // Calculate dimensions based on contentScale and container size - val containerWidth = width - val containerHeight = height - - if (videoRatio != null) { - val containerRatio = containerWidth / containerHeight - - when (contentScale) { - ContentScale.Fit, ContentScale.Inside -> { - // Scale to fit within container while maintaining aspect ratio - this.width = "${containerWidth}px" - this.height = "${containerHeight}px" - left = "${x}px" - top = "${y}px" - objectFit = "contain" // Use CSS object-fit to maintain aspect ratio and fit within container - } - ContentScale.Crop -> { - // Scale to cover container while maintaining aspect ratio - this.width = "${containerWidth}px" - this.height = "${containerHeight}px" - left = "${x}px" - top = "${y}px" - objectFit = "cover" // Use CSS object-fit to maintain aspect ratio and cover container - } - ContentScale.FillWidth -> { - // Fill width, maintain aspect ratio - val scaledHeight = containerWidth / videoRatio - this.width = "${containerWidth}px" - this.height = "${scaledHeight}px" - left = "${x}px" - top = "${y + (containerHeight - scaledHeight) / 2}px" - objectFit = "contain" // Use contain to preserve aspect ratio - } - ContentScale.FillHeight -> { - // Fill height, maintain aspect ratio - val scaledWidth = containerHeight * videoRatio - this.width = "${scaledWidth}px" - this.height = "${containerHeight}px" - left = "${x + (containerWidth - scaledWidth) / 2}px" - top = "${y}px" - objectFit = "contain" // Use contain to preserve aspect ratio - } - ContentScale.FillBounds -> { - // Fill the entire container without respecting aspect ratio - this.width = "${containerWidth}px" - this.height = "${containerHeight}px" - left = "${x}px" - top = "${y}px" - objectFit = "fill" // Use CSS object-fit to stretch without preserving ratio - } - else -> { - // Default positioning based on the container - this.width = "${width}px" - this.height = "${height}px" - left = "${x}px" - top = "${y}px" - objectFit = "contain" // Default to maintain aspect ratio - } - } - } else { - // No video ratio available, use default positioning - this.width = "${width}px" - this.height = "${height}px" - left = "${x}px" - top = "${y}px" - - // Set object-fit based on contentScale even when ratio is unknown - when (contentScale) { - ContentScale.FillBounds -> objectFit = "fill" // Stretch without preserving ratio - ContentScale.Crop -> objectFit = "cover" // Cover while maintaining aspect ratio - else -> objectFit = "contain" // Default to maintain aspect ratio - } - } - } - } -} - -@Composable -internal fun HtmlView( - factory: Document.() -> T, - modifier: Modifier = Modifier, - update: (T) -> Unit = NoOpUpdate, - isFullscreen: Boolean = false, - contentScale: ContentScale = ContentScale.Fit, - videoRatio: Float? = null -) { - val info = remember { ComponentInfo() } - val root = LocalLayerContainer.current - val density = LocalDensity.current.density - val focusManager = LocalFocusManager.current - val focusSwitcher = remember { FocusSwitcher(info, focusManager) } - - Box(modifier.onGloballyPositioned { coords -> - val pos = coords.positionInWindow().round() - val size = coords.size - setElementPosition( - info.component as HTMLElement, - size.width / density, - size.height / density, - pos.x / density, - pos.y / density, - isFullscreen, - contentScale, - videoRatio - ) - }) { - focusSwitcher.Content() - } - - DisposableEffect(factory) { - info.apply { - container = document.createElement("div") - component = document.factory() - updater = Updater(component, update) - } - - root.insertBefore(info.container, root.firstChild) - info.container.append(info.component) - setElementPosition(info.component as HTMLElement, 0f, 0f, 0f, 0f, false, contentScale, videoRatio) - - onDispose { - root.removeChild(info.container) - info.updater.dispose() - } - } - - SideEffect { - info.updater.update = update - } -} - -private class Updater( - private val component: T, - update: (T) -> Unit -) { - private var isDisposed = false - private val observer = SnapshotStateObserver { it() } - - var update: (T) -> Unit = update - set(value) { - if (field != value) { - field = value - performUpdate() - } - } - - private fun performUpdate() { - observer.observeReads(component, { if(!isDisposed) performUpdate() }) { - update(component) - } - } - - init { - observer.start() - performUpdate() - } - - fun dispose() { - observer.stop() - observer.clear() - isDisposed = true - } -} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt index 9fb2e8be..c91e1627 100644 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt +++ b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt @@ -1,8 +1,8 @@ package io.github.kdroidfilter.composemediaplayer.util -import io.github.kdroidfilter.composemediaplayer.toUriString import io.github.vinceglb.filekit.PlatformFile +import org.w3c.dom.url.URL actual fun PlatformFile.getUri(): String { - return this.toUriString() -} \ No newline at end of file + return URL.createObjectURL(this.file) +} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt similarity index 100% rename from mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt rename to mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt similarity index 90% rename from mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt rename to mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt index e46e01f0..121c020c 100644 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.js.ExperimentalWasmJsInterop /** * Manages fullscreen functionality for the video player @@ -13,6 +14,7 @@ object FullscreenManager { /** * Exit fullscreen if document is in fullscreen mode */ + @OptIn(ExperimentalWasmJsInterop::class) fun exitFullscreen() { if (document.fullscreenElement != null) { document.exitFullscreen() @@ -22,6 +24,7 @@ object FullscreenManager { /** * Request fullscreen mode */ + @OptIn(ExperimentalWasmJsInterop::class) fun requestFullScreen() { val document = document.documentElement document?.requestFullscreen() @@ -43,4 +46,4 @@ object FullscreenManager { } onFullscreenChange(!isCurrentlyFullscreen) } -} \ No newline at end of file +} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.wasmjs.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt similarity index 97% rename from mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.wasmjs.kt rename to mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index a13ebbed..4c820b51 100644 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.wasmjs.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -10,11 +10,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import io.github.kdroidfilter.composemediaplayer.InitialPlayerState +import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* import kotlinx.io.IOException -import org.w3c.dom.url.URL import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -258,7 +258,7 @@ actual open class VideoPlayerState { * @param initializeplayerState Controls whether playback should start automatically after opening */ actual fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { - val fileUri = file.toUriString() + val fileUri = file.getUri() openUri(fileUri, initializeplayerState) } @@ -413,11 +413,4 @@ actual open class VideoPlayerState { } } -/** - * Converts a PlatformFile to a URI string that can be used by the media player. - * - * @return A URI string representing the file - */ -fun PlatformFile.toUriString(): String { - return URL.createObjectURL(this.file) -} \ No newline at end of file +internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState = VideoPlayerState() diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt new file mode 100644 index 00000000..e950960f --- /dev/null +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt @@ -0,0 +1,738 @@ +package io.github.kdroidfilter.composemediaplayer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import io.github.kdroidfilter.composemediaplayer.jsinterop.MediaError +import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer +import io.github.kdroidfilter.composemediaplayer.util.FullScreenLayout +import io.github.kdroidfilter.composemediaplayer.util.toTimeMs +import kotlinx.browser.document +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLVideoElement +import org.w3c.dom.events.Event +import kotlin.math.abs + +internal val webVideoLogger = Logger.withTag("WebVideoPlayerSurface").apply { Logger.setMinSeverity(Severity.Warn) } + +// Cache mime type mappings for better performance +internal val EXTENSION_TO_MIME_TYPE = mapOf( + "mp4" to "video/mp4", + "webm" to "video/webm", + "ogg" to "video/ogg", + "mov" to "video/quicktime", + "avi" to "video/x-msvideo", + "mkv" to "video/x-matroska" +) + +// Helper functions for common operations +internal fun HTMLVideoElement.safePlay() { + try { + play() + } catch (e: Exception) { + webVideoLogger.e { "Error playing video: ${e.message}" } + } +} + +internal fun HTMLVideoElement.safePause() { + try { + pause() + } catch (e: Exception) { + webVideoLogger.e { "Error pausing video: ${e.message}" } + } +} + +internal fun HTMLVideoElement.safeSetPlaybackRate(rate: Float) { + try { + playbackRate = rate.toDouble() + } catch (e: Exception) { + webVideoLogger.e { "Error setting playback rate: ${e.message}" } + } +} + +internal fun HTMLVideoElement.safeSetCurrentTime(time: Double) { + try { + currentTime = time + } catch (e: Exception) { + webVideoLogger.e { "Error seeking to ${time}s: ${e.message}" } + } +} + +internal fun HTMLVideoElement.addEventListeners( + scope: CoroutineScope, + playerState: VideoPlayerState, + events: Map Unit>, + loadingEvents: Map = emptyMap() +) { + events.forEach { (event, handler) -> + addEventListener(event, handler) + } + + loadingEvents.forEach { (event, isLoading) -> + addEventListener(event) { + scope.launch { playerState._isLoading = isLoading } + } + } +} + +fun Modifier.videoRatioClip(videoRatio: Float?, contentScale: ContentScale = ContentScale.Fit): Modifier = + drawBehind { videoRatio?.let { drawVideoRatioRect(it, contentScale) } } + +// Optimized drawing function to reduce calculations during rendering +private fun DrawScope.drawVideoRatioRect(ratio: Float, contentScale: ContentScale) { + val containerWidth = size.width + val containerHeight = size.height + val containerRatio = containerWidth / containerHeight + + when (contentScale) { + ContentScale.Fit, ContentScale.Inside -> { + val (rectWidth, rectHeight) = if (containerRatio > ratio) { + val height = containerHeight + val width = height * ratio + width to height + } else { + val width = containerWidth + val height = width / ratio + width to height + } + val xOffset = (containerWidth - rectWidth) / 2f + val yOffset = (containerHeight - rectHeight) / 2f + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear, + topLeft = Offset(xOffset, yOffset), + size = Size(rectWidth, rectHeight) + ) + } + ContentScale.Crop -> { + val (rectWidth, rectHeight) = if (containerRatio < ratio) { + val height = containerHeight + val width = height * ratio + width to height + } else { + val width = containerWidth + val height = width / ratio + width to height + } + val xOffset = (containerWidth - rectWidth) / 2f + val yOffset = (containerHeight - rectHeight) / 2f + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear, + topLeft = Offset(xOffset, yOffset), + size = Size(rectWidth, rectHeight) + ) + } + ContentScale.FillWidth -> { + val width = containerWidth + val height = width / ratio + val yOffset = (containerHeight - height) / 2f + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear, + topLeft = Offset(0f, yOffset), + size = Size(width, height) + ) + } + ContentScale.FillHeight -> { + val height = containerHeight + val width = height * ratio + val xOffset = (containerWidth - width) / 2f + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear, + topLeft = Offset(xOffset, 0f), + size = Size(width, height) + ) + } + ContentScale.FillBounds -> { + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear, + topLeft = Offset(0f, 0f), + size = Size(containerWidth, containerHeight) + ) + } + else -> { + val (rectWidth, rectHeight) = if (containerRatio > ratio) { + val height = containerHeight + val width = height * ratio + width to height + } else { + val width = containerWidth + val height = width / ratio + width to height + } + val xOffset = (containerWidth - rectWidth) / 2f + val yOffset = (containerHeight - rectHeight) / 2f + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear, + topLeft = Offset(xOffset, yOffset), + size = Size(rectWidth, rectHeight) + ) + } + } +} + +@Composable +internal fun SubtitleOverlay(playerState: VideoPlayerState) { + if (!playerState.subtitlesEnabled || playerState.currentSubtitleTrack == null) { + return + } + + val durationMs = remember(playerState.durationText) { + playerState.durationText.toTimeMs() + } + + val currentTimeMs = remember(playerState.sliderPos, durationMs) { + ((playerState.sliderPos / 1000f) * durationMs).toLong() + } + + ComposeSubtitleLayer( + currentTimeMs = currentTimeMs, + durationMs = durationMs, + isPlaying = playerState.isPlaying, + subtitleTrack = playerState.currentSubtitleTrack, + subtitlesEnabled = true, + textStyle = playerState.subtitleTextStyle, + backgroundColor = playerState.subtitleBackgroundColor + ) +} + +@Composable +internal fun VideoBox( + playerState: VideoPlayerState, + videoRatio: Float?, + contentScale: ContentScale, + isFullscreenMode: Boolean, + overlay: @Composable () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(if (isFullscreenMode) Color.Black else Color.Transparent) + .videoRatioClip(videoRatio, contentScale) + ) { + SubtitleOverlay(playerState) + Box(modifier = Modifier.fillMaxSize()) { + overlay() + } + } +} + +@Composable +internal fun VideoContentLayout( + playerState: VideoPlayerState, + modifier: Modifier, + videoRatio: Float?, + contentScale: ContentScale, + overlay: @Composable () -> Unit, + videoElementContent: @Composable () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + if (playerState.isFullscreen) { + FullScreenLayout(onDismissRequest = { playerState.isFullscreen = false }) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center + ) { + VideoBox(playerState, videoRatio, contentScale, true, overlay) + } + } + } else { + Box(modifier = modifier) { + VideoBox(playerState, videoRatio, contentScale, false, overlay) + } + } + videoElementContent() + } +} + +internal fun HTMLVideoElement.applyInteropBehindCanvas() { + val wrapper = parentElement as? HTMLElement ?: return + wrapper.style.apply { + zIndex = "-1" + setProperty("pointer-events", "none") + backgroundColor = "transparent" + display = "flex" + alignItems = "center" + justifyContent = "center" + } +} + +internal fun HTMLVideoElement.applyContentScale(contentScale: ContentScale, videoRatio: Float?) { + style.apply { + backgroundColor = "black" + setProperty("pointer-events", "none") + display = "block" + + when (contentScale) { + ContentScale.Crop -> { + width = "100%" + height = "100%" + objectFit = "cover" + } + ContentScale.FillBounds -> { + width = "100%" + height = "100%" + objectFit = "fill" + } + ContentScale.FillWidth -> { + objectFit = "contain" + if (videoRatio != null) { + width = "100%" + height = "auto" + } else { + width = "100%" + height = "100%" + } + } + ContentScale.FillHeight -> { + objectFit = "contain" + if (videoRatio != null) { + width = "auto" + height = "100%" + } else { + width = "100%" + height = "100%" + } + } + else -> { + width = "100%" + height = "100%" + objectFit = "contain" + } + } + } +} + +internal fun createVideoElement(useCors: Boolean = true): HTMLVideoElement { + return (document.createElement("video") as HTMLVideoElement).apply { + controls = false + style.width = "100%" + style.height = "100%" + style.backgroundColor = "black" + style.setProperty("pointer-events", "none") + style.display = "block" + + if (useCors) { + crossOrigin = "anonymous" + } else { + removeAttribute("crossorigin") + } + + setAttribute("playsinline", "") + setAttribute("webkit-playsinline", "") + setAttribute("preload", "auto") + setAttribute("x-webkit-airplay", "allow") + } +} + +internal fun setupVideoElement( + video: HTMLVideoElement, + playerState: VideoPlayerState, + scope: CoroutineScope, + enableAudioDetection: Boolean = true, + useCors: Boolean = true, + onCorsError: () -> Unit = {}, +) { + val audioAnalyzer = if (enableAudioDetection) AudioLevelProcessor(video) else null + var initializationJob: Job? = null + var corsErrorDetected = false + + playerState.clearError() + playerState.metadata.audioChannels = null + playerState.metadata.audioSampleRate = null + + fun initAudioAnalyzer() { + if (!enableAudioDetection || corsErrorDetected) return + initializationJob?.cancel() + initializationJob = scope.launch { + val success = audioAnalyzer?.initialize() ?: false + if (!success) { + corsErrorDetected = true + } else { + audioAnalyzer.let { analyzer -> + playerState.metadata.audioChannels = analyzer.audioChannels + playerState.metadata.audioSampleRate = analyzer.audioSampleRate + } + } + } + } + + video.addEventListeners( + scope = scope, + playerState = playerState, + events = mapOf( + "timeupdate" to { event -> playerState.onTimeUpdateEvent(event) }, + "ended" to { scope.launch { playerState.pause() } } + ), + loadingEvents = mapOf( + "seeking" to true, + "waiting" to true, + "playing" to false, + "seeked" to false, + "canplaythrough" to false, + "canplay" to false + ) + ) + + val conditionalLoadingEvents = mapOf( + "suspend" to { video.readyState >= 3 }, + "loadedmetadata" to { true } + ) + + conditionalLoadingEvents.forEach { (event, condition) -> + video.addEventListener(event) { + if (event == "loadedmetadata") { + initAudioAnalyzer() + } + + scope.launch { + if (condition()) { + playerState._isLoading = false + } + + if (event == "loadedmetadata") { + if (playerState.isPlaying) { + video.safePlay() + } + } + } + } + } + + var audioLevelJob: Job? = null + + video.addEventListener("play") { + if (enableAudioDetection && !corsErrorDetected && initializationJob?.isActive != true) { + initAudioAnalyzer() + } + + if (enableAudioDetection && audioLevelJob?.isActive != true) { + audioLevelJob = scope.launch { + while (video.paused.not()) { + val (left, right) = if (!corsErrorDetected) { + audioAnalyzer?.getAudioLevels() ?: (0f to 0f) + } else { + 0f to 0f + } + playerState.updateAudioLevels(left, right) + delay(100) + } + } + } + } + + video.addEventListener("pause") { + audioLevelJob?.cancel() + audioLevelJob = null + } + + video.addEventListener("error") { + scope.launch { + playerState._isLoading = false + corsErrorDetected = true + + val error = video.error + if (error != null) { + if (useCors) { + playerState.clearError() + onCorsError() + } else { + val errorMsg = if (error.code == MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) { + "Failed to load because the video format is not supported" + } else { + "Failed to load because no supported source was found" + } + playerState.setError(VideoPlayerError.SourceError(errorMsg)) + } + } + } + } + + video.loop = playerState.loop + + if (video.src.isNotEmpty() && playerState.isPlaying) { + video.safePlay() + } +} + +internal fun VideoPlayerState.onTimeUpdateEvent(event: Event) { + (event.target as? HTMLVideoElement)?.let { + onTimeUpdate(it.currentTime.toFloat(), it.duration.toFloat()) + } +} + +internal fun HTMLVideoElement.setupMetadataListener( + playerState: VideoPlayerState, + onVideoRatioChange: (Float) -> Unit +) { + addEventListener("loadedmetadata") { + val width = videoWidth + val height = videoHeight + if (height != 0) { + onVideoRatioChange(width.toFloat() / height.toFloat()) + + with(playerState.metadata) { + this.width = width + this.height = height + duration = (this@setupMetadataListener.duration * 1000).toLong() + + val src = this@setupMetadataListener.src + if (src.isNotEmpty()) { + val lastDotIndex = src.lastIndexOf('.') + if (lastDotIndex > 0 && lastDotIndex < src.length - 1) { + val extension = src.substring(lastDotIndex + 1).lowercase() + mimeType = EXTENSION_TO_MIME_TYPE[extension] + } + + try { + val lastSlashIndex = src.lastIndexOf('/') + val lastBackslashIndex = src.lastIndexOf('\\') + val startIndex = maxOf(lastSlashIndex, lastBackslashIndex) + 1 + + if (startIndex > 0 && startIndex < src.length) { + val endIndex = if (lastDotIndex > startIndex) lastDotIndex else src.length + val filename = src.substring(startIndex, endIndex) + if (filename.isNotEmpty()) { + title = filename + } + } + } catch (e: Exception) { + webVideoLogger.w { "Failed to extract title from filename: ${e.message}" } + } + } + } + } + } +} + +@Composable +internal fun VideoPlayerEffects( + playerState: VideoPlayerState, + videoElement: HTMLVideoElement?, + scope: CoroutineScope, + useCors: Boolean, + onLastPositionChange: (Double) -> Unit, + onWasPlayingChange: (Boolean) -> Unit, + onLastPlaybackSpeedChange: (Float) -> Unit, + lastPosition: Double, + wasPlaying: Boolean, + lastPlaybackSpeed: Float +) { + // Handle fullscreen + LaunchedEffect(playerState.isFullscreen) { + try { + if (!playerState.isFullscreen) { + FullscreenManager.exitFullscreen() + } + } catch (e: Exception) { + webVideoLogger.e { "Error handling fullscreen: ${e.message}" } + } + } + + // Listen for fullscreen change events + DisposableEffect(Unit) { + val fullscreenChangeListener: (Event) -> Unit = { + videoElement?.let { video -> + val isDocumentFullscreen = video.ownerDocument?.fullscreenElement != null + if (!isDocumentFullscreen && playerState.isFullscreen) { + playerState.isFullscreen = false + } + } + } + + val fullscreenEvents = listOf( + "fullscreenchange", "webkitfullscreenchange", + "mozfullscreenchange", "MSFullscreenChange" + ) + + fullscreenEvents.forEach { event -> + document.addEventListener(event, fullscreenChangeListener) + } + + onDispose { + fullscreenEvents.forEach { event -> + document.removeEventListener(event, fullscreenChangeListener) + } + } + } + + // Handle source change effect + LaunchedEffect(videoElement, playerState.sourceUri) { + videoElement?.let { video -> + val sourceUri = playerState.sourceUri ?: "" + if (sourceUri.isNotEmpty()) { + playerState.clearError() + video.src = sourceUri + video.load() + if (playerState.isPlaying) video.safePlay() else video.safePause() + } + } + } + + // Handle play/pause + LaunchedEffect(videoElement, playerState.isPlaying) { + videoElement?.let { video -> + if (playerState.isPlaying) video.safePlay() else video.safePause() + } + } + + // Handle loop property + LaunchedEffect(playerState.loop) { + videoElement?.let { video -> + video.loop = playerState.loop + } + } + + // Store state before video element recreation + LaunchedEffect(useCors) { + videoElement?.let { + onLastPositionChange(it.currentTime) + onWasPlayingChange(playerState.isPlaying) + onLastPlaybackSpeedChange(playerState.playbackSpeed) + } + } + + // Restore state after video element recreation + LaunchedEffect(videoElement, useCors) { + videoElement?.let { video -> + if (lastPosition > 0) { + video.safeSetCurrentTime(lastPosition) + onLastPositionChange(0.0) + } + + if (lastPlaybackSpeed != 1.0f) { + video.safeSetPlaybackRate(lastPlaybackSpeed) + onLastPlaybackSpeedChange(1.0f) + } + + if (wasPlaying) { + video.safePlay() + onWasPlayingChange(false) + } + } + } + + // Handle seeking + LaunchedEffect(playerState.sliderPos) { + if (!playerState.userDragging && playerState.hasMedia) { + playerState.seekJob?.cancel() + + videoElement?.let { video -> + val duration = video.duration.toFloat() + if (duration > 0f) { + val newTime = (playerState.sliderPos / VideoPlayerState.PERCENTAGE_MULTIPLIER) * duration + val currentTime = video.currentTime + + if (abs(currentTime - newTime) > 0.5) { + playerState.seekJob = scope.launch { + video.safeSetCurrentTime(newTime.toDouble()) + } + } + } + } + } + } + + // Listen for external play/pause events + DisposableEffect(videoElement) { + val video = videoElement ?: return@DisposableEffect onDispose {} + + val playListener: (Event) -> Unit = { + if (!playerState.isPlaying) scope.launch { playerState.play() } + } + + val pauseListener: (Event) -> Unit = { + if (playerState.isPlaying) scope.launch { playerState.pause() } + } + + video.addEventListener("play", playListener) + video.addEventListener("pause", pauseListener) + + onDispose { + video.removeEventListener("play", playListener) + video.removeEventListener("pause", pauseListener) + } + } +} + +@Composable +internal fun VideoVolumeAndSpeedEffects( + playerState: VideoPlayerState, + videoElement: HTMLVideoElement? +) { + var pendingVolumeChange by remember { mutableStateOf(null) } + var pendingPlaybackSpeedChange by remember { mutableStateOf(null) } + + DisposableEffect(videoElement) { + val video = videoElement ?: return@DisposableEffect onDispose {} + + playerState.applyVolumeCallback = { value -> + if (playerState._isLoading) { + pendingVolumeChange = value.toDouble() + } else { + video.volume = value.toDouble() + pendingVolumeChange = null + } + } + + if (!playerState._isLoading) { + video.volume = playerState.volume.toDouble() + } else { + pendingVolumeChange = playerState.volume.toDouble() + } + + playerState.applyPlaybackSpeedCallback = { value -> + if (playerState._isLoading) { + pendingPlaybackSpeedChange = value + } else { + video.safeSetPlaybackRate(value) + pendingPlaybackSpeedChange = null + } + } + + if (!playerState._isLoading) { + video.safeSetPlaybackRate(playerState.playbackSpeed) + } else { + pendingPlaybackSpeedChange = playerState.playbackSpeed + } + + val seekedListener: (Event) -> Unit = { + pendingVolumeChange?.let { volume -> + video.volume = volume + pendingVolumeChange = null + } + pendingPlaybackSpeedChange?.let { speed -> + video.safeSetPlaybackRate(speed) + pendingPlaybackSpeedChange = null + } + } + + video.addEventListener("seeked", seekedListener) + + onDispose { + video.removeEventListener("seeked", seekedListener) + playerState.applyVolumeCallback = null + playerState.applyPlaybackSpeedCallback = null + } + } +} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt similarity index 92% rename from mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt rename to mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt index 544f2887..f83dbe00 100644 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt @@ -6,10 +6,13 @@ import kotlin.js.JsAny import org.khronos.webgl.Float32Array import org.khronos.webgl.Uint8Array import org.w3c.dom.HTMLMediaElement +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.definedExternally /** * Represents the main audio context */ +@OptIn(ExperimentalWasmJsInterop::class) external class AudioContext : JsAny { constructor() val destination: AudioDestinationNode @@ -26,7 +29,8 @@ external class AudioContext : JsAny { /** * Represents a generic node of the Web Audio API */ -external open class AudioNode : JsAny { +@OptIn(ExperimentalWasmJsInterop::class) +open external class AudioNode : JsAny { fun connect(destination: AudioNode, output: Int = definedExternally, input: Int = definedExternally): AudioNode fun disconnect() } @@ -94,4 +98,3 @@ external class AnalyserNode : AudioNode { fun getFloatFrequencyData(array: Float32Array) fun getFloatTimeDomainData(array: Float32Array) } - diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/MediaError.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/MediaError.kt similarity index 92% rename from mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/MediaError.kt rename to mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/MediaError.kt index b87ecc0e..7760f0c5 100644 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/MediaError.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/MediaError.kt @@ -2,11 +2,13 @@ package io.github.kdroidfilter.composemediaplayer.jsinterop +import kotlin.js.ExperimentalWasmJsInterop import kotlin.js.JsAny /** * Represents a media error in the HTMLMediaElement */ +@OptIn(ExperimentalWasmJsInterop::class) external class MediaError : JsAny { /** * Error code for the media error @@ -37,4 +39,4 @@ external class MediaError : JsAny { */ val MEDIA_ERR_SRC_NOT_SUPPORTED: Short } -} \ No newline at end of file +} diff --git a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.wasmjs.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt similarity index 73% rename from mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.wasmjs.kt rename to mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt index d628648a..92814c66 100644 --- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.wasmjs.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt @@ -1,6 +1,7 @@ package io.github.kdroidfilter.composemediaplayer.subtitle -import io.github.kdroidfilter.composemediaplayer.wasmVideoLogger +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import kotlinx.browser.window import kotlinx.coroutines.suspendCancellableCoroutine import org.w3c.dom.url.URL @@ -14,6 +15,10 @@ import kotlin.coroutines.resume * @param src The source URI of the subtitle file * @return The content of the subtitle file as a string */ +private val webSubtitleLogger = Logger.withTag("WebSubtitleLoader").apply { + Logger.setMinSeverity(Severity.Warn) +} + actual suspend fun loadSubtitleContent(src: String): String = suspendCancellableCoroutine { continuation -> try { // Handle different types of URLs @@ -29,7 +34,7 @@ actual suspend fun loadSubtitleContent(src: String): String = suspendCancellable // Handle file: URLs src.startsWith("file:") -> { - wasmVideoLogger.d { "File URLs are not directly supported in browser. Using as-is: $src" } + webSubtitleLogger.d { "File URLs are not directly supported in browser. Using as-is: $src" } src } @@ -39,14 +44,14 @@ actual suspend fun loadSubtitleContent(src: String): String = suspendCancellable // Try to resolve relative to the current page URL(src, window.location.href).toString() } catch (e: Exception) { - wasmVideoLogger.e { "Failed to resolve URL: $src - ${e.message}" } + webSubtitleLogger.e { "Failed to resolve URL: $src - ${e.message}" } src // Use as-is if resolution fails } } } // Log the URL we're fetching - wasmVideoLogger.d { "Fetching subtitle content from: $url" } + webSubtitleLogger.d { "Fetching subtitle content from: $url" } // Use XMLHttpRequest to fetch the content val xhr = XMLHttpRequest() @@ -55,16 +60,16 @@ actual suspend fun loadSubtitleContent(src: String): String = suspendCancellable xhr.onload = { if (xhr.status.toInt() in 200..299) { - val content = xhr.responseText ?: "" + val content = xhr.responseText continuation.resume(content) } else { - wasmVideoLogger.e { "Failed to fetch subtitle content: ${xhr.status} ${xhr.statusText}" } + webSubtitleLogger.e { "Failed to fetch subtitle content: ${xhr.status} ${xhr.statusText}" } continuation.resume("") } } xhr.onerror = { - wasmVideoLogger.e { "Error fetching subtitle content" } + webSubtitleLogger.e { "Error fetching subtitle content" } continuation.resume("") } @@ -74,12 +79,12 @@ actual suspend fun loadSubtitleContent(src: String): String = suspendCancellable continuation.invokeOnCancellation { try { xhr.abort() - } catch (e: Exception) { + } catch (_: Exception) { // Ignore abort errors } } } catch (e: Exception) { - wasmVideoLogger.e { "Error loading subtitle content: ${e.message}" } + webSubtitleLogger.e { "Error loading subtitle content: ${e.message}" } continuation.resume("") } } diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index fa68fcd0..b3905473 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -17,19 +17,33 @@ kotlin { androidTarget() jvm() + js(IR) { + outputModuleName.set("composeApp") + browser { + val rootDirPath = project.rootDir.path + val projectDirPath = project.projectDir.path + commonWebpackConfig { + outputFileName = "composeApp.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + // Serve sources to debug inside browser + static(rootDirPath) + static(projectDirPath) + } + } + } + binaries.executable() + } wasmJs { - outputModuleName = "composeApp" + outputModuleName.set("composeApp") browser { val rootDirPath = project.rootDir.path val projectDirPath = project.projectDir.path commonWebpackConfig { outputFileName = "composeApp.js" devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(rootDirPath) - add(projectDirPath) - } + // Serve sources to debug inside browser + static(rootDirPath) + static(projectDirPath) } } } @@ -51,6 +65,7 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) + implementation(compose.components.uiToolingPreview) implementation(project(":mediaplayer")) implementation(compose.materialIconsExtended) implementation(libs.filekit.dialogs.compose) @@ -59,20 +74,29 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.activityCompose) + implementation(libs.androidx.core) } jvmMain.dependencies { implementation(compose.desktop.currentOs) } + webMain.dependencies { + implementation(libs.kotlinx.browser) + + } } } +dependencies { + debugImplementation(compose.uiTooling) +} + android { namespace = "sample.app" compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 targetSdk = 36 applicationId = "sample.app.androidApp" diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/PreviewSamples.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/PreviewSamples.kt new file mode 100644 index 00000000..f9b2fd42 --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/PreviewSamples.kt @@ -0,0 +1,77 @@ +package sample.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import io.github.kdroidfilter.composemediaplayer.InitialPlayerState +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface +import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState +import org.jetbrains.compose.ui.tooling.preview.Preview +import sample.app.singleplayer.ControlsCard +import sample.app.singleplayer.PlayerHeader +import sample.app.singleplayer.PrimaryControls +import sample.app.singleplayer.TimelineControls + +@Preview +@Composable +private fun SinglePlayerUiPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + val playerState = rememberVideoPlayerState() + playerState.volume = 0.7f + playerState.loop = true + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + PlayerHeader(title = "Compose Media Player Sample") + + VideoPlayerSurface( + playerState = playerState, + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentScale = ContentScale.Fit + ) + + Spacer(modifier = Modifier.height(160.dp)) + TimelineControls(playerState) + + Spacer(modifier = Modifier.height(16.dp)) + PrimaryControls( + playerState = playerState, + videoFileLauncher = {}, + onSubtitleDialogRequest = {}, + onMetadataDialogRequest = {}, + onContentScaleDialogRequest = {} + ) + + Spacer(modifier = Modifier.height(16.dp)) + ControlsCard( + playerState = playerState, + videoUrl = "https://example.com/video.mp4", + onVideoUrlChange = {}, + onOpenUrl = {}, + initialPlayerState = InitialPlayerState.PLAY, + onInitialPlayerStateChange = {} + ) + } + } + } +} diff --git a/sample/composeApp/src/wasmJsMain/kotlin/sample/app/main.kt b/sample/composeApp/src/webMain/kotlin/sample/app/main.kt similarity index 93% rename from sample/composeApp/src/wasmJsMain/kotlin/sample/app/main.kt rename to sample/composeApp/src/webMain/kotlin/sample/app/main.kt index 2434b160..805cece4 100644 --- a/sample/composeApp/src/wasmJsMain/kotlin/sample/app/main.kt +++ b/sample/composeApp/src/webMain/kotlin/sample/app/main.kt @@ -6,7 +6,7 @@ import sample.app.App @OptIn(ExperimentalComposeUiApi::class) fun main() { - ComposeViewport(document.body!!) { + ComposeViewport { hideLoader() App() } diff --git a/sample/composeApp/src/wasmJsMain/resources/index.html b/sample/composeApp/src/webMain/resources/index.html similarity index 100% rename from sample/composeApp/src/wasmJsMain/resources/index.html rename to sample/composeApp/src/webMain/resources/index.html diff --git a/sample/composeApp/src/wasmJsMain/resources/styles.css b/sample/composeApp/src/webMain/resources/styles.css similarity index 100% rename from sample/composeApp/src/wasmJsMain/resources/styles.css rename to sample/composeApp/src/webMain/resources/styles.css