diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..858d8fa6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+# .editorconfig
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+max_line_length = 120
+tab_width = 4
+trim_trailing_whitespace = true
+
+[*.{kt,kts}]
+ij_kotlin_allow_trailing_comma = true
+ij_kotlin_allow_trailing_comma_on_call_site = true
+ij_kotlin_imports_layout = *, java.**, javax.**, kotlin.**, ^
+ktlint_standard_no-wildcard-imports = disabled
+ktlint_standard_function-naming = disabled
+ktlint_standard_backing-property-naming = disabled
diff --git a/.github/workflows/build-natives.yml b/.github/workflows/build-natives.yml
index d8bbcd37..a35ab426 100644
--- a/.github/workflows/build-natives.yml
+++ b/.github/workflows/build-natives.yml
@@ -8,55 +8,113 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout Repo
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
- name: Build Windows native DLLs
shell: cmd
working-directory: mediaplayer/src/jvmMain/native/windows
run: call build.bat
+ env:
+ NATIVE_LIBS_OUTPUT_DIR: ${{ github.workspace }}/build/nativeLibs
- name: Verify Windows natives
shell: bash
run: |
for f in \
- mediaplayer/src/jvmMain/resources/win32-x86-64/NativeVideoPlayer.dll \
- mediaplayer/src/jvmMain/resources/win32-arm64/NativeVideoPlayer.dll; do
+ build/nativeLibs/win32-x86-64/NativeVideoPlayer.dll \
+ build/nativeLibs/win32-arm64/NativeVideoPlayer.dll; do
if [ ! -f "$f" ]; then echo "MISSING: $f" >&2; exit 1; fi
echo "OK: $f ($(wc -c < "$f") bytes)"
done
- - name: Upload Windows DLLs
- uses: actions/upload-artifact@v4
+ - name: Upload Windows natives
+ uses: actions/upload-artifact@v7
with:
- name: windows-natives
- path: |
- mediaplayer/src/jvmMain/resources/win32-x86-64/
- mediaplayer/src/jvmMain/resources/win32-arm64/
+ name: native-windows
+ path: build/nativeLibs/
retention-days: 1
macos:
runs-on: macos-latest
steps:
- name: Checkout Repo
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
- name: Build macOS native dylibs
run: bash mediaplayer/src/jvmMain/native/macos/build.sh
+ env:
+ NATIVE_LIBS_OUTPUT_DIR: ${{ github.workspace }}/build/nativeLibs
- name: Verify macOS natives
run: |
for f in \
- mediaplayer/src/jvmMain/resources/darwin-aarch64/libNativeVideoPlayer.dylib \
- mediaplayer/src/jvmMain/resources/darwin-x86-64/libNativeVideoPlayer.dylib; do
+ build/nativeLibs/darwin-aarch64/libNativeVideoPlayer.dylib \
+ build/nativeLibs/darwin-x86-64/libNativeVideoPlayer.dylib; do
if [ ! -f "$f" ]; then echo "MISSING: $f" >&2; exit 1; fi
echo "OK: $f ($(wc -c < "$f") bytes)"
done
- - name: Upload macOS dylibs
- uses: actions/upload-artifact@v4
+ - name: Upload macOS natives
+ uses: actions/upload-artifact@v7
+ with:
+ name: native-macos
+ path: build/nativeLibs/
+ retention-days: 1
+
+ linux:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ include:
+ - os: ubuntu-latest
+ arch: x86-64
+ - os: ubuntu-24.04-arm
+ arch: aarch64
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Install GStreamer dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libgstreamer1.0-dev \
+ libgstreamer-plugins-base1.0-dev \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good
+
+ - name: Build Linux native library
+ working-directory: mediaplayer/src/jvmMain/native/linux
+ run: bash build.sh
+ env:
+ NATIVE_LIBS_OUTPUT_DIR: ${{ github.workspace }}/build/nativeLibs
+
+ - name: Verify Linux natives
+ run: |
+ test -f build/nativeLibs/linux-${{ matrix.arch }}/libNativeVideoPlayer.so
+ ls -la build/nativeLibs/linux-${{ matrix.arch }}/
+
+ - name: Upload Linux library
+ uses: actions/upload-artifact@v7
with:
- name: macos-natives
- path: |
- mediaplayer/src/jvmMain/resources/darwin-aarch64/
- mediaplayer/src/jvmMain/resources/darwin-x86-64/
+ name: native-linux-${{ matrix.arch }}
+ path: build/nativeLibs/
retention-days: 1
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index f3d9d605..243855f5 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -1,78 +1,147 @@
name: Build and Test
on:
+ push:
+ branches: [ master ]
pull_request:
branches: [ master ]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
jobs:
build-natives:
uses: ./.github/workflows/build-natives.yml
- build-and-test:
+ jvm:
needs: build-natives
- strategy:
- matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
- fail-fast: false
-
- runs-on: ${{ matrix.os }}
-
+ runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- - name: Download Windows natives
- uses: actions/download-artifact@v4
+ - name: Download all native libraries
+ uses: actions/download-artifact@v8
with:
- name: windows-natives
- path: mediaplayer/src/jvmMain/resources/
+ path: mediaplayer/src/jvmMain/resources/composemediaplayer/native/
+ pattern: native-*
merge-multiple: true
- - name: Download macOS natives
- uses: actions/download-artifact@v4
- with:
- name: macos-natives
- path: mediaplayer/src/jvmMain/resources/
- merge-multiple: true
-
- - name: Verify all natives present
- shell: bash
+ - name: Verify natives
run: |
- EXPECTED=(
- "mediaplayer/src/jvmMain/resources/win32-x86-64/NativeVideoPlayer.dll"
- "mediaplayer/src/jvmMain/resources/win32-arm64/NativeVideoPlayer.dll"
- "mediaplayer/src/jvmMain/resources/darwin-aarch64/libNativeVideoPlayer.dylib"
- "mediaplayer/src/jvmMain/resources/darwin-x86-64/libNativeVideoPlayer.dylib"
- )
- MISSING=0
- for f in "${EXPECTED[@]}"; do
- if [ -f "$f" ]; then
- echo "OK: $f ($(wc -c < "$f") bytes)"
- else
- echo "MISSING: $f" >&2
- MISSING=1
- fi
+ for f in \
+ linux-x86-64/libNativeVideoPlayer.so \
+ linux-aarch64/libNativeVideoPlayer.so \
+ darwin-aarch64/libNativeVideoPlayer.dylib \
+ darwin-x86-64/libNativeVideoPlayer.dylib \
+ win32-x86-64/NativeVideoPlayer.dll \
+ win32-arm64/NativeVideoPlayer.dll; do
+ path="mediaplayer/src/jvmMain/resources/composemediaplayer/native/$f"
+ if [ ! -f "$path" ]; then echo "MISSING: $path" >&2; exit 1; fi
+ echo "OK: $path"
done
- if [ "$MISSING" = "1" ]; then exit 1; fi
- name: Set up JDK
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
- if: runner.os != 'Windows'
+ - name: Build and test JVM
+ run: ./gradlew :mediaplayer:compileKotlinJvm :mediaplayer:jvmTest --no-daemon --continue
+
+ - name: Upload test reports
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: test-reports-jvm
+ path: '**/build/reports/tests/'
- - name: Build and test with Gradle
- run: ./gradlew build test --no-daemon
- shell: bash
+ android:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Build Android
+ run: ./gradlew :mediaplayer:compileReleaseKotlinAndroid :mediaplayer:testReleaseUnitTest --no-daemon --continue
- name: Upload test reports
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: always()
with:
- name: test-reports-${{ matrix.os }}
+ name: test-reports-android
path: '**/build/reports/tests/'
+
+ ios:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Build iOS
+ run: ./gradlew :mediaplayer:compileKotlinIosArm64 :mediaplayer:compileKotlinIosSimulatorArm64 --no-daemon
+
+ js:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Build JS
+ run: ./gradlew :mediaplayer:compileKotlinJs --no-daemon
+
+ wasmJs:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Build WasmJS
+ run: ./gradlew :mediaplayer:compileKotlinWasmJs --no-daemon
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK
+ uses: actions/setup-java@v5
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Run ktlint and detekt
+ run: ./gradlew ktlintCheck detekt --no-daemon --continue
diff --git a/.github/workflows/publish-documentation-and-sample.yml b/.github/workflows/publish-documentation-and-sample.yml
index f970785f..3a088e60 100644
--- a/.github/workflows/publish-documentation-and-sample.yml
+++ b/.github/workflows/publish-documentation-and-sample.yml
@@ -23,10 +23,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Java (Temurin 17)
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
@@ -55,7 +55,7 @@ jobs:
# Upload to the "pages" artifact so it is available for the next job
- name: Upload artifact for GitHub Pages
- uses: actions/upload-pages-artifact@v3
+ uses: actions/upload-pages-artifact@v4
with:
path: build/final
@@ -70,6 +70,6 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
with:
path: build/final
diff --git a/.github/workflows/publish-on-maven-central.yml b/.github/workflows/publish-on-maven-central.yml
index 04879e9f..6673bfc4 100644
--- a/.github/workflows/publish-on-maven-central.yml
+++ b/.github/workflows/publish-on-maven-central.yml
@@ -15,29 +15,24 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- - name: Download Windows natives
- uses: actions/download-artifact@v4
+ - name: Download all native libraries
+ uses: actions/download-artifact@v8
with:
- name: windows-natives
- path: mediaplayer/src/jvmMain/resources/
- merge-multiple: true
-
- - name: Download macOS natives
- uses: actions/download-artifact@v4
- with:
- name: macos-natives
- path: mediaplayer/src/jvmMain/resources/
+ path: mediaplayer/src/jvmMain/resources/composemediaplayer/native/
+ pattern: native-*
merge-multiple: true
- name: Verify all natives present
run: |
EXPECTED=(
- "mediaplayer/src/jvmMain/resources/win32-x86-64/NativeVideoPlayer.dll"
- "mediaplayer/src/jvmMain/resources/win32-arm64/NativeVideoPlayer.dll"
- "mediaplayer/src/jvmMain/resources/darwin-aarch64/libNativeVideoPlayer.dylib"
- "mediaplayer/src/jvmMain/resources/darwin-x86-64/libNativeVideoPlayer.dylib"
+ "mediaplayer/src/jvmMain/resources/composemediaplayer/native/darwin-aarch64/libNativeVideoPlayer.dylib"
+ "mediaplayer/src/jvmMain/resources/composemediaplayer/native/darwin-x86-64/libNativeVideoPlayer.dylib"
+ "mediaplayer/src/jvmMain/resources/composemediaplayer/native/linux-x86-64/libNativeVideoPlayer.so"
+ "mediaplayer/src/jvmMain/resources/composemediaplayer/native/linux-aarch64/libNativeVideoPlayer.so"
+ "mediaplayer/src/jvmMain/resources/composemediaplayer/native/win32-x86-64/NativeVideoPlayer.dll"
+ "mediaplayer/src/jvmMain/resources/composemediaplayer/native/win32-arm64/NativeVideoPlayer.dll"
)
MISSING=0
for f in "${EXPECTED[@]}"; do
@@ -51,7 +46,7 @@ jobs:
if [ "$MISSING" = "1" ]; then exit 1; fi
- name: Set up JDK
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
diff --git a/.gitignore b/.gitignore
index db588951..3a946f3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,11 +15,8 @@ Pods/
*.jks
*.gpg
*yarn.lock
-# Native compiled binaries (built in CI)
-/mediaplayer/src/jvmMain/resources/win32-x86-64/
-/mediaplayer/src/jvmMain/resources/win32-arm64/
-/mediaplayer/src/jvmMain/resources/darwin-aarch64/
-/mediaplayer/src/jvmMain/resources/darwin-x86-64/
+# Native compiled binaries (built locally or in CI)
+/mediaplayer/src/jvmMain/resources/composemediaplayer/native/
# Native build artifacts
/mediaplayer/src/jvmMain/native/windows/build-x64/
diff --git a/.run/Browser.run.xml b/.run/Browser.run.xml
index a54f39a9..32a33739 100644
--- a/.run/Browser.run.xml
+++ b/.run/Browser.run.xml
@@ -17,8 +17,11 @@
true
true
+ false
false
false
+ true
+ true
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 144d22aa..21f27421 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,4 +5,34 @@ plugins {
alias(libs.plugins.kotlinCocoapods).apply(false)
alias(libs.plugins.dokka).apply(false)
alias(libs.plugins.vannitktech.maven.publish).apply(false)
+ alias(libs.plugins.detekt)
+ alias(libs.plugins.ktlint)
+}
+
+// Code quality
+detekt {
+ config.setFrom(files("config/detekt/detekt.yml"))
+ buildUponDefaultConfig = true
+}
+
+ktlint {
+ ignoreFailures.set(true)
+}
+
+subprojects {
+ if (name == "composeApp") return@subprojects
+ apply(plugin = "org.jlleitschuh.gradle.ktlint")
+
+ ktlint {
+ debug.set(false)
+ verbose.set(true)
+ android.set(false)
+ outputToConsole.set(true)
+ ignoreFailures.set(true)
+ enableExperimentalRules.set(true)
+ filter {
+ exclude("**/generated/**")
+ include("**/kotlin/**")
+ }
+ }
}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
new file mode 100644
index 00000000..adc74a88
--- /dev/null
+++ b/config/detekt/detekt.yml
@@ -0,0 +1,459 @@
+config:
+ validation: true
+ warningsAsErrors: false
+ checkExhaustiveness: false
+ excludes: []
+
+processors:
+ active: true
+ exclude:
+ - 'DetektProgressListener'
+
+console-reports:
+ active: true
+ exclude:
+ - 'ProjectStatisticsReport'
+ - 'ComplexityReport'
+ - 'NotificationReport'
+ - 'IssuesReport'
+ - 'FileBasedIssuesReport'
+
+comments:
+ active: true
+ AbsentOrWrongFileLicense:
+ active: false
+ DeprecatedBlockTag:
+ active: false
+ DocumentationOverPrivateFunction:
+ active: false
+ DocumentationOverPrivateProperty:
+ active: false
+ EndOfSentenceFormat:
+ active: false
+ KDocReferencesNonPublicProperty:
+ active: false
+ OutdatedDocumentation:
+ active: false
+ UndocumentedPublicClass:
+ active: false
+ UndocumentedPublicFunction:
+ active: false
+ UndocumentedPublicProperty:
+ active: false
+
+complexity:
+ active: true
+ CognitiveComplexMethod:
+ active: false
+ ComplexCondition:
+ active: true
+ allowedConditions: 4
+ ComplexInterface:
+ active: false
+ CyclomaticComplexMethod:
+ active: true
+ allowedComplexity: 20
+ ignoreSingleWhenExpression: false
+ ignoreSimpleWhenEntries: false
+ LabeledExpression:
+ active: false
+ LargeClass:
+ active: true
+ allowedLines: 800
+ LongMethod:
+ active: true
+ excludes: ['**/sample/**']
+ allowedLines: 150
+ LongParameterList:
+ active: true
+ allowedFunctionParameters: 7
+ allowedConstructorParameters: 7
+ ignoreDefaultParameters: false
+ ignoreDataClasses: true
+ MethodOverloading:
+ active: false
+ NamedArguments:
+ active: false
+ NestedBlockDepth:
+ active: true
+ allowedDepth: 4
+ NestedScopeFunctions:
+ active: false
+ ReplaceSafeCallChainWithRun:
+ active: false
+ StringLiteralDuplication:
+ active: false
+ TooManyFunctions:
+ active: true
+ excludes: ['**/test/**', '**/sample/**']
+ allowedFunctionsPerFile: 15
+ allowedFunctionsPerClass: 50
+ allowedFunctionsPerInterface: 15
+ allowedFunctionsPerObject: 15
+ allowedFunctionsPerEnum: 11
+ ignoreDeprecated: false
+ ignorePrivate: false
+ ignoreOverridden: false
+
+coroutines:
+ active: true
+ GlobalCoroutineUsage:
+ active: false
+ InjectDispatcher:
+ active: true
+ dispatcherNames:
+ - 'IO'
+ - 'Default'
+ - 'Unconfined'
+ RedundantSuspendModifier:
+ active: true
+ SleepInsteadOfDelay:
+ active: true
+ SuspendFunWithFlowReturnType:
+ active: true
+
+empty-blocks:
+ active: true
+ EmptyCatchBlock:
+ active: true
+ allowedExceptionNameRegex: '_|(ignore|expected).*'
+ EmptyClassBlock:
+ active: true
+ EmptyDefaultConstructor:
+ active: true
+ EmptyDoWhileBlock:
+ active: true
+ EmptyElseBlock:
+ active: true
+ EmptyFinallyBlock:
+ active: true
+ EmptyForBlock:
+ active: true
+ EmptyFunctionBlock:
+ active: true
+ ignoreOverridden: false
+ EmptyIfBlock:
+ active: true
+ EmptyInitBlock:
+ active: true
+ EmptyKotlinFile:
+ active: true
+ EmptySecondaryConstructor:
+ active: true
+ EmptyTryBlock:
+ active: true
+ EmptyWhenBlock:
+ active: true
+ EmptyWhileBlock:
+ active: true
+
+exceptions:
+ active: true
+ ExceptionRaisedInUnexpectedLocation:
+ active: true
+ methodNames:
+ - 'equals'
+ - 'finalize'
+ - 'hashCode'
+ - 'toString'
+ InstanceOfCheckForException:
+ active: true
+ NotImplementedDeclaration:
+ active: false
+ PrintStackTrace:
+ active: true
+ RethrowCaughtException:
+ active: true
+ ReturnFromFinally:
+ active: true
+ SwallowedException:
+ active: true
+ ignoredExceptionTypes:
+ - 'InterruptedException'
+ - 'MalformedURLException'
+ - 'NumberFormatException'
+ - 'ParseException'
+ allowedExceptionNameRegex: '_|(ignore|expected).*'
+ ThrowingExceptionFromFinally:
+ active: true
+ ThrowingExceptionsWithoutMessageOrCause:
+ active: true
+ exceptions:
+ - 'ArrayIndexOutOfBoundsException'
+ - 'Exception'
+ - 'IllegalArgumentException'
+ - 'IllegalStateException'
+ - 'IndexOutOfBoundsException'
+ - 'NullPointerException'
+ - 'RuntimeException'
+ - 'Throwable'
+ ThrowingNewInstanceOfSameException:
+ active: true
+ TooGenericExceptionCaught:
+ active: true
+ exceptionNames:
+ - 'ArrayIndexOutOfBoundsException'
+ - 'Error'
+ - 'Exception'
+ - 'IllegalMonitorStateException'
+ - 'IndexOutOfBoundsException'
+ - 'NullPointerException'
+ - 'RuntimeException'
+ - 'Throwable'
+ allowedExceptionNameRegex: '_|(ignore|expected).*'
+ TooGenericExceptionThrown:
+ active: true
+ exceptionNames:
+ - 'Error'
+ - 'Exception'
+ - 'RuntimeException'
+ - 'Throwable'
+
+naming:
+ active: true
+ BooleanPropertyNaming:
+ active: false
+ ClassNaming:
+ active: true
+ classPattern: '[A-Z][a-zA-Z0-9]*'
+ ConstructorParameterNaming:
+ active: true
+ parameterPattern: '[a-z][A-Za-z0-9]*'
+ privateParameterPattern: '[a-z][A-Za-z0-9]*'
+ EnumNaming:
+ active: true
+ enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
+ FunctionNaming:
+ active: true
+ excludes: ['**/test/**']
+ functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
+ FunctionParameterNaming:
+ active: true
+ parameterPattern: '[a-z][A-Za-z0-9]*'
+ InvalidPackageDeclaration:
+ active: true
+ rootPackage: ''
+ requireRootInDeclaration: false
+ excludes: ['**/build/**']
+ MatchingDeclarationName:
+ active: true
+ mustBeFirst: true
+ MemberNameEqualsClassName:
+ active: true
+ ignoreOverridden: true
+ NoNameShadowing:
+ active: true
+ ObjectPropertyNaming:
+ active: true
+ constantPattern: '[A-Za-z][_A-Za-z0-9]*'
+ propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
+ privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
+ PackageNaming:
+ active: true
+ packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
+ TopLevelPropertyNaming:
+ active: true
+ constantPattern: '[A-Z][_A-Z0-9]*'
+ propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
+ privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
+ VariableNaming:
+ active: true
+ variablePattern: '[a-z][A-Za-z0-9]*'
+ privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
+
+performance:
+ active: true
+ ArrayPrimitive:
+ active: true
+ ForEachOnRange:
+ active: true
+ SpreadOperator:
+ active: true
+ excludes: ['**/test/**']
+ UnnecessaryTemporaryInstantiation:
+ active: true
+
+potential-bugs:
+ active: true
+ AvoidReferentialEquality:
+ active: true
+ forbiddenTypePatterns:
+ - 'kotlin.String'
+ DoubleMutabilityForCollection:
+ active: true
+ EqualsAlwaysReturnsTrueOrFalse:
+ active: true
+ EqualsWithHashCodeExist:
+ active: true
+ ExplicitGarbageCollectionCall:
+ active: true
+ HasPlatformType:
+ active: true
+ IgnoredReturnValue:
+ active: true
+ restrictToConfig: true
+ returnValueAnnotations:
+ - 'CheckResult'
+ - '*.CheckResult'
+ - 'CheckReturnValue'
+ - '*.CheckReturnValue'
+ ImplicitDefaultLocale:
+ active: true
+ InvalidRange:
+ active: true
+ IteratorHasNextCallsNextMethod:
+ active: true
+ IteratorNotThrowingNoSuchElementException:
+ active: true
+ MapGetWithNotNullAssertionOperator:
+ active: true
+ UnnecessaryNotNullOperator:
+ active: true
+ UnnecessarySafeCall:
+ active: true
+ UnreachableCatchBlock:
+ active: true
+ UnreachableCode:
+ active: true
+ UnsafeCallOnNullableType:
+ active: true
+ excludes: ['**/test/**']
+ UnsafeCast:
+ active: true
+ UnusedUnaryOperator:
+ active: true
+ UselessPostfixExpression:
+ active: true
+ WrongEqualsTypeParameter:
+ active: true
+
+style:
+ active: true
+ DestructuringDeclarationWithTooManyEntries:
+ active: true
+ maxDestructuringEntries: 3
+ EqualsNullCall:
+ active: true
+ ExplicitItLambdaMultipleParameters:
+ active: true
+ ExplicitItLambdaParameter:
+ active: true
+ ForbiddenComment:
+ active: true
+ comments:
+ - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
+ value: 'FIXME:'
+ - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
+ value: 'STOPSHIP:'
+ ForbiddenVoid:
+ active: true
+ ignoreOverridden: false
+ ignoreUsageInGenerics: false
+ FunctionOnlyReturningConstant:
+ active: true
+ ignoreOverridableFunction: true
+ ignoreActualFunction: true
+ LoopWithTooManyJumpStatements:
+ active: true
+ maxJumpCount: 1
+ MagicNumber:
+ active: true
+ excludes: ['**/test/**', '**/*.kts', '**/sample/**']
+ ignoreNumbers:
+ - '-1'
+ - '0'
+ - '1'
+ - '2'
+ ignoreHashCodeFunction: true
+ ignorePropertyDeclaration: false
+ ignoreConstantDeclaration: true
+ ignoreCompanionObjectPropertyDeclaration: true
+ ignoreAnnotation: false
+ ignoreNamedArgument: true
+ ignoreEnums: false
+ ignoreExtensionFunctions: true
+ MaxLineLength:
+ active: true
+ maxLineLength: 120
+ excludePackageStatements: true
+ excludeImportStatements: true
+ excludeCommentStatements: false
+ excludeRawStrings: true
+ MayBeConstant:
+ active: true
+ ModifierOrder:
+ active: true
+ NestedClassesVisibility:
+ active: true
+ NewLineAtEndOfFile:
+ active: true
+ ObjectLiteralToLambda:
+ active: true
+ OptionalAbstractKeyword:
+ active: true
+ ProtectedMemberInFinalClass:
+ active: true
+ RedundantHigherOrderMapUsage:
+ active: true
+ ReturnCount:
+ active: true
+ max: 6
+ excludedFunctions:
+ - 'equals'
+ excludeLabeled: false
+ excludeReturnFromLambda: true
+ excludeGuardClauses: true
+ SafeCast:
+ active: true
+ SerialVersionUIDInSerializableClass:
+ active: true
+ ThrowsCount:
+ active: true
+ max: 2
+ UnnecessaryApply:
+ active: true
+ UnnecessaryFilter:
+ active: true
+ UnnecessaryInheritance:
+ active: true
+ UnusedParameter:
+ active: true
+ allowedNames: 'ignored|expected'
+ UnusedPrivateClass:
+ active: true
+ UnusedPrivateFunction:
+ active: true
+ allowedNames: ''
+ UnusedPrivateProperty:
+ active: true
+ allowedNames: 'ignored|expected|serialVersionUID'
+ UnusedVariable:
+ active: true
+ allowedNames: 'ignored|_'
+ UseAnyOrNoneInsteadOfFind:
+ active: true
+ UseArrayLiteralsInAnnotations:
+ active: true
+ UseCheckNotNull:
+ active: true
+ UseCheckOrError:
+ active: true
+ UseIsNullOrEmpty:
+ active: true
+ UseOrEmpty:
+ active: true
+ UseRequire:
+ active: true
+ UseRequireNotNull:
+ active: true
+ UselessCallOnNotNull:
+ active: true
+ UtilityClassWithPublicConstructor:
+ active: true
+ VarCouldBeVal:
+ active: true
+ ignoreLateinitVar: false
+ WildcardImport:
+ active: true
+ excludeImports:
+ - 'java.util.*'
diff --git a/gradle.properties b/gradle.properties
index 03fc209d..9cabf573 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,6 +10,8 @@ org.gradle.parallel=true
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4G
kotlin.mpp.enableCInteropCommonization=true
+kotlin.native.ignoreDisabledTargets=true
+kotlin.native.enableKlibsCrossCompilation=false
#Android
android.useAndroidX=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3a2c8ad4..352ba052 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,22 +1,18 @@
[versions]
androidcontextprovider = "1.0.1"
-cocoapods = "1.9.0"
-filekit = "0.12.0"
-gst1JavaCore = "1.4.0"
-kermit = "2.0.8"
-kotlin = "2.3.10"
+filekit = "0.13.0"
+kotlin = "2.3.20"
agp = "8.13.2"
kotlinx-coroutines = "1.10.2"
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"
-slf4jSimple = "2.0.17"
+kotlinxDatetime = "0.7.1"
+compose = "1.10.3"
+androidx-activityCompose = "1.13.0"
+androidx-core = "1.18.0"
+media3Exoplayer = "1.10.0"
+detekt = "1.23.8"
+ktlint = "14.2.0"
# minSdk = 21 failed to compile because the project indirectly depends on the library [androidx.navigationevent:navigationevent-android:1.0.1] which requires minSdk = 23
android-minSdk="23"
@@ -28,8 +24,6 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" }
filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekit" }
filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" }
-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" }
@@ -39,12 +33,13 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
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" }
-jna-platform-jpms = { module = "net.java.dev.jna:jna-platform-jpms", version.ref = "jna"}
-platformtools-darkmodedetector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version.ref = "platformtoolsDarkmodedetector" }
-slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" }
+compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" }
+compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" }
+compose-material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.9.0" }
+compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" }
+compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose" }
+compose-ui-tooling-preview = { module = "org.jetbrains.compose.components:components-ui-tooling-preview", version.ref = "compose" }
+compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version = "1.7.3" }
[plugins]
@@ -54,5 +49,7 @@ android-library = { id = "com.android.library", version.ref = "agp" }
compose = { id = "org.jetbrains.compose", version.ref = "compose" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
-vannitktech-maven-publish = {id = "com.vanniktech.maven.publish", version = "0.33.0"}
-dokka = { id = "org.jetbrains.dokka" , version = "2.0.0"}
+vannitktech-maven-publish = {id = "com.vanniktech.maven.publish", version = "0.36.0"}
+dokka = { id = "org.jetbrains.dokka" , version = "2.2.0"}
+detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
+ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index ff23a68d..c61a118f 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/mediaplayer/ComposeMediaPlayer.podspec b/mediaplayer/ComposeMediaPlayer.podspec
index d6cec2ef..a99bb032 100644
--- a/mediaplayer/ComposeMediaPlayer.podspec
+++ b/mediaplayer/ComposeMediaPlayer.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'ComposeMediaPlayer'
- spec.version = 'null'
+ spec.version = '0.0.1-dev'
spec.homepage = 'https://github.com/kdroidFilter/Compose-Media-Player'
spec.source = { :http=> ''}
spec.authors = ''
diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts
index ae351348..0049bff5 100644
--- a/mediaplayer/build.gradle.kts
+++ b/mediaplayer/build.gradle.kts
@@ -1,7 +1,6 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.apache.tools.ant.taskdefs.condition.Os
-import org.jetbrains.dokka.gradle.DokkaTask
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
@@ -18,17 +17,13 @@ plugins {
group = "io.github.kdroidfilter.composemediaplayer"
val ref = System.getenv("GITHUB_REF") ?: ""
-val version = if (ref.startsWith("refs/tags/")) {
- val tag = ref.removePrefix("refs/tags/")
- if (tag.startsWith("v")) tag.substring(1) else tag
-} else "dev"
-
-
-tasks.withType().configureEach {
- moduleName.set("Compose Media Player")
- offlineMode.set(true)
-}
-
+val projectVersion =
+ if (ref.startsWith("refs/tags/")) {
+ val tag = ref.removePrefix("refs/tags/")
+ if (tag.startsWith("v")) tag.substring(1) else tag
+ } else {
+ "dev"
+ }
kotlin {
jvmToolchain(17)
@@ -46,45 +41,44 @@ kotlin {
binaries.executable()
}
- listOf(
- iosArm64(),
- iosSimulatorArm64(),
- iosX64(),
- ).forEach { target ->
- target.compilations.getByName("main") {
- // The default file path is src/nativeInterop/cinterop/.def
- val nskeyvalueobserving by cinterops.creating
+ if (Os.isFamily(Os.FAMILY_MAC)) {
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach { target ->
+ target.compilations.getByName("main") {
+ // The default file path is src/nativeInterop/cinterop/.def
+ val nskeyvalueobserving by cinterops.creating
+ }
}
- }
-
- cocoapods {
- version = version.toString()
- summary = "A multiplatform video player library for Compose applications"
- homepage = "https://github.com/kdroidFilter/Compose-Media-Player"
- name = "ComposeMediaPlayer"
+ cocoapods {
+ version = if (projectVersion == "dev") "0.0.1-dev" else projectVersion
+ summary = "A multiplatform video player library for Compose applications"
+ homepage = "https://github.com/kdroidFilter/Compose-Media-Player"
+ name = "ComposeMediaPlayer"
+
+ framework {
+ baseName = "ComposeMediaPlayer"
+ isStatic = false
+ @OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
+ transitiveExport = false
+ }
- framework {
- baseName = "ComposeMediaPlayer"
- isStatic = false
- @OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
- transitiveExport = false
+ // Maps custom Xcode configuration to NativeBuildType
+ xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG
+ xcodeConfigurationToNativeBuildType["CUSTOM_RELEASE"] = NativeBuildType.RELEASE
}
-
- // Maps custom Xcode configuration to NativeBuildType
- xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG
- xcodeConfigurationToNativeBuildType["CUSTOM_RELEASE"] = NativeBuildType.RELEASE
}
sourceSets {
commonMain.dependencies {
- implementation(compose.runtime)
- implementation(compose.foundation)
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.test)
api(libs.filekit.core)
implementation(libs.kotlinx.datetime)
- implementation(libs.kermit)
}
commonTest.dependencies {
@@ -108,7 +102,6 @@ kotlin {
jvmMain.dependencies {
implementation(libs.kotlinx.coroutines.swing)
- implementation(libs.slf4j.simple)
}
jvmTest.dependencies {
@@ -117,28 +110,28 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
}
- iosMain.dependencies {
- }
+ if (Os.isFamily(Os.FAMILY_MAC)) {
+ iosMain.dependencies {
+ }
- iosTest.dependencies {
- implementation(kotlin("test"))
- implementation(libs.kotlinx.coroutines.test)
+ iosTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ }
}
webMain.dependencies {
implementation(libs.kotlinx.browser)
- implementation(compose.ui)
-
+ implementation(libs.compose.ui)
}
wasmJsTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
-
}
- //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers
+ // https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers
targets.withType {
compilations["main"].compileTaskProvider.configure {
compilerOptions {
@@ -146,7 +139,6 @@ kotlin {
}
}
}
-
}
android {
@@ -154,20 +146,24 @@ android {
compileSdk = 36
defaultConfig {
- minSdk = libs.versions.android.minSdk.get().toInt()
+ minSdk =
+ libs.versions.android.minSdk
+ .get()
+ .toInt()
}
}
-val nativeResourceDir = layout.projectDirectory.dir("src/jvmMain/resources")
+val nativeResourceDir = layout.projectDirectory.dir("src/jvmMain/resources/composemediaplayer/native")
val buildNativeMacOs by tasks.registering(Exec::class) {
description = "Compiles the Swift native library into macOS dylibs (arm64 + x64)"
group = "build"
- val hasPrebuilt = nativeResourceDir
- .dir("darwin-aarch64")
- .file("libNativeVideoPlayer.dylib")
- .asFile
- .exists()
+ val hasPrebuilt =
+ nativeResourceDir
+ .dir("darwin-aarch64")
+ .file("libNativeVideoPlayer.dylib")
+ .asFile
+ .exists()
enabled = Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt
val nativeDir = layout.projectDirectory.dir("src/jvmMain/native/macos")
@@ -180,11 +176,12 @@ val buildNativeMacOs by tasks.registering(Exec::class) {
val buildNativeWindows by tasks.registering(Exec::class) {
description = "Compiles the C++ native library into Windows DLLs (x64 + ARM64)"
group = "build"
- val hasPrebuilt = nativeResourceDir
- .dir("win32-x86-64")
- .file("NativeVideoPlayer.dll")
- .asFile
- .exists()
+ val hasPrebuilt =
+ nativeResourceDir
+ .dir("win32-x86-64")
+ .file("NativeVideoPlayer.dll")
+ .asFile
+ .exists()
enabled = Os.isFamily(Os.FAMILY_WINDOWS) && !hasPrebuilt
val nativeDir = layout.projectDirectory.dir("src/jvmMain/native/windows")
@@ -197,11 +194,12 @@ val buildNativeWindows by tasks.registering(Exec::class) {
val buildNativeLinux by tasks.registering(Exec::class) {
description = "Compiles the C native library into Linux .so (GStreamer + JNI)"
group = "build"
- val hasPrebuilt = nativeResourceDir
- .dir("linux-x86-64")
- .file("libNativeVideoPlayer.so")
- .asFile
- .exists()
+ val hasPrebuilt =
+ nativeResourceDir
+ .dir("linux-x86-64")
+ .file("libNativeVideoPlayer.so")
+ .asFile
+ .exists()
enabled = Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt
val nativeDir = layout.projectDirectory.dir("src/jvmMain/native/linux")
@@ -221,12 +219,11 @@ tasks.configureEach {
}
}
-
mavenPublishing {
coordinates(
groupId = "io.github.kdroidfilter",
artifactId = "composemediaplayer",
- version = version.toString()
+ version = projectVersion,
)
pom {
diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt
deleted file mode 100644
index cc20562a..00000000
--- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt
+++ /dev/null
@@ -1,93 +0,0 @@
-package io.github.kdroidfilter.composemediaplayer
-
-import androidx.media3.common.audio.AudioProcessor
-import androidx.media3.common.audio.BaseAudioProcessor
-import androidx.media3.common.util.UnstableApi
-import java.nio.ByteBuffer
-import kotlin.math.abs
-import kotlin.math.log10
-import kotlin.math.sqrt
-
-@UnstableApi
-class AudioLevelProcessor : BaseAudioProcessor() {
- private var channelCount = 0
- private var sampleRateHz = 0
- private var bytesPerFrame = 0
- private var onAudioLevelUpdate: ((Float, Float) -> Unit)? = null
-
- fun setOnAudioLevelUpdateListener(listener: (Float, Float) -> Unit) {
- onAudioLevelUpdate = listener
- }
-
- override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat {
- channelCount = inputAudioFormat.channelCount
- sampleRateHz = inputAudioFormat.sampleRate
- bytesPerFrame = inputAudioFormat.bytesPerFrame
- return inputAudioFormat
- }
-
- override fun queueInput(inputBuffer: ByteBuffer) {
- if (!inputBuffer.hasRemaining()) {
- return
- }
-
- var leftSum = 0.0
- var rightSum = 0.0
- var sampleCount = 0
-
- // Copy the buffer so as not to affect the original position
- val buffer = inputBuffer.duplicate()
-
- while (buffer.remaining() >= 2) {
- // Reading 16-bit samples
- val sample = buffer.short / Short.MAX_VALUE.toFloat()
-
- if (channelCount >= 2) {
- // Stereo
- if (sampleCount % 2 == 0) {
- leftSum += abs(sample.toDouble())
- } else {
- rightSum += abs(sample.toDouble())
- }
- } else {
- // Mono - same value for both channels
- leftSum += abs(sample.toDouble())
- rightSum += abs(sample.toDouble())
- }
- sampleCount++
- }
-
- // Calculate RMS and convert to dB
- val samplesPerChannel = if (channelCount >= 2) sampleCount / 2 else sampleCount
- val leftRms = if (samplesPerChannel > 0) sqrt(leftSum / samplesPerChannel) else 0.0
- val rightRms = if (samplesPerChannel > 0) sqrt(rightSum / samplesPerChannel) else 0.0
-
- // Convert to percentage (0-100)
- val leftLevel = convertToPercentage(leftRms)
- val rightLevel = convertToPercentage(rightRms)
-
- onAudioLevelUpdate?.invoke(leftLevel, rightLevel)
-
- // Pass the original buffer as is
- val output = replaceOutputBuffer(inputBuffer.remaining())
- output.put(inputBuffer)
- output.flip()
- }
-
- private fun convertToPercentage(rms: Double): Float {
- if (rms <= 0) return 0f
- // Apply a scaling factor to make Android values more consistent with wasmjs
- // wasmjs uses frequency domain data which typically results in lower values
- val scaledRms = rms * 0.3 // Scale down the RMS value to be more in line with wasmjs
- val db = 20 * log10(scaledRms)
- // Convert from -60dB..0dB to 0..100%
- // First normalize to 0..1 range
- val normalized = ((db + 60) / 60).toFloat().coerceIn(0f, 1f)
- // Then convert to percentage
- return normalized * 100f
- }
-
- override fun onReset() {
- onAudioLevelUpdate?.invoke(0f, 0f)
- }
-}
diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/SurfaceType.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/SurfaceType.kt
index 59bc0a4b..79cbdc7d 100644
--- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/SurfaceType.kt
+++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/SurfaceType.kt
@@ -2,5 +2,5 @@ package io.github.kdroidfilter.composemediaplayer
enum class SurfaceType {
TextureView,
- SurfaceView;
-}
\ No newline at end of file
+ SurfaceView,
+}
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 e7fe0bdb..74704698 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
@@ -26,9 +26,8 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Severity
import com.kdroid.androidcontextprovider.ContextProvider
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import io.github.kdroidfilter.composemediaplayer.util.formatTime
import io.github.vinceglb.filekit.AndroidFile
import io.github.vinceglb.filekit.PlatformFile
@@ -48,40 +47,33 @@ actual fun createVideoPlayerState(): VideoPlayerState =
userDragging = false,
loop = false,
playbackSpeed = 1f,
- leftLevel = 0f,
- rightLevel = 0f,
positionText = "00:00",
durationText = "00:00",
currentTime = 0.0,
isFullscreen = false,
aspectRatio = 16f / 9f,
- error = VideoPlayerError.UnknownError(
- "Android context is not available (preview or missing ContextProvider initialization)."
- ),
+ error =
+ VideoPlayerError.UnknownError(
+ "Android context is not available (preview or missing ContextProvider initialization).",
+ ),
metadata = VideoMetadata(),
subtitlesEnabled = false,
currentSubtitleTrack = null,
availableSubtitleTracks = mutableListOf(),
subtitleTextStyle = TextStyle.Default,
- subtitleBackgroundColor = Color.Transparent
+ subtitleBackgroundColor = Color.Transparent,
)
}
-/**
- * Logger for WebAssembly video player surface
- */
-internal val androidVideoLogger = Logger.withTag("AndroidVideoPlayerSurface")
- .apply { Logger.setMinSeverity(Severity.Warn) }
+internal val androidVideoLogger = TaggedLogger("AndroidVideoPlayerSurface")
@UnstableApi
@Stable
-open class DefaultVideoPlayerState: VideoPlayerState {
+open class DefaultVideoPlayerState : VideoPlayerState {
private val context: Context = ContextProvider.getContext()
internal var exoPlayer: ExoPlayer? = null
private var updateJob: Job? = null
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
- private val audioProcessor = AudioLevelProcessor()
-
// Protection contre les race conditions
private var isPlayerReleased = false
private val playerInitializationLock = Object()
@@ -116,8 +108,8 @@ open class DefaultVideoPlayerState: VideoPlayerState {
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- )
+ textAlign = TextAlign.Center,
+ ),
)
override var subtitleBackgroundColor by mutableStateOf(Color.Black.copy(alpha = 0.5f))
@@ -135,9 +127,11 @@ open class DefaultVideoPlayerState: VideoPlayerState {
subtitlesEnabled = true
exoPlayer?.let { player ->
- val trackParameters = player.trackSelectionParameters.buildUpon()
- .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
- .build()
+ val trackParameters =
+ player.trackSelectionParameters
+ .buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
+ .build()
player.trackSelectionParameters = trackParameters
playerView?.subtitleView?.visibility = android.view.View.GONE
@@ -149,10 +143,12 @@ open class DefaultVideoPlayerState: VideoPlayerState {
subtitlesEnabled = false
exoPlayer?.let { player ->
- val parameters = player.trackSelectionParameters.buildUpon()
- .setPreferredTextLanguage(null)
- .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
- .build()
+ val parameters =
+ player.trackSelectionParameters
+ .buildUpon()
+ .setPreferredTextLanguage(null)
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
+ .build()
player.trackSelectionParameters = parameters
playerView?.subtitleView?.visibility = android.view.View.GONE
@@ -221,12 +217,6 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
}
- // Audio levels
- private var _leftLevel by mutableFloatStateOf(0f)
- private var _rightLevel by mutableFloatStateOf(0f)
- override val leftLevel: Float get() = _leftLevel
- override val rightLevel: Float get() = _rightLevel
-
// Aspect ratio
private var _aspectRatio by mutableFloatStateOf(16f / 9f)
override val aspectRatio: Float get() = _aspectRatio
@@ -246,12 +236,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
override val durationText: String get() = formatTime(_duration)
override val currentTime: Double get() = _currentTime
-
init {
- audioProcessor.setOnAudioLevelUpdateListener { left, right ->
- _leftLevel = left
- _rightLevel = right
- }
initializePlayer()
registerScreenLockReceiver()
}
@@ -262,66 +247,72 @@ open class DefaultVideoPlayerState: VideoPlayerState {
val model = android.os.Build.MODEL
// Liste des appareils connus pour avoir des problèmes MediaCodec
- val problematicDevices = setOf(
- "SM-A155F", // Galaxy A15
- "SM-A156B", // Galaxy A15 5G
- // Ajouter d'autres modèles problématiques ici
- )
+ val problematicDevices =
+ setOf(
+ "SM-A155F", // Galaxy A15
+ "SM-A156B", // Galaxy A15 5G
+ // Ajouter d'autres modèles problématiques ici
+ )
return device in problematicDevices ||
- model in problematicDevices ||
- manufacturer.equals("mediatek", ignoreCase = true)
+ model in problematicDevices ||
+ manufacturer.equals("mediatek", ignoreCase = true)
}
private fun registerScreenLockReceiver() {
unregisterScreenLockReceiver()
- screenLockReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- when (intent?.action) {
- Intent.ACTION_SCREEN_OFF -> {
- androidVideoLogger.d { "Screen turned off (locked)" }
- synchronized(playerInitializationLock) {
- if (!isPlayerReleased && exoPlayer != null) {
- wasPlayingBeforeScreenLock = _isPlaying
- if (_isPlaying) {
- try {
- androidVideoLogger.d { "Pausing playback due to screen lock" }
- exoPlayer?.pause()
- } catch (e: Exception) {
- androidVideoLogger.e { "Error pausing on screen lock: ${e.message}" }
+ screenLockReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context?,
+ intent: Intent?,
+ ) {
+ when (intent?.action) {
+ Intent.ACTION_SCREEN_OFF -> {
+ androidVideoLogger.d { "Screen turned off (locked)" }
+ synchronized(playerInitializationLock) {
+ if (!isPlayerReleased && exoPlayer != null) {
+ wasPlayingBeforeScreenLock = _isPlaying
+ if (_isPlaying) {
+ try {
+ androidVideoLogger.d { "Pausing playback due to screen lock" }
+ exoPlayer?.pause()
+ } catch (e: Exception) {
+ androidVideoLogger.e { "Error pausing on screen lock: ${e.message}" }
+ }
}
}
}
}
- }
- Intent.ACTION_SCREEN_ON -> {
- androidVideoLogger.d { "Screen turned on (unlocked)" }
- synchronized(playerInitializationLock) {
- if (!isPlayerReleased && wasPlayingBeforeScreenLock && exoPlayer != null) {
- try {
- // Ajouter un petit délai pour s'assurer que le système est prêt
- coroutineScope.launch {
- delay(200)
- if (!isPlayerReleased) {
- androidVideoLogger.d { "Resuming playback after screen unlock" }
- exoPlayer?.play()
+ Intent.ACTION_SCREEN_ON -> {
+ androidVideoLogger.d { "Screen turned on (unlocked)" }
+ synchronized(playerInitializationLock) {
+ if (!isPlayerReleased && wasPlayingBeforeScreenLock && exoPlayer != null) {
+ try {
+ // Ajouter un petit délai pour s'assurer que le système est prêt
+ coroutineScope.launch {
+ delay(200)
+ if (!isPlayerReleased) {
+ androidVideoLogger.d { "Resuming playback after screen unlock" }
+ exoPlayer?.play()
+ }
}
+ } catch (e: Exception) {
+ androidVideoLogger.e { "Error resuming after screen unlock: ${e.message}" }
}
- } catch (e: Exception) {
- androidVideoLogger.e { "Error resuming after screen unlock: ${e.message}" }
}
}
}
}
}
}
- }
- val filter = IntentFilter().apply {
- addAction(Intent.ACTION_SCREEN_OFF)
- addAction(Intent.ACTION_SCREEN_ON)
- }
+ val filter =
+ IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_OFF)
+ addAction(Intent.ACTION_SCREEN_ON)
+ }
context.registerReceiver(screenLockReceiver, filter)
androidVideoLogger.d { "Screen lock receiver registered" }
}
@@ -342,138 +333,149 @@ open class DefaultVideoPlayerState: VideoPlayerState {
synchronized(playerInitializationLock) {
if (isPlayerReleased) return
- val audioSink = DefaultAudioSink.Builder(context)
- .setAudioProcessors(arrayOf(audioProcessor))
- .build()
-
- val renderersFactory = object : DefaultRenderersFactory(context) {
- override fun buildAudioSink(
- context: Context,
- enableFloatOutput: Boolean,
- enableAudioTrackPlaybackParams: Boolean
- ): AudioSink = audioSink
- }.apply {
- setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
- // Activer le fallback du décodeur pour une meilleure stabilité
- setEnableDecoderFallback(true)
-
- // Sur les appareils problématiques, utiliser des paramètres plus conservateurs
- if (shouldUseConservativeCodecHandling()) {
- // On ne peut pas désactiver l'async queueing car la méthode n'existe pas
- // Mais on peut utiliser le MediaCodecSelector par défaut
- setMediaCodecSelector(MediaCodecSelector.DEFAULT)
+ val audioSink =
+ DefaultAudioSink
+ .Builder(context)
+ .build()
+
+ val renderersFactory =
+ object : DefaultRenderersFactory(context) {
+ override fun buildAudioSink(
+ context: Context,
+ enableFloatOutput: Boolean,
+ enableAudioTrackPlaybackParams: Boolean,
+ ): AudioSink = audioSink
+ }.apply {
+ setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
+ // Activer le fallback du décodeur pour une meilleure stabilité
+ setEnableDecoderFallback(true)
+
+ // Sur les appareils problématiques, utiliser des paramètres plus conservateurs
+ if (shouldUseConservativeCodecHandling()) {
+ // On ne peut pas désactiver l'async queueing car la méthode n'existe pas
+ // Mais on peut utiliser le MediaCodecSelector par défaut
+ setMediaCodecSelector(MediaCodecSelector.DEFAULT)
+ }
}
- }
- exoPlayer = ExoPlayer.Builder(context)
- .setRenderersFactory(renderersFactory)
- .setHandleAudioBecomingNoisy(true)
- .setWakeMode(C.WAKE_MODE_LOCAL)
- .setPauseAtEndOfMediaItems(false)
- .setReleaseTimeoutMs(2000) // Augmenter le timeout de libération
- .build()
- .apply {
- playerListener = createPlayerListener()
- addListener(playerListener!!)
- volume = _volume
- }
+ exoPlayer =
+ ExoPlayer
+ .Builder(context)
+ .setRenderersFactory(renderersFactory)
+ .setHandleAudioBecomingNoisy(true)
+ .setWakeMode(C.WAKE_MODE_LOCAL)
+ .setPauseAtEndOfMediaItems(false)
+ .setReleaseTimeoutMs(2000) // Augmenter le timeout de libération
+ .build()
+ .apply {
+ playerListener = createPlayerListener()
+ addListener(playerListener!!)
+ volume = _volume
+ }
}
}
- private fun createPlayerListener() = object : Player.Listener {
- override fun onPlaybackStateChanged(playbackState: Int) {
- // Ajouter une vérification de sécurité
- if (isPlayerReleased) return
+ private fun createPlayerListener() =
+ object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ // Ajouter une vérification de sécurité
+ if (isPlayerReleased) return
- when (playbackState) {
- Player.STATE_BUFFERING -> {
- _isLoading = true
- }
+ when (playbackState) {
+ Player.STATE_BUFFERING -> {
+ _isLoading = true
+ }
- Player.STATE_READY -> {
- _isLoading = false
- exoPlayer?.let { player ->
- if (!isPlayerReleased) {
- _duration = player.duration.toDouble() / 1000.0
- _isPlaying = player.isPlaying
- if (player.isPlaying) startPositionUpdates()
- extractFormatMetadata(player)
+ Player.STATE_READY -> {
+ _isLoading = false
+ exoPlayer?.let { player ->
+ if (!isPlayerReleased) {
+ _duration = player.duration.toDouble() / 1000.0
+ _isPlaying = player.isPlaying
+ if (player.isPlaying) startPositionUpdates()
+ extractFormatMetadata(player)
+ }
}
}
- }
- Player.STATE_ENDED -> {
- _isLoading = false
- stopPositionUpdates()
- _isPlaying = false
- }
+ Player.STATE_ENDED -> {
+ _isLoading = false
+ stopPositionUpdates()
+ _isPlaying = false
+ }
- Player.STATE_IDLE -> {
- _isLoading = false
+ Player.STATE_IDLE -> {
+ _isLoading = false
+ }
}
}
- }
- override fun onIsPlayingChanged(playing: Boolean) {
- if (!isPlayerReleased) {
- _isPlaying = playing
- if (playing) {
- startPositionUpdates()
- } else {
- stopPositionUpdates()
+ override fun onIsPlayingChanged(playing: Boolean) {
+ if (!isPlayerReleased) {
+ _isPlaying = playing
+ if (playing) {
+ startPositionUpdates()
+ } else {
+ stopPositionUpdates()
+ }
}
}
- }
- override fun onVideoSizeChanged(videoSize: VideoSize) {
- if (videoSize.width > 0 && videoSize.height > 0) {
- _aspectRatio = videoSize.width.toFloat() / videoSize.height.toFloat()
- _metadata.width = videoSize.width
- _metadata.height = videoSize.height
+ override fun onVideoSizeChanged(videoSize: VideoSize) {
+ if (videoSize.width > 0 && videoSize.height > 0) {
+ _aspectRatio = videoSize.width.toFloat() / videoSize.height.toFloat()
+ _metadata.width = videoSize.width
+ _metadata.height = videoSize.height
+ }
}
- }
-
- override fun onPlayerError(error: PlaybackException) {
- androidVideoLogger.e { "Player error occurred: ${error.errorCode} - ${error.message}" }
-
- // Créer un rapport d'erreur détaillé
- val errorDetails = mapOf(
- "error_code" to error.errorCode.toString(),
- "error_message" to (error.message ?: "Unknown"),
- "device" to android.os.Build.DEVICE,
- "model" to android.os.Build.MODEL,
- "manufacturer" to android.os.Build.MANUFACTURER,
- "android_version" to android.os.Build.VERSION.SDK_INT.toString(),
- "codec_info" to error.cause?.message
- )
-
- // Log the error details (you can send this to your crash reporting service)
- androidVideoLogger.e { "Detailed error info: $errorDetails" }
- // Gestion des erreurs spécifiques au codec
- when (error.errorCode) {
- PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
- PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> {
- _error = VideoPlayerError.CodecError("Decoder error: ${error.message}")
- // Tenter une récupération pour les erreurs de codec
- attemptPlayerRecovery()
- }
- PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
- PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> {
- _error = VideoPlayerError.NetworkError("Network error: ${error.message}")
- }
- PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
- PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
- _error = VideoPlayerError.SourceError("Invalid media source: ${error.message}")
- }
- else -> {
- _error = VideoPlayerError.UnknownError("Playback error: ${error.message}")
+ override fun onPlayerError(error: PlaybackException) {
+ androidVideoLogger.e { "Player error occurred: ${error.errorCode} - ${error.message}" }
+
+ // Créer un rapport d'erreur détaillé
+ val errorDetails =
+ mapOf(
+ "error_code" to error.errorCode.toString(),
+ "error_message" to (error.message ?: "Unknown"),
+ "device" to android.os.Build.DEVICE,
+ "model" to android.os.Build.MODEL,
+ "manufacturer" to android.os.Build.MANUFACTURER,
+ "android_version" to
+ android.os.Build.VERSION.SDK_INT
+ .toString(),
+ "codec_info" to error.cause?.message,
+ )
+
+ // Log the error details (you can send this to your crash reporting service)
+ androidVideoLogger.e { "Detailed error info: $errorDetails" }
+
+ // Gestion des erreurs spécifiques au codec
+ when (error.errorCode) {
+ PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
+ PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
+ -> {
+ _error = VideoPlayerError.CodecError("Decoder error: ${error.message}")
+ // Tenter une récupération pour les erreurs de codec
+ attemptPlayerRecovery()
+ }
+ PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
+ PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
+ -> {
+ _error = VideoPlayerError.NetworkError("Network error: ${error.message}")
+ }
+ PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
+ PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
+ -> {
+ _error = VideoPlayerError.SourceError("Invalid media source: ${error.message}")
+ }
+ else -> {
+ _error = VideoPlayerError.UnknownError("Playback error: ${error.message}")
+ }
}
+ _isPlaying = false
+ _isLoading = false
}
- _isPlaying = false
- _isLoading = false
}
- }
private fun attemptPlayerRecovery() {
coroutineScope.launch {
@@ -522,19 +524,20 @@ open class DefaultVideoPlayerState: VideoPlayerState {
private fun startPositionUpdates() {
stopPositionUpdates()
- updateJob = coroutineScope.launch {
- while (isActive) {
- exoPlayer?.let { player ->
- if (player.playbackState == Player.STATE_READY && !isPlayerReleased) {
- _currentTime = player.currentPosition.toDouble() / 1000.0
- if (!userDragging && _duration > 0) {
- _sliderPos = (_currentTime / _duration * 1000).toFloat()
+ updateJob =
+ coroutineScope.launch {
+ while (isActive) {
+ exoPlayer?.let { player ->
+ if (player.playbackState == Player.STATE_READY && !isPlayerReleased) {
+ _currentTime = player.currentPosition.toDouble() / 1000.0
+ if (!userDragging && _duration > 0) {
+ _sliderPos = (_currentTime / _duration * 1000).toFloat()
+ }
}
}
+ delay(16) // ~60fps update rate
}
- delay(16) // ~60fps update rate
}
- }
}
private fun stopPositionUpdates() {
@@ -542,24 +545,34 @@ open class DefaultVideoPlayerState: VideoPlayerState {
updateJob = null
}
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
val mediaItemBuilder = MediaItem.Builder().setUri(uri)
val mediaItem = mediaItemBuilder.build()
openFromMediaItem(mediaItem, initializeplayerState)
}
- override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) {
+ override fun openFile(
+ file: PlatformFile,
+ initializeplayerState: InitialPlayerState,
+ ) {
val mediaItemBuilder = MediaItem.Builder()
- val videoUri: Uri = when (val androidFile = file.androidFile) {
- is AndroidFile.UriWrapper -> androidFile.uri
- is AndroidFile.FileWrapper -> Uri.fromFile(androidFile.file)
- }
+ val videoUri: Uri =
+ when (val androidFile = file.androidFile) {
+ is AndroidFile.UriWrapper -> androidFile.uri
+ is AndroidFile.FileWrapper -> Uri.fromFile(androidFile.file)
+ }
mediaItemBuilder.setUri(videoUri)
val mediaItem = mediaItemBuilder.build()
openFromMediaItem(mediaItem, initializeplayerState)
}
- private fun openFromMediaItem(mediaItem: MediaItem, initializeplayerState: InitialPlayerState) {
+ private fun openFromMediaItem(
+ mediaItem: MediaItem,
+ initializeplayerState: InitialPlayerState,
+ ) {
synchronized(playerInitializationLock) {
if (isPlayerReleased) return
@@ -707,8 +720,6 @@ open class DefaultVideoPlayerState: VideoPlayerState {
_currentTime = 0.0
_duration = 0.0
_sliderPos = 0f
- _leftLevel = 0f
- _rightLevel = 0f
_isPlaying = false
_isLoading = false
_error = null
diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt
index de1df643..ee15605a 100644
--- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt
+++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt
@@ -35,14 +35,14 @@ actual fun VideoPlayerSurface(
playerState: VideoPlayerState,
modifier: Modifier,
contentScale: ContentScale,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
VideoPlayerSurfaceInternal(
playerState = playerState,
modifier = modifier,
contentScale = contentScale,
overlay = overlay,
- surfaceType = SurfaceType.TextureView
+ surfaceType = SurfaceType.TextureView,
)
}
@@ -53,14 +53,14 @@ fun VideoPlayerSurface(
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
surfaceType: SurfaceType = SurfaceType.TextureView,
- overlay: @Composable () -> Unit = {}
+ overlay: @Composable () -> Unit = {},
) {
VideoPlayerSurfaceInternal(
playerState = playerState,
modifier = modifier,
contentScale = contentScale,
overlay = overlay,
- surfaceType = surfaceType
+ surfaceType = surfaceType,
)
}
@@ -71,7 +71,7 @@ private fun VideoPlayerSurfaceInternal(
modifier: Modifier,
contentScale: ContentScale,
surfaceType: SurfaceType,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
if (LocalInspectionMode.current) {
VideoPlayerSurfacePreview(modifier = modifier, overlay = overlay)
@@ -101,8 +101,9 @@ private fun VideoPlayerSurfaceInternal(
onDispose {
try {
// Détacher la vue du player
- if (playerState is DefaultVideoPlayerState)
+ if (playerState is DefaultVideoPlayerState) {
playerState.attachPlayerView(null)
+ }
} catch (e: Exception) {
androidVideoLogger.e { "Error detaching PlayerView on dispose: ${e.message}" }
}
@@ -117,11 +118,14 @@ private fun VideoPlayerSurfaceInternal(
isFullscreen = false
// Call playerState.toggleFullscreen() to ensure proper cleanup
playerState.toggleFullscreen()
- }
+ },
) {
- Box(modifier = Modifier
- .fillMaxSize()
- .background(Color.Black)) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ ) {
VideoPlayerContent(
playerState = playerState,
modifier = Modifier.fillMaxHeight(),
@@ -154,15 +158,16 @@ private fun VideoPlayerContent(
) {
Box(
modifier = modifier,
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
if (playerState.hasMedia) {
AndroidView(
- modifier = contentScale.toCanvasModifier(
- playerState.aspectRatio,
- playerState.metadata.width,
- playerState.metadata.height
- ),
+ modifier =
+ contentScale.toCanvasModifier(
+ playerState.aspectRatio,
+ playerState.metadata.width,
+ playerState.metadata.height,
+ ),
factory = { context ->
try {
// Créer PlayerView avec le type de surface approprié
@@ -188,7 +193,6 @@ private fun VideoPlayerContent(
// Désactiver la vue de sous-titres native car nous utilisons des sous-titres basés sur Compose
subtitleView?.visibility = android.view.View.GONE
-
}
} catch (e: Exception) {
androidVideoLogger.e { "Error creating PlayerView: ${e.message}" }
@@ -201,7 +205,10 @@ private fun VideoPlayerContent(
update = { playerView ->
try {
// Vérifier que le player est toujours valide avant la mise à jour
- if (playerState is DefaultVideoPlayerState && playerState.exoPlayer != null && playerView.player != null) {
+ if (playerState is DefaultVideoPlayerState &&
+ playerState.exoPlayer != null &&
+ playerView.player != null
+ ) {
// Mettre à jour le mode de redimensionnement lorsque contentScale change
playerView.resizeMode = mapContentScaleToResizeMode(contentScale)
}
@@ -225,20 +232,22 @@ private fun VideoPlayerContent(
} catch (e: Exception) {
androidVideoLogger.e { "Error releasing PlayerView: ${e.message}" }
}
- }
+ },
)
// Ajouter une couche de sous-titres basée sur Compose
if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) {
// Calculer le temps actuel en millisecondes
- val currentTimeMs = remember(playerState.sliderPos, playerState.durationText) {
- (playerState.sliderPos / 1000f * playerState.durationText.toTimeMs()).toLong()
- }
+ val currentTimeMs =
+ remember(playerState.sliderPos, playerState.durationText) {
+ (playerState.sliderPos / 1000f * playerState.durationText.toTimeMs()).toLong()
+ }
// Calculer la durée en millisecondes
- val durationMs = remember(playerState.durationText) {
- playerState.durationText.toTimeMs()
- }
+ val durationMs =
+ remember(playerState.durationText) {
+ playerState.durationText.toTimeMs()
+ }
ComposeSubtitleLayer(
currentTimeMs = currentTimeMs,
@@ -247,7 +256,7 @@ private fun VideoPlayerContent(
subtitleTrack = playerState.currentSubtitleTrack,
subtitlesEnabled = playerState.subtitlesEnabled,
textStyle = playerState.subtitleTextStyle,
- backgroundColor = playerState.subtitleBackgroundColor
+ backgroundColor = playerState.subtitleBackgroundColor,
)
}
}
@@ -261,8 +270,8 @@ private fun VideoPlayerContent(
}
@OptIn(UnstableApi::class)
-private fun mapContentScaleToResizeMode(contentScale: ContentScale): Int {
- return when (contentScale) {
+private fun mapContentScaleToResizeMode(contentScale: ContentScale): Int =
+ when (contentScale) {
ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
ContentScale.FillBounds -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ContentScale.Fit, ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_FIT
@@ -270,19 +279,19 @@ private fun mapContentScaleToResizeMode(contentScale: ContentScale): Int {
ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
}
-}
@OptIn(UnstableApi::class)
private fun createPlayerViewWithSurfaceType(
context: Context,
- surfaceType: SurfaceType
-): PlayerView {
- return try {
+ surfaceType: SurfaceType,
+): PlayerView =
+ try {
// Essayer d'abord d'inflater les layouts personnalisés
- val layoutId = when (surfaceType) {
- SurfaceType.SurfaceView -> R.layout.player_view_surface
- SurfaceType.TextureView -> R.layout.player_view_texture
- }
+ val layoutId =
+ when (surfaceType) {
+ SurfaceType.SurfaceView -> R.layout.player_view_surface
+ SurfaceType.TextureView -> R.layout.player_view_texture
+ }
LayoutInflater.from(context).inflate(layoutId, null) as PlayerView
} catch (e: Exception) {
@@ -322,4 +331,3 @@ private fun createPlayerViewWithSurfaceType(
throw e2
}
}
-}
diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.android.kt
index 020a0689..316b6f3a 100644
--- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.android.kt
+++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.android.kt
@@ -17,57 +17,59 @@ import java.net.URL
* @param src The source URI of the subtitle file
* @return The content of the subtitle file as a string
*/
-actual suspend fun loadSubtitleContent(src: String): String = withContext(Dispatchers.IO) {
- try {
- when {
- // Handle HTTP/HTTPS URLs
- src.startsWith("http://") || src.startsWith("https://") -> {
- val url = URL(src)
- val connection = url.openConnection() as HttpURLConnection
- connection.connectTimeout = 10000
- connection.readTimeout = 10000
-
- val reader = BufferedReader(InputStreamReader(connection.inputStream))
- val content = reader.use { it.readText() }
- connection.disconnect()
- content
- }
-
- // Handle content:// URIs
- src.startsWith("content://") -> {
- val context = ContextProvider.getContext()
- val uri = Uri.parse(src)
- context.contentResolver.openInputStream(uri)?.use { inputStream ->
- inputStream.bufferedReader().use { it.readText() }
- } ?: ""
- }
-
- // Handle file:// URIs or local file paths
- else -> {
- val context = ContextProvider.getContext()
- val uri = if (src.startsWith("file://")) {
- Uri.parse(src)
- } else {
- Uri.fromFile(java.io.File(src))
+actual suspend fun loadSubtitleContent(src: String): String =
+ withContext(Dispatchers.IO) {
+ try {
+ when {
+ // Handle HTTP/HTTPS URLs
+ src.startsWith("http://") || src.startsWith("https://") -> {
+ val url = URL(src)
+ val connection = url.openConnection() as HttpURLConnection
+ connection.connectTimeout = 10000
+ connection.readTimeout = 10000
+
+ val reader = BufferedReader(InputStreamReader(connection.inputStream))
+ val content = reader.use { it.readText() }
+ connection.disconnect()
+ content
}
-
- try {
+
+ // Handle content:// URIs
+ src.startsWith("content://") -> {
+ val context = ContextProvider.getContext()
+ val uri = Uri.parse(src)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().use { it.readText() }
} ?: ""
- } catch (e: Exception) {
- // Fallback to direct file access if content resolver fails
+ }
+
+ // Handle file:// URIs or local file paths
+ else -> {
+ val context = ContextProvider.getContext()
+ val uri =
+ if (src.startsWith("file://")) {
+ Uri.parse(src)
+ } else {
+ Uri.fromFile(java.io.File(src))
+ }
+
try {
- java.io.File(src).readText()
- } catch (e2: Exception) {
- androidVideoLogger.e { "Failed to load subtitle file: ${e2.message}" }
- ""
+ context.contentResolver.openInputStream(uri)?.use { inputStream ->
+ inputStream.bufferedReader().use { it.readText() }
+ } ?: ""
+ } catch (e: Exception) {
+ // Fallback to direct file access if content resolver fails
+ try {
+ java.io.File(src).readText()
+ } catch (e2: Exception) {
+ androidVideoLogger.e { "Failed to load subtitle file: ${e2.message}" }
+ ""
+ }
}
}
}
+ } catch (e: Exception) {
+ androidVideoLogger.e { "Error loading subtitle content: ${e.message}" }
+ ""
}
- } catch (e: Exception) {
- androidVideoLogger.e { "Error loading subtitle content: ${e.message}" }
- ""
}
-}
\ No newline at end of file
diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
index 683f3c41..0e5ac19c 100644
--- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
+++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
@@ -3,9 +3,8 @@ package io.github.kdroidfilter.composemediaplayer.util
import io.github.vinceglb.filekit.AndroidFile
import io.github.vinceglb.filekit.PlatformFile
-actual fun PlatformFile.getUri(): String {
- return when (val androidFile = this.androidFile) {
+actual fun PlatformFile.getUri(): String =
+ when (val androidFile = this.androidFile) {
is AndroidFile.UriWrapper -> androidFile.uri.toString()
is AndroidFile.FileWrapper -> androidFile.file.path
}
-}
\ No newline at end of file
diff --git a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt
index 17d1c7d6..693f9860 100644
--- a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt
+++ b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt
@@ -1,13 +1,13 @@
package io.github.kdroidfilter.composemediaplayer
-import kotlin.test.AfterTest
-import kotlin.test.BeforeTest
-import kotlin.test.Test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class VideoPlayerStatePreviewTest {
@@ -29,4 +29,3 @@ class VideoPlayerStatePreviewTest {
playerState.dispose()
}
}
-
diff --git a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
index 143dae0b..60522483 100644
--- a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
+++ b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
@@ -11,7 +11,6 @@ import kotlin.test.assertTrue
* Tests for the Android implementation of VideoPlayerState
*/
class VideoPlayerStateTest {
-
/**
* Helper function to check if ContextProvider is available and initialized
* If not, the test will be skipped
@@ -50,8 +49,6 @@ class VideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
// Clean up
@@ -169,11 +166,12 @@ class VideoPlayerStateTest {
assertTrue(playerState.availableSubtitleTracks.isEmpty())
// Create a test subtitle track
- val testTrack = SubtitleTrack(
- label = "English",
- language = "en",
- src = "test.vtt"
- )
+ val testTrack =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "test.vtt",
+ )
// Select the subtitle track
playerState.selectSubtitleTrack(testTrack)
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/InitialPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/InitialPlayerState.kt
index df114e23..c4edea59 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/InitialPlayerState.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/InitialPlayerState.kt
@@ -12,5 +12,5 @@ enum class InitialPlayerState {
/**
* The player will remain paused after opening the media.
*/
- PAUSE
-}
\ No newline at end of file
+ PAUSE,
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrack.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrack.kt
index ab507bca..17047aab 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrack.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrack.kt
@@ -2,10 +2,9 @@ package io.github.kdroidfilter.composemediaplayer
import androidx.compose.runtime.Stable
-
@Stable
data class SubtitleTrack(
val label: String,
val language: String,
- val src: String
+ val src: String,
)
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadata.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadata.kt
index 0f0d01f6..eb5764f9 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadata.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadata.kt
@@ -2,7 +2,6 @@ package io.github.kdroidfilter.composemediaplayer
import androidx.compose.runtime.Stable
-
/**
* Represents metadata information of a video file.
*
@@ -38,15 +37,14 @@ data class VideoMetadata(
*
* @return true if all properties are null, false otherwise.
*/
- fun isAllNull(): Boolean {
- return title == null &&
- duration == null &&
- width == null &&
- height == null &&
- bitrate == null &&
- frameRate == null &&
- mimeType == null &&
- audioChannels == null &&
- audioSampleRate == null
- }
+ fun isAllNull(): Boolean =
+ title == null &&
+ duration == null &&
+ width == null &&
+ height == null &&
+ bitrate == null &&
+ frameRate == null &&
+ mimeType == null &&
+ audioChannels == null &&
+ audioSampleRate == null
}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerError.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerError.kt
index cd1be110..e95f44c0 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerError.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerError.kt
@@ -1,6 +1,5 @@
package io.github.kdroidfilter.composemediaplayer
-
/**
* Represents different types of errors that can occur during video playback in a video player.
*
@@ -14,8 +13,19 @@ package io.github.kdroidfilter.composemediaplayer
* - `UnknownError`: Covers any issues that do not fit into the other categories.
*/
sealed class VideoPlayerError {
- data class CodecError(val message: String): VideoPlayerError()
- data class NetworkError(val message: String): VideoPlayerError()
- data class SourceError(val message: String): VideoPlayerError()
- data class UnknownError(val message: String): VideoPlayerError()
-}
\ No newline at end of file
+ data class CodecError(
+ val message: String,
+ ) : VideoPlayerError()
+
+ data class NetworkError(
+ val message: String,
+ ) : VideoPlayerError()
+
+ data class SourceError(
+ val message: String,
+ ) : VideoPlayerError()
+
+ data class UnknownError(
+ val message: String,
+ ) : VideoPlayerError()
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
index 59783d37..7dfd67f4 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
@@ -5,7 +5,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.TextStyle
import io.github.vinceglb.filekit.PlatformFile
@@ -22,7 +21,6 @@ import io.github.vinceglb.filekit.PlatformFile
*/
@Stable
interface VideoPlayerState {
-
// Properties related to media state
val hasMedia: Boolean
@@ -58,16 +56,6 @@ interface VideoPlayerState {
const val MAX_PLAYBACK_SPEED = 2.0f
}
- /**
- * Provides the audio level for the left channel as a percentage.
- */
- val leftLevel: Float
-
- /**
- * Provides the audio level for the right channel as a percentage.
- */
- val rightLevel: Float
-
/**
* Returns the current playback position as a formatted string.
*/
@@ -82,6 +70,7 @@ interface VideoPlayerState {
val aspectRatio: Float
// Functions to control playback
+
/**
* Starts or resumes video playback.
*/
@@ -101,17 +90,27 @@ interface VideoPlayerState {
* Seeks to a specific playback position based on the provided normalized value.
*/
fun seekTo(value: Float)
+
fun toggleFullscreen()
// Functions to manage media sources
+
/**
* Opens a video file or URL for playback.
*/
- fun openUri(uri: String, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY)
- fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY)
+ fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY,
+ )
+
+ fun openFile(
+ file: PlatformFile,
+ initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY,
+ )
// Error handling
val error: VideoPlayerError?
+
fun clearError()
// Metadata
@@ -123,10 +122,13 @@ interface VideoPlayerState {
val availableSubtitleTracks: MutableList
var subtitleTextStyle: TextStyle
var subtitleBackgroundColor: Color
+
fun selectSubtitleTrack(track: SubtitleTrack?)
+
fun disableSubtitles()
// Cleanup
+
/**
* Releases resources used by the video player and disposes of the state.
*/
@@ -170,8 +172,6 @@ data class PreviewableVideoPlayerState(
override var userDragging: Boolean = false,
override var loop: Boolean = true,
override var playbackSpeed: Float = 1f,
- override val leftLevel: Float = 1f,
- override val rightLevel: Float = 1f,
override val positionText: String = "00:05",
override val durationText: String = "00:10",
override val currentTime: Double = 5000.0,
@@ -186,20 +186,30 @@ data class PreviewableVideoPlayerState(
override var subtitleBackgroundColor: Color = Color.Transparent,
) : VideoPlayerState {
override fun play() {}
+
override fun pause() {}
+
override fun stop() {}
+
override fun seekTo(value: Float) {}
+
override fun toggleFullscreen() {}
+
override fun openUri(
uri: String,
- initializeplayerState: InitialPlayerState
+ initializeplayerState: InitialPlayerState,
) {}
+
override fun openFile(
file: PlatformFile,
- initializeplayerState: InitialPlayerState
+ initializeplayerState: InitialPlayerState,
) {}
+
override fun clearError() {}
+
override fun selectSubtitleTrack(track: SubtitleTrack?) {}
+
override fun disableSubtitles() {}
+
override fun dispose() {}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt
index 480ee2c8..556db8a5 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt
@@ -19,8 +19,8 @@ import androidx.compose.ui.layout.ContentScale
*/
@Composable
expect fun VideoPlayerSurface(
- playerState: VideoPlayerState,
+ playerState: VideoPlayerState,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
- overlay: @Composable () -> Unit = {}
+ overlay: @Composable () -> Unit = {},
)
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfacePreview.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfacePreview.kt
index ab245a86..2f208c05 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfacePreview.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfacePreview.kt
@@ -18,11 +18,11 @@ internal fun VideoPlayerSurfacePreview(
) {
Box(
modifier = modifier.background(Color.Black.copy(alpha = 0.08f)),
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
BasicText(
text = message,
- style = TextStyle(color = Color.Gray)
+ style = TextStyle(color = Color.Gray),
)
Box(modifier = Modifier.fillMaxSize()) {
overlay()
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/ComposeSubtitleLayer.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/ComposeSubtitleLayer.kt
index 65c0a39a..d1ed00ce 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/ComposeSubtitleLayer.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/ComposeSubtitleLayer.kt
@@ -36,64 +36,67 @@ fun ComposeSubtitleLayer(
subtitleTrack: SubtitleTrack?,
subtitlesEnabled: Boolean,
modifier: Modifier = Modifier,
- textStyle: TextStyle = TextStyle(
- color = Color.White,
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- ),
- backgroundColor: Color = Color.Black.copy(alpha = 0.5f)
+ textStyle: TextStyle =
+ TextStyle(
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Normal,
+ textAlign = TextAlign.Center,
+ ),
+ backgroundColor: Color = Color.Black.copy(alpha = 0.5f),
) {
// State to hold the parsed subtitle cues
var subtitles by remember { mutableStateOf(null) }
// Load subtitles when the subtitle track changes
LaunchedEffect(subtitleTrack) {
- subtitles = if (subtitleTrack != null && subtitlesEnabled) {
- try {
- withContext(Dispatchers.Default) {
- // Load and parse the subtitle file
- val content = loadSubtitleContent(subtitleTrack.src)
+ subtitles =
+ if (subtitleTrack != null && subtitlesEnabled) {
+ try {
+ withContext(Dispatchers.Default) {
+ // Load and parse the subtitle file
+ val content = loadSubtitleContent(subtitleTrack.src)
- // Determine the subtitle format based on file extension and content
- val isSrtByExtension = subtitleTrack.src.endsWith(".srt", ignoreCase = true)
+ // Determine the subtitle format based on file extension and content
+ val isSrtByExtension = subtitleTrack.src.endsWith(".srt", ignoreCase = true)
- // Check content for SRT format (typically starts with a number followed by timing)
- val isSrtByContent = content.trim().let {
- val lines = it.lines()
- lines.size >= 2 &&
- lines[0].trim().toIntOrNull() != null &&
- lines[1].contains("-->") &&
- lines[1].contains(",") // SRT uses comma for milliseconds
- }
+ // Check content for SRT format (typically starts with a number followed by timing)
+ val isSrtByContent =
+ content.trim().let {
+ val lines = it.lines()
+ lines.size >= 2 &&
+ lines[0].trim().toIntOrNull() != null &&
+ lines[1].contains("-->") &&
+ lines[1].contains(",") // SRT uses comma for milliseconds
+ }
- // Check content for WebVTT format (starts with WEBVTT)
- val isVttByContent = content.trim().startsWith("WEBVTT")
+ // Check content for WebVTT format (starts with WEBVTT)
+ val isVttByContent = content.trim().startsWith("WEBVTT")
- // Use the appropriate parser based on format detection
- if (isSrtByExtension || (isSrtByContent && !isVttByContent)) {
- SrtParser.parse(content)
- } else {
- // Default to WebVTT parser for other formats
- WebVttParser.parse(content)
+ // Use the appropriate parser based on format detection
+ if (isSrtByExtension || (isSrtByContent && !isVttByContent)) {
+ SrtParser.parse(content)
+ } else {
+ // Default to WebVTT parser for other formats
+ WebVttParser.parse(content)
+ }
}
+ } catch (e: Exception) {
+ // If there's an error loading or parsing the subtitle file,
+ // return an empty subtitle list
+ SubtitleCueList()
}
- } catch (e: Exception) {
- // If there's an error loading or parsing the subtitle file,
- // return an empty subtitle list
- SubtitleCueList()
+ } else {
+ // If no subtitle track is selected or subtitles are disabled,
+ // return null to hide the subtitle display
+ null
}
- } else {
- // If no subtitle track is selected or subtitles are disabled,
- // return null to hide the subtitle display
- null
- }
}
// Display the subtitles if available
Box(
modifier = modifier.fillMaxSize(),
- contentAlignment = Alignment.BottomCenter
+ contentAlignment = Alignment.BottomCenter,
) {
subtitles?.let { cueList ->
if (subtitlesEnabled) {
@@ -102,14 +105,13 @@ fun ComposeSubtitleLayer(
currentTimeMs = currentTimeMs,
isPlaying = isPlaying,
textStyle = textStyle,
- backgroundColor = backgroundColor
+ backgroundColor = backgroundColor,
)
}
}
}
}
-
/**
* Loads the content of a subtitle file from the given source.
* This is implemented in a platform-specific way.
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParser.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParser.kt
index b9ca15e2..d5b8b713 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParser.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParser.kt
@@ -10,6 +10,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
object SrtParser {
// SRT time format uses comma instead of period for milliseconds: "00:00:00,000"
private val TIME_PATTERN = Regex("(\\d{2}):(\\d{2}):(\\d{2}),(\\d{3})")
+
// SRT timing line format: "00:00:00,000 --> 00:00:00,000"
private val CUE_TIMING_PATTERN = Regex("(\\d{2}:\\d{2}:\\d{2},\\d{3}) --> (\\d{2}:\\d{2}:\\d{2},\\d{3})")
@@ -114,8 +115,8 @@ object SrtParser {
* @return A SubtitleCueList containing the parsed subtitle cues
*/
@OptIn(ExperimentalEncodingApi::class)
- suspend fun loadFromUrl(url: String): SubtitleCueList {
- return withContext(Dispatchers.Default) {
+ suspend fun loadFromUrl(url: String): SubtitleCueList =
+ withContext(Dispatchers.Default) {
try {
// Use the platform-specific loadSubtitleContent function to fetch the content
val content = loadSubtitleContent(url)
@@ -124,5 +125,4 @@ object SrtParser {
SubtitleCueList() // Return empty list on error
}
}
- }
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleCue.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleCue.kt
index 86e69c75..7378f4f6 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleCue.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleCue.kt
@@ -13,7 +13,7 @@ import androidx.compose.runtime.Immutable
data class SubtitleCue(
val startTime: Long,
val endTime: Long,
- val text: String
+ val text: String,
) {
/**
* Checks if this subtitle cue should be displayed at the given time.
@@ -21,9 +21,7 @@ data class SubtitleCue(
* @param currentTimeMs The current playback time in milliseconds
* @return True if the cue should be displayed, false otherwise
*/
- fun isActive(currentTimeMs: Long): Boolean {
- return currentTimeMs in startTime..endTime
- }
+ fun isActive(currentTimeMs: Long): Boolean = currentTimeMs in startTime..endTime
}
/**
@@ -33,7 +31,7 @@ data class SubtitleCue(
*/
@Immutable
data class SubtitleCueList(
- val cues: List = emptyList()
+ val cues: List = emptyList(),
) {
/**
* Gets the active subtitle cues at the given time.
@@ -41,7 +39,5 @@ data class SubtitleCueList(
* @param currentTimeMs The current playback time in milliseconds
* @return The list of active subtitle cues
*/
- fun getActiveCues(currentTimeMs: Long): List {
- return cues.filter { it.isActive(currentTimeMs) }
- }
-}
\ No newline at end of file
+ fun getActiveCues(currentTimeMs: Long): List = cues.filter { it.isActive(currentTimeMs) }
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleDisplay.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleDisplay.kt
index 27aa27fa..7c0788e7 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleDisplay.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleDisplay.kt
@@ -37,23 +37,25 @@ fun SubtitleDisplay(
subtitles: SubtitleCueList,
currentTimeMs: Long,
modifier: Modifier = Modifier,
- textStyle: TextStyle = TextStyle(
- color = Color.White,
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- ),
- backgroundColor: Color = Color.Black.copy(alpha = 0.5f)
+ textStyle: TextStyle =
+ TextStyle(
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Normal,
+ textAlign = TextAlign.Center,
+ ),
+ backgroundColor: Color = Color.Black.copy(alpha = 0.5f),
) {
// Get active cues at the current time
val activeCues = subtitles.getActiveCues(currentTimeMs)
if (activeCues.isNotEmpty()) {
Box(
- modifier = modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
- contentAlignment = Alignment.BottomCenter
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ contentAlignment = Alignment.BottomCenter,
) {
// Join all active cue texts with line breaks
val subtitleText = activeCues.joinToString("\n") { it.text }
@@ -61,9 +63,10 @@ fun SubtitleDisplay(
BasicText(
text = subtitleText,
style = textStyle,
- modifier = Modifier
- .background(backgroundColor, shape = RoundedCornerShape(4.dp))
- .padding(horizontal = 8.dp, vertical = 4.dp)
+ modifier =
+ Modifier
+ .background(backgroundColor, shape = RoundedCornerShape(4.dp))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@@ -86,13 +89,14 @@ fun AutoUpdatingSubtitleDisplay(
currentTimeMs: Long,
isPlaying: Boolean,
modifier: Modifier = Modifier,
- textStyle: TextStyle = TextStyle(
- color = Color.White,
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- ),
- backgroundColor: Color = Color.Black.copy(alpha = 0.5f)
+ textStyle: TextStyle =
+ TextStyle(
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Normal,
+ textAlign = TextAlign.Center,
+ ),
+ backgroundColor: Color = Color.Black.copy(alpha = 0.5f),
) {
var displayTimeMs by remember { mutableStateOf(currentTimeMs) }
@@ -119,6 +123,6 @@ fun AutoUpdatingSubtitleDisplay(
currentTimeMs = displayTimeMs,
modifier = modifier,
textStyle = textStyle,
- backgroundColor = backgroundColor
+ backgroundColor = backgroundColor,
)
}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/WebVttParser.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/WebVttParser.kt
index 53c31612..d0344447 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/WebVttParser.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/WebVttParser.kt
@@ -9,11 +9,16 @@ import kotlin.io.encoding.ExperimentalEncodingApi
*/
object WebVttParser {
private const val WEBVTT_HEADER = "WEBVTT"
+
// Support both formats: "00:00:00.000" (with hours) and "00:00.000" (without hours)
private val TIME_PATTERN_WITH_HOURS = Regex("(\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})")
private val TIME_PATTERN_WITHOUT_HOURS = Regex("(\\d{2}):(\\d{2})\\.(\\d{3})")
+
// Support both formats in the timing line
- private val CUE_TIMING_PATTERN = Regex("(\\d{2}:\\d{2}:\\d{2}\\.\\d{3}|\\d{2}:\\d{2}\\.\\d{3}) --> (\\d{2}:\\d{2}:\\d{2}\\.\\d{3}|\\d{2}:\\d{2}\\.\\d{3})")
+ private val CUE_TIMING_PATTERN =
+ Regex(
+ "(\\d{2}:\\d{2}:\\d{2}\\.\\d{3}|\\d{2}:\\d{2}\\.\\d{3}) --> (\\d{2}:\\d{2}:\\d{2}\\.\\d{3}|\\d{2}:\\d{2}\\.\\d{3})",
+ )
/**
* Parses a WebVTT file content into a SubtitleCueList.
@@ -108,8 +113,8 @@ object WebVttParser {
* @return A SubtitleCueList containing the parsed subtitle cues
*/
@OptIn(ExperimentalEncodingApi::class)
- suspend fun loadFromUrl(url: String): SubtitleCueList {
- return withContext(Dispatchers.Default) {
+ suspend fun loadFromUrl(url: String): SubtitleCueList =
+ withContext(Dispatchers.Default) {
try {
// Use the platform-specific loadSubtitleContent function to fetch the content
val content = loadSubtitleContent(url)
@@ -118,5 +123,4 @@ object WebVttParser {
SubtitleCueList() // Return empty list on error
}
}
- }
}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Constants.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Constants.kt
index 9e113a04..452851a4 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Constants.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Constants.kt
@@ -1,3 +1,3 @@
package io.github.kdroidfilter.composemediaplayer.util
-internal const val DEFAULT_ASPECT_RATIO = 16f / 9f
\ No newline at end of file
+internal const val DEFAULT_ASPECT_RATIO = 16f / 9f
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/ContentScaleCanvasUtils.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/ContentScaleCanvasUtils.kt
index 8ad32a71..1845d4e6 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/ContentScaleCanvasUtils.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/ContentScaleCanvasUtils.kt
@@ -20,39 +20,46 @@ import androidx.compose.ui.unit.dp
* @return A [Modifier] instance configured according to the given [ContentScale] and parameters.
*/
@Composable
-internal fun ContentScale.toCanvasModifier(aspectRatio: Float, width: Int?, height : Int?) : Modifier = when (this) {
- ContentScale.Fit,
- ContentScale.Inside ->
- Modifier
- .fillMaxHeight()
- .aspectRatio(aspectRatio)
+internal fun ContentScale.toCanvasModifier(
+ aspectRatio: Float,
+ width: Int?,
+ height: Int?,
+): Modifier =
+ when (this) {
+ ContentScale.Fit,
+ ContentScale.Inside,
+ ->
+ Modifier
+ .fillMaxHeight()
+ .aspectRatio(aspectRatio)
- // ↳ Fills the entire width, ratio preserved
- ContentScale.FillWidth ->
- Modifier
- .fillMaxWidth()
- .aspectRatio(aspectRatio)
+ // ↳ Fills the entire width, ratio preserved
+ ContentScale.FillWidth ->
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(aspectRatio)
- // ↳ Fills the entire height, ratio preserved
- ContentScale.FillHeight ->
- Modifier
- .fillMaxHeight()
- .aspectRatio(aspectRatio)
+ // ↳ Fills the entire height, ratio preserved
+ ContentScale.FillHeight ->
+ Modifier
+ .fillMaxHeight()
+ .aspectRatio(aspectRatio)
- // ↳ Fills the entire container; the excess will be clipped in drawImage
- ContentScale.Crop,
- ContentScale.FillBounds ->
- Modifier.fillMaxSize()
+ // ↳ Fills the entire container; the excess will be clipped in drawImage
+ ContentScale.Crop,
+ ContentScale.FillBounds,
+ ->
+ Modifier.fillMaxSize()
- // ↳ No resizing: we use the actual size of the media
- ContentScale.None ->
- Modifier
- .width((width ?: 0).dp)
- .height((height ?: 0).dp)
+ // ↳ No resizing: we use the actual size of the media
+ ContentScale.None ->
+ Modifier
+ .width((width ?: 0).dp)
+ .height((height ?: 0).dp)
- // ↳ Fallback value (should be impossible)
- else -> Modifier
-}
+ // ↳ Fallback value (should be impossible)
+ else -> Modifier
+ }
/**
* Draws [image] in this [DrawScope] respecting the requested [contentScale].
@@ -64,7 +71,7 @@ internal fun ContentScale.toCanvasModifier(aspectRatio: Float, width: Int?, heig
internal fun DrawScope.drawScaledImage(
image: ImageBitmap,
dstSize: IntSize,
- contentScale: ContentScale
+ contentScale: ContentScale,
) {
if (contentScale == ContentScale.Crop) {
/* --------------------------------------------------------------
@@ -75,22 +82,23 @@ internal fun DrawScope.drawScaledImage(
val frameH = image.height
// Scale factor so that the image fully covers dstSize
- val scale = maxOf(
- dstSize.width / frameW.toFloat(),
- dstSize.height / frameH.toFloat()
- )
+ val scale =
+ maxOf(
+ dstSize.width / frameW.toFloat(),
+ dstSize.height / frameH.toFloat(),
+ )
// Visible area of the source bitmap after the covering scale
- val srcW = (dstSize.width / scale).toInt()
+ val srcW = (dstSize.width / scale).toInt()
val srcH = (dstSize.height / scale).toInt()
val srcX = ((frameW - srcW) / 2).coerceAtLeast(0)
val srcY = ((frameH - srcH) / 2).coerceAtLeast(0)
drawImage(
- image = image,
- srcOffset = IntOffset(srcX, srcY),
- srcSize = IntSize(srcW, srcH),
- dstSize = dstSize // draw into full destination rect
+ image = image,
+ srcOffset = IntOffset(srcX, srcY),
+ srcSize = IntSize(srcW, srcH),
+ dstSize = dstSize, // draw into full destination rect
)
} else {
/* --------------------------------------------------------------
@@ -99,8 +107,8 @@ internal fun DrawScope.drawScaledImage(
* graphicsLayer / Modifier.size, so we just draw the full bitmap.
* -------------------------------------------------------------- */
drawImage(
- image = image,
- dstSize = dstSize
+ image = image,
+ dstSize = dstSize,
)
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/FullScreenLayout.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/FullScreenLayout.kt
index 43cf2eaf..494a8b4a 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/FullScreenLayout.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/FullScreenLayout.kt
@@ -13,15 +13,16 @@ import androidx.compose.ui.window.DialogProperties
internal fun FullScreenLayout(
modifier: Modifier = Modifier,
onDismissRequest: () -> Unit = {},
- content: @Composable () -> Unit
+ content: @Composable () -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
- properties = DialogProperties(
- usePlatformDefaultWidth = false,
- dismissOnBackPress = false,
- dismissOnClickOutside = false
- )
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = false,
+ dismissOnClickOutside = false,
+ ),
) {
Box(modifier = modifier.fillMaxSize().background(Color.Black)) {
content()
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Logger.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Logger.kt
new file mode 100644
index 00000000..8d19e3da
--- /dev/null
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Logger.kt
@@ -0,0 +1,104 @@
+package io.github.kdroidfilter.composemediaplayer.util
+
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.jvm.JvmField
+import kotlin.time.Clock
+
+/**
+ * Logging level hierarchy for ComposeMediaPlayer internal logging.
+ */
+class ComposeMediaPlayerLoggingLevel private constructor(
+ private val priority: Int,
+) : Comparable {
+ override fun compareTo(other: ComposeMediaPlayerLoggingLevel): Int = priority.compareTo(other.priority)
+
+ companion object {
+ @JvmField
+ val VERBOSE = ComposeMediaPlayerLoggingLevel(0)
+
+ @JvmField val DEBUG = ComposeMediaPlayerLoggingLevel(1)
+
+ @JvmField val INFO = ComposeMediaPlayerLoggingLevel(2)
+
+ @JvmField val WARN = ComposeMediaPlayerLoggingLevel(3)
+
+ @JvmField val ERROR = ComposeMediaPlayerLoggingLevel(4)
+ }
+}
+
+/** Global switch — set to `true` to enable ComposeMediaPlayer internal logging. */
+var allowComposeMediaPlayerLogging: Boolean = false
+
+/** Minimum severity to emit. Messages below this level are discarded. */
+var composeMediaPlayerLoggingLevel: ComposeMediaPlayerLoggingLevel =
+ ComposeMediaPlayerLoggingLevel.VERBOSE
+
+private fun getCurrentTimestamp(): String {
+ val now =
+ kotlin.time.Clock.System
+ .now()
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ return "${now.date} ${now.hour.pad()}:${now.minute.pad()}:${now.second.pad()}" +
+ ".${(now.nanosecond / 1_000_000).pad(3)}"
+}
+
+private fun Int.pad(len: Int = 2): String = toString().padStart(len, '0')
+
+// -- Tagged logger ----------------------------------------------------------
+
+internal class TaggedLogger(
+ private val tag: String,
+) {
+ fun v(message: () -> String) = verboseln { "[$tag] ${message()}" }
+
+ fun d(message: () -> String) = debugln { "[$tag] ${message()}" }
+
+ fun i(message: () -> String) = infoln { "[$tag] ${message()}" }
+
+ fun w(message: () -> String) = warnln { "[$tag] ${message()}" }
+
+ fun e(message: () -> String) = errorln { "[$tag] ${message()}" }
+}
+
+// -- Top-level logging functions --------------------------------------------
+
+internal fun verboseln(message: () -> String) {
+ if (allowComposeMediaPlayerLogging &&
+ composeMediaPlayerLoggingLevel <= ComposeMediaPlayerLoggingLevel.VERBOSE
+ ) {
+ println("[${getCurrentTimestamp()}] V: ${message()}")
+ }
+}
+
+internal fun debugln(message: () -> String) {
+ if (allowComposeMediaPlayerLogging &&
+ composeMediaPlayerLoggingLevel <= ComposeMediaPlayerLoggingLevel.DEBUG
+ ) {
+ println("[${getCurrentTimestamp()}] D: ${message()}")
+ }
+}
+
+internal fun infoln(message: () -> String) {
+ if (allowComposeMediaPlayerLogging &&
+ composeMediaPlayerLoggingLevel <= ComposeMediaPlayerLoggingLevel.INFO
+ ) {
+ println("[${getCurrentTimestamp()}] I: ${message()}")
+ }
+}
+
+internal fun warnln(message: () -> String) {
+ if (allowComposeMediaPlayerLogging &&
+ composeMediaPlayerLoggingLevel <= ComposeMediaPlayerLoggingLevel.WARN
+ ) {
+ println("[${getCurrentTimestamp()}] W: ${message()}")
+ }
+}
+
+internal fun errorln(message: () -> String) {
+ if (allowComposeMediaPlayerLogging &&
+ composeMediaPlayerLoggingLevel <= ComposeMediaPlayerLoggingLevel.ERROR
+ ) {
+ println("[${getCurrentTimestamp()}] E: ${message()}")
+ }
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtils.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtils.kt
index fdaae2be..697d914a 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtils.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtils.kt
@@ -1,6 +1,5 @@
package io.github.kdroidfilter.composemediaplayer.util
-
/**
* Formats a given time into either "HH:MM:SS" (if hours > 0) or "MM:SS".
*
@@ -8,13 +7,17 @@ package io.github.kdroidfilter.composemediaplayer.util
* if interpreting nanoseconds, pass as Long).
* @param isNanoseconds Set to true when you're passing nanoseconds (Long) for [value].
*/
-internal fun formatTime(value: Number, isNanoseconds: Boolean = false): String {
+internal fun formatTime(
+ value: Number,
+ isNanoseconds: Boolean = false,
+): String {
// Convert the input to seconds (Double) if it's nanoseconds
- val totalSeconds = if (isNanoseconds) {
- value.toLong() / 1_000_000_000.0
- } else {
- value.toDouble()
- }
+ val totalSeconds =
+ if (isNanoseconds) {
+ value.toLong() / 1_000_000_000.0
+ } else {
+ value.toDouble()
+ }
// Calculate hours, minutes, and seconds directly from total seconds
// This handles large time values correctly without date-time wrapping
@@ -25,7 +28,10 @@ internal fun formatTime(value: Number, isNanoseconds: Boolean = false): String {
// Build the final string
return if (hours > 0) {
- "${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
+ "${hours.toString().padStart(
+ 2,
+ '0',
+ )}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
} else {
"${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
}
diff --git a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrackTest.kt b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrackTest.kt
index ae2e0f83..af4bf097 100644
--- a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrackTest.kt
+++ b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/SubtitleTrackTest.kt
@@ -6,14 +6,14 @@ import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class SubtitleTrackTest {
-
@Test
fun testSubtitleTrackCreation() {
- val track = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/en.vtt"
- )
+ val track =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/en.vtt",
+ )
assertEquals("English", track.label)
assertEquals("en", track.language)
@@ -22,23 +22,26 @@ class SubtitleTrackTest {
@Test
fun testSubtitleTrackEquality() {
- val track1 = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/en.vtt"
- )
+ val track1 =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/en.vtt",
+ )
- val track2 = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/en.vtt"
- )
+ val track2 =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/en.vtt",
+ )
- val track3 = SubtitleTrack(
- label = "French",
- language = "fr",
- src = "subtitles/fr.vtt"
- )
+ val track3 =
+ SubtitleTrack(
+ label = "French",
+ language = "fr",
+ src = "subtitles/fr.vtt",
+ )
assertEquals(track1, track2, "Identical subtitle tracks should be equal")
assertNotEquals(track1, track3, "Different subtitle tracks should not be equal")
@@ -46,11 +49,12 @@ class SubtitleTrackTest {
@Test
fun testSubtitleTrackCopy() {
- val original = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/en.vtt"
- )
+ val original =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/en.vtt",
+ )
val copy = original.copy(label = "English (US)")
@@ -64,11 +68,12 @@ class SubtitleTrackTest {
@Test
fun testSubtitleTrackToString() {
- val track = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/en.vtt"
- )
+ val track =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/en.vtt",
+ )
val toString = track.toString()
diff --git a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadataTest.kt b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadataTest.kt
index f3c4533a..90038ba8 100644
--- a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadataTest.kt
+++ b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoMetadataTest.kt
@@ -6,22 +6,22 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class VideoMetadataTest {
-
@Test
fun testEmptyMetadata() {
val metadata = VideoMetadata()
assertTrue(metadata.isAllNull(), "A newly created metadata object should have all null properties")
}
-
+
@Test
fun testPartialMetadata() {
- val metadata = VideoMetadata(
- title = "Test Video",
- duration = 60000L, // 1 minute
- width = 1920,
- height = 1080
- )
-
+ val metadata =
+ VideoMetadata(
+ title = "Test Video",
+ duration = 60000L, // 1 minute
+ width = 1920,
+ height = 1080,
+ )
+
assertFalse(metadata.isAllNull(), "Metadata with some properties set should not be all null")
assertEquals("Test Video", metadata.title)
assertEquals(60000L, metadata.duration)
@@ -33,21 +33,22 @@ class VideoMetadataTest {
assertEquals(null, metadata.audioChannels)
assertEquals(null, metadata.audioSampleRate)
}
-
+
@Test
fun testFullMetadata() {
- val metadata = VideoMetadata(
- title = "Complete Test Video",
- duration = 120000L, // 2 minutes
- width = 3840,
- height = 2160,
- bitrate = 5000000L, // 5 Mbps
- frameRate = 30.0f,
- mimeType = "video/mp4",
- audioChannels = 2,
- audioSampleRate = 48000
- )
-
+ val metadata =
+ VideoMetadata(
+ title = "Complete Test Video",
+ duration = 120000L, // 2 minutes
+ width = 3840,
+ height = 2160,
+ bitrate = 5000000L, // 5 Mbps
+ frameRate = 30.0f,
+ mimeType = "video/mp4",
+ audioChannels = 2,
+ audioSampleRate = 48000,
+ )
+
assertFalse(metadata.isAllNull(), "Fully populated metadata should not be all null")
assertEquals("Complete Test Video", metadata.title)
assertEquals(120000L, metadata.duration)
@@ -59,53 +60,57 @@ class VideoMetadataTest {
assertEquals(2, metadata.audioChannels)
assertEquals(48000, metadata.audioSampleRate)
}
-
+
@Test
fun testDataClassEquality() {
- val metadata1 = VideoMetadata(
- title = "Equality Test",
- duration = 300000L, // 5 minutes
- width = 1280,
- height = 720
- )
-
- val metadata2 = VideoMetadata(
- title = "Equality Test",
- duration = 300000L,
- width = 1280,
- height = 720
- )
-
- val metadata3 = VideoMetadata(
- title = "Different Title",
- duration = 300000L,
- width = 1280,
- height = 720
- )
-
+ val metadata1 =
+ VideoMetadata(
+ title = "Equality Test",
+ duration = 300000L, // 5 minutes
+ width = 1280,
+ height = 720,
+ )
+
+ val metadata2 =
+ VideoMetadata(
+ title = "Equality Test",
+ duration = 300000L,
+ width = 1280,
+ height = 720,
+ )
+
+ val metadata3 =
+ VideoMetadata(
+ title = "Different Title",
+ duration = 300000L,
+ width = 1280,
+ height = 720,
+ )
+
assertEquals(metadata1, metadata2, "Identical metadata objects should be equal")
assertFalse(metadata1 == metadata3, "Metadata objects with different properties should not be equal")
}
-
+
@Test
fun testCopyFunction() {
- val original = VideoMetadata(
- title = "Original Video",
- duration = 180000L, // 3 minutes
- width = 1920,
- height = 1080
- )
-
+ val original =
+ VideoMetadata(
+ title = "Original Video",
+ duration = 180000L, // 3 minutes
+ width = 1920,
+ height = 1080,
+ )
+
val copy = original.copy(title = "Modified Video", bitrate = 3000000L)
-
+
assertEquals("Modified Video", copy.title)
assertEquals(original.duration, copy.duration)
assertEquals(original.width, copy.width)
assertEquals(original.height, copy.height)
assertEquals(3000000L, copy.bitrate)
-
+
// Original should remain unchanged
assertEquals("Original Video", original.title)
assertEquals(null, original.bitrate)
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerErrorTest.kt b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerErrorTest.kt
index bc9e11ae..fbb41529 100644
--- a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerErrorTest.kt
+++ b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerErrorTest.kt
@@ -6,7 +6,6 @@ import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class VideoPlayerErrorTest {
-
@Test
fun testCodecError() {
val error = VideoPlayerError.CodecError("Unsupported codec")
@@ -71,8 +70,10 @@ class VideoPlayerErrorTest {
for (i in errors.indices) {
for (j in errors.indices) {
if (i != j) {
- assertTrue(errors[i] != errors[j],
- "Different error types should not be equal: ${errors[i]} vs ${errors[j]}")
+ assertTrue(
+ errors[i] != errors[j],
+ "Different error types should not be equal: ${errors[i]} vs ${errors[j]}",
+ )
}
}
}
diff --git a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtilsTest.kt b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtilsTest.kt
index 15e39204..156d387c 100644
--- a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtilsTest.kt
+++ b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/util/TimeUtilsTest.kt
@@ -4,7 +4,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
class TimeUtilsTest {
-
@Test
fun testFormatTimeWithSeconds() {
// Test with seconds (as Double)
@@ -19,7 +18,7 @@ class TimeUtilsTest {
assertEquals("01:01:01", formatTime(3661.0))
assertEquals("99:59:59", formatTime(359999.0))
}
-
+
@Test
fun testFormatTimeWithNanoseconds() {
// Test with nanoseconds (as Long)
@@ -33,7 +32,7 @@ class TimeUtilsTest {
assertEquals("01:00:01", formatTime(3601_000_000_000L, true))
assertEquals("01:01:01", formatTime(3661_000_000_000L, true))
}
-
+
@Test
fun testToTimeMs() {
// Test conversion from time string to milliseconds
@@ -46,33 +45,33 @@ class TimeUtilsTest {
assertEquals(3600000, "01:00:00".toTimeMs())
assertEquals(3601000, "01:00:01".toTimeMs())
assertEquals(3661000, "01:01:01".toTimeMs())
-
+
// Test invalid formats
assertEquals(0, "invalid".toTimeMs())
assertEquals(0, "".toTimeMs())
assertEquals(0, ":".toTimeMs())
assertEquals(0, "::".toTimeMs())
}
-
+
@Test
fun testRoundTrip() {
// Test that converting from seconds to string and back to milliseconds works correctly
val testSeconds = listOf(0.0, 1.0, 59.0, 60.0, 61.0, 3599.0, 3600.0, 3601.0, 3661.0)
-
+
for (seconds in testSeconds) {
val formatted = formatTime(seconds)
val milliseconds = formatted.toTimeMs()
-
+
// Allow for small rounding differences due to floating point
val expectedMs = (seconds * 1000).toLong()
val tolerance = 1000L // 1 second tolerance due to rounding to whole seconds in formatTime
-
+
assertEquals(
- true,
+ true,
kotlin.math.abs(expectedMs - milliseconds) <= tolerance,
"Round trip conversion failed for $seconds seconds. " +
- "Expected ~$expectedMs ms, got $milliseconds ms (formatted as $formatted)"
+ "Expected ~$expectedMs ms, got $milliseconds ms (formatted as $formatted)",
)
}
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenVideoPlayerView.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenVideoPlayerView.kt
index d799561a..3ae91e1d 100644
--- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenVideoPlayerView.kt
+++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenVideoPlayerView.kt
@@ -6,21 +6,14 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import co.touchlab.kermit.Logger
import io.github.kdroidfilter.composemediaplayer.util.FullScreenLayout
import io.github.kdroidfilter.composemediaplayer.util.VideoPlayerStateRegistry
import kotlinx.cinterop.ExperimentalForeignApi
-import platform.Foundation.NSNotificationCenter
-import platform.UIKit.UIDevice
-import platform.UIKit.UIDeviceOrientationDidChangeNotification
/**
* Opens a fullscreen view for the video player on iOS.
@@ -32,7 +25,7 @@ import platform.UIKit.UIDeviceOrientationDidChangeNotification
@Composable
fun openFullscreenView(
playerState: VideoPlayerState,
- renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit
+ renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit,
) {
// Register the player state to be accessible from the fullscreen view
VideoPlayerStateRegistry.registerState(playerState)
@@ -45,13 +38,12 @@ fun openFullscreenView(
* @param renderSurface A composable function that renders the video player surface
*/
@Composable
-private fun FullscreenVideoPlayerView(
- renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit
-) {
+private fun FullscreenVideoPlayerView(renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit) {
// Get the player state from the registry
- val playerState = remember {
- VideoPlayerStateRegistry.getRegisteredState()
- }
+ val playerState =
+ remember {
+ VideoPlayerStateRegistry.getRegisteredState()
+ }
// We don't need to handle view disposal during rotation
// The DisposableEffect is removed as it was causing the fullscreen player
@@ -65,7 +57,7 @@ private fun FullscreenVideoPlayerView(
// Create a fullscreen view using FullScreenLayout
playerState?.let { state ->
FullScreenLayout(
- onDismissRequest = { exitFullScreen() }
+ onDismissRequest = { exitFullScreen() },
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
renderSurface(state, Modifier.fillMaxSize(), true)
diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt
index 3c2d1978..934b0d97 100644
--- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt
+++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt
@@ -1,4 +1,5 @@
@file:OptIn(ExperimentalForeignApi::class)
+
package io.github.kdroidfilter.composemediaplayer
import androidx.compose.runtime.Stable
@@ -10,7 +11,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
-import co.touchlab.kermit.Logger
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import io.github.kdroidfilter.composemediaplayer.util.formatTime
import io.github.kdroidfilter.composemediaplayer.util.getUri
import io.github.vinceglb.filekit.PlatformFile
@@ -46,9 +47,10 @@ import platform.darwin.dispatch_get_main_queue
actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState()
-@Stable
-open class DefaultVideoPlayerState: VideoPlayerState {
+private val iosLogger = TaggedLogger("iOSVideoPlayerState")
+@Stable
+open class DefaultVideoPlayerState : VideoPlayerState {
// Base states
private var _volume = mutableStateOf(1.0f)
override var volume: Float
@@ -68,7 +70,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
get() = _loop
set(value) {
_loop = value
- Logger.d { "Loop setting changed to: $value" }
+ iosLogger.d { "Loop setting changed to: $value" }
}
// Playback speed control
@@ -130,10 +132,10 @@ open class DefaultVideoPlayerState: VideoPlayerState {
// App lifecycle notification observers
private var backgroundObserver: Any? = null
private var foregroundObserver: Any? = null
-
+
// Flag to track if player was playing before going to background
private var wasPlayingBeforeBackground: Boolean = false
-
+
// Flag to track if the state has been disposed
private var isDisposed = false
@@ -142,10 +144,6 @@ open class DefaultVideoPlayerState: VideoPlayerState {
private var _duration: Double = 0.0
override val currentTime: Double get() = _currentTime
- // Audio levels (not yet implemented)
- override val leftLevel: Float = 0f
- override val rightLevel: Float = 0f
-
// Observable video aspect ratio (default to 16:9)
private var _videoAspectRatio by mutableStateOf(16.0 / 9.0)
val videoAspectRatio: CGFloat
@@ -159,116 +157,138 @@ open class DefaultVideoPlayerState: VideoPlayerState {
private fun configureAudioSession() {
val session = AVAudioSession.sharedInstance()
try {
- session.setCategory(AVAudioSessionCategoryPlayback, mode = AVAudioSessionModeMoviePlayback, options = 0u, error = null)
+ session.setCategory(
+ AVAudioSessionCategoryPlayback,
+ mode = AVAudioSessionModeMoviePlayback,
+ options = 0u,
+ error = null,
+ )
session.setActive(true, error = null)
} catch (e: Exception) {
- Logger.e { "Failed to configure audio session: ${e.message}" }
+ iosLogger.e { "Failed to configure audio session: ${e.message}" }
}
}
private fun startPositionUpdates(player: AVPlayer) {
val interval = CMTimeMakeWithSeconds(1.0 / 60.0, NSEC_PER_SEC.toInt()) // approx. 60 fps
- timeObserverToken = player.addPeriodicTimeObserverForInterval(
- interval = interval,
- queue = dispatch_get_main_queue(),
- usingBlock = { time ->
- val currentSeconds = CMTimeGetSeconds(time)
- val durationSeconds = player.currentItem?.duration?.let { CMTimeGetSeconds(it) } ?: 0.0
- _currentTime = currentSeconds
- _duration = durationSeconds
-
- // Update duration in metadata
- if (durationSeconds > 0 && !durationSeconds.isNaN()) {
- _metadata.duration = (durationSeconds * 1000).toLong()
- }
+ timeObserverToken =
+ player.addPeriodicTimeObserverForInterval(
+ interval = interval,
+ queue = dispatch_get_main_queue(),
+ usingBlock = { time ->
+ val currentSeconds = CMTimeGetSeconds(time)
+ val durationSeconds = player.currentItem?.duration?.let { CMTimeGetSeconds(it) } ?: 0.0
+ _currentTime = currentSeconds
+ _duration = durationSeconds
+
+ // Update duration in metadata
+ if (durationSeconds > 0 && !durationSeconds.isNaN()) {
+ _metadata.duration = (durationSeconds * 1000).toLong()
+ }
- if (!(userDragging || isLoading) && durationSeconds > 0 && !currentSeconds.isNaN() && !durationSeconds.isNaN()) {
- sliderPos = ((currentSeconds / durationSeconds) * 1000).toFloat()
- }
- _positionText = if (currentSeconds.isNaN()) "00:00" else formatTime(currentSeconds.toFloat())
- _durationText = if (durationSeconds.isNaN()) "00:00" else formatTime(durationSeconds.toFloat())
-
- player.currentItem?.presentationSize?.useContents {
- // Only update if dimensions are valid (greater than 0)
- if (width > 0 && height > 0) {
- // Try to use real aspect ratio if available, fallback to 16:9
- val realAspect = width / height
- _videoAspectRatio = realAspect
-
- // Update width and height in metadata if they're not already set or if they're zero
- if (_metadata.width == null || _metadata.width == 0 || _metadata.height == null || _metadata.height == 0) {
- _metadata.width = width.toInt()
- _metadata.height = height.toInt()
- Logger.d { "Video resolution updated during playback: ${width.toInt()}x${height.toInt()}" }
+ if (!(userDragging || isLoading) &&
+ durationSeconds > 0 &&
+ !currentSeconds.isNaN() &&
+ !durationSeconds.isNaN()
+ ) {
+ sliderPos = ((currentSeconds / durationSeconds) * 1000).toFloat()
+ }
+ _positionText = if (currentSeconds.isNaN()) "00:00" else formatTime(currentSeconds.toFloat())
+ _durationText = if (durationSeconds.isNaN()) "00:00" else formatTime(durationSeconds.toFloat())
+
+ player.currentItem?.presentationSize?.useContents {
+ // Only update if dimensions are valid (greater than 0)
+ if (width > 0 && height > 0) {
+ // Try to use real aspect ratio if available, fallback to 16:9
+ val realAspect = width / height
+ _videoAspectRatio = realAspect
+
+ // Update width and height in metadata if they're not already set or if they're zero
+ if (_metadata.width == null ||
+ _metadata.width == 0 ||
+ _metadata.height == null ||
+ _metadata.height == 0
+ ) {
+ _metadata.width = width.toInt()
+ _metadata.height = height.toInt()
+ iosLogger.d {
+ "Video resolution updated during playback: ${width.toInt()}x${height.toInt()}"
+ }
+ }
}
}
- }
- }
- )
+ },
+ )
}
- private fun setupObservers(player: AVPlayer, item: AVPlayerItem) {
+ private fun setupObservers(
+ player: AVPlayer,
+ item: AVPlayerItem,
+ ) {
// KVO for timeControlStatus (Playing, Paused, Loading)
- timeControlStatusObserver = player.observe("timeControlStatus") { _ ->
- when (player.timeControlStatus) {
- AVPlayerTimeControlStatusPlaying -> {
- _isPlaying = true
- _isLoading = false
- }
- AVPlayerTimeControlStatusPaused -> {
- if (player.reasonForWaitingToPlay == null) {
- _isPlaying = false
+ timeControlStatusObserver =
+ player.observe("timeControlStatus") { _ ->
+ when (player.timeControlStatus) {
+ AVPlayerTimeControlStatusPlaying -> {
+ _isPlaying = true
+ _isLoading = false
+ }
+ AVPlayerTimeControlStatusPaused -> {
+ if (player.reasonForWaitingToPlay == null) {
+ _isPlaying = false
+ }
+ _isLoading = false
+ }
+ AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> {
+ _isLoading = true
}
- _isLoading = false
- }
- AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> {
- _isLoading = true
}
}
- }
// KVO for status (Ready, Failed)
- statusObserver = item.observe("status") { _ ->
- when (item.status) {
- AVPlayerItemStatusReadyToPlay -> {
- _isLoading = false
- Logger.d { "Player Item Ready" }
- }
- AVPlayerItemStatusFailed -> {
- _isLoading = false
- _isPlaying = false
- Logger.e { "Player Item Failed: ${item.error?.localizedDescription}" }
+ statusObserver =
+ item.observe("status") { _ ->
+ when (item.status) {
+ AVPlayerItemStatusReadyToPlay -> {
+ _isLoading = false
+ iosLogger.d { "Player Item Ready" }
+ }
+ AVPlayerItemStatusFailed -> {
+ _isLoading = false
+ _isPlaying = false
+ iosLogger.e { "Player Item Failed: ${item.error?.localizedDescription}" }
+ }
}
}
- }
// Periodic Time Observer
startPositionUpdates(player)
// Notification for End of Playback
- endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
- name = AVPlayerItemDidPlayToEndTimeNotification,
- `object` = item,
- queue = null
- ) { _ ->
- if (_loop) {
- val zeroTime = CMTimeMake(0, 1)
- player.seekToTime(
- time = CMTimeMakeWithSeconds(0.0, NSEC_PER_SEC.toInt()),
- toleranceBefore = zeroTime,
- toleranceAfter = zeroTime
- ) { finished ->
- if (finished) {
- dispatch_async(dispatch_get_main_queue()) {
- player.playImmediatelyAtRate(_playbackSpeed)
+ endObserver =
+ NSNotificationCenter.defaultCenter.addObserverForName(
+ name = AVPlayerItemDidPlayToEndTimeNotification,
+ `object` = item,
+ queue = null,
+ ) { _ ->
+ if (_loop) {
+ val zeroTime = CMTimeMake(0, 1)
+ player.seekToTime(
+ time = CMTimeMakeWithSeconds(0.0, NSEC_PER_SEC.toInt()),
+ toleranceBefore = zeroTime,
+ toleranceAfter = zeroTime,
+ ) { finished ->
+ if (finished) {
+ dispatch_async(dispatch_get_main_queue()) {
+ player.playImmediatelyAtRate(_playbackSpeed)
+ }
}
}
+ } else {
+ player.pause()
+ _isPlaying = false
}
- } else {
- player.pause()
- _isPlaying = false
}
- }
setupAppLifecycleObservers()
}
@@ -285,52 +305,54 @@ open class DefaultVideoPlayerState: VideoPlayerState {
removeAppLifecycleObservers()
// Add observer for when app goes to background (screen lock)
- backgroundObserver = NSNotificationCenter.defaultCenter.addObserverForName(
- name = UIApplicationDidEnterBackgroundNotification,
- `object` = UIApplication.sharedApplication,
- queue = null
- ) { _ ->
- Logger.d { "App entered background (screen locked)" }
- // Store current playing state before background
- wasPlayingBeforeBackground = _isPlaying
-
- // If player is paused by the system, update our state to match
- player?.let { player ->
- if (player.rate == 0.0f) {
- Logger.d { "Player was paused by system, updating isPlaying state" }
- _isPlaying = false
+ backgroundObserver =
+ NSNotificationCenter.defaultCenter.addObserverForName(
+ name = UIApplicationDidEnterBackgroundNotification,
+ `object` = UIApplication.sharedApplication,
+ queue = null,
+ ) { _ ->
+ iosLogger.d { "App entered background (screen locked)" }
+ // Store current playing state before background
+ wasPlayingBeforeBackground = _isPlaying
+
+ // If player is paused by the system, update our state to match
+ player?.let { player ->
+ if (player.rate == 0.0f) {
+ iosLogger.d { "Player was paused by system, updating isPlaying state" }
+ _isPlaying = false
+ }
}
}
- }
-
+
// Add observer for when app comes to foreground (screen unlock)
- foregroundObserver = NSNotificationCenter.defaultCenter.addObserverForName(
- name = UIApplicationWillEnterForegroundNotification,
- `object` = UIApplication.sharedApplication,
- queue = null
- ) { _ ->
- Logger.d { "App will enter foreground (screen unlocked)" }
- // If player was playing before going to background, resume playback
- if (wasPlayingBeforeBackground) {
- Logger.d { "Player was playing before background, resuming" }
- player?.let { player ->
- // Only resume if the player is overridely paused
- if (player.rate == 0.0f) {
- player.playImmediatelyAtRate(_playbackSpeed)
+ foregroundObserver =
+ NSNotificationCenter.defaultCenter.addObserverForName(
+ name = UIApplicationWillEnterForegroundNotification,
+ `object` = UIApplication.sharedApplication,
+ queue = null,
+ ) { _ ->
+ iosLogger.d { "App will enter foreground (screen unlocked)" }
+ // If player was playing before going to background, resume playback
+ if (wasPlayingBeforeBackground) {
+ iosLogger.d { "Player was playing before background, resuming" }
+ player?.let { player ->
+ // Only resume if the player is overridely paused
+ if (player.rate == 0.0f) {
+ player.playImmediatelyAtRate(_playbackSpeed)
+ }
}
}
}
- }
-
- Logger.d { "App lifecycle observers set up" }
+
+ iosLogger.d { "App lifecycle observers set up" }
}
-
+
private fun removeAppLifecycleObservers() {
backgroundObserver?.let {
NSNotificationCenter.defaultCenter.removeObserver(it)
backgroundObserver = null
}
-
+
foregroundObserver?.let {
NSNotificationCenter.defaultCenter.removeObserver(it)
foregroundObserver = null
@@ -381,12 +403,16 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param uri The URI of the media to open
* @param initializeplayerState Controls whether playback should start automatically after opening
*/
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
- Logger.d { "openUri called with uri: $uri, initializeplayerState: $initializeplayerState" }
- val nsUrl = NSURL.URLWithString(uri) ?: run {
- Logger.d { "Failed to create NSURL from uri: $uri" }
- return
- }
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
+ iosLogger.d { "openUri called with uri: $uri, initializeplayerState: $initializeplayerState" }
+ val nsUrl =
+ NSURL.URLWithString(uri) ?: run {
+ iosLogger.d { "Failed to create NSURL from uri: $uri" }
+ return
+ }
// Clean up the current player completely before creating a new one
cleanupCurrentPlayer()
@@ -440,7 +466,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
heightTemp = height.toInt()
// Try to use real aspect ratio if available, fallback to 16:9
videoAspectRatioTemp = width / height
- Logger.d { "Video resolution from track: ${width.toInt()}x${height.toInt()}" }
+ iosLogger.d { "Video resolution from track: ${width.toInt()}x${height.toInt()}" }
}
}
}
@@ -460,7 +486,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
// Create player item from asset to get more accurate metadata
val playerItem = AVPlayerItem(asset)
- val durationSeconds = CMTimeGetSeconds(playerItem.duration)
+ val durationSeconds = CMTimeGetSeconds(playerItem.duration)
if (durationSeconds > 0 && !durationSeconds.isNaN()) {
_metadata.duration = (durationSeconds * 1000).toLong()
}
@@ -472,7 +498,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
dispatch_async(dispatch_get_main_queue()) {
// Check if disposed
if (isDisposed) {
- Logger.d { "player disposed, canceling initialization" }
+ iosLogger.d { "player disposed, canceling initialization" }
return@dispatch_async
}
@@ -487,17 +513,18 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
// Create the final player with the fully loaded asset
- val newPlayer = AVPlayer(playerItem = playerItem).apply {
- volume = this@DefaultVideoPlayerState.volume
- // Don't set rate here, as it can cause auto-play
- actionAtItemEnd = AVPlayerActionAtItemEndNone
+ val newPlayer =
+ AVPlayer(playerItem = playerItem).apply {
+ volume = this@DefaultVideoPlayerState.volume
+ // Don't set rate here, as it can cause auto-play
+ actionAtItemEnd = AVPlayerActionAtItemEndNone
- // For HLS auto-playing needs to be true
- automaticallyWaitsToMinimizeStalling = true
+ // For HLS auto-playing needs to be true
+ automaticallyWaitsToMinimizeStalling = true
- // Disable AirPlay
- allowsExternalPlayback = false
- }
+ // Disable AirPlay
+ allowsExternalPlayback = false
+ }
player = newPlayer
_hasMedia = true
@@ -517,9 +544,9 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
override fun play() {
- Logger.d { "play called" }
+ iosLogger.d { "play called" }
if (player == null) {
- Logger.d { "play: player is null" }
+ iosLogger.d { "play: player is null" }
return
}
// Configure audio session
@@ -529,7 +556,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
override fun pause() {
- Logger.d { "pause called" }
+ iosLogger.d { "pause called" }
// Ensure the pause call is on the main thread:
dispatch_async(dispatch_get_main_queue()) {
player?.pause()
@@ -538,7 +565,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
override fun stop() {
- Logger.d { "stop called" }
+ iosLogger.d { "stop called" }
player?.pause()
player?.seekToTime(CMTimeMakeWithSeconds(0.0, 1))
_isPlaying = false
@@ -565,7 +592,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
currentPlayer.seekToTime(
time = seekTime,
toleranceBefore = zeroTime,
- toleranceAfter = zeroTime
+ toleranceAfter = zeroTime,
) { finished ->
if (finished) {
dispatch_async(dispatch_get_main_queue()) {
@@ -580,19 +607,19 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
override fun clearError() {
- Logger.d { "clearError called" }
+ iosLogger.d { "clearError called" }
}
/**
* Toggles the fullscreen state of the video player
*/
override fun toggleFullscreen() {
- Logger.d { "toggleFullscreen called" }
+ iosLogger.d { "toggleFullscreen called" }
_isFullscreen = !_isFullscreen
}
override fun dispose() {
- Logger.d { "dispose called" }
+ iosLogger.d { "dispose called" }
isDisposed = true
cleanupCurrentPlayer()
_hasMedia = false
@@ -602,16 +629,20 @@ open class DefaultVideoPlayerState: VideoPlayerState {
_metadata = VideoMetadata(audioChannels = 2)
}
- override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) {
- Logger.d { "openFile called with file: $file, initializeplayerState: $initializeplayerState" }
+ override fun openFile(
+ file: PlatformFile,
+ initializeplayerState: InitialPlayerState,
+ ) {
+ iosLogger.d { "openFile called with file: $file, initializeplayerState: $initializeplayerState" }
// Use the getUri extension function to get a proper file URL
val fileUrl = file.getUri()
- Logger.d { "Opening file with URL: $fileUrl" }
+ iosLogger.d { "Opening file with URL: $fileUrl" }
openUri(fileUrl, initializeplayerState)
}
override val metadata: VideoMetadata
get() = _metadata
+
// Subtitle state
private var _subtitlesEnabled by mutableStateOf(false)
override var subtitlesEnabled: Boolean
@@ -631,12 +662,13 @@ open class DefaultVideoPlayerState: VideoPlayerState {
override val availableSubtitleTracks: MutableList
get() = _availableSubtitleTracks
- override var subtitleTextStyle: TextStyle = TextStyle(
- color = Color.White,
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- )
+ override var subtitleTextStyle: TextStyle =
+ TextStyle(
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Normal,
+ textAlign = TextAlign.Center,
+ )
override var subtitleBackgroundColor: Color = Color.Black.copy(alpha = 0.5f)
@@ -647,7 +679,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param track The subtitle track to select, or null to disable subtitles
*/
override fun selectSubtitleTrack(track: SubtitleTrack?) {
- Logger.d { "selectSubtitleTrack called with track: $track" }
+ iosLogger.d { "selectSubtitleTrack called with track: $track" }
if (track == null) {
disableSubtitles()
return
@@ -665,7 +697,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* Disables subtitle display.
*/
override fun disableSubtitles() {
- Logger.d { "disableSubtitles called" }
+ iosLogger.d { "disableSubtitles called" }
// Update state
currentSubtitleTrack = null
subtitlesEnabled = false
@@ -677,13 +709,14 @@ open class DefaultVideoPlayerState: VideoPlayerState {
@OptIn(ExperimentalForeignApi::class)
private class KVOObserver(
- private val block: (Any?) -> Unit
-) : NSObject(), NSKeyValueObservingProtocol {
+ private val block: (Any?) -> Unit,
+) : NSObject(),
+ NSKeyValueObservingProtocol {
override fun observeValueForKeyPath(
keyPath: String?,
ofObject: Any?,
change: Map?,
- context: COpaquePointer?
+ context: COpaquePointer?,
) {
block(change?.get(NSKeyValueChangeNewKey))
}
@@ -693,14 +726,14 @@ private class KVOObserver(
private fun NSObject.observe(
keyPath: String,
options: NSKeyValueObservingOptions = NSKeyValueObservingOptionNew,
- block: (Any?) -> Unit
+ block: (Any?) -> Unit,
): NSObject {
val observer = KVOObserver(block)
this.addObserver(
observer,
forKeyPath = keyPath,
options = options,
- context = null
+ context = null,
)
return observer
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt
index 5a7df9ca..16d0b08a 100644
--- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt
+++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt
@@ -10,8 +10,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.viewinterop.UIKitView
-import co.touchlab.kermit.Logger
import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier
import io.github.kdroidfilter.composemediaplayer.util.toTimeMs
import kotlinx.cinterop.BetaInteropApi
@@ -30,16 +30,25 @@ import platform.UIKit.UIColor
import platform.UIKit.UIView
import platform.UIKit.UIViewMeta
+private val iosSurfaceLogger = TaggedLogger("iOSVideoPlayerSurface")
+
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun VideoPlayerSurface(
playerState: VideoPlayerState,
modifier: Modifier,
contentScale: ContentScale,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
// Set pauseOnDispose to false to prevent pausing during screen rotation
- VideoPlayerSurfaceImpl(playerState, modifier, contentScale, overlay, isInFullscreenView = false, pauseOnDispose = false)
+ VideoPlayerSurfaceImpl(
+ playerState,
+ modifier,
+ contentScale,
+ overlay,
+ isInFullscreenView = false,
+ pauseOnDispose = false,
+ )
}
@OptIn(ExperimentalForeignApi::class)
@@ -50,18 +59,18 @@ fun VideoPlayerSurfaceImpl(
contentScale: ContentScale,
overlay: @Composable () -> Unit,
isInFullscreenView: Boolean = false,
- pauseOnDispose: Boolean = true
+ pauseOnDispose: Boolean = true,
) {
// Cleanup when deleting the view
DisposableEffect(Unit) {
onDispose {
- Logger.d { "[VideoPlayerSurface] Disposing" }
+ iosSurfaceLogger.d { "[VideoPlayerSurface] Disposing" }
// Only pause if pauseOnDispose is true (prevents pausing during rotation or fullscreen transitions)
if (pauseOnDispose) {
- Logger.d { "[VideoPlayerSurface] Pausing on dispose" }
+ iosSurfaceLogger.d { "[VideoPlayerSurface] Pausing on dispose" }
playerState.pause()
} else {
- Logger.d { "[VideoPlayerSurface] Not pausing on dispose (rotation or fullscreen transition)" }
+ iosSurfaceLogger.d { "[VideoPlayerSurface] Not pausing on dispose (rotation or fullscreen transition)" }
}
}
}
@@ -70,15 +79,16 @@ fun VideoPlayerSurfaceImpl(
Box(
modifier = modifier,
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
if (playerState.hasMedia) {
UIKitView(
- modifier = contentScale.toCanvasModifier(
- aspectRatio = playerState.aspectRatio,
- width = playerState.metadata.width,
- height = playerState.metadata.height
- ),
+ modifier =
+ contentScale.toCanvasModifier(
+ aspectRatio = playerState.aspectRatio,
+ width = playerState.metadata.width,
+ height = playerState.metadata.height,
+ ),
factory = {
PlayerUIView(frame = cValue()).apply {
player = currentPlayer
@@ -95,30 +105,38 @@ fun VideoPlayerSurfaceImpl(
playerView.hidden = !playerState.hasMedia
// Update the videoGravity when contentScale changes
- val videoGravity = when (contentScale) {
- ContentScale.Crop,
- ContentScale.FillHeight -> AVLayerVideoGravityResizeAspectFill // ⬅️ changement
- ContentScale.FillWidth -> AVLayerVideoGravityResizeAspectFill // (même logique)
- ContentScale.FillBounds -> AVLayerVideoGravityResize // pas d’aspect-ratio
- ContentScale.Fit,
- ContentScale.Inside -> AVLayerVideoGravityResizeAspect
-
- else -> AVLayerVideoGravityResizeAspect
- }
+ val videoGravity =
+ when (contentScale) {
+ ContentScale.Crop,
+ ContentScale.FillHeight,
+ -> AVLayerVideoGravityResizeAspectFill // ⬅️ changement
+ ContentScale.FillWidth -> AVLayerVideoGravityResizeAspectFill // (même logique)
+ ContentScale.FillBounds -> AVLayerVideoGravityResize // pas d’aspect-ratio
+ ContentScale.Fit,
+ ContentScale.Inside,
+ -> AVLayerVideoGravityResizeAspect
+
+ else -> AVLayerVideoGravityResizeAspect
+ }
playerView.videoGravity = videoGravity
- Logger.d { "View configured with contentScale: $contentScale, videoGravity: $videoGravity" }
+ iosSurfaceLogger.d {
+ "View configured with contentScale: $contentScale, videoGravity: $videoGravity"
+ }
},
onRelease = { playerView ->
playerView.player = null
- }
+ },
)
// Add Compose-based subtitle layer
if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) {
// Calculate current time in milliseconds
- val currentTimeMs = (playerState.sliderPos / 1000f *
- playerState.durationText.toTimeMs()).toLong()
+ val currentTimeMs =
+ (
+ playerState.sliderPos / 1000f *
+ playerState.durationText.toTimeMs()
+ ).toLong()
// Calculate duration in milliseconds
val durationMs = playerState.durationText.toTimeMs()
@@ -130,7 +148,7 @@ fun VideoPlayerSurfaceImpl(
subtitleTrack = playerState.currentSubtitleTrack,
subtitlesEnabled = playerState.subtitlesEnabled,
textStyle = playerState.subtitleTextStyle,
- backgroundColor = playerState.subtitleBackgroundColor
+ backgroundColor = playerState.subtitleBackgroundColor,
)
}
}
@@ -172,4 +190,3 @@ private class PlayerUIView : UIView {
(layer as? AVPlayerLayer)?.videoGravity = value
}
}
-
diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.ios.kt
index 2067a8e8..f7d78467 100644
--- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.ios.kt
+++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.ios.kt
@@ -1,8 +1,8 @@
package io.github.kdroidfilter.composemediaplayer.subtitle
+import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSString
import platform.Foundation.NSURL
import platform.Foundation.NSUTF8StringEncoding
@@ -17,55 +17,57 @@ import platform.Foundation.stringWithContentsOfURL
* @return The content of the subtitle file as a string
*/
@OptIn(ExperimentalForeignApi::class)
-actual suspend fun loadSubtitleContent(src: String): String = withContext(Dispatchers.Default) {
- try {
- when {
- // Handle HTTP/HTTPS URLs
- src.startsWith("http://") || src.startsWith("https://") -> {
- val nsUrl = NSURL(string = src)
- nsUrl?.let {
- try {
- NSString.stringWithContentsOfURL(it, encoding = NSUTF8StringEncoding, error = null) ?: ""
- } catch (e: Exception) {
- println("Error loading URL: ${e.message}")
- ""
- }
- } ?: ""
- }
+actual suspend fun loadSubtitleContent(src: String): String =
+ withContext(Dispatchers.Default) {
+ try {
+ when {
+ // Handle HTTP/HTTPS URLs
+ src.startsWith("http://") || src.startsWith("https://") -> {
+ val nsUrl = NSURL(string = src)
+ nsUrl?.let {
+ try {
+ NSString.stringWithContentsOfURL(it, encoding = NSUTF8StringEncoding, error = null) ?: ""
+ } catch (e: Exception) {
+ println("Error loading URL: ${e.message}")
+ ""
+ }
+ } ?: ""
+ }
- // Handle file:// URIs
- src.startsWith("file://") -> {
- val nsUrl = NSURL(string = src)
- nsUrl?.let {
- try {
- NSString.stringWithContentsOfURL(it, encoding = NSUTF8StringEncoding, error = null) ?: ""
- } catch (e: Exception) {
- println("Error loading file URL: ${e.message}")
- ""
- }
- } ?: ""
- }
+ // Handle file:// URIs
+ src.startsWith("file://") -> {
+ val nsUrl = NSURL(string = src)
+ nsUrl?.let {
+ try {
+ NSString.stringWithContentsOfURL(it, encoding = NSUTF8StringEncoding, error = null) ?: ""
+ } catch (e: Exception) {
+ println("Error loading file URL: ${e.message}")
+ ""
+ }
+ } ?: ""
+ }
- // Handle local file paths
- else -> {
- try {
- NSString.stringWithContentsOfFile(src, encoding = NSUTF8StringEncoding, error = null) ?: ""
- } catch (e: Exception) {
- // Try as file URL
+ // Handle local file paths
+ else -> {
try {
- val fileUrl = NSURL.fileURLWithPath(src)
- fileUrl?.let {
- NSString.stringWithContentsOfURL(it, encoding = NSUTF8StringEncoding, error = null) ?: ""
- } ?: ""
- } catch (e2: Exception) {
- println("Error loading file path: ${e2.message}")
- ""
+ NSString.stringWithContentsOfFile(src, encoding = NSUTF8StringEncoding, error = null) ?: ""
+ } catch (e: Exception) {
+ // Try as file URL
+ try {
+ val fileUrl = NSURL.fileURLWithPath(src)
+ fileUrl?.let {
+ NSString.stringWithContentsOfURL(it, encoding = NSUTF8StringEncoding, error = null)
+ ?: ""
+ } ?: ""
+ } catch (e2: Exception) {
+ println("Error loading file path: ${e2.message}")
+ ""
+ }
}
}
}
+ } catch (e: Exception) {
+ println("Error loading subtitle content: ${e.message}")
+ ""
}
- } catch (e: Exception) {
- println("Error loading subtitle content: ${e.message}")
- ""
}
-}
diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt
index cc6f1243..4468c8f0 100644
--- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt
+++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt
@@ -25,9 +25,7 @@ object VideoPlayerStateRegistry {
*
* @return The registered VideoPlayerState or null if none is registered
*/
- fun getRegisteredState(): VideoPlayerState? {
- return registeredState
- }
+ fun getRegisteredState(): VideoPlayerState? = registeredState
/**
* Clear the registered VideoPlayerState instance.
@@ -35,4 +33,4 @@ object VideoPlayerStateRegistry {
fun clearRegisteredState() {
registeredState = null
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
index 527ab929..5b0cc858 100644
--- a/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
+++ b/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
@@ -2,15 +2,14 @@ package io.github.kdroidfilter.composemediaplayer
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
-import kotlin.test.assertFalse
/**
* Tests for the iOS implementation of VideoPlayerState
*/
class VideoPlayerStateTest {
-
/**
* Test the creation of VideoPlayerState
*/
@@ -27,8 +26,6 @@ class VideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
// Clean up
@@ -133,11 +130,12 @@ class VideoPlayerStateTest {
assertTrue(playerState.availableSubtitleTracks.isEmpty())
// Create a test subtitle track
- val testTrack = SubtitleTrack(
- label = "English",
- language = "en",
- src = "test.vtt"
- )
+ val testTrack =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "test.vtt",
+ )
// Select the subtitle track
playerState.selectSubtitleTrack(testTrack)
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
index 91175a69..785a0ad3 100644
--- a/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.js.kt
+++ b/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.js.kt
@@ -15,7 +15,7 @@ actual fun VideoPlayerSurface(
playerState: VideoPlayerState,
modifier: Modifier,
contentScale: ContentScale,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
if (playerState.hasMedia) {
var videoElement by remember { mutableStateOf(null) }
@@ -39,12 +39,12 @@ actual fun VideoPlayerSurface(
onLastPlaybackSpeedChange = { lastPlaybackSpeed = it },
lastPosition = lastPosition,
wasPlaying = wasPlaying,
- lastPlaybackSpeed = lastPlaybackSpeed
+ lastPlaybackSpeed = lastPlaybackSpeed,
)
VideoVolumeAndSpeedEffects(
playerState = playerState,
- videoElement = videoElement
+ videoElement = videoElement,
)
// Video content layout with WebElementView
@@ -53,7 +53,7 @@ actual fun VideoPlayerSurface(
modifier = modifier,
videoRatio = videoRatio,
contentScale = contentScale,
- overlay = overlay
+ overlay = overlay,
) {
key(useCors) {
WebElementView(
@@ -66,9 +66,8 @@ actual fun VideoPlayerSurface(
video = this,
playerState = playerState,
scope = scope,
- enableAudioDetection = true,
useCors = useCors,
- onCorsError = { useCors = false }
+ onCorsError = { useCors = false },
)
}
},
@@ -81,7 +80,7 @@ actual fun VideoPlayerSurface(
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
index c91e1627..d57549b8 100644
--- a/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
+++ b/mediaplayer/src/jsMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
@@ -3,6 +3,4 @@ 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)
-}
+actual fun PlatformFile.getUri(): String = URL.createObjectURL(this.file)
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt
index 15dcdbf1..d0cb69cf 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt
@@ -22,8 +22,6 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState(
* - `sliderPos`: Represents the current playback position as a normalized value between 0.0 and 1.0.
* - `userDragging`: Denotes whether the user is manually adjusting the playback position.
* - `loop`: Specifies if the video should loop when it reaches the end.
- * - `leftLevel`: Provides the audio level for the left channel as a percentage.
- * - `rightLevel`: Provides the audio level for the right channel as a percentage.
* - `positionText`: Returns the current playback position as a formatted string.
* - `durationText`: Returns the total duration of the video as a formatted string.
*
@@ -36,12 +34,13 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState(
* - `dispose()`: Releases resources used by the video player and disposes of the state.
*/
@Stable
-open class DefaultVideoPlayerState: VideoPlayerState {
- val delegate: VideoPlayerState = when (CurrentPlatform.os) {
- CurrentPlatform.OS.WINDOWS -> WindowsVideoPlayerState()
- CurrentPlatform.OS.MAC -> MacVideoPlayerState()
- CurrentPlatform.OS.LINUX -> LinuxVideoPlayerState()
- }
+open class DefaultVideoPlayerState : VideoPlayerState {
+ val delegate: VideoPlayerState =
+ when (CurrentPlatform.os) {
+ CurrentPlatform.OS.WINDOWS -> WindowsVideoPlayerState()
+ CurrentPlatform.OS.MAC -> MacVideoPlayerState()
+ CurrentPlatform.OS.LINUX -> LinuxVideoPlayerState()
+ }
override val hasMedia: Boolean get() = delegate.hasMedia
override val isPlaying: Boolean get() = delegate.isPlaying
@@ -84,8 +83,8 @@ open class DefaultVideoPlayerState: VideoPlayerState {
override val aspectRatio: Float get() = delegate.aspectRatio
override var subtitlesEnabled = delegate.subtitlesEnabled
- override var currentSubtitleTrack : SubtitleTrack? = delegate.currentSubtitleTrack
- override val availableSubtitleTracks = delegate.availableSubtitleTracks
+ override var currentSubtitleTrack: SubtitleTrack? = delegate.currentSubtitleTrack
+ override val availableSubtitleTracks = delegate.availableSubtitleTracks
override var subtitleTextStyle: TextStyle
get() = delegate.subtitleTextStyle
set(value) {
@@ -96,22 +95,36 @@ open class DefaultVideoPlayerState: VideoPlayerState {
set(value) {
delegate.subtitleBackgroundColor = value
}
+
override fun selectSubtitleTrack(track: SubtitleTrack?) = delegate.selectSubtitleTrack(track)
+
override fun disableSubtitles() = delegate.disableSubtitles()
- override val leftLevel: Float get() = delegate.leftLevel
- override val rightLevel: Float get() = delegate.rightLevel
override val positionText: String get() = delegate.positionText
override val durationText: String get() = delegate.durationText
override val currentTime: Double get() = delegate.currentTime
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) = delegate.openUri(uri, initializeplayerState)
- override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) = delegate.openUri(file.file.path, initializeplayerState)
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) = delegate.openUri(uri, initializeplayerState)
+
+ override fun openFile(
+ file: PlatformFile,
+ initializeplayerState: InitialPlayerState,
+ ) = delegate.openUri(file.file.path, initializeplayerState)
+
override fun play() = delegate.play()
+
override fun pause() = delegate.pause()
+
override fun stop() = delegate.stop()
+
override fun seekTo(value: Float) = delegate.seekTo(value)
+
override fun toggleFullscreen() = delegate.toggleFullscreen()
+
override fun dispose() = delegate.dispose()
+
override fun clearError() = delegate.clearError()
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt
index dc7f91ba..b7638870 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt
@@ -3,7 +3,6 @@ package io.github.kdroidfilter.composemediaplayer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalInspectionMode
import io.github.kdroidfilter.composemediaplayer.linux.LinuxVideoPlayerState
import io.github.kdroidfilter.composemediaplayer.linux.LinuxVideoPlayerSurface
import io.github.kdroidfilter.composemediaplayer.mac.MacVideoPlayerState
@@ -28,18 +27,19 @@ import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerSurfa
*/
@Composable
actual fun VideoPlayerSurface(
- playerState: VideoPlayerState,
+ playerState: VideoPlayerState,
modifier: Modifier,
contentScale: ContentScale,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
- if (playerState is DefaultVideoPlayerState)
+ if (playerState is DefaultVideoPlayerState) {
when (val delegate = playerState.delegate) {
is WindowsVideoPlayerState -> WindowsVideoPlayerSurface(delegate, modifier, contentScale, overlay)
is MacVideoPlayerState -> MacVideoPlayerSurface(delegate, modifier, contentScale, overlay)
is LinuxVideoPlayerState -> LinuxVideoPlayerSurface(delegate, modifier, contentScale, overlay)
else -> throw IllegalArgumentException("Unsupported player state type")
}
- else
+ } else {
throw IllegalArgumentException("Unsupported player state type")
+ }
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt
index 1df7ec4d..9c800776 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt
@@ -31,7 +31,7 @@ import io.github.kdroidfilter.composemediaplayer.util.VideoPlayerStateRegistry
@Composable
fun openFullscreenWindow(
playerState: VideoPlayerState,
- renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit
+ renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit,
) {
// Register the player state to be accessible from the fullscreen window
VideoPlayerStateRegistry.registerState(playerState)
@@ -44,13 +44,12 @@ fun openFullscreenWindow(
* @param renderSurface A composable function that renders the video player surface
*/
@Composable
-private fun FullscreenVideoPlayerWindow(
- renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit
-) {
+private fun FullscreenVideoPlayerWindow(renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit) {
// Get the player state from the registry
- val playerState = remember {
- VideoPlayerStateRegistry.getRegisteredState()
- }
+ val playerState =
+ remember {
+ VideoPlayerStateRegistry.getRegisteredState()
+ }
// Create a window state for fullscreen
val windowState = rememberWindowState(placement = WindowPlacement.Maximized)
@@ -83,11 +82,12 @@ private fun FullscreenVideoPlayerWindow(
} else {
false
}
- }) {
+ },
+ ) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
playerState?.let { state ->
renderSurface(state, Modifier.fillMaxSize(), true)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFullscreenVideoPlayerWindow.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFullscreenVideoPlayerWindow.kt
index 36958a60..d3b10804 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFullscreenVideoPlayerWindow.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFullscreenVideoPlayerWindow.kt
@@ -16,7 +16,7 @@ import io.github.kdroidfilter.composemediaplayer.common.openFullscreenWindow
fun openFullscreenWindow(
playerState: LinuxVideoPlayerState,
overlay: @Composable () -> Unit = {},
- contentScale: ContentScale
+ contentScale: ContentScale,
) {
openFullscreenWindow(
playerState = playerState,
@@ -26,8 +26,8 @@ fun openFullscreenWindow(
modifier = modifier,
overlay = overlay,
isInFullscreenWindow = isInFullscreenWindow,
- contentScale = contentScale
+ contentScale = contentScale,
)
- }
+ },
)
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt
new file mode 100644
index 00000000..4f7c89dd
--- /dev/null
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt
@@ -0,0 +1,86 @@
+package io.github.kdroidfilter.composemediaplayer.linux
+
+import io.github.kdroidfilter.composemediaplayer.util.NativeLibraryLoader
+import java.nio.ByteBuffer
+
+/**
+ * JNI direct mapping to the native Linux GStreamer video player library.
+ * Handles are opaque Long values (native pointer cast to jlong, 0 = null).
+ */
+internal object LinuxNativeBridge {
+ init {
+ NativeLibraryLoader.load("NativeVideoPlayer", LinuxNativeBridge::class.java)
+ }
+
+ // Playback control
+ @JvmStatic external fun nCreatePlayer(): Long
+
+ @JvmStatic external fun nOpenUri(
+ handle: Long,
+ uri: String,
+ )
+
+ @JvmStatic external fun nPlay(handle: Long)
+
+ @JvmStatic external fun nPause(handle: Long)
+
+ @JvmStatic external fun nSetVolume(
+ handle: Long,
+ volume: Float,
+ )
+
+ @JvmStatic external fun nGetVolume(handle: Long): Float
+
+ @JvmStatic external fun nSeekTo(
+ handle: Long,
+ time: Double,
+ )
+
+ @JvmStatic external fun nDisposePlayer(handle: Long)
+
+ @JvmStatic external fun nSetPlaybackSpeed(
+ handle: Long,
+ speed: Float,
+ )
+
+ @JvmStatic external fun nGetPlaybackSpeed(handle: Long): Float
+
+ // Frame access
+ @JvmStatic external fun nGetLatestFrameAddress(handle: Long): Long
+
+ @JvmStatic external fun nWrapPointer(
+ address: Long,
+ size: Long,
+ ): ByteBuffer?
+
+ @JvmStatic external fun nGetFrameWidth(handle: Long): Int
+
+ @JvmStatic external fun nGetFrameHeight(handle: Long): Int
+
+ @JvmStatic external fun nSetOutputSize(
+ handle: Long,
+ width: Int,
+ height: Int,
+ ): Int
+
+ // Timing
+ @JvmStatic external fun nGetVideoDuration(handle: Long): Double
+
+ @JvmStatic external fun nGetCurrentTime(handle: Long): Double
+
+ // Metadata
+ @JvmStatic external fun nGetVideoTitle(handle: Long): String?
+
+ @JvmStatic external fun nGetVideoBitrate(handle: Long): Long
+
+ @JvmStatic external fun nGetVideoMimeType(handle: Long): String?
+
+ @JvmStatic external fun nGetAudioChannels(handle: Long): Int
+
+ @JvmStatic external fun nGetAudioSampleRate(handle: Long): Int
+
+ @JvmStatic external fun nGetFrameRate(handle: Long): Float
+
+ // Playback completion
+ @JvmStatic external fun nConsumeDidPlayToEnd(handle: Long): Boolean
+}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt
index 0933ac7a..4e560927 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt
@@ -8,14 +8,12 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Logger.Companion.setMinSeverity
-import co.touchlab.kermit.Severity
import io.github.kdroidfilter.composemediaplayer.InitialPlayerState
import io.github.kdroidfilter.composemediaplayer.SubtitleTrack
import io.github.kdroidfilter.composemediaplayer.VideoMetadata
import io.github.kdroidfilter.composemediaplayer.VideoPlayerError
import io.github.kdroidfilter.composemediaplayer.VideoPlayerState
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import io.github.kdroidfilter.composemediaplayer.util.formatTime
import io.github.vinceglb.filekit.PlatformFile
import kotlinx.coroutines.*
@@ -29,10 +27,8 @@ import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.abs
-import kotlin.math.log10
-internal val linuxLogger = Logger.withTag("LinuxVideoPlayerState")
- .apply { setMinSeverity(Severity.Warn) }
+internal val linuxLogger = TaggedLogger("LinuxVideoPlayerState")
/**
* LinuxVideoPlayerState — JNI-based implementation using a native C GStreamer player.
@@ -42,7 +38,6 @@ internal val linuxLogger = Logger.withTag("LinuxVideoPlayerState")
*/
@Stable
class LinuxVideoPlayerState : VideoPlayerState {
-
// Native player pointer (AtomicLong for lock-free reads from the frame hot path)
private val playerPtrAtomic = AtomicLong(0L)
private val playerPtr: Long get() = playerPtrAtomic.get()
@@ -59,12 +54,6 @@ class LinuxVideoPlayerState : VideoPlayerState {
private var skiaBitmapB: Bitmap? = null
private var nextSkiaBitmapA: Boolean = true
- // Audio levels
- private val _leftLevel = mutableStateOf(0.0f)
- private val _rightLevel = mutableStateOf(0.0f)
- override val leftLevel: Float get() = _leftLevel.value
- override val rightLevel: Float get() = _rightLevel.value
-
// Surface display size (pixels) for output scaling
private var surfaceWidth = 0
private var surfaceHeight = 0
@@ -102,8 +91,8 @@ class LinuxVideoPlayerState : VideoPlayerState {
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- )
+ textAlign = TextAlign.Center,
+ ),
)
override var subtitleBackgroundColor: Color by mutableStateOf(Color.Black.copy(alpha = 0.5f))
override val metadata: VideoMetadata = VideoMetadata()
@@ -117,9 +106,10 @@ class LinuxVideoPlayerState : VideoPlayerState {
override val durationText: String get() = _durationText.value
override val currentTime: Double
- get() = runBlocking {
- if (hasMedia) getPositionSafely() else 0.0
- }
+ get() =
+ runBlocking {
+ if (hasMedia) getPositionSafely() else 0.0
+ }
private val _aspectRatio = mutableStateOf(16f / 9f)
override val aspectRatio: Float get() = _aspectRatio.value
@@ -149,11 +139,12 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
private val updateInterval: Long
- get() = if (captureFrameRate > 0) {
- (1000.0f / captureFrameRate).toLong()
- } else {
- 33L // ~30fps default
- }
+ get() =
+ if (captureFrameRate > 0) {
+ (1000.0f / captureFrameRate).toLong()
+ } else {
+ 33L // ~30fps default
+ }
private val bufferingCheckInterval = 200L
private val bufferingTimeoutThreshold = 500L
@@ -169,39 +160,42 @@ class LinuxVideoPlayerState : VideoPlayerState {
@OptIn(FlowPreview::class)
private fun startUIUpdateJob() {
uiUpdateJob?.cancel()
- uiUpdateJob = ioScope.launch {
- _currentFrameState.debounce(1).collect { newFrame ->
- ensureActive()
- withContext(Dispatchers.Main) {
- (currentFrameState as MutableState).value = newFrame
+ uiUpdateJob =
+ ioScope.launch {
+ _currentFrameState.debounce(1).collect { newFrame ->
+ ensureActive()
+ withContext(Dispatchers.Main) {
+ (currentFrameState as MutableState).value = newFrame
+ }
}
}
- }
}
- private suspend fun initPlayer() = ioScope.launch {
- linuxLogger.d { "initPlayer() - Creating native player" }
- try {
- val ptr = SharedVideoPlayer.nCreatePlayer()
- if (ptr != 0L) {
- playerPtrAtomic.set(ptr)
- linuxLogger.d { "Native player created successfully" }
- applyVolume()
- applyPlaybackSpeed()
- } else {
- linuxLogger.e { "Failed to create native player" }
- withContext(Dispatchers.Main) {
- error = VideoPlayerError.UnknownError("Failed to create native player")
+ private suspend fun initPlayer() =
+ ioScope
+ .launch {
+ linuxLogger.d { "initPlayer() - Creating native player" }
+ try {
+ val ptr = LinuxNativeBridge.nCreatePlayer()
+ if (ptr != 0L) {
+ playerPtrAtomic.set(ptr)
+ linuxLogger.d { "Native player created successfully" }
+ applyVolume()
+ applyPlaybackSpeed()
+ } else {
+ linuxLogger.e { "Failed to create native player" }
+ withContext(Dispatchers.Main) {
+ error = VideoPlayerError.UnknownError("Failed to create native player")
+ }
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ linuxLogger.e { "Exception in initPlayer: ${e.message}" }
+ withContext(Dispatchers.Main) {
+ error = VideoPlayerError.UnknownError("Failed to initialize player: ${e.message}")
+ }
}
- }
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- linuxLogger.e { "Exception in initPlayer: ${e.message}" }
- withContext(Dispatchers.Main) {
- error = VideoPlayerError.UnknownError("Failed to initialize player: ${e.message}")
- }
- }
- }.join()
+ }.join()
private fun checkExistsIfLocalFile(uri: String): Boolean {
val schemeDelimiter = uri.indexOf("://")
@@ -215,7 +209,10 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
}
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
linuxLogger.d { "openUri() - Opening URI: $uri" }
lastUri = uri
@@ -278,7 +275,10 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
}
- override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) {
+ override fun openFile(
+ file: PlatformFile,
+ initializeplayerState: InitialPlayerState,
+ ) {
openUri(file.file.path, initializeplayerState)
}
@@ -287,13 +287,14 @@ class LinuxVideoPlayerState : VideoPlayerState {
stopFrameUpdates()
stopBufferingCheck()
- val ptrToDispose = withContext(frameDispatcher) {
- playerPtrAtomic.getAndSet(0L)
- }
+ val ptrToDispose =
+ withContext(frameDispatcher) {
+ playerPtrAtomic.getAndSet(0L)
+ }
if (ptrToDispose != 0L) {
try {
- SharedVideoPlayer.nDisposePlayer(ptrToDispose)
+ LinuxNativeBridge.nDisposePlayer(ptrToDispose)
} catch (e: Exception) {
if (e is CancellationException) throw e
linuxLogger.e { "Error disposing player: ${e.message}" }
@@ -307,10 +308,10 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
if (playerPtr == 0L) {
- val ptr = SharedVideoPlayer.nCreatePlayer()
+ val ptr = LinuxNativeBridge.nCreatePlayer()
if (ptr != 0L) {
if (!playerPtrAtomic.compareAndSet(0L, ptr)) {
- SharedVideoPlayer.nDisposePlayer(ptr)
+ LinuxNativeBridge.nDisposePlayer(ptr)
} else {
applyVolume()
applyPlaybackSpeed()
@@ -331,7 +332,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
return try {
- SharedVideoPlayer.nOpenUri(ptr, uri)
+ LinuxNativeBridge.nOpenUri(ptr, uri)
pollDimensionsUntilReady(ptr)
updateMetadata()
true
@@ -342,10 +343,13 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
}
- private suspend fun pollDimensionsUntilReady(ptr: Long, maxAttempts: Int = 20) {
+ private suspend fun pollDimensionsUntilReady(
+ ptr: Long,
+ maxAttempts: Int = 20,
+ ) {
for (attempt in 1..maxAttempts) {
- val width = SharedVideoPlayer.nGetFrameWidth(ptr)
- val height = SharedVideoPlayer.nGetFrameHeight(ptr)
+ val width = LinuxNativeBridge.nGetFrameWidth(ptr)
+ val height = LinuxNativeBridge.nGetFrameHeight(ptr)
if (width > 0 && height > 0) {
linuxLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" }
return
@@ -360,7 +364,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return
try {
- captureFrameRate = SharedVideoPlayer.nGetFrameRate(ptr)
+ captureFrameRate = LinuxNativeBridge.nGetFrameRate(ptr)
linuxLogger.d { "Frame rate: $captureFrameRate" }
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -373,21 +377,22 @@ class LinuxVideoPlayerState : VideoPlayerState {
if (ptr == 0L) return
try {
- val width = SharedVideoPlayer.nGetFrameWidth(ptr)
- val height = SharedVideoPlayer.nGetFrameHeight(ptr)
- val duration = SharedVideoPlayer.nGetVideoDuration(ptr).toLong()
- val frameRate = SharedVideoPlayer.nGetFrameRate(ptr)
- val newAspectRatio = if (width > 0 && height > 0) {
- width.toFloat() / height.toFloat()
- } else {
- _aspectRatio.value
- }
+ val width = LinuxNativeBridge.nGetFrameWidth(ptr)
+ val height = LinuxNativeBridge.nGetFrameHeight(ptr)
+ val duration = LinuxNativeBridge.nGetVideoDuration(ptr).toLong()
+ val frameRate = LinuxNativeBridge.nGetFrameRate(ptr)
+ val newAspectRatio =
+ if (width > 0 && height > 0) {
+ width.toFloat() / height.toFloat()
+ } else {
+ _aspectRatio.value
+ }
- val title = SharedVideoPlayer.nGetVideoTitle(ptr)
- val bitrate = SharedVideoPlayer.nGetVideoBitrate(ptr)
- val mimeType = SharedVideoPlayer.nGetVideoMimeType(ptr)
- val audioChannels = SharedVideoPlayer.nGetAudioChannels(ptr)
- val audioSampleRate = SharedVideoPlayer.nGetAudioSampleRate(ptr)
+ val title = LinuxNativeBridge.nGetVideoTitle(ptr)
+ val bitrate = LinuxNativeBridge.nGetVideoBitrate(ptr)
+ val mimeType = LinuxNativeBridge.nGetVideoMimeType(ptr)
+ val audioChannels = LinuxNativeBridge.nGetAudioChannels(ptr)
+ val audioSampleRate = LinuxNativeBridge.nGetAudioSampleRate(ptr)
withContext(Dispatchers.Main) {
metadata.duration = duration
@@ -411,17 +416,17 @@ class LinuxVideoPlayerState : VideoPlayerState {
private fun startFrameUpdates() {
stopFrameUpdates()
- frameUpdateJob = ioScope.launch {
- while (isActive) {
- ensureActive()
- updateFrameAsync()
- if (!userDragging) {
- updatePositionAsync()
- updateAudioLevelsAsync()
+ frameUpdateJob =
+ ioScope.launch {
+ while (isActive) {
+ ensureActive()
+ updateFrameAsync()
+ if (!userDragging) {
+ updatePositionAsync()
+ }
+ delay(updateInterval)
}
- delay(updateInterval)
}
- }
}
private fun stopFrameUpdates() {
@@ -431,13 +436,14 @@ class LinuxVideoPlayerState : VideoPlayerState {
private fun startBufferingCheck() {
stopBufferingCheck()
- bufferingCheckJob = ioScope.launch {
- while (isActive) {
- ensureActive()
- checkBufferingState()
- delay(bufferingCheckInterval)
+ bufferingCheckJob =
+ ioScope.launch {
+ while (isActive) {
+ ensureActive()
+ checkBufferingState()
+ delay(bufferingCheckInterval)
+ }
}
- }
}
private suspend fun checkBufferingState() {
@@ -460,11 +466,11 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return@withContext
- val width = SharedVideoPlayer.nGetFrameWidth(ptr)
- val height = SharedVideoPlayer.nGetFrameHeight(ptr)
+ val width = LinuxNativeBridge.nGetFrameWidth(ptr)
+ val height = LinuxNativeBridge.nGetFrameHeight(ptr)
if (width <= 0 || height <= 0) return@withContext
- val frameAddress = SharedVideoPlayer.nGetLatestFrameAddress(ptr)
+ val frameAddress = LinuxNativeBridge.nGetLatestFrameAddress(ptr)
if (frameAddress == 0L) return@withContext
val pixelCount = width * height
@@ -472,8 +478,9 @@ class LinuxVideoPlayerState : VideoPlayerState {
var framePublished = false
withContext(Dispatchers.Default) {
- val srcBuf = SharedVideoPlayer.nWrapPointer(frameAddress, frameSizeBytes)
- ?: return@withContext
+ val srcBuf =
+ LinuxNativeBridge.nWrapPointer(frameAddress, frameSizeBytes)
+ ?: return@withContext
// Allocate/reuse double-buffered bitmaps
if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) {
@@ -499,8 +506,9 @@ class LinuxVideoPlayerState : VideoPlayerState {
srcBuf.rewind()
val destRowBytes = pixmap.rowBytes.toInt()
val destSizeBytes = destRowBytes.toLong() * height.toLong()
- val destBuf = SharedVideoPlayer.nWrapPointer(pixelsAddr, destSizeBytes)
- ?: return@withContext
+ val destBuf =
+ LinuxNativeBridge.nWrapPointer(pixelsAddr, destSizeBytes)
+ ?: return@withContext
copyBgraFrame(srcBuf, destBuf, width, height, destRowBytes)
_currentFrameState.value = targetBitmap.asComposeImageBitmap()
@@ -520,32 +528,6 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
}
- private suspend fun updateAudioLevelsAsync() {
- if (!hasMedia) return
- try {
- val ptr = playerPtr
- if (ptr != 0L) {
- val newLeft = SharedVideoPlayer.nGetLeftAudioLevel(ptr)
- val newRight = SharedVideoPlayer.nGetRightAudioLevel(ptr)
-
- fun convertToPercentage(level: Float): Float {
- if (level <= 0f) return 0f
- val db = 20 * log10(level)
- val normalized = ((db + 60) / 60).coerceIn(0f, 1f)
- return normalized * 100f
- }
-
- withContext(Dispatchers.Main) {
- _leftLevel.value = convertToPercentage(newLeft)
- _rightLevel.value = convertToPercentage(newRight)
- }
- }
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- linuxLogger.e { "Error updating audio levels: ${e.message}" }
- }
- }
-
private suspend fun updatePositionAsync() {
if (!hasMedia || userDragging) return
try {
@@ -577,9 +559,12 @@ class LinuxVideoPlayerState : VideoPlayerState {
}
}
- private suspend fun checkLoopingAsync(current: Double, duration: Double) {
+ private suspend fun checkLoopingAsync(
+ current: Double,
+ duration: Double,
+ ) {
val ptr = playerPtr
- val ended = ptr != 0L && SharedVideoPlayer.nConsumeDidPlayToEnd(ptr)
+ val ended = ptr != 0L && LinuxNativeBridge.nConsumeDidPlayToEnd(ptr)
if (!ended && (duration <= 0 || current < duration - 0.5)) return
if (loop) {
@@ -611,7 +596,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return
try {
- SharedVideoPlayer.nPlay(ptr)
+ LinuxNativeBridge.nPlay(ptr)
withContext(Dispatchers.Main) { isPlaying = true }
startFrameUpdates()
startBufferingCheck()
@@ -630,7 +615,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return
try {
- SharedVideoPlayer.nPause(ptr)
+ LinuxNativeBridge.nPause(ptr)
withContext(Dispatchers.Main) {
isPlaying = false
isLoading = false
@@ -685,10 +670,10 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return
- SharedVideoPlayer.nSeekTo(ptr, seekTime.toDouble())
+ LinuxNativeBridge.nSeekTo(ptr, seekTime.toDouble())
if (isPlaying) {
- SharedVideoPlayer.nPlay(ptr)
+ LinuxNativeBridge.nPlay(ptr)
delay(10)
updateFrameAsync()
ioScope.launch {
@@ -728,23 +713,24 @@ class LinuxVideoPlayerState : VideoPlayerState {
playerScope.cancel()
ioScope.launch {
- val ptrToDispose = withContext(frameDispatcher) {
- val ptr = playerPtrAtomic.getAndSet(0L)
-
- skiaBitmapA?.close()
- skiaBitmapB?.close()
- skiaBitmapA = null
- skiaBitmapB = null
- skiaBitmapWidth = 0
- skiaBitmapHeight = 0
- nextSkiaBitmapA = true
-
- ptr
- }
+ val ptrToDispose =
+ withContext(frameDispatcher) {
+ val ptr = playerPtrAtomic.getAndSet(0L)
+
+ skiaBitmapA?.close()
+ skiaBitmapB?.close()
+ skiaBitmapA = null
+ skiaBitmapB = null
+ skiaBitmapWidth = 0
+ skiaBitmapHeight = 0
+ nextSkiaBitmapA = true
+
+ ptr
+ }
if (ptrToDispose != 0L) {
try {
- SharedVideoPlayer.nDisposePlayer(ptrToDispose)
+ LinuxNativeBridge.nDisposePlayer(ptrToDispose)
} catch (e: Exception) {
if (e is CancellationException) throw e
linuxLogger.e { "Error disposing player: ${e.message}" }
@@ -779,7 +765,10 @@ class LinuxVideoPlayerState : VideoPlayerState {
// --- Output scaling ---
- fun onResized(width: Int, height: Int) {
+ fun onResized(
+ width: Int,
+ height: Int,
+ ) {
if (width <= 0 || height <= 0) return
if (width == surfaceWidth && height == surfaceHeight) return
@@ -788,14 +777,15 @@ class LinuxVideoPlayerState : VideoPlayerState {
isResizing.set(true)
resizeJob?.cancel()
- resizeJob = ioScope.launch {
- delay(120)
- try {
- applyOutputScaling()
- } finally {
- isResizing.set(false)
+ resizeJob =
+ ioScope.launch {
+ delay(120)
+ try {
+ applyOutputScaling()
+ } finally {
+ isResizing.set(false)
+ }
}
- }
}
private suspend fun applyOutputScaling() {
@@ -804,7 +794,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
if (sw <= 0 || sh <= 0) return
val ptr = playerPtr
if (ptr == 0L) return
- SharedVideoPlayer.nSetOutputSize(ptr, sw, sh)
+ LinuxNativeBridge.nSetOutputSize(ptr, sw, sh)
}
// --- Internal helpers ---
@@ -842,7 +832,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return 0.0
return try {
- SharedVideoPlayer.nGetCurrentTime(ptr)
+ LinuxNativeBridge.nGetCurrentTime(ptr)
} catch (e: Exception) {
if (e is CancellationException) throw e
0.0
@@ -853,7 +843,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return 0.0
return try {
- SharedVideoPlayer.nGetVideoDuration(ptr)
+ LinuxNativeBridge.nGetVideoDuration(ptr)
} catch (e: Exception) {
if (e is CancellationException) throw e
0.0
@@ -862,19 +852,23 @@ class LinuxVideoPlayerState : VideoPlayerState {
private suspend fun applyVolume() {
val ptr = playerPtr
- if (ptr != 0L) try {
- SharedVideoPlayer.nSetVolume(ptr, _volumeState.value)
- } catch (e: Exception) {
- if (e is CancellationException) throw e
+ if (ptr != 0L) {
+ try {
+ LinuxNativeBridge.nSetVolume(ptr, _volumeState.value)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ }
}
}
private suspend fun applyPlaybackSpeed() {
val ptr = playerPtr
- if (ptr != 0L) try {
- SharedVideoPlayer.nSetPlaybackSpeed(ptr, _playbackSpeedState.value)
- } catch (e: Exception) {
- if (e is CancellationException) throw e
+ if (ptr != 0L) {
+ try {
+ LinuxNativeBridge.nSetPlaybackSpeed(ptr, _playbackSpeedState.value)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ }
}
}
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt
index 5515d03e..35ea655f 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt
@@ -16,7 +16,6 @@ import io.github.kdroidfilter.composemediaplayer.util.drawScaledImage
import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier
import io.github.kdroidfilter.composemediaplayer.util.toTimeMs
-
/**
* A composable function that renders a video player surface using a native GStreamer
* player via JNI with offscreen rendering.
@@ -33,13 +32,14 @@ fun LinuxVideoPlayerSurface(
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
overlay: @Composable () -> Unit = {},
- isInFullscreenWindow: Boolean = false
+ isInFullscreenWindow: Boolean = false,
) {
Box(
- modifier = modifier.onSizeChanged { size ->
- playerState.onResized(size.width, size.height)
- },
- contentAlignment = Alignment.Center
+ modifier =
+ modifier.onSizeChanged { size ->
+ playerState.onResized(size.width, size.height)
+ },
+ contentAlignment = Alignment.Center,
) {
// Only render video in this surface if we're not in fullscreen mode or if this is the fullscreen window
if (playerState.hasMedia && (!playerState.isFullscreen || isInFullscreenWindow)) {
@@ -48,24 +48,28 @@ fun LinuxVideoPlayerSurface(
currentFrame?.let { frame ->
Canvas(
- modifier = contentScale.toCanvasModifier(
- playerState.aspectRatio,
- playerState.metadata.width,
- playerState.metadata.height
- ),
+ modifier =
+ contentScale.toCanvasModifier(
+ playerState.aspectRatio,
+ playerState.metadata.width,
+ playerState.metadata.height,
+ ),
) {
drawScaledImage(
image = frame,
dstSize = IntSize(size.width.toInt(), size.height.toInt()),
- contentScale = contentScale
+ contentScale = contentScale,
)
}
}
// Add Compose-based subtitle layer
if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) {
- val currentTimeMs = (playerState.sliderPos / 1000f *
- playerState.durationText.toTimeMs()).toLong()
+ val currentTimeMs =
+ (
+ playerState.sliderPos / 1000f *
+ playerState.durationText.toTimeMs()
+ ).toLong()
val durationMs = playerState.durationText.toTimeMs()
ComposeSubtitleLayer(
@@ -75,7 +79,7 @@ fun LinuxVideoPlayerSurface(
subtitleTrack = playerState.currentSubtitleTrack,
subtitlesEnabled = playerState.subtitlesEnabled,
textStyle = playerState.subtitleTextStyle,
- backgroundColor = playerState.subtitleBackgroundColor
+ backgroundColor = playerState.subtitleBackgroundColor,
)
}
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt
deleted file mode 100644
index 44fe7633..00000000
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package io.github.kdroidfilter.composemediaplayer.linux
-
-import java.io.File
-import java.nio.ByteBuffer
-import java.nio.file.Files
-
-/**
- * JNI direct mapping to the native Linux GStreamer video player library.
- * Handles are opaque Long values (native pointer cast to jlong, 0 = null).
- */
-internal object SharedVideoPlayer {
- init {
- loadNativeLibrary()
- }
-
- private fun loadNativeLibrary() {
- val osArch = System.getProperty("os.arch", "").lowercase()
- val resourceDir = if (osArch == "aarch64" || osArch == "arm64") "linux-aarch64" else "linux-x86-64"
- val libName = "libNativeVideoPlayer.so"
-
- val stream = SharedVideoPlayer::class.java.getResourceAsStream("/$resourceDir/$libName")
- ?: throw UnsatisfiedLinkError(
- "Native library not found in resources: /$resourceDir/$libName"
- )
-
- val tempDir = Files.createTempDirectory("nativevideoplayer").toFile()
- val tempFile = File(tempDir, libName)
- stream.use { input -> tempFile.outputStream().use { input.copyTo(it) } }
- System.load(tempFile.absolutePath)
- tempFile.deleteOnExit()
- tempDir.deleteOnExit()
- }
-
- // Playback control
- @JvmStatic external fun nCreatePlayer(): Long
- @JvmStatic external fun nOpenUri(handle: Long, uri: String)
- @JvmStatic external fun nPlay(handle: Long)
- @JvmStatic external fun nPause(handle: Long)
- @JvmStatic external fun nSetVolume(handle: Long, volume: Float)
- @JvmStatic external fun nGetVolume(handle: Long): Float
- @JvmStatic external fun nSeekTo(handle: Long, time: Double)
- @JvmStatic external fun nDisposePlayer(handle: Long)
- @JvmStatic external fun nSetPlaybackSpeed(handle: Long, speed: Float)
- @JvmStatic external fun nGetPlaybackSpeed(handle: Long): Float
-
- // Frame access
- @JvmStatic external fun nGetLatestFrameAddress(handle: Long): Long
- @JvmStatic external fun nWrapPointer(address: Long, size: Long): ByteBuffer?
- @JvmStatic external fun nGetFrameWidth(handle: Long): Int
- @JvmStatic external fun nGetFrameHeight(handle: Long): Int
- @JvmStatic external fun nSetOutputSize(handle: Long, width: Int, height: Int): Int
-
- // Timing
- @JvmStatic external fun nGetVideoDuration(handle: Long): Double
- @JvmStatic external fun nGetCurrentTime(handle: Long): Double
-
- // Audio levels
- @JvmStatic external fun nGetLeftAudioLevel(handle: Long): Float
- @JvmStatic external fun nGetRightAudioLevel(handle: Long): Float
-
- // Metadata
- @JvmStatic external fun nGetVideoTitle(handle: Long): String?
- @JvmStatic external fun nGetVideoBitrate(handle: Long): Long
- @JvmStatic external fun nGetVideoMimeType(handle: Long): String?
- @JvmStatic external fun nGetAudioChannels(handle: Long): Int
- @JvmStatic external fun nGetAudioSampleRate(handle: Long): Int
- @JvmStatic external fun nGetFrameRate(handle: Long): Float
-
- // Playback completion
- @JvmStatic external fun nConsumeDidPlayToEnd(handle: Long): Boolean
-}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt
index aceb82f3..e9aef095 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt
@@ -2,7 +2,10 @@ package io.github.kdroidfilter.composemediaplayer.mac
import java.nio.ByteBuffer
-internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int {
+internal fun calculateFrameHash(
+ buffer: ByteBuffer,
+ pixelCount: Int,
+): Int {
if (pixelCount <= 0) return 0
var hash = 1
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFullscreenVideoPlayerWindow.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFullscreenVideoPlayerWindow.kt
index 902e1a03..6f0dc168 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFullscreenVideoPlayerWindow.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFullscreenVideoPlayerWindow.kt
@@ -16,7 +16,7 @@ import io.github.kdroidfilter.composemediaplayer.common.openFullscreenWindow
fun openFullscreenWindow(
playerState: MacVideoPlayerState,
overlay: @Composable () -> Unit = {},
- contentScale: ContentScale = ContentScale.Fit
+ contentScale: ContentScale = ContentScale.Fit,
) {
openFullscreenWindow(
playerState = playerState,
@@ -26,8 +26,8 @@ fun openFullscreenWindow(
modifier = modifier,
contentScale = contentScale,
overlay = overlay,
- isInFullscreenWindow = isInFullscreenWindow
+ isInFullscreenWindow = isInFullscreenWindow,
)
- }
+ },
)
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt
similarity index 54%
rename from mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt
rename to mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt
index b365c06a..6f5cfac5 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt
@@ -1,76 +1,96 @@
package io.github.kdroidfilter.composemediaplayer.mac
-import java.io.File
+import io.github.kdroidfilter.composemediaplayer.util.NativeLibraryLoader
import java.nio.ByteBuffer
-import java.nio.file.Files
/**
* JNI direct mapping to the native macOS video player library.
* Handles are opaque Long values (native pointer cast to jlong, 0 = null).
*/
-internal object SharedVideoPlayer {
+internal object MacNativeBridge {
init {
- loadNativeLibrary()
- }
-
- private fun loadNativeLibrary() {
- val osArch = System.getProperty("os.arch", "").lowercase()
- val resourceDir =
- if (osArch == "aarch64" || osArch == "arm64") "darwin-aarch64" else "darwin-x86-64"
- val libName = "libNativeVideoPlayer.dylib"
-
- val stream = SharedVideoPlayer::class.java.getResourceAsStream("/$resourceDir/$libName")
- ?: throw UnsatisfiedLinkError(
- "Native library not found in resources: /$resourceDir/$libName"
- )
-
- val tempDir = Files.createTempDirectory("nativevideoplayer").toFile()
- val tempFile = File(tempDir, libName)
- stream.use { input -> tempFile.outputStream().use { input.copyTo(it) } }
- System.load(tempFile.absolutePath)
- tempFile.deleteOnExit()
- tempDir.deleteOnExit()
+ NativeLibraryLoader.load("NativeVideoPlayer", MacNativeBridge::class.java)
}
// Playback control
@JvmStatic external fun nCreatePlayer(): Long
- @JvmStatic external fun nOpenUri(handle: Long, uri: String)
+
+ @JvmStatic external fun nOpenUri(
+ handle: Long,
+ uri: String,
+ )
+
@JvmStatic external fun nPlay(handle: Long)
+
@JvmStatic external fun nPause(handle: Long)
- @JvmStatic external fun nSetVolume(handle: Long, volume: Float)
+
+ @JvmStatic external fun nSetVolume(
+ handle: Long,
+ volume: Float,
+ )
+
@JvmStatic external fun nGetVolume(handle: Long): Float
- @JvmStatic external fun nSeekTo(handle: Long, time: Double)
+
+ @JvmStatic external fun nSeekTo(
+ handle: Long,
+ time: Double,
+ )
+
@JvmStatic external fun nDisposePlayer(handle: Long)
- @JvmStatic external fun nSetPlaybackSpeed(handle: Long, speed: Float)
+
+ @JvmStatic external fun nSetPlaybackSpeed(
+ handle: Long,
+ speed: Float,
+ )
+
@JvmStatic external fun nGetPlaybackSpeed(handle: Long): Float
// Frame access — lock/unlock CVPixelBuffer directly (zero intermediate copy)
// outInfo must be IntArray(3); filled with [width, height, bytesPerRow] on success.
// Returns the native base address of the locked buffer, or 0 on failure.
// MUST call nUnlockFrame after reading.
- @JvmStatic external fun nLockFrame(handle: Long, outInfo: IntArray): Long
+ @JvmStatic external fun nLockFrame(
+ handle: Long,
+ outInfo: IntArray,
+ ): Long
+
@JvmStatic external fun nUnlockFrame(handle: Long)
- @JvmStatic external fun nWrapPointer(address: Long, size: Long): ByteBuffer?
+
+ @JvmStatic external fun nWrapPointer(
+ address: Long,
+ size: Long,
+ ): ByteBuffer?
+
@JvmStatic external fun nGetFrameWidth(handle: Long): Int
+
@JvmStatic external fun nGetFrameHeight(handle: Long): Int
- @JvmStatic external fun nSetOutputSize(handle: Long, width: Int, height: Int): Int
+
+ @JvmStatic external fun nSetOutputSize(
+ handle: Long,
+ width: Int,
+ height: Int,
+ ): Int
// Timing / rate info
@JvmStatic external fun nGetVideoFrameRate(handle: Long): Float
+
@JvmStatic external fun nGetScreenRefreshRate(handle: Long): Float
+
@JvmStatic external fun nGetCaptureFrameRate(handle: Long): Float
+
@JvmStatic external fun nGetVideoDuration(handle: Long): Double
- @JvmStatic external fun nGetCurrentTime(handle: Long): Double
- // Audio levels
- @JvmStatic external fun nGetLeftAudioLevel(handle: Long): Float
- @JvmStatic external fun nGetRightAudioLevel(handle: Long): Float
+ @JvmStatic external fun nGetCurrentTime(handle: Long): Double
// Metadata
@JvmStatic external fun nGetVideoTitle(handle: Long): String?
+
@JvmStatic external fun nGetVideoBitrate(handle: Long): Long
+
@JvmStatic external fun nGetVideoMimeType(handle: Long): String?
+
@JvmStatic external fun nGetAudioChannels(handle: Long): Int
+
@JvmStatic external fun nGetAudioSampleRate(handle: Long): Int
// Playback completion
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt
index 199c1030..f35a3d5f 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt
@@ -8,47 +8,39 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Logger.Companion.setMinSeverity
-import co.touchlab.kermit.Severity
import io.github.kdroidfilter.composemediaplayer.InitialPlayerState
-import io.github.kdroidfilter.composemediaplayer.VideoPlayerState
import io.github.kdroidfilter.composemediaplayer.SubtitleTrack
import io.github.kdroidfilter.composemediaplayer.VideoMetadata
import io.github.kdroidfilter.composemediaplayer.VideoPlayerError
+import io.github.kdroidfilter.composemediaplayer.VideoPlayerState
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import io.github.kdroidfilter.composemediaplayer.util.formatTime
import io.github.vinceglb.filekit.PlatformFile
-import io.github.vinceglb.filekit.utils.toFile
-import io.github.vinceglb.filekit.utils.toPath
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicLong
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
import kotlin.math.abs
-import kotlin.math.log10
-// Initialize logger using Kermit
-internal val macLogger = Logger.withTag("MacVideoPlayerState")
- .apply { setMinSeverity(Severity.Warn) }
+internal val macLogger = TaggedLogger("MacVideoPlayerState")
/**
* MacVideoPlayerState handles the native Mac video player state.
*
- * This implementation uses a native video player via SharedVideoPlayer.
- * All debug logs are handled with Kermit.
+ * This implementation uses a native video player via MacNativeBridge.
*/
class MacVideoPlayerState : VideoPlayerState {
-
// Main state variables
// AtomicLong allows lock-free reads of the native pointer from the frame hot path
private val playerPtrAtomic = AtomicLong(0L)
private val playerPtr: Long get() = playerPtrAtomic.get()
+
// Serial dispatcher for frame processing — ensures only one frame is processed at a time
private val frameDispatcher = Dispatchers.Default.limitedParallelism(1)
private val _currentFrameState = MutableStateFlow(null)
@@ -59,12 +51,6 @@ class MacVideoPlayerState : VideoPlayerState {
private var skiaBitmapB: Bitmap? = null
private var nextSkiaBitmapA: Boolean = true
- // Audio level state variables (added for left and right levels)
- private val _leftLevel = mutableStateOf(0.0f)
- private val _rightLevel = mutableStateOf(0.0f)
- override val leftLevel: Float get() = _leftLevel.value
- override val rightLevel: Float get() = _rightLevel.value
-
// Surface display size (pixels) — used to scale native output resolution
private var surfaceWidth = 0
private var surfaceHeight = 0
@@ -102,8 +88,8 @@ class MacVideoPlayerState : VideoPlayerState {
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- )
+ textAlign = TextAlign.Center,
+ ),
)
override var subtitleBackgroundColor: Color by mutableStateOf(Color.Black.copy(alpha = 0.5f))
override val metadata: VideoMetadata = VideoMetadata()
@@ -116,11 +102,12 @@ class MacVideoPlayerState : VideoPlayerState {
private val _durationText = mutableStateOf("00:00")
override val durationText: String get() = _durationText.value
-
+
override val currentTime: Double
- get() = runBlocking {
- if (hasMedia) getPositionSafely() else 0.0
- }
+ get() =
+ runBlocking {
+ if (hasMedia) getPositionSafely() else 0.0
+ }
// Non-blocking aspect ratio property
private val _aspectRatio = mutableStateOf(16f / 9f)
@@ -158,11 +145,12 @@ class MacVideoPlayerState : VideoPlayerState {
}
private val updateInterval: Long
- get() = if (captureFrameRate > 0) {
- (1000.0f / captureFrameRate).toLong()
- } else {
- 33L // Default value (in ms) if no valid capture rate is provided
- }
+ get() =
+ if (captureFrameRate > 0) {
+ (1000.0f / captureFrameRate).toLong()
+ } else {
+ 33L // Default value (in ms) if no valid capture rate is provided
+ }
// Buffering detection constants
private val bufferingCheckInterval = 200L // Increased from 100ms to reduce CPU usage
@@ -183,41 +171,43 @@ class MacVideoPlayerState : VideoPlayerState {
@OptIn(FlowPreview::class)
private fun startUIUpdateJob() {
uiUpdateJob?.cancel()
- uiUpdateJob = ioScope.launch {
- _currentFrameState.debounce(1).collect { newFrame ->
- ensureActive() // Checks that the coroutine is still active
- withContext(Dispatchers.Main) {
- (currentFrameState as MutableState).value = newFrame
+ uiUpdateJob =
+ ioScope.launch {
+ _currentFrameState.debounce(1).collect { newFrame ->
+ ensureActive() // Checks that the coroutine is still active
+ withContext(Dispatchers.Main) {
+ (currentFrameState as MutableState).value = newFrame
+ }
}
}
- }
}
-
/** Initializes the native video player on the IO thread. */
- private suspend fun initPlayer() = ioScope.launch {
- macLogger.d { "initPlayer() - Creating native player" }
- try {
- val ptr = SharedVideoPlayer.nCreatePlayer()
- if (ptr != 0L) {
- playerPtrAtomic.set(ptr)
- macLogger.d { "Native player created successfully" }
- applyVolume()
- applyPlaybackSpeed()
- } else {
- macLogger.e { "Error: Failed to create native player" }
- withContext(Dispatchers.Main) {
- error = VideoPlayerError.UnknownError("Failed to create native player")
+ private suspend fun initPlayer() =
+ ioScope
+ .launch {
+ macLogger.d { "initPlayer() - Creating native player" }
+ try {
+ val ptr = MacNativeBridge.nCreatePlayer()
+ if (ptr != 0L) {
+ playerPtrAtomic.set(ptr)
+ macLogger.d { "Native player created successfully" }
+ applyVolume()
+ applyPlaybackSpeed()
+ } else {
+ macLogger.e { "Error: Failed to create native player" }
+ withContext(Dispatchers.Main) {
+ error = VideoPlayerError.UnknownError("Failed to create native player")
+ }
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ macLogger.e { "Exception in initPlayer: ${e.message}" }
+ withContext(Dispatchers.Main) {
+ error = VideoPlayerError.UnknownError("Failed to initialize player: ${e.message}")
+ }
}
- }
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- macLogger.e { "Exception in initPlayer: ${e.message}" }
- withContext(Dispatchers.Main) {
- error = VideoPlayerError.UnknownError("Failed to initialize player: ${e.message}")
- }
- }
- }.join()
+ }.join()
/** Updates the frame rate information from the native player. */
private suspend fun updateFrameRateInfo() {
@@ -226,10 +216,12 @@ class MacVideoPlayerState : VideoPlayerState {
if (ptr == 0L) return
try {
- videoFrameRate = SharedVideoPlayer.nGetVideoFrameRate(ptr)
- screenRefreshRate = SharedVideoPlayer.nGetScreenRefreshRate(ptr)
- captureFrameRate = SharedVideoPlayer.nGetCaptureFrameRate(ptr)
- macLogger.d { "Frame Rates - Video: $videoFrameRate, Screen: $screenRefreshRate, Capture: $captureFrameRate" }
+ videoFrameRate = MacNativeBridge.nGetVideoFrameRate(ptr)
+ screenRefreshRate = MacNativeBridge.nGetScreenRefreshRate(ptr)
+ captureFrameRate = MacNativeBridge.nGetCaptureFrameRate(ptr)
+ macLogger.d {
+ "Frame Rates - Video: $videoFrameRate, Screen: $screenRefreshRate, Capture: $captureFrameRate"
+ }
} catch (e: Exception) {
if (e is CancellationException) throw e
macLogger.e { "Error updating frame rate info: ${e.message}" }
@@ -251,7 +243,10 @@ class MacVideoPlayerState : VideoPlayerState {
}
}
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
macLogger.d { "openUri() - Opening URI: $uri, initializeplayerState: $initializeplayerState" }
lastUri = uri
@@ -267,7 +262,7 @@ class MacVideoPlayerState : VideoPlayerState {
ioScope.launch {
withContext(Dispatchers.Main) {
isLoading = true
- error = null // Clear any previous errors only if we got this far
+ error = null // Clear any previous errors only if we got this far
playbackSpeed = 1.0f
}
@@ -335,7 +330,7 @@ class MacVideoPlayerState : VideoPlayerState {
override fun openFile(
file: PlatformFile,
- initializeplayerState: InitialPlayerState
+ initializeplayerState: InitialPlayerState,
) {
openUri(file.file.path, initializeplayerState)
}
@@ -347,14 +342,15 @@ class MacVideoPlayerState : VideoPlayerState {
stopFrameUpdates()
stopBufferingCheck()
- val ptrToDispose = withContext(frameDispatcher) {
- playerPtrAtomic.getAndSet(0L)
- }
+ val ptrToDispose =
+ withContext(frameDispatcher) {
+ playerPtrAtomic.getAndSet(0L)
+ }
// Release resources outside of the mutex lock
if (ptrToDispose != 0L) {
try {
- SharedVideoPlayer.nDisposePlayer(ptrToDispose)
+ MacNativeBridge.nDisposePlayer(ptrToDispose)
} catch (e: Exception) {
if (e is CancellationException) throw e
macLogger.e { "Error disposing player: ${e.message}" }
@@ -370,11 +366,11 @@ class MacVideoPlayerState : VideoPlayerState {
}
if (playerPtr == 0L) {
- val ptr = SharedVideoPlayer.nCreatePlayer()
+ val ptr = MacNativeBridge.nCreatePlayer()
if (ptr != 0L) {
if (!playerPtrAtomic.compareAndSet(0L, ptr)) {
// Another coroutine already initialized the player; discard ours
- SharedVideoPlayer.nDisposePlayer(ptr)
+ MacNativeBridge.nDisposePlayer(ptr)
} else {
applyVolume()
applyPlaybackSpeed()
@@ -402,7 +398,7 @@ class MacVideoPlayerState : VideoPlayerState {
return try {
// Open video asynchronously
- SharedVideoPlayer.nOpenUri(ptr, uri)
+ MacNativeBridge.nOpenUri(ptr, uri)
// Instead of directly calling `updateMetadata()`,
// we poll until valid dimensions are available
@@ -425,10 +421,13 @@ class MacVideoPlayerState : VideoPlayerState {
* are no longer zero. If dimensions are still zero after
* a specified number of attempts, stop waiting.
*/
- private suspend fun pollDimensionsUntilReady(ptr: Long, maxAttempts: Int = 20) {
+ private suspend fun pollDimensionsUntilReady(
+ ptr: Long,
+ maxAttempts: Int = 20,
+ ) {
for (attempt in 1..maxAttempts) {
- val width = SharedVideoPlayer.nGetFrameWidth(ptr)
- val height = SharedVideoPlayer.nGetFrameHeight(ptr)
+ val width = MacNativeBridge.nGetFrameWidth(ptr)
+ val height = MacNativeBridge.nGetFrameHeight(ptr)
if (width > 0 && height > 0) {
macLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" }
@@ -447,26 +446,27 @@ class MacVideoPlayerState : VideoPlayerState {
if (ptr == 0L) return
try {
- val width = SharedVideoPlayer.nGetFrameWidth(ptr)
- val height = SharedVideoPlayer.nGetFrameHeight(ptr)
- val duration = SharedVideoPlayer.nGetVideoDuration(ptr).toLong()
- val frameRate = SharedVideoPlayer.nGetVideoFrameRate(ptr)
+ val width = MacNativeBridge.nGetFrameWidth(ptr)
+ val height = MacNativeBridge.nGetFrameHeight(ptr)
+ val duration = MacNativeBridge.nGetVideoDuration(ptr).toLong()
+ val frameRate = MacNativeBridge.nGetVideoFrameRate(ptr)
// Calculate aspect ratio
- val newAspectRatio = if (width > 0 && height > 0) {
- width.toFloat() / height.toFloat()
- } else {
- // Au lieu de forcer 16f/9f, ne changez pas l’aspect si la vidéo n’est pas encore prête.
- // Par exemple, on peut conserver l’ancien aspect ratio :
- _aspectRatio.value
- }
+ val newAspectRatio =
+ if (width > 0 && height > 0) {
+ width.toFloat() / height.toFloat()
+ } else {
+ // Au lieu de forcer 16f/9f, ne changez pas l’aspect si la vidéo n’est pas encore prête.
+ // Par exemple, on peut conserver l’ancien aspect ratio :
+ _aspectRatio.value
+ }
// Get additional metadata
- val title = SharedVideoPlayer.nGetVideoTitle(ptr)
- val bitrate = SharedVideoPlayer.nGetVideoBitrate(ptr)
- val mimeType = SharedVideoPlayer.nGetVideoMimeType(ptr)
- val audioChannels = SharedVideoPlayer.nGetAudioChannels(ptr)
- val audioSampleRate = SharedVideoPlayer.nGetAudioSampleRate(ptr)
+ val title = MacNativeBridge.nGetVideoTitle(ptr)
+ val bitrate = MacNativeBridge.nGetVideoBitrate(ptr)
+ val mimeType = MacNativeBridge.nGetVideoMimeType(ptr)
+ val audioChannels = MacNativeBridge.nGetAudioChannels(ptr)
+ val audioSampleRate = MacNativeBridge.nGetAudioSampleRate(ptr)
withContext(Dispatchers.Main) {
// Update metadata
@@ -495,18 +495,17 @@ class MacVideoPlayerState : VideoPlayerState {
private fun startFrameUpdates() {
macLogger.d { "startFrameUpdates() - Starting frame updates" }
stopFrameUpdates()
- frameUpdateJob = ioScope.launch {
- while (isActive) {
- ensureActive() // Check if coroutine is still active
- updateFrameAsync()
- if (!userDragging) {
- updatePositionAsync()
- // Call the audio level update separately
- updateAudioLevelsAsync()
+ frameUpdateJob =
+ ioScope.launch {
+ while (isActive) {
+ ensureActive() // Check if coroutine is still active
+ updateFrameAsync()
+ if (!userDragging) {
+ updatePositionAsync()
+ }
+ delay(updateInterval)
}
- delay(updateInterval)
}
- }
}
/** Stops periodic frame updates. */
@@ -520,13 +519,14 @@ class MacVideoPlayerState : VideoPlayerState {
private fun startBufferingCheck() {
macLogger.d { "startBufferingCheck() - Starting buffering detection" }
stopBufferingCheck()
- bufferingCheckJob = ioScope.launch {
- while (isActive) {
- ensureActive() // Check if coroutine is still active
- checkBufferingState()
- delay(bufferingCheckInterval)
+ bufferingCheckJob =
+ ioScope.launch {
+ while (isActive) {
+ ensureActive() // Check if coroutine is still active
+ checkBufferingState()
+ delay(bufferingCheckInterval)
+ }
}
- }
}
/** Checks if the media is currently buffering. */
@@ -561,7 +561,7 @@ class MacVideoPlayerState : VideoPlayerState {
// Lock the CVPixelBuffer directly — eliminates the Swift-side memcpy.
// outInfo = [width, height, bytesPerRow]
val outInfo = IntArray(3)
- val frameAddress = SharedVideoPlayer.nLockFrame(ptr, outInfo)
+ val frameAddress = MacNativeBridge.nLockFrame(ptr, outInfo)
if (frameAddress == 0L) return@withContext
val width = outInfo[0]
@@ -569,7 +569,7 @@ class MacVideoPlayerState : VideoPlayerState {
val srcBytesPerRow = outInfo[2]
if (width <= 0 || height <= 0) {
- SharedVideoPlayer.nUnlockFrame(ptr)
+ MacNativeBridge.nUnlockFrame(ptr)
return@withContext
}
@@ -578,8 +578,9 @@ class MacVideoPlayerState : VideoPlayerState {
try {
withContext(Dispatchers.Default) {
- val srcBuf = SharedVideoPlayer.nWrapPointer(frameAddress, frameSizeBytes)
- ?: return@withContext
+ val srcBuf =
+ MacNativeBridge.nWrapPointer(frameAddress, frameSizeBytes)
+ ?: return@withContext
// Allocate/reuse two bitmaps (double-buffering) to avoid writing while the UI draws.
if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) {
@@ -605,15 +606,16 @@ class MacVideoPlayerState : VideoPlayerState {
srcBuf.rewind()
val dstRowBytes = pixmap.rowBytes.toInt()
val dstSizeBytes = dstRowBytes.toLong() * height.toLong()
- val destBuf = SharedVideoPlayer.nWrapPointer(pixelsAddr, dstSizeBytes)
- ?: return@withContext
+ val destBuf =
+ MacNativeBridge.nWrapPointer(pixelsAddr, dstSizeBytes)
+ ?: return@withContext
copyBgraFrame(srcBuf, destBuf, width, height, srcBytesPerRow, dstRowBytes)
_currentFrameState.value = targetBitmap.asComposeImageBitmap()
framePublished = true
}
} finally {
- SharedVideoPlayer.nUnlockFrame(ptr)
+ MacNativeBridge.nUnlockFrame(ptr)
}
if (framePublished) {
@@ -633,37 +635,6 @@ class MacVideoPlayerState : VideoPlayerState {
}
}
- private suspend fun updateAudioLevelsAsync() {
- if (!hasMedia) return
-
- try {
- val ptr = playerPtr
- if (ptr != 0L) {
- val newLeft = SharedVideoPlayer.nGetLeftAudioLevel(ptr)
- val newRight = SharedVideoPlayer.nGetRightAudioLevel(ptr)
-// macLogger.d { "Audio levels fetched: L=$newLeft, R=$newRight" }
-
- // Converts the linear level to a percentage on a logarithmic scale.
- fun convertToPercentage(level: Float): Float {
- if (level <= 0f) return 0f
- // Conversion to decibels: 20 * log10(level)
- val db = 20 * log10(level)
- // Assume that -60 dB corresponds to silence and 0 dB to maximum level.
- val normalized = ((db + 60) / 60).coerceIn(0f, 1f)
- return normalized * 100f
- }
-
- withContext(Dispatchers.Main) {
- _leftLevel.value = convertToPercentage(newLeft)
- _rightLevel.value = convertToPercentage(newRight)
- }
- }
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- macLogger.e { "Error updating audio levels: ${e.message}" }
- }
- }
-
/**
* Updates the playback position, slider, and audio levels on a background
* thread.
@@ -710,9 +681,12 @@ class MacVideoPlayerState : VideoPlayerState {
}
/** Checks if looping is enabled and restarts the video if needed. */
- private suspend fun checkLoopingAsync(current: Double, duration: Double) {
+ private suspend fun checkLoopingAsync(
+ current: Double,
+ duration: Double,
+ ) {
val ptr = playerPtr
- val ended = ptr != 0L && SharedVideoPlayer.nConsumeDidPlayToEnd(ptr)
+ val ended = ptr != 0L && MacNativeBridge.nConsumeDidPlayToEnd(ptr)
// Also check position as fallback for content where the notification may not fire
if (!ended && (duration <= 0 || current < duration - 0.5)) return
@@ -753,7 +727,7 @@ class MacVideoPlayerState : VideoPlayerState {
if (ptr == 0L) return
try {
- SharedVideoPlayer.nPlay(ptr)
+ MacNativeBridge.nPlay(ptr)
withContext(Dispatchers.Main) {
isPlaying = true
@@ -781,7 +755,7 @@ class MacVideoPlayerState : VideoPlayerState {
if (ptr == 0L) return
try {
- SharedVideoPlayer.nPause(ptr)
+ MacNativeBridge.nPause(ptr)
withContext(Dispatchers.Main) {
isPlaying = false
@@ -848,10 +822,10 @@ class MacVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return
- SharedVideoPlayer.nSeekTo(ptr, seekTime.toDouble())
+ MacNativeBridge.nSeekTo(ptr, seekTime.toDouble())
if (isPlaying) {
- SharedVideoPlayer.nPlay(ptr)
+ MacNativeBridge.nPlay(ptr)
// Reduce delay to update frame faster for local videos
delay(10)
updateFrameAsync()
@@ -889,25 +863,26 @@ class MacVideoPlayerState : VideoPlayerState {
ioScope.launch {
// Get player pointer and clear cached bitmaps while frame updates are paused.
- val ptrToDispose = withContext(frameDispatcher) {
- val ptrToDispose = playerPtrAtomic.getAndSet(0L)
-
- skiaBitmapA?.close()
- skiaBitmapB?.close()
- skiaBitmapA = null
- skiaBitmapB = null
- skiaBitmapWidth = 0
- skiaBitmapHeight = 0
- nextSkiaBitmapA = true
-
- ptrToDispose
- }
+ val ptrToDispose =
+ withContext(frameDispatcher) {
+ val ptrToDispose = playerPtrAtomic.getAndSet(0L)
+
+ skiaBitmapA?.close()
+ skiaBitmapB?.close()
+ skiaBitmapA = null
+ skiaBitmapB = null
+ skiaBitmapWidth = 0
+ skiaBitmapHeight = 0
+ nextSkiaBitmapA = true
+
+ ptrToDispose
+ }
// Dispose native resources outside the mutex lock
if (ptrToDispose != 0L) {
macLogger.d { "dispose() - Disposing native player" }
try {
- SharedVideoPlayer.nDisposePlayer(ptrToDispose)
+ MacNativeBridge.nDisposePlayer(ptrToDispose)
} catch (e: Exception) {
if (e is CancellationException) throw e
macLogger.e { "Error disposing player: ${e.message}" }
@@ -915,7 +890,6 @@ class MacVideoPlayerState : VideoPlayerState {
}
resetState()
-
}
// Cancel ioScope last to ensure cleanup completes
@@ -936,7 +910,7 @@ class MacVideoPlayerState : VideoPlayerState {
_currentFrameState.value = null
}
- /**
+ /**
* Sets an error in a consistent way, ensuring it's always set on the main thread.
* For synchronous calls, this will block until the error is set.
*/
@@ -969,7 +943,7 @@ class MacVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return 0.0
return try {
- SharedVideoPlayer.nGetCurrentTime(ptr)
+ MacNativeBridge.nGetCurrentTime(ptr)
} catch (e: Exception) {
if (e is CancellationException) throw e
macLogger.e { "Error getting position: ${e.message}" }
@@ -982,7 +956,7 @@ class MacVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return 0.0
return try {
- SharedVideoPlayer.nGetVideoDuration(ptr)
+ MacNativeBridge.nGetVideoDuration(ptr)
} catch (e: Exception) {
if (e is CancellationException) throw e
macLogger.e { "Error getting duration: ${e.message}" }
@@ -997,11 +971,13 @@ class MacVideoPlayerState : VideoPlayerState {
*/
private suspend fun applyVolume() {
val ptr = playerPtr
- if (ptr != 0L) try {
- SharedVideoPlayer.nSetVolume(ptr, _volumeState.value)
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- macLogger.e { "Error applying volume: ${e.message}" }
+ if (ptr != 0L) {
+ try {
+ MacNativeBridge.nSetVolume(ptr, _volumeState.value)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ macLogger.e { "Error applying volume: ${e.message}" }
+ }
}
}
@@ -1012,11 +988,13 @@ class MacVideoPlayerState : VideoPlayerState {
*/
private suspend fun applyPlaybackSpeed() {
val ptr = playerPtr
- if (ptr != 0L) try {
- SharedVideoPlayer.nSetPlaybackSpeed(ptr, _playbackSpeedState.value)
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- macLogger.e { "Error applying playback speed: ${e.message}" }
+ if (ptr != 0L) {
+ try {
+ MacNativeBridge.nSetPlaybackSpeed(ptr, _playbackSpeedState.value)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ macLogger.e { "Error applying playback speed: ${e.message}" }
+ }
}
}
@@ -1069,7 +1047,10 @@ class MacVideoPlayerState : VideoPlayerState {
* asks the native layer to decode at the surface size instead of native
* resolution, saving significant memory for high-resolution video.
*/
- fun onResized(width: Int, height: Int) {
+ fun onResized(
+ width: Int,
+ height: Int,
+ ) {
if (width <= 0 || height <= 0) return
if (width == surfaceWidth && height == surfaceHeight) return
@@ -1078,14 +1059,15 @@ class MacVideoPlayerState : VideoPlayerState {
isResizing.set(true)
resizeJob?.cancel()
- resizeJob = ioScope.launch {
- delay(120)
- try {
- applyOutputScaling()
- } finally {
- isResizing.set(false)
+ resizeJob =
+ ioScope.launch {
+ delay(120)
+ try {
+ applyOutputScaling()
+ } finally {
+ isResizing.set(false)
+ }
}
- }
}
/**
@@ -1099,6 +1081,6 @@ class MacVideoPlayerState : VideoPlayerState {
val ptr = playerPtr
if (ptr == 0L) return
- SharedVideoPlayer.nSetOutputSize(ptr, sw, sh)
+ MacNativeBridge.nSetOutputSize(ptr, sw, sh)
}
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt
index da31feef..c5791fd1 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt
@@ -16,7 +16,6 @@ import io.github.kdroidfilter.composemediaplayer.util.drawScaledImage
import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier
import io.github.kdroidfilter.composemediaplayer.util.toTimeMs
-
/**
* A Composable function that renders a video player surface for MacOS.
* Fills the entire canvas area with the video frame while maintaining aspect ratio.
@@ -39,10 +38,11 @@ fun MacVideoPlayerSurface(
isInFullscreenWindow: Boolean = false,
) {
Box(
- modifier = modifier.onSizeChanged { size ->
- playerState.onResized(size.width, size.height)
- },
- contentAlignment = Alignment.Center
+ modifier =
+ modifier.onSizeChanged { size ->
+ playerState.onResized(size.width, size.height)
+ },
+ contentAlignment = Alignment.Center,
) {
// Only render video in this surface if we're not in fullscreen mode or if this is the fullscreen window
if (playerState.hasMedia && (!playerState.isFullscreen || isInFullscreenWindow)) {
@@ -51,12 +51,17 @@ fun MacVideoPlayerSurface(
currentFrame?.let { frame ->
Canvas(
- modifier = contentScale.toCanvasModifier(playerState.aspectRatio,playerState.metadata.width,playerState.metadata.height),
+ modifier =
+ contentScale.toCanvasModifier(
+ playerState.aspectRatio,
+ playerState.metadata.width,
+ playerState.metadata.height,
+ ),
) {
drawScaledImage(
- image = frame,
- dstSize = IntSize(size.width.toInt(), size.height.toInt()),
- contentScale = contentScale
+ image = frame,
+ dstSize = IntSize(size.width.toInt(), size.height.toInt()),
+ contentScale = contentScale,
)
}
}
@@ -64,8 +69,11 @@ fun MacVideoPlayerSurface(
// Add Compose-based subtitle layer
if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) {
// Calculate current time in milliseconds
- val currentTimeMs = (playerState.sliderPos / 1000f *
- playerState.durationText.toTimeMs()).toLong()
+ val currentTimeMs =
+ (
+ playerState.sliderPos / 1000f *
+ playerState.durationText.toTimeMs()
+ ).toLong()
// Calculate duration in milliseconds
val durationMs = playerState.durationText.toTimeMs()
@@ -77,7 +85,7 @@ fun MacVideoPlayerSurface(
subtitleTrack = playerState.currentSubtitleTrack,
subtitlesEnabled = playerState.subtitlesEnabled,
textStyle = playerState.subtitleTextStyle,
- backgroundColor = playerState.subtitleBackgroundColor
+ backgroundColor = playerState.subtitleBackgroundColor,
)
}
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.jvm.kt
index 9f8ded49..e46b1c31 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.jvm.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.jvm.kt
@@ -16,48 +16,49 @@ import java.net.URL
* @param src The source URI of the subtitle file
* @return The content of the subtitle file as a string
*/
-actual suspend fun loadSubtitleContent(src: String): String = withContext(Dispatchers.IO) {
- try {
- when {
- // Handle HTTP/HTTPS URLs
- src.startsWith("http://") || src.startsWith("https://") -> {
- val url = URL(src)
- val connection = url.openConnection() as HttpURLConnection
- connection.connectTimeout = 10000
- connection.readTimeout = 10000
-
- val reader = BufferedReader(InputStreamReader(connection.inputStream))
- val content = reader.use { it.readText() }
- connection.disconnect()
- content
- }
-
- // Handle file:// URIs
- src.startsWith("file://") -> {
- val file = File(URI(src))
- file.readText()
- }
-
- // Handle local file paths
- else -> {
- val file = File(src)
- if (file.exists()) {
+actual suspend fun loadSubtitleContent(src: String): String =
+ withContext(Dispatchers.IO) {
+ try {
+ when {
+ // Handle HTTP/HTTPS URLs
+ src.startsWith("http://") || src.startsWith("https://") -> {
+ val url = URL(src)
+ val connection = url.openConnection() as HttpURLConnection
+ connection.connectTimeout = 10000
+ connection.readTimeout = 10000
+
+ val reader = BufferedReader(InputStreamReader(connection.inputStream))
+ val content = reader.use { it.readText() }
+ connection.disconnect()
+ content
+ }
+
+ // Handle file:// URIs
+ src.startsWith("file://") -> {
+ val file = File(URI(src))
file.readText()
- } else {
- // Try to interpret as a URI
- try {
- val uri = URI(src)
- val uriFile = File(uri)
- uriFile.readText()
- } catch (e: Exception) {
- println("Error loading subtitle file: ${e.message}")
- ""
+ }
+
+ // Handle local file paths
+ else -> {
+ val file = File(src)
+ if (file.exists()) {
+ file.readText()
+ } else {
+ // Try to interpret as a URI
+ try {
+ val uri = URI(src)
+ val uriFile = File(uri)
+ uriFile.readText()
+ } catch (e: Exception) {
+ println("Error loading subtitle file: ${e.message}")
+ ""
+ }
}
}
}
+ } catch (e: Exception) {
+ println("Error loading subtitle content: ${e.message}")
+ ""
}
- } catch (e: Exception) {
- println("Error loading subtitle content: ${e.message}")
- ""
}
-}
\ No newline at end of file
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/NativeLibraryLoader.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/NativeLibraryLoader.kt
new file mode 100644
index 00000000..272b1d3a
--- /dev/null
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/NativeLibraryLoader.kt
@@ -0,0 +1,110 @@
+package io.github.kdroidfilter.composemediaplayer.util
+
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * Loads native libraries following a two-stage strategy:
+ * 1. Try [System.loadLibrary] (works for packaged apps / GraalVM native-image
+ * where the lib sits on `java.library.path`).
+ * 2. Fallback: extract from the classpath (`composemediaplayer/native//`)
+ * into a persistent cache and load from there.
+ */
+internal object NativeLibraryLoader {
+ private const val RESOURCE_PREFIX = "composemediaplayer/native"
+ private val loadedLibraries = mutableSetOf()
+
+ @Synchronized
+ fun load(
+ libraryName: String,
+ callerClass: Class<*>,
+ ): Boolean {
+ if (libraryName in loadedLibraries) return true
+
+ // 1. Try system library path (packaged app / GraalVM native-image)
+ try {
+ System.loadLibrary(libraryName)
+ loadedLibraries += libraryName
+ return true
+ } catch (_: UnsatisfiedLinkError) {
+ // Not on java.library.path, try classpath extraction
+ }
+
+ // 2. Extract from classpath to persistent cache
+ val file = extractToCache(libraryName, callerClass) ?: return false
+ System.load(file.absolutePath)
+ loadedLibraries += libraryName
+ return true
+ }
+
+ private fun extractToCache(
+ libraryName: String,
+ callerClass: Class<*>,
+ ): File? {
+ val platform = detectPlatform()
+ val fileName = mapLibraryName(libraryName)
+ val resourcePath = "$RESOURCE_PREFIX/$platform/$fileName"
+
+ val resourceUrl = callerClass.classLoader?.getResource(resourcePath) ?: return null
+
+ val cacheDir = resolveCacheDir(platform)
+ cacheDir.mkdirs()
+ val cachedFile = File(cacheDir, fileName)
+
+ // Validate cache: re-extract if size differs or file is missing
+ val resourceSize = resourceUrl.openConnection().contentLengthLong
+ if (cachedFile.exists() && cachedFile.length() == resourceSize) {
+ return cachedFile
+ }
+
+ // Atomic extract: write to temp then move
+ val tmpFile = File(cacheDir, "$fileName.tmp")
+ try {
+ resourceUrl.openStream().use { input ->
+ Files.copy(input, tmpFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+ Files.move(tmpFile.toPath(), cachedFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ cachedFile.setExecutable(true)
+ } finally {
+ tmpFile.delete()
+ }
+
+ return cachedFile
+ }
+
+ private fun resolveCacheDir(platform: String): File {
+ val os = System.getProperty("os.name")?.lowercase() ?: ""
+ val base =
+ when {
+ os.contains("win") ->
+ File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home"))
+ else ->
+ File(System.getProperty("user.home"), ".cache")
+ }
+ return File(base, "composemediaplayer/native/$platform")
+ }
+
+ private fun detectPlatform(): String {
+ val os = System.getProperty("os.name")?.lowercase() ?: ""
+ val arch = System.getProperty("os.arch") ?: ""
+ return when {
+ os.contains("win") ->
+ if (arch.contains("aarch64") || arch.contains("arm")) "win32-arm64" else "win32-x86-64"
+ os.contains("linux") ->
+ if (arch.contains("aarch64") || arch.contains("arm")) "linux-aarch64" else "linux-x86-64"
+ os.contains("mac") ->
+ if (arch.contains("aarch64") || arch.contains("arm")) "darwin-aarch64" else "darwin-x86-64"
+ else -> "unknown"
+ }
+ }
+
+ private fun mapLibraryName(name: String): String {
+ val os = System.getProperty("os.name")?.lowercase() ?: ""
+ return when {
+ os.contains("win") -> "$name.dll"
+ os.contains("mac") -> "lib$name.dylib"
+ else -> "lib$name.so"
+ }
+ }
+}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
index 31c3639c..67b8a1cf 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/Uri.kt
@@ -3,6 +3,4 @@ package io.github.kdroidfilter.composemediaplayer.util
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.path
-actual fun PlatformFile.getUri(): String {
- return this.path.toString()
-}
\ No newline at end of file
+actual fun PlatformFile.getUri(): String = this.path.toString()
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt
index cb7fa6bf..7e77ec1d 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt
@@ -27,9 +27,7 @@ object VideoPlayerStateRegistry {
*
* @return The registered WindowsVideoPlayerState or null if none is registered
*/
- fun getRegisteredState(): VideoPlayerState? {
- return registeredState?.get()
- }
+ fun getRegisteredState(): VideoPlayerState? = registeredState?.get()
/**
* Clear the registered WindowsVideoPlayerState instance.
@@ -37,4 +35,4 @@ object VideoPlayerStateRegistry {
fun clearRegisteredState() {
registeredState = null
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/MediaFoundationLib.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/MediaFoundationLib.kt
deleted file mode 100644
index c28a42b3..00000000
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/MediaFoundationLib.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package io.github.kdroidfilter.composemediaplayer.windows
-
-import io.github.kdroidfilter.composemediaplayer.VideoMetadata
-import java.io.File
-import java.nio.ByteBuffer
-import java.nio.file.Files
-
-internal object MediaFoundationLib {
- /** Expected native API version — must match NATIVE_VIDEO_PLAYER_VERSION in the DLL. */
- private const val EXPECTED_NATIVE_VERSION = 2
-
- init {
- loadNativeLibrary()
- val nativeVersion = nGetNativeVersion()
- require(nativeVersion == EXPECTED_NATIVE_VERSION) {
- "NativeVideoPlayer DLL version mismatch: expected $EXPECTED_NATIVE_VERSION but got $nativeVersion. " +
- "Please rebuild the native DLL or update the Kotlin bindings."
- }
- }
-
- private fun loadNativeLibrary() {
- val osArch = System.getProperty("os.arch", "").lowercase()
- val resourceDir =
- if (osArch == "aarch64" || osArch == "arm64") "win32-arm64" else "win32-x86-64"
- val libName = "NativeVideoPlayer.dll"
-
- val stream = MediaFoundationLib::class.java.getResourceAsStream("/$resourceDir/$libName")
- ?: throw UnsatisfiedLinkError("Native library not found in resources: /$resourceDir/$libName")
-
- val tempDir = Files.createTempDirectory("nativevideoplayer").toFile()
- val tempFile = File(tempDir, libName)
- stream.use { input -> tempFile.outputStream().use { input.copyTo(it) } }
- System.load(tempFile.absolutePath)
- tempFile.deleteOnExit()
- tempDir.deleteOnExit()
- }
-
- // ----- Helpers -----
-
- fun createInstance(): Long {
- val handle = nCreateInstance()
- return if (handle != 0L) handle else 0L
- }
-
- fun destroyInstance(handle: Long) = nDestroyInstance(handle)
-
- fun getVideoMetadata(handle: Long): VideoMetadata? {
- val title = CharArray(256)
- val mimeType = CharArray(64)
- val longVals = LongArray(2)
- val intVals = IntArray(4)
- val floatVals = FloatArray(1)
- val hasFlags = BooleanArray(9)
-
- val hr = nGetVideoMetadata(handle, title, mimeType, longVals, intVals, floatVals, hasFlags)
- if (hr < 0) return null
-
- return VideoMetadata(
- title = if (hasFlags[0]) String(title).trim { it <= ' ' || it == '\u0000' } else null,
- duration = if (hasFlags[1]) longVals[0] / 10000 else null,
- width = if (hasFlags[2]) intVals[0] else null,
- height = if (hasFlags[3]) intVals[1] else null,
- bitrate = if (hasFlags[4]) longVals[1] else null,
- frameRate = if (hasFlags[5]) floatVals[0] else null,
- mimeType = if (hasFlags[6]) String(mimeType).trim { it <= ' ' || it == '\u0000' } else null,
- audioChannels = if (hasFlags[7]) intVals[2] else null,
- audioSampleRate = if (hasFlags[8]) intVals[3] else null,
- )
- }
-
- // ----- JNI native methods (registered via JNI_OnLoad / RegisterNatives) -----
-
- @JvmStatic external fun nGetNativeVersion(): Int
- @JvmStatic external fun nInitMediaFoundation(): Int
- @JvmStatic external fun nCreateInstance(): Long
- @JvmStatic external fun nDestroyInstance(handle: Long)
- @JvmStatic external fun nOpenMedia(handle: Long, url: String, startPlayback: Boolean): Int
- @JvmStatic external fun nReadVideoFrame(handle: Long, outResult: IntArray): ByteBuffer?
- @JvmStatic external fun nUnlockVideoFrame(handle: Long): Int
- @JvmStatic external fun nCloseMedia(handle: Long)
- @JvmStatic external fun nIsEOF(handle: Long): Boolean
- @JvmStatic external fun nGetVideoSize(handle: Long, outSize: IntArray)
- @JvmStatic external fun nGetVideoFrameRate(handle: Long, outRate: IntArray): Int
- @JvmStatic external fun nSeekMedia(handle: Long, position: Long): Int
- @JvmStatic external fun nGetMediaDuration(handle: Long, outDuration: LongArray): Int
- @JvmStatic external fun nGetMediaPosition(handle: Long, outPosition: LongArray): Int
- @JvmStatic external fun nSetPlaybackState(handle: Long, isPlaying: Boolean, stop: Boolean): Int
- @JvmStatic external fun nShutdownMediaFoundation(): Int
- @JvmStatic external fun nSetAudioVolume(handle: Long, volume: Float): Int
- @JvmStatic external fun nGetAudioVolume(handle: Long, outVolume: FloatArray): Int
- @JvmStatic external fun nGetAudioLevels(handle: Long, outLevels: FloatArray): Int
- @JvmStatic external fun nSetPlaybackSpeed(handle: Long, speed: Float): Int
- @JvmStatic external fun nGetPlaybackSpeed(handle: Long, outSpeed: FloatArray): Int
- @JvmStatic external fun nWrapPointer(address: Long, size: Long): ByteBuffer?
- @JvmStatic external fun nSetOutputSize(handle: Long, width: Int, height: Int): Int
-
- @JvmStatic private external fun nGetVideoMetadata(
- handle: Long, title: CharArray, mimeType: CharArray,
- longVals: LongArray, intVals: IntArray, floatVals: FloatArray, hasFlags: BooleanArray
- ): Int
-
- // ----- Convenience wrappers (keep old API names for minimal caller changes) -----
-
- fun InitMediaFoundation(): Int = nInitMediaFoundation()
- fun ShutdownMediaFoundation(): Int = nShutdownMediaFoundation()
- fun OpenMedia(handle: Long, url: String, startPlayback: Boolean): Int = nOpenMedia(handle, url, startPlayback)
- fun CloseMedia(handle: Long) = nCloseMedia(handle)
- fun IsEOF(handle: Long): Boolean = nIsEOF(handle)
- fun UnlockVideoFrame(handle: Long): Int = nUnlockVideoFrame(handle)
- fun SeekMedia(handle: Long, position: Long): Int = nSeekMedia(handle, position)
- fun SetPlaybackState(handle: Long, isPlaying: Boolean, stop: Boolean): Int = nSetPlaybackState(handle, isPlaying, stop)
- fun SetAudioVolume(handle: Long, volume: Float): Int = nSetAudioVolume(handle, volume)
- fun SetPlaybackSpeed(handle: Long, speed: Float): Int = nSetPlaybackSpeed(handle, speed)
-
- fun ReadVideoFrame(handle: Long, outResult: IntArray): ByteBuffer? = nReadVideoFrame(handle, outResult)
- fun GetVideoSize(handle: Long, outSize: IntArray) = nGetVideoSize(handle, outSize)
- fun GetMediaDuration(handle: Long, outDuration: LongArray): Int = nGetMediaDuration(handle, outDuration)
- fun GetMediaPosition(handle: Long, outPosition: LongArray): Int = nGetMediaPosition(handle, outPosition)
- fun GetAudioVolume(handle: Long, outVolume: FloatArray): Int = nGetAudioVolume(handle, outVolume)
- fun GetAudioLevels(handle: Long, outLevels: FloatArray): Int = nGetAudioLevels(handle, outLevels)
- fun GetPlaybackSpeed(handle: Long, outSpeed: FloatArray): Int = nGetPlaybackSpeed(handle, outSpeed)
- fun SetOutputSize(handle: Long, width: Int, height: Int): Int = nSetOutputSize(handle, width, height)
-}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFrameUtils.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFrameUtils.kt
index 7f80ce73..d09679cc 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFrameUtils.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFrameUtils.kt
@@ -10,7 +10,10 @@ import java.nio.ByteBuffer
* @param pixelCount Total number of pixels in the frame
* @return A hash value representing the frame content
*/
-internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int {
+internal fun calculateFrameHash(
+ buffer: ByteBuffer,
+ pixelCount: Int,
+): Int {
if (pixelCount <= 0) return 0
var hash = 1
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFullscreenVideoPlayerWindow.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFullscreenVideoPlayerWindow.kt
index 2e9c9293..31c48e18 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFullscreenVideoPlayerWindow.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsFullscreenVideoPlayerWindow.kt
@@ -16,7 +16,7 @@ import io.github.kdroidfilter.composemediaplayer.common.openFullscreenWindow
fun openFullscreenWindow(
playerState: WindowsVideoPlayerState,
contentScale: androidx.compose.ui.layout.ContentScale = androidx.compose.ui.layout.ContentScale.Fit,
- overlay: @Composable () -> Unit = {}
+ overlay: @Composable () -> Unit = {},
) {
openFullscreenWindow(
playerState = playerState,
@@ -26,8 +26,8 @@ fun openFullscreenWindow(
modifier = modifier,
contentScale = contentScale,
overlay = overlay,
- isInFullscreenWindow = isInFullscreenWindow
+ isInFullscreenWindow = isInFullscreenWindow,
)
- }
+ },
)
}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt
new file mode 100644
index 00000000..145bcdb3
--- /dev/null
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt
@@ -0,0 +1,228 @@
+package io.github.kdroidfilter.composemediaplayer.windows
+
+import io.github.kdroidfilter.composemediaplayer.VideoMetadata
+import io.github.kdroidfilter.composemediaplayer.util.NativeLibraryLoader
+import java.nio.ByteBuffer
+
+internal object WindowsNativeBridge {
+ /** Expected native API version — must match NATIVE_VIDEO_PLAYER_VERSION in the DLL. */
+ private const val EXPECTED_NATIVE_VERSION = 2
+
+ init {
+ NativeLibraryLoader.load("NativeVideoPlayer", WindowsNativeBridge::class.java)
+ val nativeVersion = nGetNativeVersion()
+ require(nativeVersion == EXPECTED_NATIVE_VERSION) {
+ "NativeVideoPlayer DLL version mismatch: expected $EXPECTED_NATIVE_VERSION but got $nativeVersion. " +
+ "Please rebuild the native DLL or update the Kotlin bindings."
+ }
+ }
+
+ // ----- Helpers -----
+
+ fun createInstance(): Long {
+ val handle = nCreateInstance()
+ return if (handle != 0L) handle else 0L
+ }
+
+ fun destroyInstance(handle: Long) = nDestroyInstance(handle)
+
+ fun getVideoMetadata(handle: Long): VideoMetadata? {
+ val title = CharArray(256)
+ val mimeType = CharArray(64)
+ val longVals = LongArray(2)
+ val intVals = IntArray(4)
+ val floatVals = FloatArray(1)
+ val hasFlags = BooleanArray(9)
+
+ val hr = nGetVideoMetadata(handle, title, mimeType, longVals, intVals, floatVals, hasFlags)
+ if (hr < 0) return null
+
+ return VideoMetadata(
+ title = if (hasFlags[0]) String(title).trim { it <= ' ' || it == '\u0000' } else null,
+ duration = if (hasFlags[1]) longVals[0] / 10000 else null,
+ width = if (hasFlags[2]) intVals[0] else null,
+ height = if (hasFlags[3]) intVals[1] else null,
+ bitrate = if (hasFlags[4]) longVals[1] else null,
+ frameRate = if (hasFlags[5]) floatVals[0] else null,
+ mimeType = if (hasFlags[6]) String(mimeType).trim { it <= ' ' || it == '\u0000' } else null,
+ audioChannels = if (hasFlags[7]) intVals[2] else null,
+ audioSampleRate = if (hasFlags[8]) intVals[3] else null,
+ )
+ }
+
+ // ----- JNI native methods (registered via JNI_OnLoad / RegisterNatives) -----
+
+ @JvmStatic external fun nGetNativeVersion(): Int
+
+ @JvmStatic external fun nInitMediaFoundation(): Int
+
+ @JvmStatic external fun nCreateInstance(): Long
+
+ @JvmStatic external fun nDestroyInstance(handle: Long)
+
+ @JvmStatic external fun nOpenMedia(
+ handle: Long,
+ url: String,
+ startPlayback: Boolean,
+ ): Int
+
+ @JvmStatic external fun nReadVideoFrame(
+ handle: Long,
+ outResult: IntArray,
+ ): ByteBuffer?
+
+ @JvmStatic external fun nUnlockVideoFrame(handle: Long): Int
+
+ @JvmStatic external fun nCloseMedia(handle: Long)
+
+ @JvmStatic external fun nIsEOF(handle: Long): Boolean
+
+ @JvmStatic external fun nGetVideoSize(
+ handle: Long,
+ outSize: IntArray,
+ )
+
+ @JvmStatic external fun nGetVideoFrameRate(
+ handle: Long,
+ outRate: IntArray,
+ ): Int
+
+ @JvmStatic external fun nSeekMedia(
+ handle: Long,
+ position: Long,
+ ): Int
+
+ @JvmStatic external fun nGetMediaDuration(
+ handle: Long,
+ outDuration: LongArray,
+ ): Int
+
+ @JvmStatic external fun nGetMediaPosition(
+ handle: Long,
+ outPosition: LongArray,
+ ): Int
+
+ @JvmStatic external fun nSetPlaybackState(
+ handle: Long,
+ isPlaying: Boolean,
+ stop: Boolean,
+ ): Int
+
+ @JvmStatic external fun nShutdownMediaFoundation(): Int
+
+ @JvmStatic external fun nSetAudioVolume(
+ handle: Long,
+ volume: Float,
+ ): Int
+
+ @JvmStatic external fun nGetAudioVolume(
+ handle: Long,
+ outVolume: FloatArray,
+ ): Int
+
+ @JvmStatic external fun nSetPlaybackSpeed(
+ handle: Long,
+ speed: Float,
+ ): Int
+
+ @JvmStatic external fun nGetPlaybackSpeed(
+ handle: Long,
+ outSpeed: FloatArray,
+ ): Int
+
+ @JvmStatic external fun nWrapPointer(
+ address: Long,
+ size: Long,
+ ): ByteBuffer?
+
+ @JvmStatic external fun nSetOutputSize(
+ handle: Long,
+ width: Int,
+ height: Int,
+ ): Int
+
+ @JvmStatic private external fun nGetVideoMetadata(
+ handle: Long,
+ title: CharArray,
+ mimeType: CharArray,
+ longVals: LongArray,
+ intVals: IntArray,
+ floatVals: FloatArray,
+ hasFlags: BooleanArray,
+ ): Int
+
+ // ----- Convenience wrappers (keep old API names for minimal caller changes) -----
+
+ fun InitMediaFoundation(): Int = nInitMediaFoundation()
+
+ fun ShutdownMediaFoundation(): Int = nShutdownMediaFoundation()
+
+ fun OpenMedia(
+ handle: Long,
+ url: String,
+ startPlayback: Boolean,
+ ): Int = nOpenMedia(handle, url, startPlayback)
+
+ fun CloseMedia(handle: Long) = nCloseMedia(handle)
+
+ fun IsEOF(handle: Long): Boolean = nIsEOF(handle)
+
+ fun UnlockVideoFrame(handle: Long): Int = nUnlockVideoFrame(handle)
+
+ fun SeekMedia(
+ handle: Long,
+ position: Long,
+ ): Int = nSeekMedia(handle, position)
+
+ fun SetPlaybackState(
+ handle: Long,
+ isPlaying: Boolean,
+ stop: Boolean,
+ ): Int = nSetPlaybackState(handle, isPlaying, stop)
+
+ fun SetAudioVolume(
+ handle: Long,
+ volume: Float,
+ ): Int = nSetAudioVolume(handle, volume)
+
+ fun SetPlaybackSpeed(
+ handle: Long,
+ speed: Float,
+ ): Int = nSetPlaybackSpeed(handle, speed)
+
+ fun ReadVideoFrame(
+ handle: Long,
+ outResult: IntArray,
+ ): ByteBuffer? = nReadVideoFrame(handle, outResult)
+
+ fun GetVideoSize(
+ handle: Long,
+ outSize: IntArray,
+ ) = nGetVideoSize(handle, outSize)
+
+ fun GetMediaDuration(
+ handle: Long,
+ outDuration: LongArray,
+ ): Int = nGetMediaDuration(handle, outDuration)
+
+ fun GetMediaPosition(
+ handle: Long,
+ outPosition: LongArray,
+ ): Int = nGetMediaPosition(handle, outPosition)
+
+ fun GetAudioVolume(
+ handle: Long,
+ outVolume: FloatArray,
+ ): Int = nGetAudioVolume(handle, outVolume)
+
+ fun GetPlaybackSpeed(
+ handle: Long,
+ outSpeed: FloatArray,
+ ): Int = nGetPlaybackSpeed(handle, outSpeed)
+
+ fun SetOutputSize(
+ handle: Long,
+ width: Int,
+ height: Int,
+ ): Int = nSetOutputSize(handle, width, height)
+}
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt
index 31ed3285..43ca526a 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt
@@ -11,14 +11,12 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Logger.Companion.setMinSeverity
-import co.touchlab.kermit.Severity
import io.github.kdroidfilter.composemediaplayer.InitialPlayerState
import io.github.kdroidfilter.composemediaplayer.SubtitleTrack
import io.github.kdroidfilter.composemediaplayer.VideoMetadata
import io.github.kdroidfilter.composemediaplayer.VideoPlayerError
import io.github.kdroidfilter.composemediaplayer.VideoPlayerState
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import io.github.kdroidfilter.composemediaplayer.util.formatTime
import io.github.vinceglb.filekit.PlatformFile
import kotlinx.coroutines.CancellationException
@@ -48,18 +46,12 @@ import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import java.io.File
-import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.write
-import kotlin.math.min
-/**
- * Logger for Windows video player implementation
- */
-internal val windowsLogger = Logger.withTag("WindowsVideoPlayerState")
- .apply { setMinSeverity(Severity.Warn) }
+internal val windowsLogger = TaggedLogger("WindowsVideoPlayerState")
/**
* Windows implementation of the video player state.
@@ -78,7 +70,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
*/
private fun ensureMfInitialized() {
if (!isMfBootstrapped.getAndSet(true)) {
- val hr = MediaFoundationLib.InitMediaFoundation()
+ val hr = WindowsNativeBridge.InitMediaFoundation()
if (hr < 0) {
windowsLogger.e { "Media Foundation initialization failed (hr=0x${hr.toString(16)})" }
}
@@ -92,7 +84,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
}
/** Instance of the native Media Foundation player */
- private val player = MediaFoundationLib
+ private val player = WindowsNativeBridge
/** Coroutine scope for all async operations */
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -159,11 +151,15 @@ class WindowsVideoPlayerState : VideoPlayerState {
private var _userDragging by mutableStateOf(false)
override var userDragging: Boolean
get() = _userDragging
- set(value) { _userDragging = value }
+ set(value) {
+ _userDragging = value
+ }
private var _loop by mutableStateOf(false)
override var loop: Boolean
get() = _loop
- set(value) { _loop = value }
+ set(value) {
+ _loop = value
+ }
private var _playbackSpeed by mutableStateOf(1.0f)
override var playbackSpeed: Float
@@ -185,15 +181,13 @@ class WindowsVideoPlayerState : VideoPlayerState {
}
}
- // Updating audio levels via GetAudioLevels
- private var _leftLevel by mutableStateOf(0f)
- override val leftLevel: Float get() = _leftLevel
- private var _rightLevel by mutableStateOf(0f)
- override val rightLevel: Float get() = _rightLevel
-
private var _error: VideoPlayerError? = null
override val error get() = _error
- override fun clearError() { _error = null; errorMessage = null }
+
+ override fun clearError() {
+ _error = null
+ errorMessage = null
+ }
// Current frame management
private var _currentFrame: Bitmap? by mutableStateOf(null)
@@ -202,9 +196,12 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Aspect ratio property
override val aspectRatio: Float
- get() = if (videoWidth > 0 && videoHeight > 0)
- videoWidth.toFloat() / videoHeight.toFloat()
- else 16f / 9f
+ get() =
+ if (videoWidth > 0 && videoHeight > 0) {
+ videoWidth.toFloat() / videoHeight.toFloat()
+ } else {
+ 16f / 9f
+ }
// Metadata and UI state
private var _metadata by mutableStateOf(VideoMetadata())
@@ -212,12 +209,13 @@ class WindowsVideoPlayerState : VideoPlayerState {
override var subtitlesEnabled = false
override var currentSubtitleTrack: SubtitleTrack? = null
override val availableSubtitleTracks = mutableListOf()
- override var subtitleTextStyle: TextStyle = TextStyle(
- color = Color.White,
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- )
+ override var subtitleTextStyle: TextStyle =
+ TextStyle(
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Normal,
+ textAlign = TextAlign.Center,
+ )
override var subtitleBackgroundColor: Color = Color.Black.copy(alpha = 0.5f)
override var isLoading by mutableStateOf(false)
private set
@@ -242,19 +240,18 @@ class WindowsVideoPlayerState : VideoPlayerState {
private val isResizing = AtomicBoolean(false)
private var videoJob: Job? = null
private var resizeJob: Job? = null
- private var audioLevelsJob: Job? = null
-
// Memory optimization for frame processing
private val frameQueueSize = 1
- private val frameChannel = Channel(
- capacity = frameQueueSize,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
+ private val frameChannel =
+ Channel(
+ capacity = frameQueueSize,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
// Data structure for a frame
private data class FrameData(
val bitmap: Bitmap,
- val timestamp: Double
+ val timestamp: Double,
)
// Double-buffering for zero-copy frame rendering
@@ -272,7 +269,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Kick off native initialization immediately
scope.launch {
try {
- val handle = MediaFoundationLib.createInstance()
+ val handle = WindowsNativeBridge.createInstance()
if (handle == 0L) {
setError("Failed to create video player instance")
return@launch
@@ -302,7 +299,6 @@ class WindowsVideoPlayerState : VideoPlayerState {
try {
// Cancel all jobs with immediate effect
videoJob?.cancel()
- audioLevelsJob?.cancel()
resizeJob?.cancel()
// Wait a bit for coroutines to cancel
@@ -335,7 +331,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Destroy the player instance
try {
- MediaFoundationLib.destroyInstance(instance)
+ WindowsNativeBridge.destroyInstance(instance)
} catch (e: Exception) {
windowsLogger.e { "Exception destroying instance: ${e.message}" }
}
@@ -385,7 +381,6 @@ class WindowsVideoPlayerState : VideoPlayerState {
lastFrameHash = Int.MIN_VALUE
}
-
// Reset all state
_currentTime = 0.0
_duration = 0.0
@@ -403,7 +398,6 @@ class WindowsVideoPlayerState : VideoPlayerState {
private fun releaseAllResources() {
// Cancel any remaining jobs related to video processing
videoJob?.cancel()
- audioLevelsJob?.cancel()
resizeJob?.cancel()
// Drain the frame channel (tryReceive is non-suspending)
@@ -449,7 +443,10 @@ class WindowsVideoPlayerState : VideoPlayerState {
* @param uri The path to the media file or URL to open
* @param initializeplayerState Controls whether playback should start automatically after opening
*/
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
if (isDisposing.get()) {
windowsLogger.w { "Ignoring openUri call - player is being disposed" }
return
@@ -475,7 +472,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
override fun openFile(
file: PlatformFile,
- initializeplayerState: InitialPlayerState
+ initializeplayerState: InitialPlayerState,
) {
openUri(file.file.path, initializeplayerState)
}
@@ -486,7 +483,10 @@ class WindowsVideoPlayerState : VideoPlayerState {
* @param uri The path to the media file or URL to open
* @param initializeplayerState Controls whether playback should start automatically after opening
*/
- private fun openUriInternal(uri: String, initializeplayerState: InitialPlayerState) {
+ private fun openUriInternal(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
scope.launch {
if (isDisposing.get()) {
return@launch
@@ -520,7 +520,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
_metadata = VideoMetadata()
_hasMedia = false
userPaused = false
-
+
// Reset initialFrameRead flag to ensure we read an initial frame for the new video
initialFrameRead.set(false)
@@ -578,16 +578,17 @@ class WindowsVideoPlayerState : VideoPlayerState {
_duration = durArr[0] / 10000000.0
// Retrieve metadata using the native function
- val retrievedMetadata = MediaFoundationLib.getVideoMetadata(instance)
+ val retrievedMetadata = WindowsNativeBridge.getVideoMetadata(instance)
if (retrievedMetadata != null) {
_metadata = retrievedMetadata
} else {
// If metadata retrieval failed, create a basic metadata object with what we know
- _metadata = VideoMetadata(
- width = videoWidth,
- height = videoHeight,
- duration = (_duration * 1000).toLong() // Convert to milliseconds
- )
+ _metadata =
+ VideoMetadata(
+ width = videoWidth,
+ height = videoHeight,
+ duration = (_duration * 1000).toLong(), // Convert to milliseconds
+ )
}
// Set _hasMedia to true only if everything succeeded
@@ -625,20 +626,13 @@ class WindowsVideoPlayerState : VideoPlayerState {
_isPlaying = startPlayback
// Start video processing
- videoJob = scope.launch {
- launch { produceFrames() }
- launch { consumeFrames() }
- }
-
- // Start a task to update audio levels
- audioLevelsJob = scope.launch {
- while (isActive && _hasMedia && !isDisposing.get()) {
- updateAudioLevels()
- delay(50)
+ videoJob =
+ scope.launch {
+ launch { produceFrames() }
+ launch { consumeFrames() }
}
- }
- }
+ }
} catch (e: Exception) {
setError("Error while opening media: ${e.message}")
_hasMedia = false
@@ -649,29 +643,6 @@ class WindowsVideoPlayerState : VideoPlayerState {
}
}
- /**
- * Updates the audio level meters
- */
- private fun updateAudioLevels() {
- if (isDisposing.get()) return
-
- // Use tryLock to avoid blocking media operations (open, seek, etc.)
- // when polling audio levels. Skipped updates are retried in 50ms.
- if (!mediaOperationMutex.tryLock()) return
- try {
- videoPlayerInstance.takeIf { it != 0L }?.let { instance ->
- val levelsArr = FloatArray(2)
- val hr = player.GetAudioLevels(instance, levelsArr)
- if (hr >= 0) {
- _leftLevel = levelsArr[0]
- _rightLevel = levelsArr[1]
- }
- }
- } finally {
- mediaOperationMutex.unlock()
- }
- }
-
/**
* Zero-copy optimized frame producer using double-buffering and direct memory access.
*
@@ -694,9 +665,9 @@ class WindowsVideoPlayerState : VideoPlayerState {
continue
} else if (loop) {
try {
- userPaused = false // Reset userPaused when looping
- initialFrameRead.set(false) // Reset initialFrameRead flag
- lastFrameHash = Int.MIN_VALUE // Reset hash for new loop
+ userPaused = false // Reset userPaused when looping
+ initialFrameRead.set(false) // Reset initialFrameRead flag
+ lastFrameHash = Int.MIN_VALUE // Reset hash for new loop
seekTo(0f)
play()
} catch (e: Exception) {
@@ -719,7 +690,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Wait for playback state, allowing initial frame when paused
// If the return value is false, we should wait and not process frames
if (!waitForPlaybackState(allowInitialFrame = true)) {
- delay(100) // Add a small delay to prevent busy waiting
+ delay(100) // Add a small delay to prevent busy waiting
continue
}
} catch (e: CancellationException) {
@@ -742,7 +713,8 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Re-query video size — HLS adaptive bitrate may change resolution
val sizeArr = IntArray(2)
player.GetVideoSize(instance, sizeArr)
- if (sizeArr[0] > 0 && sizeArr[1] > 0 &&
+ if (sizeArr[0] > 0 &&
+ sizeArr[1] > 0 &&
(sizeArr[0] != videoWidth || sizeArr[1] != videoHeight)
) {
videoWidth = sizeArr[0]
@@ -810,12 +782,13 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Single memory copy: native buffer → Skia bitmap
val dstRowBytes = pixmap.rowBytes
val dstSizeBytes = dstRowBytes.toLong() * height.toLong()
- val dstBuffer = MediaFoundationLib.nWrapPointer(pixelsAddr, dstSizeBytes)
- ?: run {
- player.UnlockVideoFrame(instance)
- yield()
- continue
- }
+ val dstBuffer =
+ WindowsNativeBridge.nWrapPointer(pixelsAddr, dstSizeBytes)
+ ?: run {
+ player.UnlockVideoFrame(instance)
+ yield()
+ continue
+ }
srcBuffer.rewind()
copyBgraFrame(srcBuffer, dstBuffer, width, height, dstRowBytes)
@@ -824,17 +797,17 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Get frame timestamp
val posArr = LongArray(1)
- val frameTime = if (player.GetMediaPosition(instance, posArr) >= 0) {
- posArr[0] / 10000000.0
- } else {
- 0.0
- }
+ val frameTime =
+ if (player.GetMediaPosition(instance, posArr) >= 0) {
+ posArr[0] / 10000000.0
+ } else {
+ 0.0
+ }
// Send frame to channel
frameChannel.trySend(FrameData(targetBitmap, frameTime))
delay(1)
-
} catch (e: CancellationException) {
break
} catch (e: Exception) {
@@ -859,18 +832,19 @@ class WindowsVideoPlayerState : VideoPlayerState {
if (waitIfResizing()) continue
try {
- val frameData = frameChannel.tryReceive().getOrNull() ?: run {
- if (isLoading && !frameReceived) {
- loadingTimeout++
- if (loadingTimeout > 200) {
- windowsLogger.w { "No frames received for 3 seconds, forcing isLoading to false" }
- isLoading = false
- loadingTimeout = 0
+ val frameData =
+ frameChannel.tryReceive().getOrNull() ?: run {
+ if (isLoading && !frameReceived) {
+ loadingTimeout++
+ if (loadingTimeout > 200) {
+ windowsLogger.w { "No frames received for 3 seconds, forcing isLoading to false" }
+ isLoading = false
+ loadingTimeout = 0
+ }
}
- }
- delay(16)
- return@run null
- } ?: continue
+ delay(16)
+ return@run null
+ } ?: continue
loadingTimeout = 0
frameReceived = true
@@ -881,15 +855,15 @@ class WindowsVideoPlayerState : VideoPlayerState {
}
_currentTime = frameData.timestamp
- _progress = if (_duration > 0.0) {
- (_currentTime / _duration).toFloat().coerceIn(0f, 1f)
- } else {
- 0f // Live stream — no meaningful progress
- }
+ _progress =
+ if (_duration > 0.0) {
+ (_currentTime / _duration).toFloat().coerceIn(0f, 1f)
+ } else {
+ 0f // Live stream — no meaningful progress
+ }
isLoading = false
delay(1)
-
} catch (e: CancellationException) {
break
} catch (e: Exception) {
@@ -954,10 +928,11 @@ class WindowsVideoPlayerState : VideoPlayerState {
}
if (_hasMedia && (videoJob == null || videoJob?.isActive == false)) {
- videoJob = scope.launch {
- launch { produceFrames() }
- launch { consumeFrames() }
- }
+ videoJob =
+ scope.launch {
+ launch { produceFrames() }
+ launch { consumeFrames() }
+ }
}
}
@@ -969,13 +944,13 @@ class WindowsVideoPlayerState : VideoPlayerState {
executeMediaOperation(
operation = "pause",
- precondition = _isPlaying
+ precondition = _isPlaying,
) {
userPaused = true
// Reset initialFrameRead flag when switching to pause state
// This ensures that we'll read a new initial frame to display
initialFrameRead.set(false)
-
+
setPlaybackState(false, "Error while pausing playback")
}
}
@@ -988,7 +963,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
if (isDisposing.get()) return
executeMediaOperation(
- operation = "stop"
+ operation = "stop",
) {
setPlaybackState(false, "Error while stopping playback", true)
delay(50)
@@ -1002,10 +977,10 @@ class WindowsVideoPlayerState : VideoPlayerState {
errorMessage = null
_error = null
userPaused = false
-
+
// Reset initialFrameRead flag to ensure we read a new frame when playing again
initialFrameRead.set(false)
-
+
videoPlayerInstance.takeIf { it != 0L }?.let { instance ->
player.CloseMedia(instance)
}
@@ -1018,7 +993,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
executeMediaOperation(
operation = "seek",
- precondition = _hasMedia && videoPlayerInstance != 0L
+ precondition = _hasMedia && videoPlayerInstance != 0L,
) {
val instance = videoPlayerInstance
if (instance != 0L) {
@@ -1053,20 +1028,24 @@ class WindowsVideoPlayerState : VideoPlayerState {
val posArr2 = LongArray(1)
if (player.GetMediaPosition(instance, posArr2) >= 0) {
_currentTime = posArr2[0] / 10000000.0
- _progress = if (_duration > 0.0) {
- (_currentTime / _duration).toFloat().coerceIn(0f, 1f)
- } else 0f
+ _progress =
+ if (_duration > 0.0) {
+ (_currentTime / _duration).toFloat().coerceIn(0f, 1f)
+ } else {
+ 0f
+ }
}
if (!isDisposing.get()) {
- videoJob = scope.launch {
- launch { produceFrames() }
- launch { consumeFrames() }
- }
+ videoJob =
+ scope.launch {
+ launch { produceFrames() }
+ launch { consumeFrames() }
+ }
}
delay(8)
-
+
// If the player is paused, ensure isLoading is set to false
// This prevents the UI from showing loading state indefinitely after seeking when paused
if (userPaused) {
@@ -1088,7 +1067,10 @@ class WindowsVideoPlayerState : VideoPlayerState {
* Temporarily pauses frame processing to avoid artifacts during resize
* For 4K videos, we need a longer delay to prevent memory pressure
*/
- fun onResized(width: Int = 0, height: Int = 0) {
+ fun onResized(
+ width: Int = 0,
+ height: Int = 0,
+ ) {
if (isDisposing.get()) return
if (width <= 0 || height <= 0) return
@@ -1101,14 +1083,15 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Mark resizing in progress and debounce rapid events
isResizing.set(true)
resizeJob?.cancel()
- resizeJob = scope.launch {
- delay(120)
- try {
- applyOutputScaling()
- } finally {
- isResizing.set(false)
+ resizeJob =
+ scope.launch {
+ delay(120)
+ try {
+ applyOutputScaling()
+ } finally {
+ isResizing.set(false)
+ }
}
- }
}
/**
@@ -1157,8 +1140,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
*
* @return ImageInfo configured for the current video frame
*/
- private fun createVideoImageInfo() =
- ImageInfo(videoWidth, videoHeight, ColorType.BGRA_8888, ColorAlphaType.OPAQUE)
+ private fun createVideoImageInfo() = ImageInfo(videoWidth, videoHeight, ColorType.BGRA_8888, ColorAlphaType.OPAQUE)
/**
* Sets the playback state (playing or paused)
@@ -1168,7 +1150,11 @@ class WindowsVideoPlayerState : VideoPlayerState {
* @param bStop True to stop playback completely, false to pause
* @return True if the operation succeeded, false otherwise
*/
- private fun setPlaybackState(playing: Boolean, errorMessage: String, bStop: Boolean = false): Boolean {
+ private fun setPlaybackState(
+ playing: Boolean,
+ errorMessage: String,
+ bStop: Boolean = false,
+ ): Boolean {
return videoPlayerInstance.takeIf { it != 0L }?.let { instance ->
for (attempt in 1..3) {
val res = player.SetPlaybackState(instance, playing, bStop)
@@ -1189,19 +1175,16 @@ class WindowsVideoPlayerState : VideoPlayerState {
}
/**
- * Waits for the playback state to become active
- * If playback doesn't start within 5 seconds, attempts to restart it
- * unless the user has intentionally paused the video
+ * Flag to track if we've read at least one frame when paused.
+ * Initialize to false to ensure we read an initial frame when the player is first loaded.
*/
- // Flag to track if we've read at least one frame when paused
- // Initialize to false to ensure we read an initial frame when the player is first loaded
private val initialFrameRead = AtomicBoolean(false)
-
+
/**
* Waits for the playback state to become active
* If playback doesn't start within 5 seconds, attempts to restart it
* unless the user has intentionally paused the video
- *
+ *
* @param allowInitialFrame If true, allows reading one frame even when paused (for thumbnail)
* @return True if the method should continue processing frames, false if it should wait
*/
@@ -1236,7 +1219,9 @@ class WindowsVideoPlayerState : VideoPlayerState {
if (isResizing.get()) {
resizeWaitCount++
if (resizeWaitCount > 200) { // ~1.6s max wait
- windowsLogger.w { "waitIfResizing: timeout after ${resizeWaitCount} iterations, forcing isResizing=false" }
+ windowsLogger.w {
+ "waitIfResizing: timeout after $resizeWaitCount iterations, forcing isResizing=false"
+ }
isResizing.set(false)
resizeWaitCount = 0
return false
@@ -1258,9 +1243,8 @@ class WindowsVideoPlayerState : VideoPlayerState {
*
* @return True if the player is initialized and has media loaded, false otherwise
*/
- private fun readyForPlayback(): Boolean {
- return initReady.isCompleted && videoPlayerInstance != 0L && _hasMedia && !isDisposing.get()
- }
+ private fun readyForPlayback(): Boolean =
+ initReady.isCompleted && videoPlayerInstance != 0L && _hasMedia && !isDisposing.get()
/**
* Executes a media operation with proper error handling and mutex locking
@@ -1272,7 +1256,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
private fun executeMediaOperation(
operation: String,
precondition: Boolean = true,
- block: suspend () -> Unit
+ block: suspend () -> Unit,
) {
if (!precondition || isDisposing.get()) return
diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerSurface.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerSurface.kt
index 5c7bcd15..3bd278bb 100644
--- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerSurface.kt
+++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerSurface.kt
@@ -16,7 +16,6 @@ import io.github.kdroidfilter.composemediaplayer.util.drawScaledImage
import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier
import io.github.kdroidfilter.composemediaplayer.util.toTimeMs
-
/**
* A composable function that provides a surface for rendering video frames
* within the Windows video player. It adjusts to size changes and ensures the video
@@ -40,10 +39,11 @@ fun WindowsVideoPlayerSurface(
isInFullscreenWindow: Boolean = false,
) {
Box(
- modifier = modifier.onSizeChanged { size ->
- playerState.onResized(size.width, size.height)
- },
- contentAlignment = Alignment.Center
+ modifier =
+ modifier.onSizeChanged { size ->
+ playerState.onResized(size.width, size.height)
+ },
+ contentAlignment = Alignment.Center,
) {
// Only render video in this surface if we're not in fullscreen mode or if this is the fullscreen window
if (playerState.hasMedia && (!playerState.isFullscreen || isInFullscreenWindow)) {
@@ -52,12 +52,17 @@ fun WindowsVideoPlayerSurface(
currentFrame?.let { frame ->
Canvas(
- modifier = contentScale.toCanvasModifier(playerState.aspectRatio,playerState.metadata.width,playerState.metadata.height)
+ modifier =
+ contentScale.toCanvasModifier(
+ playerState.aspectRatio,
+ playerState.metadata.width,
+ playerState.metadata.height,
+ ),
) {
drawScaledImage(
- image = frame,
- dstSize = IntSize(size.width.toInt(), size.height.toInt()),
- contentScale = contentScale
+ image = frame,
+ dstSize = IntSize(size.width.toInt(), size.height.toInt()),
+ contentScale = contentScale,
)
}
}
@@ -65,8 +70,11 @@ fun WindowsVideoPlayerSurface(
// Add Compose-based subtitle layer
if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) {
// Calculate current time in milliseconds
- val currentTimeMs = (playerState.sliderPos / 1000f *
- playerState.durationText.toTimeMs()).toLong()
+ val currentTimeMs =
+ (
+ playerState.sliderPos / 1000f *
+ playerState.durationText.toTimeMs()
+ ).toLong()
// Calculate duration in milliseconds
val durationMs = playerState.durationText.toTimeMs()
@@ -78,7 +86,7 @@ fun WindowsVideoPlayerSurface(
subtitleTrack = playerState.currentSubtitleTrack,
subtitlesEnabled = playerState.subtitlesEnabled,
textStyle = playerState.subtitleTextStyle,
- backgroundColor = playerState.subtitleBackgroundColor
+ backgroundColor = playerState.subtitleBackgroundColor,
)
}
}
diff --git a/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt b/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt
index cf49ec6f..edb87e17 100644
--- a/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt
+++ b/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt
@@ -41,13 +41,19 @@ target_compile_options(NativeVideoPlayer PRIVATE
)
# Determine output directory based on architecture
+if(DEFINED ENV{NATIVE_LIBS_OUTPUT_DIR})
+ set(BASE_OUTPUT_DIR "$ENV{NATIVE_LIBS_OUTPUT_DIR}")
+else()
+ set(BASE_OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/composemediaplayer/native")
+endif()
+
execute_process(COMMAND uname -m OUTPUT_VARIABLE ARCH OUTPUT_STRIP_TRAILING_WHITESPACE)
if(ARCH STREQUAL "x86_64" OR ARCH STREQUAL "amd64")
- set(RESOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/linux-x86-64")
+ set(RESOURCE_DIR "${BASE_OUTPUT_DIR}/linux-x86-64")
elseif(ARCH STREQUAL "aarch64" OR ARCH STREQUAL "arm64")
- set(RESOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/linux-aarch64")
+ set(RESOURCE_DIR "${BASE_OUTPUT_DIR}/linux-aarch64")
else()
- set(RESOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/linux-${ARCH}")
+ set(RESOURCE_DIR "${BASE_OUTPUT_DIR}/linux-${ARCH}")
endif()
# Copy library to resources after build
diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c
index caa1dcf1..e8137358 100644
--- a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c
+++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c
@@ -33,10 +33,6 @@ struct VideoPlayer {
int32_t output_width;
int32_t output_height;
- // Audio levels
- float left_level;
- float right_level;
-
// Metadata
pthread_mutex_t meta_lock;
char* title;
@@ -136,31 +132,6 @@ static void process_bus_message(VideoPlayer* p, GstMessage* msg) {
break;
}
- case GST_MESSAGE_ELEMENT: {
- if (p->level && GST_MESSAGE_SRC(msg) == GST_OBJECT(p->level)) {
- const GstStructure* st = gst_message_get_structure(msg);
- if (st && gst_structure_has_name(st, "level")) {
- const GValue* peak_val = gst_structure_get_value(st, "peak");
- if (peak_val && GST_VALUE_HOLDS_ARRAY(peak_val)) {
- guint n = gst_value_array_get_size(peak_val);
- if (n >= 1) {
- const GValue* v0 = gst_value_array_get_value(peak_val, 0);
- gdouble db_left = g_value_get_double(v0);
- p->left_level = (float)pow(10.0, db_left / 20.0);
- }
- if (n >= 2) {
- const GValue* v1 = gst_value_array_get_value(peak_val, 1);
- gdouble db_right = g_value_get_double(v1);
- p->right_level = (float)pow(10.0, db_right / 20.0);
- } else {
- p->right_level = p->left_level;
- }
- }
- }
- }
- break;
- }
-
default:
break;
}
@@ -338,9 +309,6 @@ int nvp_open_uri(VideoPlayer* p, const char* uri) {
p->frame_rate = 0.0f;
pthread_mutex_unlock(&p->meta_lock);
- p->left_level = 0.0f;
- p->right_level = 0.0f;
-
// Convert raw file paths to file:// URIs if needed.
// GStreamer playbin requires a valid URI scheme.
gchar* resolved_uri = NULL;
@@ -478,18 +446,6 @@ double nvp_get_current_time(VideoPlayer* p) {
return 0.0;
}
-// ---------------------------------------------------------------------------
-// Audio levels
-// ---------------------------------------------------------------------------
-
-float nvp_get_left_audio_level(VideoPlayer* p) {
- return p ? p->left_level : 0.0f;
-}
-
-float nvp_get_right_audio_level(VideoPlayer* p) {
- return p ? p->right_level : 0.0f;
-}
-
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h
index 5f3d485c..a74609f8 100644
--- a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h
+++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h
@@ -37,10 +37,6 @@ int32_t nvp_set_output_size(VideoPlayer* p, int32_t width, int32_t height);
double nvp_get_duration(VideoPlayer* p);
double nvp_get_current_time(VideoPlayer* p);
-// Audio levels (0.0 - 1.0 linear)
-float nvp_get_left_audio_level(VideoPlayer* p);
-float nvp_get_right_audio_level(VideoPlayer* p);
-
// Metadata (caller must free returned strings with free())
char* nvp_get_title(VideoPlayer* p);
int64_t nvp_get_bitrate(VideoPlayer* p);
diff --git a/mediaplayer/src/jvmMain/native/linux/build.sh b/mediaplayer/src/jvmMain/native/linux/build.sh
index ae5f2ed0..adb2c7d3 100755
--- a/mediaplayer/src/jvmMain/native/linux/build.sh
+++ b/mediaplayer/src/jvmMain/native/linux/build.sh
@@ -3,8 +3,10 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
+OUTPUT_DIR="${NATIVE_LIBS_OUTPUT_DIR:-$SCRIPT_DIR/../../resources/composemediaplayer/native}"
echo "=== Building Linux NativeVideoPlayer ==="
+echo "Output dir: $OUTPUT_DIR"
# Clean and create build directory
rm -rf "$BUILD_DIR"
@@ -20,12 +22,12 @@ echo "=== Build completed ==="
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64)
- echo "Output: $SCRIPT_DIR/../../resources/linux-x86-64/libNativeVideoPlayer.so"
+ echo "Output: $OUTPUT_DIR/linux-x86-64/libNativeVideoPlayer.so"
;;
aarch64|arm64)
- echo "Output: $SCRIPT_DIR/../../resources/linux-aarch64/libNativeVideoPlayer.so"
+ echo "Output: $OUTPUT_DIR/linux-aarch64/libNativeVideoPlayer.so"
;;
*)
- echo "Output: $SCRIPT_DIR/../../resources/linux-$ARCH/libNativeVideoPlayer.so"
+ echo "Output: $OUTPUT_DIR/linux-$ARCH/libNativeVideoPlayer.so"
;;
esac
diff --git a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c
index d24c59cb..9eb7794f 100644
--- a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c
+++ b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c
@@ -86,14 +86,6 @@ static void JNICALL jni_DisposePlayer(JNIEnv* env, jclass cls, jlong handle) {
if (handle) nvp_destroy(toCtx(handle));
}
-static jfloat JNICALL jni_GetLeftAudioLevel(JNIEnv* env, jclass cls, jlong handle) {
- return handle ? nvp_get_left_audio_level(toCtx(handle)) : 0.0f;
-}
-
-static jfloat JNICALL jni_GetRightAudioLevel(JNIEnv* env, jclass cls, jlong handle) {
- return handle ? nvp_get_right_audio_level(toCtx(handle)) : 0.0f;
-}
-
static void JNICALL jni_SetPlaybackSpeed(JNIEnv* env, jclass cls, jlong handle, jfloat speed) {
if (handle) nvp_set_playback_speed(toCtx(handle), (float)speed);
}
@@ -160,8 +152,6 @@ static const JNINativeMethod g_methods[] = {
{ "nGetCurrentTime", "(J)D", (void*)jni_GetCurrentTime },
{ "nSeekTo", "(JD)V", (void*)jni_SeekTo },
{ "nDisposePlayer", "(J)V", (void*)jni_DisposePlayer },
- { "nGetLeftAudioLevel", "(J)F", (void*)jni_GetLeftAudioLevel },
- { "nGetRightAudioLevel", "(J)F", (void*)jni_GetRightAudioLevel },
{ "nSetPlaybackSpeed", "(JF)V", (void*)jni_SetPlaybackSpeed },
{ "nGetPlaybackSpeed", "(J)F", (void*)jni_GetPlaybackSpeed },
{ "nGetVideoTitle", "(J)Ljava/lang/String;", (void*)jni_GetVideoTitle },
@@ -179,7 +169,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
return -1;
jclass cls = (*env)->FindClass(
- env, "io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer");
+ env, "io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge");
if (!cls) return -1;
int count = (int)(sizeof(g_methods) / sizeof(g_methods[0]));
diff --git a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift
index 5ffef3ea..f507d36f 100644
--- a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift
+++ b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift
@@ -7,7 +7,7 @@ import AppKit
/// Class that manages video playback and frame capture into an optimized shared buffer.
/// Frame capture rate adapts to the lower of screen refresh rate and video frame rate.
/// Includes full HLS (HTTP Live Streaming) support with adaptive bitrate streaming.
-class SharedVideoPlayer {
+class MacVideoPlayer {
private var player: AVPlayer?
private var videoOutput: AVPlayerItemVideoOutput?
@@ -45,10 +45,6 @@ class SharedVideoPlayer {
private var isReadyForPlayback = false
private var pendingPlay = false
- // Two properties to store the left and right audio levels.
- private var leftAudioLevel: Float = 0.0
- private var rightAudioLevel: Float = 0.0
-
// Playback speed control (1.0 is normal speed)
private var playbackSpeed: Float = 1.0
@@ -956,15 +952,6 @@ class SharedVideoPlayer {
}
- /// Retrieve the audio levels.
- func getLeftAudioLevel() -> Float {
- return leftAudioLevel
- }
-
- func getRightAudioLevel() -> Float {
- return rightAudioLevel
- }
-
// MARK: - Audio Tap Callbacks
/// Callback: Initialization of the tap.
@@ -989,72 +976,19 @@ class SharedVideoPlayer {
// Release any resources allocated in prepare.
}
- /// Callback: Process audio. This is where you calculate the audio levels.
+ /// Callback: Process audio (pass-through).
private let tapProcess: MTAudioProcessingTapProcessCallback = {
(tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut) in
- // Get the tap context (the SharedVideoPlayer instance)
- let opaqueSelf = MTAudioProcessingTapGetStorage(tap)
- let mySelf = Unmanaged.fromOpaque(opaqueSelf).takeUnretainedValue()
-
- var localFrames = numberFrames
-
- // Retrieve the audio buffers
+ // Retrieve the audio buffers so they flow through the pipeline
let status = MTAudioProcessingTapGetSourceAudio(
- tap, localFrames, bufferListInOut, flagsOut, nil, nil)
+ tap, numberFrames, bufferListInOut, flagsOut, nil, nil)
if status != noErr {
print("MTAudioProcessingTapGetSourceAudio failed with status: \(status)")
return
}
- // Process the audio buffers to calculate left and right channel levels.
- let bufferList = bufferListInOut.pointee
-
- // Vérifier que les buffers sont valides
- guard bufferList.mNumberBuffers > 0 else {
- print("No audio buffers available")
- return
- }
-
- // Vérifier le format audio (nous attendons du Float32)
- guard let mBuffers = bufferList.mBuffers.mData,
- bufferList.mBuffers.mDataByteSize > 0 else {
- print("Invalid audio buffer data")
- return
- }
-
- // Assuming interleaved float data (adjust if using a different format)
- let data = mBuffers.bindMemory(
- to: Float.self, capacity: Int(bufferList.mBuffers.mDataByteSize / 4))
- let frameCount = Int(localFrames)
- var leftSum: Float = 0.0
- var rightSum: Float = 0.0
- var leftCount = 0
- var rightCount = 0
-
- // Assuming stereo (2 channels)
- if frameCount > 0 {
- for frame in 0.. 0 ? leftSum / Float(leftCount) : 0.0
- let avgRight = rightCount > 0 ? rightSum / Float(rightCount) : 0.0
-
- // Update the properties
- mySelf.leftAudioLevel = avgLeft
- mySelf.rightAudioLevel = avgRight
- }
-
- numberFramesOut.pointee = localFrames
+ numberFramesOut.pointee = numberFrames
}
// Dans la méthode setupAudioTap, ajoutez une vérification du format audio et un log
@@ -1086,12 +1020,12 @@ class SharedVideoPlayer {
process: self.tapProcess
)
- var tap: MTAudioProcessingTap?
+ var tap: Unmanaged?
// Create the audio processing tap
let status = MTAudioProcessingTapCreate(
kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tap
)
- if status == noErr, let tap = tap {
+ if status == noErr, let tap = tap?.takeRetainedValue() {
print("Audio tap created successfully")
inputParams.audioTapProcessor = tap
let audioMix = AVMutableAudioMix()
@@ -1367,7 +1301,7 @@ class SharedVideoPlayer {
@_cdecl("createVideoPlayer")
public func createVideoPlayer() -> UnsafeMutableRawPointer? {
- let player = SharedVideoPlayer()
+ let player = MacVideoPlayer()
return Unmanaged.passRetained(player).toOpaque()
}
@@ -1380,7 +1314,7 @@ public func openUri(_ context: UnsafeMutableRawPointer?, _ uri: UnsafePointer.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
// Use a background queue for heavy operations to avoid blocking the main thread
DispatchQueue.global(qos: .userInitiated).async {
player.openUri(swiftUri)
@@ -1390,7 +1324,7 @@ public func openUri(_ context: UnsafeMutableRawPointer?, _ uri: UnsafePointer.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.play()
}
@@ -1399,7 +1333,7 @@ public func playVideo(_ context: UnsafeMutableRawPointer?) {
@_cdecl("pauseVideo")
public func pauseVideo(_ context: UnsafeMutableRawPointer?) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.pause()
}
@@ -1408,7 +1342,7 @@ public func pauseVideo(_ context: UnsafeMutableRawPointer?) {
@_cdecl("setVolume")
public func setVolume(_ context: UnsafeMutableRawPointer?, _ volume: Float) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.setVolume(level: volume)
}
@@ -1417,84 +1351,84 @@ public func setVolume(_ context: UnsafeMutableRawPointer?, _ volume: Float) {
@_cdecl("getVolume")
public func getVolume(_ context: UnsafeMutableRawPointer?) -> Float {
guard let context = context else { return 0.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getVolume()
}
@_cdecl("lockLatestFrame")
public func lockLatestFrame(_ context: UnsafeMutableRawPointer?, _ outInfo: UnsafeMutablePointer?) -> UnsafeMutableRawPointer? {
guard let context = context, let outInfo = outInfo else { return nil }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.lockLatestFrame(outInfo)
}
@_cdecl("unlockLatestFrame")
public func unlockLatestFrame(_ context: UnsafeMutableRawPointer?) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
player.unlockLatestFrame()
}
@_cdecl("getFrameWidth")
public func getFrameWidth(_ context: UnsafeMutableRawPointer?) -> Int32 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return Int32(player.getFrameWidth())
}
@_cdecl("getFrameHeight")
public func getFrameHeight(_ context: UnsafeMutableRawPointer?) -> Int32 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return Int32(player.getFrameHeight())
}
@_cdecl("setOutputSize")
public func setOutputSize(_ context: UnsafeMutableRawPointer?, _ width: Int32, _ height: Int32) -> Int32 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.setOutputSize(width: Int(width), height: Int(height)) ? 1 : 0
}
@_cdecl("getVideoFrameRate")
public func getVideoFrameRate(_ context: UnsafeMutableRawPointer?) -> Float {
guard let context = context else { return 0.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getVideoFrameRate()
}
@_cdecl("getScreenRefreshRate")
public func getScreenRefreshRate(_ context: UnsafeMutableRawPointer?) -> Float {
guard let context = context else { return 0.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getScreenRefreshRate()
}
@_cdecl("getCaptureFrameRate")
public func getCaptureFrameRate(_ context: UnsafeMutableRawPointer?) -> Float {
guard let context = context else { return 0.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getCaptureFrameRate()
}
@_cdecl("getVideoDuration")
public func getVideoDuration(_ context: UnsafeMutableRawPointer?) -> Double {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getDuration()
}
@_cdecl("getCurrentTime")
public func getCurrentTime(_ context: UnsafeMutableRawPointer?) -> Double {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getCurrentTime()
}
@_cdecl("seekTo")
public func seekTo(_ context: UnsafeMutableRawPointer?, _ time: Double) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.seekTo(time: time)
}
@@ -1503,30 +1437,16 @@ public func seekTo(_ context: UnsafeMutableRawPointer?, _ time: Double) {
@_cdecl("disposeVideoPlayer")
public func disposeVideoPlayer(_ context: UnsafeMutableRawPointer?) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeRetainedValue()
+ let player = Unmanaged.fromOpaque(context).takeRetainedValue()
DispatchQueue.main.async {
player.dispose()
}
}
-@_cdecl("getLeftAudioLevel")
-public func getLeftAudioLevel(_ context: UnsafeMutableRawPointer?) -> Float {
- guard let context = context else { return 0.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
- return player.getLeftAudioLevel()
-}
-
-@_cdecl("getRightAudioLevel")
-public func getRightAudioLevel(_ context: UnsafeMutableRawPointer?) -> Float {
- guard let context = context else { return 0.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
- return player.getRightAudioLevel()
-}
-
@_cdecl("setPlaybackSpeed")
public func setPlaybackSpeed(_ context: UnsafeMutableRawPointer?, _ speed: Float) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.setPlaybackSpeed(speed: speed)
}
@@ -1535,14 +1455,14 @@ public func setPlaybackSpeed(_ context: UnsafeMutableRawPointer?, _ speed: Float
@_cdecl("getPlaybackSpeed")
public func getPlaybackSpeed(_ context: UnsafeMutableRawPointer?) -> Float {
guard let context = context else { return 1.0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getPlaybackSpeed()
}
@_cdecl("getVideoTitle")
public func getVideoTitle(_ context: UnsafeMutableRawPointer?) -> UnsafePointer? {
guard let context = context else { return nil }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
if let title = player.getVideoTitle() {
let cString = strdup(title)
return UnsafePointer(cString)
@@ -1553,14 +1473,14 @@ public func getVideoTitle(_ context: UnsafeMutableRawPointer?) -> UnsafePointer<
@_cdecl("getVideoBitrate")
public func getVideoBitrate(_ context: UnsafeMutableRawPointer?) -> Int64 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getVideoBitrate()
}
@_cdecl("getVideoMimeType")
public func getVideoMimeType(_ context: UnsafeMutableRawPointer?) -> UnsafePointer? {
guard let context = context else { return nil }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
if let mimeType = player.getVideoMimeType() {
let cString = strdup(mimeType)
return UnsafePointer(cString)
@@ -1571,21 +1491,21 @@ public func getVideoMimeType(_ context: UnsafeMutableRawPointer?) -> UnsafePoint
@_cdecl("getAudioChannels")
public func getAudioChannels(_ context: UnsafeMutableRawPointer?) -> Int32 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return Int32(player.getAudioChannels())
}
@_cdecl("getAudioSampleRate")
public func getAudioSampleRate(_ context: UnsafeMutableRawPointer?) -> Int32 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return Int32(player.getAudioSampleRate())
}
@_cdecl("consumeDidPlayToEnd")
public func consumeDidPlayToEnd(_ context: UnsafeMutableRawPointer?) -> Int32 {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.consumeDidPlayToEnd() ? 1 : 0
}
@@ -1593,14 +1513,14 @@ public func consumeDidPlayToEnd(_ context: UnsafeMutableRawPointer?) -> Int32 {
@_cdecl("getIsHLSStream")
public func getIsHLSStream(_ context: UnsafeMutableRawPointer?) -> Bool {
guard let context = context else { return false }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getIsHLSStream()
}
@_cdecl("getAvailableBitrates")
public func getAvailableBitrates(_ context: UnsafeMutableRawPointer?, _ buffer: UnsafeMutablePointer?, _ maxCount: Int32) -> Int32 {
guard let context = context, let buffer = buffer else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
let bitrates = player.getAvailableBitrates()
let count = min(Int(maxCount), bitrates.count)
for i in 0.. Float {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getCurrentBitrate()
}
@_cdecl("setPreferredMaxBitrate")
public func setPreferredMaxBitrate(_ context: UnsafeMutableRawPointer?, _ bitrate: Double) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.setPreferredMaxBitrate(bitrate)
}
@@ -1628,7 +1548,7 @@ public func setPreferredMaxBitrate(_ context: UnsafeMutableRawPointer?, _ bitrat
@_cdecl("forceQuality")
public func forceQuality(_ context: UnsafeMutableRawPointer?, _ bitrate: Float) {
guard let context = context else { return }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.async {
player.forceQuality(bitrate: bitrate)
}
@@ -1637,21 +1557,21 @@ public func forceQuality(_ context: UnsafeMutableRawPointer?, _ bitrate: Float)
@_cdecl("getBufferStatus")
public func getBufferStatus(_ context: UnsafeMutableRawPointer?) -> Float {
guard let context = context else { return 0 }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getBufferStatus()
}
@_cdecl("getIsBuffering")
public func getIsBuffering(_ context: UnsafeMutableRawPointer?) -> Bool {
guard let context = context else { return false }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
return player.getIsBuffering()
}
@_cdecl("getNetworkStatus")
public func getNetworkStatus(_ context: UnsafeMutableRawPointer?) -> UnsafePointer? {
guard let context = context else { return nil }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
let status = player.getNetworkStatus()
let cString = strdup(status)
return UnsafePointer(cString)
@@ -1660,7 +1580,7 @@ public func getNetworkStatus(_ context: UnsafeMutableRawPointer?) -> UnsafePoint
@_cdecl("getLastError")
public func getLastError(_ context: UnsafeMutableRawPointer?) -> UnsafePointer? {
guard let context = context else { return nil }
- let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ let player = Unmanaged.fromOpaque(context).takeUnretainedValue()
if let error = player.getLastError() {
let cString = strdup(error)
return UnsafePointer(cString)
diff --git a/mediaplayer/src/jvmMain/native/macos/build.sh b/mediaplayer/src/jvmMain/native/macos/build.sh
index 8bfa6018..d00ab236 100644
--- a/mediaplayer/src/jvmMain/native/macos/build.sh
+++ b/mediaplayer/src/jvmMain/native/macos/build.sh
@@ -2,11 +2,14 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-RESOURCES_DIR="$SCRIPT_DIR/../../resources"
+OUTPUT_DIR="${NATIVE_LIBS_OUTPUT_DIR:-$SCRIPT_DIR/../../resources/composemediaplayer/native}"
SWIFT_SOURCE="$SCRIPT_DIR/NativeVideoPlayer.swift"
JNI_BRIDGE="$SCRIPT_DIR/jni_bridge.c"
+echo "=== Building macOS NativeVideoPlayer ==="
+echo "Output dir: $OUTPUT_DIR"
+
# Resolve JDK include paths (required to compile jni_bridge.c)
JAVA_HOME="${JAVA_HOME:-$(/usr/libexec/java_home 2>/dev/null || echo '')}"
if [ -z "$JAVA_HOME" ]; then
@@ -16,8 +19,8 @@ fi
JNI_INCLUDES="-I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin"
# Output directories
-ARM64_DIR="$RESOURCES_DIR/darwin-aarch64"
-X64_DIR="$RESOURCES_DIR/darwin-x86-64"
+ARM64_DIR="$OUTPUT_DIR/darwin-aarch64"
+X64_DIR="$OUTPUT_DIR/darwin-x86-64"
mkdir -p "$ARM64_DIR" "$X64_DIR"
diff --git a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c
index ed8afd9e..5205ab69 100644
--- a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c
+++ b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c
@@ -27,8 +27,6 @@ extern double getVideoDuration(void* ctx);
extern double getCurrentTime(void* ctx);
extern void seekTo(void* ctx, double time);
extern void disposeVideoPlayer(void* ctx);
-extern float getLeftAudioLevel(void* ctx);
-extern float getRightAudioLevel(void* ctx);
extern void setPlaybackSpeed(void* ctx, float speed);
extern float getPlaybackSpeed(void* ctx);
extern const char* getVideoTitle(void* ctx);
@@ -140,14 +138,6 @@ static void JNICALL jni_DisposePlayer(JNIEnv* env, jclass cls, jlong handle) {
if (handle) disposeVideoPlayer(toCtx(handle));
}
-static jfloat JNICALL jni_GetLeftAudioLevel(JNIEnv* env, jclass cls, jlong handle) {
- return handle ? getLeftAudioLevel(toCtx(handle)) : 0.0f;
-}
-
-static jfloat JNICALL jni_GetRightAudioLevel(JNIEnv* env, jclass cls, jlong handle) {
- return handle ? getRightAudioLevel(toCtx(handle)) : 0.0f;
-}
-
static void JNICALL jni_SetPlaybackSpeed(JNIEnv* env, jclass cls, jlong handle, jfloat speed) {
if (handle) setPlaybackSpeed(toCtx(handle), (float)speed);
}
@@ -214,8 +204,6 @@ static const JNINativeMethod g_methods[] = {
{ "nGetCurrentTime", "(J)D", (void*)jni_GetCurrentTime },
{ "nSeekTo", "(JD)V", (void*)jni_SeekTo },
{ "nDisposePlayer", "(J)V", (void*)jni_DisposePlayer },
- { "nGetLeftAudioLevel", "(J)F", (void*)jni_GetLeftAudioLevel },
- { "nGetRightAudioLevel", "(J)F", (void*)jni_GetRightAudioLevel },
{ "nSetPlaybackSpeed", "(JF)V", (void*)jni_SetPlaybackSpeed },
{ "nGetPlaybackSpeed", "(J)F", (void*)jni_GetPlaybackSpeed },
{ "nGetVideoTitle", "(J)Ljava/lang/String;", (void*)jni_GetVideoTitle },
@@ -232,7 +220,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
return -1;
jclass cls = (*env)->FindClass(
- env, "io/github/kdroidfilter/composemediaplayer/mac/SharedVideoPlayer");
+ env, "io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge");
if (!cls) return -1;
int count = (int)(sizeof(g_methods) / sizeof(g_methods[0]));
diff --git a/mediaplayer/src/jvmMain/native/windows/AudioManager.cpp b/mediaplayer/src/jvmMain/native/windows/AudioManager.cpp
index 6fdf045a..8bd322cd 100644
--- a/mediaplayer/src/jvmMain/native/windows/AudioManager.cpp
+++ b/mediaplayer/src/jvmMain/native/windows/AudioManager.cpp
@@ -353,34 +353,4 @@ HRESULT GetVolume(const VideoPlayerInstance* inst, float* out)
return S_OK;
}
-// -------------------------------------------
-// Peak‑meter (endpoint) level in percentage
-// -------------------------------------------
-HRESULT GetAudioLevels(const VideoPlayerInstance* inst, float* left, float* right)
-{
- if (!inst || !left || !right) return E_INVALIDARG;
- if (!inst->pDevice) return E_FAIL;
-
- IAudioMeterInformation* meter = nullptr;
- HRESULT hr = inst->pDevice->Activate(__uuidof(IAudioMeterInformation), CLSCTX_ALL, nullptr,
- reinterpret_cast(&meter));
- if (FAILED(hr)) return hr;
-
- std::array peaks = {0.f, 0.f};
- hr = meter->GetChannelsPeakValues(2, peaks.data());
- meter->Release();
- if (FAILED(hr)) return hr;
-
- auto toPercent = [](float level) {
- if (level <= 0.f) return 0.f;
- float db = 20.f * log10(level);
- float pct = std::clamp((db + 60.f) / 60.f, 0.f, 1.f);
- return pct * 100.f;
- };
-
- *left = toPercent(peaks[0]);
- *right = toPercent(peaks[1]);
- return S_OK;
-}
-
} // namespace AudioManager
diff --git a/mediaplayer/src/jvmMain/native/windows/AudioManager.h b/mediaplayer/src/jvmMain/native/windows/AudioManager.h
index 0b17eee1..d9f25662 100644
--- a/mediaplayer/src/jvmMain/native/windows/AudioManager.h
+++ b/mediaplayer/src/jvmMain/native/windows/AudioManager.h
@@ -60,13 +60,4 @@ HRESULT SetVolume(VideoPlayerInstance* pInstance, float volume);
*/
HRESULT GetVolume(const VideoPlayerInstance* pInstance, float* volume);
-/**
- * @brief Gets the audio levels for a video player instance.
- * @param pInstance Pointer to the video player instance.
- * @param pLeftLevel Pointer to receive the left channel level.
- * @param pRightLevel Pointer to receive the right channel level.
- * @return S_OK on success, or an error code.
- */
-HRESULT GetAudioLevels(const VideoPlayerInstance* pInstance, float* pLeftLevel, float* pRightLevel);
-
} // namespace AudioManager
diff --git a/mediaplayer/src/jvmMain/native/windows/CMakeLists.txt b/mediaplayer/src/jvmMain/native/windows/CMakeLists.txt
index 1ebcf1fe..e2ff0172 100644
--- a/mediaplayer/src/jvmMain/native/windows/CMakeLists.txt
+++ b/mediaplayer/src/jvmMain/native/windows/CMakeLists.txt
@@ -7,13 +7,19 @@ set(CMAKE_CXX_STANDARD 17)
find_package(JNI REQUIRED)
# Check target architecture
+if(DEFINED ENV{NATIVE_LIBS_OUTPUT_DIR})
+ set(BASE_OUTPUT_DIR "$ENV{NATIVE_LIBS_OUTPUT_DIR}")
+else()
+ set(BASE_OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/composemediaplayer/native")
+endif()
+
if(CMAKE_GENERATOR_PLATFORM STREQUAL "x64" OR CMAKE_GENERATOR_PLATFORM STREQUAL "")
set(TARGET_ARCH "x64")
- set(OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/win32-x86-64")
+ set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-x86-64")
add_compile_options("/arch:AVX2")
elseif(CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64")
set(TARGET_ARCH "ARM64")
- set(OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/win32-arm64")
+ set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-arm64")
add_compile_options("/arch:arm64")
else()
message(FATAL_ERROR "Unsupported architecture: ${CMAKE_GENERATOR_PLATFORM}")
diff --git a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp
index 714ac7c6..42cb6899 100644
--- a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp
+++ b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp
@@ -1264,16 +1264,6 @@ NATIVEVIDEOPLAYER_API HRESULT GetAudioVolume(const VideoPlayerInstance* pInstanc
return GetVolume(pInstance, volume);
}
-NATIVEVIDEOPLAYER_API HRESULT GetAudioLevels(const VideoPlayerInstance* pInstance, float* pLeftLevel, float* pRightLevel) {
- // IMFMediaEngine doesn't expose per-channel audio levels
- if (pInstance && pInstance->pHLSPlayer) {
- if (pLeftLevel) *pLeftLevel = 0.0f;
- if (pRightLevel) *pRightLevel = 0.0f;
- return S_OK;
- }
- return AudioManager::GetAudioLevels(pInstance, pLeftLevel, pRightLevel);
-}
-
NATIVEVIDEOPLAYER_API HRESULT SetPlaybackSpeed(VideoPlayerInstance* pInstance, float speed) {
if (!pInstance)
return OP_E_NOT_INITIALIZED;
diff --git a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h
index 9d2e5f9f..6a934542 100644
--- a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h
+++ b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h
@@ -213,15 +213,6 @@ NATIVEVIDEOPLAYER_API HRESULT SetAudioVolume(VideoPlayerInstance* pInstance, flo
*/
NATIVEVIDEOPLAYER_API HRESULT GetAudioVolume(const VideoPlayerInstance* pInstance, float* volume);
-/**
- * @brief Gets the audio levels for left and right channels for a specific instance.
- * @param pInstance Handle to the instance.
- * @param pLeftLevel Pointer for the left channel level.
- * @param pRightLevel Pointer for the right channel level.
- * @return S_OK on success, or an error code.
- */
-NATIVEVIDEOPLAYER_API HRESULT GetAudioLevels(const VideoPlayerInstance* pInstance, float* pLeftLevel, float* pRightLevel);
-
/**
* @brief Sets the playback speed for a specific instance.
* @param pInstance Handle to the instance.
diff --git a/mediaplayer/src/jvmMain/native/windows/build.bat b/mediaplayer/src/jvmMain/native/windows/build.bat
index 18f0ab9b..39d9514f 100644
--- a/mediaplayer/src/jvmMain/native/windows/build.bat
+++ b/mediaplayer/src/jvmMain/native/windows/build.bat
@@ -43,7 +43,7 @@ rem Clean up build directories
if exist build-x64 rmdir /s /q build-x64
if exist build-arm64 rmdir /s /q build-arm64
-echo x64 DLL: ..\..\resources\win32-x86-64\NativeVideoPlayer.dll
-echo ARM64 DLL: ..\..\resources\win32-arm64\NativeVideoPlayer.dll
+echo x64 DLL: ..\..\resources\composemediaplayer\native\win32-x86-64\NativeVideoPlayer.dll
+echo ARM64 DLL: ..\..\resources\composemediaplayer\native\win32-arm64\NativeVideoPlayer.dll
endlocal
diff --git a/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp b/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp
index 1ddcbdfa..6720ee2f 100644
--- a/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp
+++ b/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp
@@ -132,15 +132,6 @@ static jint JNICALL jni_GetAudioVolume(JNIEnv* env, jclass, jlong handle, jfloat
return hr;
}
-static jint JNICALL jni_GetAudioLevels(JNIEnv* env, jclass, jlong handle, jfloatArray out) {
- if (!handle) return E_INVALIDARG;
- float l = 0, r = 0;
- HRESULT hr = GetAudioLevels(toInstance(handle), &l, &r);
- jfloat vals[2] = { l, r };
- env->SetFloatArrayRegion(out, 0, 2, vals);
- return hr;
-}
-
static jint JNICALL jni_SetPlaybackSpeed(JNIEnv*, jclass, jlong handle, jfloat speed) {
return handle ? SetPlaybackSpeed(toInstance(handle), speed) : E_INVALIDARG;
}
@@ -230,7 +221,6 @@ static const JNINativeMethod g_methods[] = {
{ const_cast("nShutdownMediaFoundation"), const_cast("()I"), (void*)jni_ShutdownMediaFoundation },
{ const_cast("nSetAudioVolume"), const_cast("(JF)I"), (void*)jni_SetAudioVolume },
{ const_cast("nGetAudioVolume"), const_cast("(J[F)I"), (void*)jni_GetAudioVolume },
- { const_cast("nGetAudioLevels"), const_cast("(J[F)I"), (void*)jni_GetAudioLevels },
{ const_cast("nSetPlaybackSpeed"), const_cast("(JF)I"), (void*)jni_SetPlaybackSpeed },
{ const_cast("nGetPlaybackSpeed"), const_cast("(J[F)I"), (void*)jni_GetPlaybackSpeed },
{ const_cast("nGetVideoMetadata"), const_cast("(J[C[C[J[I[F[Z)I"), (void*)jni_GetVideoMetadata },
@@ -243,7 +233,7 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) {
if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK)
return -1;
- jclass cls = env->FindClass("io/github/kdroidfilter/composemediaplayer/windows/MediaFoundationLib");
+ jclass cls = env->FindClass("io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge");
if (!cls) return -1;
if (env->RegisterNatives(cls, g_methods, sizeof(g_methods) / sizeof(g_methods[0])) < 0)
diff --git a/mediaplayer/src/jvmMain/resources/META-INF/native-image/native-image.properties b/mediaplayer/src/jvmMain/resources/META-INF/native-image/native-image.properties
new file mode 100644
index 00000000..a2208431
--- /dev/null
+++ b/mediaplayer/src/jvmMain/resources/META-INF/native-image/native-image.properties
@@ -0,0 +1 @@
+Args = -H:IncludeResources=composemediaplayer/native/.*
diff --git a/mediaplayer/src/jvmMain/resources/META-INF/native-image/reachability-metadata.json b/mediaplayer/src/jvmMain/resources/META-INF/native-image/reachability-metadata.json
new file mode 100644
index 00000000..47ddcdb8
--- /dev/null
+++ b/mediaplayer/src/jvmMain/resources/META-INF/native-image/reachability-metadata.json
@@ -0,0 +1,26 @@
+[
+ {
+ "type": "io.github.kdroidfilter.composemediaplayer.linux.LinuxNativeBridge",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "type": "io.github.kdroidfilter.composemediaplayer.mac.MacNativeBridge",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "type": "io.github.kdroidfilter.composemediaplayer.windows.WindowsNativeBridge",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "type": "java.lang.Runnable",
+ "methods": [
+ { "name": "run", "parameterTypes": [] }
+ ]
+ }
+]
diff --git a/mediaplayer/src/jvmMain/resources/linux-x86-64/libNativeVideoPlayer.so b/mediaplayer/src/jvmMain/resources/linux-x86-64/libNativeVideoPlayer.so
deleted file mode 100755
index 98d78dce..00000000
Binary files a/mediaplayer/src/jvmMain/resources/linux-x86-64/libNativeVideoPlayer.so and /dev/null differ
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
index 3bb4c23d..efa420f6 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
@@ -1,27 +1,25 @@
package io.github.kdroidfilter.composemediaplayer
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
-import kotlin.test.assertFalse
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.delay
-import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform
/**
* Tests for the JVM implementation of VideoPlayerState
*/
class VideoPlayerStateTest {
-
/**
* Checks if the native video player library is available.
* On Linux, this requires the native GStreamer JNI library.
* On macOS, this requires the AVFoundation JNI library.
* On Windows, this requires the Media Foundation JNI library.
*/
- private fun isNativePlayerAvailable(): Boolean {
- return try {
+ private fun isNativePlayerAvailable(): Boolean =
+ try {
val state = createVideoPlayerState()
state.dispose()
true
@@ -29,7 +27,6 @@ class VideoPlayerStateTest {
println("Native player not available: ${e.message}")
false
}
- }
@Test
fun testCreateVideoPlayerState() {
@@ -48,8 +45,6 @@ class VideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
playerState.dispose()
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceTest.kt
index 2339c2be..94b8048a 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceTest.kt
@@ -1,19 +1,16 @@
package io.github.kdroidfilter.composemediaplayer
import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests for the JVM implementation of VideoPlayerSurface
- *
+ *
* Note: Since we can't easily test the actual rendering of the surface in a unit test,
* we're just testing that the VideoPlayerSurface function exists and can be referenced.
* More comprehensive testing would require integration tests with a real UI.
*/
class VideoPlayerSurfaceTest {
-
/**
* Test that the VideoPlayerSurface function exists.
* This is a simple existence test to ensure the function is available.
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/SubtitleTrackTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/SubtitleTrackTest.kt
index a52d451d..7bdc8a98 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/SubtitleTrackTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/SubtitleTrackTest.kt
@@ -9,82 +9,87 @@ import kotlin.test.assertNotNull
* Tests for the SubtitleTrack class
*/
class SubtitleTrackTest {
-
/**
* Test the creation of SubtitleTrack
*/
@Test
fun testCreateSubtitleTrack() {
- val track = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/english.vtt"
- )
-
+ val track =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/english.vtt",
+ )
+
// Verify the track is initialized correctly
assertNotNull(track)
assertEquals("English", track.label)
assertEquals("en", track.language)
assertEquals("subtitles/english.vtt", track.src)
}
-
+
/**
* Test data class copy functionality
*/
@Test
fun testSubtitleTrackCopy() {
- val track = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/english.vtt"
- )
-
+ val track =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/english.vtt",
+ )
+
// Create a copy with some modified properties
- val copy = track.copy(
- label = "English (US)",
- src = "subtitles/english_us.vtt"
- )
-
+ val copy =
+ track.copy(
+ label = "English (US)",
+ src = "subtitles/english_us.vtt",
+ )
+
// Verify the original track is unchanged
assertEquals("English", track.label)
assertEquals("en", track.language)
assertEquals("subtitles/english.vtt", track.src)
-
+
// Verify the copy has the expected properties
assertEquals("English (US)", copy.label)
assertEquals("en", copy.language)
assertEquals("subtitles/english_us.vtt", copy.src)
}
-
+
/**
* Test data class equality
*/
@Test
fun testSubtitleTrackEquality() {
- val track1 = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/english.vtt"
- )
-
- val track2 = SubtitleTrack(
- label = "English",
- language = "en",
- src = "subtitles/english.vtt"
- )
-
- val track3 = SubtitleTrack(
- label = "French",
- language = "fr",
- src = "subtitles/french.vtt"
- )
-
+ val track1 =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/english.vtt",
+ )
+
+ val track2 =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "subtitles/english.vtt",
+ )
+
+ val track3 =
+ SubtitleTrack(
+ label = "French",
+ language = "fr",
+ src = "subtitles/french.vtt",
+ )
+
// Verify equality works as expected
assertEquals(track1, track2)
assertEquals(track1.hashCode(), track2.hashCode())
-
+
// Verify inequality works as expected
assert(track1 != track3)
assert(track1.hashCode() != track3.hashCode())
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoMetadataTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoMetadataTest.kt
index 531ed1cb..464088fa 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoMetadataTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoMetadataTest.kt
@@ -10,7 +10,6 @@ import kotlin.test.assertNull
* Tests for the VideoMetadata class
*/
class VideoMetadataTest {
-
/**
* Test the creation of VideoMetadata with default values
*/
@@ -36,17 +35,18 @@ class VideoMetadataTest {
*/
@Test
fun testCreateVideoMetadataWithValues() {
- val metadata = VideoMetadata(
- title = "Test Title",
- duration = 120000L,
- width = 1920,
- height = 1080,
- bitrate = 5000000L,
- frameRate = 30.0f,
- mimeType = "video/mp4",
- audioChannels = 2,
- audioSampleRate = 44100
- )
+ val metadata =
+ VideoMetadata(
+ title = "Test Title",
+ duration = 120000L,
+ width = 1920,
+ height = 1080,
+ bitrate = 5000000L,
+ frameRate = 30.0f,
+ mimeType = "video/mp4",
+ audioChannels = 2,
+ audioSampleRate = 44100,
+ )
// Verify the metadata properties
assertEquals("Test Title", metadata.title)
@@ -95,16 +95,18 @@ class VideoMetadataTest {
*/
@Test
fun testMetadataCopy() {
- val metadata = VideoMetadata(
- title = "Original Title",
- duration = 60000L
- )
+ val metadata =
+ VideoMetadata(
+ title = "Original Title",
+ duration = 60000L,
+ )
// Create a copy with some modified properties
- val copy = metadata.copy(
- title = "New Title",
- duration = 90000L
- )
+ val copy =
+ metadata.copy(
+ title = "New Title",
+ duration = 90000L,
+ )
// Verify the original metadata is unchanged
assertEquals("Original Title", metadata.title)
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoPlayerErrorTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoPlayerErrorTest.kt
index c698bd90..c0e7b2b8 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoPlayerErrorTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/common/VideoPlayerErrorTest.kt
@@ -10,7 +10,6 @@ import kotlin.test.assertTrue
* Tests for the VideoPlayerError class
*/
class VideoPlayerErrorTest {
-
/**
* Test the creation of CodecError
*/
@@ -111,20 +110,22 @@ class VideoPlayerErrorTest {
*/
@Test
fun testWhenExpression() {
- val errors = listOf(
- VideoPlayerError.CodecError("Codec error"),
- VideoPlayerError.NetworkError("Network error"),
- VideoPlayerError.SourceError("Source error"),
- VideoPlayerError.UnknownError("Unknown error")
- )
+ val errors =
+ listOf(
+ VideoPlayerError.CodecError("Codec error"),
+ VideoPlayerError.NetworkError("Network error"),
+ VideoPlayerError.SourceError("Source error"),
+ VideoPlayerError.UnknownError("Unknown error"),
+ )
for (error in errors) {
- val message = when (error) {
- is VideoPlayerError.CodecError -> "Codec: ${error.message}"
- is VideoPlayerError.NetworkError -> "Network: ${error.message}"
- is VideoPlayerError.SourceError -> "Source: ${error.message}"
- is VideoPlayerError.UnknownError -> "Unknown: ${error.message}"
- }
+ val message =
+ when (error) {
+ is VideoPlayerError.CodecError -> "Codec: ${error.message}"
+ is VideoPlayerError.NetworkError -> "Network: ${error.message}"
+ is VideoPlayerError.SourceError -> "Source: ${error.message}"
+ is VideoPlayerError.UnknownError -> "Unknown: ${error.message}"
+ }
when (error) {
is VideoPlayerError.CodecError -> assertEquals("Codec: Codec error", message)
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt
index 052649cb..fa8daa94 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt
@@ -22,19 +22,21 @@ import kotlin.test.assertTrue
* If the native library cannot be loaded, the tests will be skipped.
*/
class LinuxVideoPlayerStateTest {
-
@Before
fun setup() {
// Skip test if not running on Linux
- Assume.assumeTrue("Skipping Linux-specific test on non-Linux platform", CurrentPlatform.os == CurrentPlatform.OS.LINUX)
+ Assume.assumeTrue(
+ "Skipping Linux-specific test on non-Linux platform",
+ CurrentPlatform.os == CurrentPlatform.OS.LINUX,
+ )
- // Try to load the native library
+ // Try to load the native library (catch Throwable for UnsatisfiedLinkError/NoClassDefFoundError)
try {
- SharedVideoPlayer.nCreatePlayer().let { ptr ->
- if (ptr != 0L) SharedVideoPlayer.nDisposePlayer(ptr)
+ LinuxNativeBridge.nCreatePlayer().let { ptr ->
+ if (ptr != 0L) LinuxNativeBridge.nDisposePlayer(ptr)
}
- } catch (e: Exception) {
- Assume.assumeNoException("Native video player library not available", e)
+ } catch (e: Throwable) {
+ Assume.assumeTrue("Native video player library not available: ${e.message}", false)
}
}
@@ -50,8 +52,6 @@ class LinuxVideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
assertNull(playerState.error)
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt
index fd059895..a4795e1c 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt
@@ -43,7 +43,7 @@ class MacFrameUtilsTest {
}
val dst = ByteBuffer.allocate(size)
- copyBgraFrame(src, dst, width, height, rowBytes)
+ copyBgraFrame(src, dst, width, height, rowBytes, rowBytes)
for (i in 0 until size) {
assertEquals(src.get(i), dst.get(i), "Mismatch at byte index $i")
@@ -71,7 +71,7 @@ class MacFrameUtilsTest {
dst.put(i, paddingSentinel)
}
- copyBgraFrame(src, dst, width, height, dstRowBytes)
+ copyBgraFrame(src, dst, width, height, srcRowBytes, dstRowBytes)
for (row in 0 until height) {
val srcBase = row * srcRowBytes
@@ -106,8 +106,7 @@ class MacFrameUtilsTest {
val dst = ByteBuffer.allocate(dstRowBytes * height)
assertFailsWith {
- copyBgraFrame(src, dst, width, height, dstRowBytes)
+ copyBgraFrame(src, dst, width, height, srcRowBytes, dstRowBytes)
}
}
}
-
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt
index 5511c57a..7bb16b0e 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt
@@ -1,23 +1,22 @@
package io.github.kdroidfilter.composemediaplayer.mac
+import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.delay
-import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform
/**
* Tests for the Mac implementation of VideoPlayerState
- *
+ *
* Note: These tests will only run on Mac platforms. On other platforms,
* the tests will be skipped.
*/
class MacVideoPlayerStateTest {
-
/**
* Test the creation of MacVideoPlayerState
*/
@@ -28,9 +27,9 @@ class MacVideoPlayerStateTest {
println("Skipping Mac-specific test on non-Mac platform")
return
}
-
+
val playerState = MacVideoPlayerState()
-
+
// Verify the player state is initialized correctly
assertNotNull(playerState)
assertFalse(playerState.hasMedia)
@@ -40,15 +39,13 @@ class MacVideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
assertNull(playerState.error)
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test volume control
*/
@@ -59,27 +56,27 @@ class MacVideoPlayerStateTest {
println("Skipping Mac-specific test on non-Mac platform")
return
}
-
+
val playerState = MacVideoPlayerState()
-
+
// Test initial volume
assertEquals(1f, playerState.volume)
-
+
// Test setting volume
playerState.volume = 0.5f
assertEquals(0.5f, playerState.volume)
-
+
// Test volume bounds
playerState.volume = -0.1f
assertEquals(0f, playerState.volume, "Volume should be clamped to 0")
-
+
playerState.volume = 1.5f
assertEquals(1f, playerState.volume, "Volume should be clamped to 1")
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test loop setting
*/
@@ -90,23 +87,23 @@ class MacVideoPlayerStateTest {
println("Skipping Mac-specific test on non-Mac platform")
return
}
-
+
val playerState = MacVideoPlayerState()
-
+
// Test initial loop setting
assertFalse(playerState.loop)
-
+
// Test setting loop
playerState.loop = true
assertTrue(playerState.loop)
-
+
playerState.loop = false
assertFalse(playerState.loop)
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test fullscreen toggle
*/
@@ -117,23 +114,23 @@ class MacVideoPlayerStateTest {
println("Skipping Mac-specific test on non-Mac platform")
return
}
-
+
val playerState = MacVideoPlayerState()
-
+
// Test initial fullscreen state
assertFalse(playerState.isFullscreen)
-
+
// Test toggling fullscreen
playerState.toggleFullscreen()
assertTrue(playerState.isFullscreen)
-
+
playerState.toggleFullscreen()
assertFalse(playerState.isFullscreen)
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test error handling
*/
@@ -144,25 +141,25 @@ class MacVideoPlayerStateTest {
println("Skipping Mac-specific test on non-Mac platform")
return
}
-
+
val playerState = MacVideoPlayerState()
-
+
// Initially there should be no error
assertNull(playerState.error)
-
+
// Test opening a non-existent file (should cause an error)
runBlocking {
playerState.openUri("non_existent_file.mp4")
delay(500) // Give some time for the error to be set
}
-
+
// There should be an error now
assertNotNull(playerState.error)
-
+
// Test clearing the error
playerState.clearError()
assertNull(playerState.error)
-
+
// Clean up
playerState.dispose()
}
@@ -244,4 +241,4 @@ class MacVideoPlayerStateTest {
val path = assertNotNull(javaClass.classLoader.getResource("existing_file.mp4")).toURI().path
testMalformedUri("file:${path.removePrefix("/")}")
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParserTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParserTest.kt
index 71192272..b3fbe11b 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParserTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SrtParserTest.kt
@@ -3,20 +3,19 @@ package io.github.kdroidfilter.composemediaplayer.subtitle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
/**
* Tests for the SrtParser class
*/
class SrtParserTest {
-
/**
* Test parsing a simple SRT subtitle file
*/
@Test
fun testParseSrtContent() {
// Sample SRT content
- val srtContent = """
+ val srtContent =
+ """
1
00:00:01,000 --> 00:00:04,000
This is the first subtitle
@@ -29,24 +28,24 @@ class SrtParserTest {
00:01:00,000 --> 00:01:30,000
This is the third subtitle
with multiple lines
- """.trimIndent()
+ """.trimIndent()
val subtitles = SrtParser.parse(srtContent)
-
+
// Verify the parsed subtitles
assertNotNull(subtitles)
assertEquals(3, subtitles.cues.size, "Should parse 3 subtitle cues")
-
+
// Check first subtitle
assertEquals(1000, subtitles.cues[0].startTime, "First subtitle should start at 1000ms")
assertEquals(4000, subtitles.cues[0].endTime, "First subtitle should end at 4000ms")
assertEquals("This is the first subtitle", subtitles.cues[0].text)
-
+
// Check second subtitle
assertEquals(5500, subtitles.cues[1].startTime, "Second subtitle should start at 5500ms")
assertEquals(7500, subtitles.cues[1].endTime, "Second subtitle should end at 7500ms")
assertEquals("This is the second subtitle", subtitles.cues[1].text)
-
+
// Check third subtitle (with multiple lines)
assertEquals(60000, subtitles.cues[2].startTime, "Third subtitle should start at 60000ms")
assertEquals(90000, subtitles.cues[2].endTime, "Third subtitle should end at 90000ms")
@@ -59,7 +58,8 @@ class SrtParserTest {
@Test
fun testParseInvalidSrtContent() {
// Sample SRT content with some invalid entries
- val srtContent = """
+ val srtContent =
+ """
Invalid line
1
@@ -77,17 +77,17 @@ class SrtParserTest {
3
00:01:00,000 --> 00:01:30,000
Valid subtitle again
- """.trimIndent()
+ """.trimIndent()
val subtitles = SrtParser.parse(srtContent)
-
+
// Verify the parsed subtitles
assertNotNull(subtitles)
assertEquals(2, subtitles.cues.size, "Should parse only 2 valid subtitle cues")
-
+
// Check first valid subtitle
assertEquals("Valid subtitle", subtitles.cues[0].text)
-
+
// Check second valid subtitle
assertEquals("Valid subtitle again", subtitles.cues[1].text)
}
@@ -98,7 +98,8 @@ class SrtParserTest {
@Test
fun testActiveCues() {
// Sample SRT content
- val srtContent = """
+ val srtContent =
+ """
1
00:00:01,000 --> 00:00:04,000
First subtitle
@@ -106,23 +107,23 @@ class SrtParserTest {
2
00:00:05,000 --> 00:00:08,000
Second subtitle
- """.trimIndent()
+ """.trimIndent()
val subtitles = SrtParser.parse(srtContent)
-
+
// Test active cues at different times
val activeCuesAt500ms = subtitles.getActiveCues(500)
assertEquals(0, activeCuesAt500ms.size, "No subtitles should be active at 500ms")
-
+
val activeCuesAt2000ms = subtitles.getActiveCues(2000)
assertEquals(1, activeCuesAt2000ms.size, "One subtitle should be active at 2000ms")
assertEquals("First subtitle", activeCuesAt2000ms[0].text)
-
+
val activeCuesAt4500ms = subtitles.getActiveCues(4500)
assertEquals(0, activeCuesAt4500ms.size, "No subtitles should be active at 4500ms")
-
+
val activeCuesAt6000ms = subtitles.getActiveCues(6000)
assertEquals(1, activeCuesAt6000ms.size, "One subtitle should be active at 6000ms")
assertEquals("Second subtitle", activeCuesAt6000ms[0].text)
}
-}
\ No newline at end of file
+}
diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt
index b8789492..7f6c0c4a 100644
--- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt
+++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt
@@ -1,7 +1,7 @@
package io.github.kdroidfilter.composemediaplayer.windows
-import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform
import io.github.kdroidfilter.composemediaplayer.VideoPlayerError
+import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
@@ -13,12 +13,11 @@ import kotlin.test.assertTrue
/**
* Tests for the Windows implementation of VideoPlayerState
- *
+ *
* Note: These tests will only run on Windows platforms. On other platforms,
* the tests will be skipped.
*/
class WindowsVideoPlayerStateTest {
-
/**
* Test the creation of WindowsVideoPlayerState
*/
@@ -29,9 +28,9 @@ class WindowsVideoPlayerStateTest {
println("Skipping Windows-specific test on non-Windows platform")
return
}
-
+
val playerState = WindowsVideoPlayerState()
-
+
// Verify the player state is initialized correctly
assertNotNull(playerState)
assertFalse(playerState.hasMedia)
@@ -41,15 +40,13 @@ class WindowsVideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
assertNull(playerState.error)
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test volume control
*/
@@ -60,27 +57,27 @@ class WindowsVideoPlayerStateTest {
println("Skipping Windows-specific test on non-Windows platform")
return
}
-
+
val playerState = WindowsVideoPlayerState()
-
+
// Test initial volume
assertEquals(1f, playerState.volume)
-
+
// Test setting volume
playerState.volume = 0.5f
assertEquals(0.5f, playerState.volume)
-
+
// Test volume bounds
playerState.volume = -0.1f
assertEquals(0f, playerState.volume, "Volume should be clamped to 0")
-
+
playerState.volume = 1.5f
assertEquals(1f, playerState.volume, "Volume should be clamped to 1")
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test loop setting
*/
@@ -91,23 +88,23 @@ class WindowsVideoPlayerStateTest {
println("Skipping Windows-specific test on non-Windows platform")
return
}
-
+
val playerState = WindowsVideoPlayerState()
-
+
// Test initial loop setting
assertFalse(playerState.loop)
-
+
// Test setting loop
playerState.loop = true
assertTrue(playerState.loop)
-
+
playerState.loop = false
assertFalse(playerState.loop)
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test fullscreen toggle
*/
@@ -118,23 +115,23 @@ class WindowsVideoPlayerStateTest {
println("Skipping Windows-specific test on non-Windows platform")
return
}
-
+
val playerState = WindowsVideoPlayerState()
-
+
// Test initial fullscreen state
assertFalse(playerState.isFullscreen)
-
+
// Test toggling fullscreen
playerState.toggleFullscreen()
assertTrue(playerState.isFullscreen)
-
+
playerState.toggleFullscreen()
assertFalse(playerState.isFullscreen)
-
+
// Clean up
playerState.dispose()
}
-
+
/**
* Test error handling
*/
@@ -145,27 +142,27 @@ class WindowsVideoPlayerStateTest {
println("Skipping Windows-specific test on non-Windows platform")
return
}
-
+
val playerState = WindowsVideoPlayerState()
-
+
// Initially there should be no error
assertNull(playerState.error)
-
+
// Test opening a non-existent file (should cause an error)
runBlocking {
playerState.openUri("non_existent_file.mp4")
delay(500) // Give some time for the error to be set
}
-
+
// There should be an error now
assertNotNull(playerState.error)
assertTrue(playerState.error is VideoPlayerError.UnknownError)
-
+
// Test clearing the error
playerState.clearError()
assertNull(playerState.error)
-
+
// Clean up
playerState.dispose()
}
-}
\ No newline at end of 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
index 91175a69..785a0ad3 100644
--- a/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasm.kt
+++ b/mediaplayer/src/wasmJsMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.wasm.kt
@@ -15,7 +15,7 @@ actual fun VideoPlayerSurface(
playerState: VideoPlayerState,
modifier: Modifier,
contentScale: ContentScale,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
if (playerState.hasMedia) {
var videoElement by remember { mutableStateOf(null) }
@@ -39,12 +39,12 @@ actual fun VideoPlayerSurface(
onLastPlaybackSpeedChange = { lastPlaybackSpeed = it },
lastPosition = lastPosition,
wasPlaying = wasPlaying,
- lastPlaybackSpeed = lastPlaybackSpeed
+ lastPlaybackSpeed = lastPlaybackSpeed,
)
VideoVolumeAndSpeedEffects(
playerState = playerState,
- videoElement = videoElement
+ videoElement = videoElement,
)
// Video content layout with WebElementView
@@ -53,7 +53,7 @@ actual fun VideoPlayerSurface(
modifier = modifier,
videoRatio = videoRatio,
contentScale = contentScale,
- overlay = overlay
+ overlay = overlay,
) {
key(useCors) {
WebElementView(
@@ -66,9 +66,8 @@ actual fun VideoPlayerSurface(
video = this,
playerState = playerState,
scope = scope,
- enableAudioDetection = true,
useCors = useCors,
- onCorsError = { useCors = false }
+ onCorsError = { useCors = false },
)
}
},
@@ -81,7 +80,7 @@ actual fun VideoPlayerSurface(
onRelease = { video ->
video.safePause()
videoElement = null
- }
+ },
)
}
}
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 c91e1627..d57549b8 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
@@ -3,6 +3,4 @@ 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)
-}
+actual fun PlatformFile.getUri(): String = URL.createObjectURL(this.file)
diff --git a/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
index 5d439212..f3efa03e 100644
--- a/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
+++ b/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt
@@ -2,15 +2,14 @@ package io.github.kdroidfilter.composemediaplayer
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
-import kotlin.test.assertFalse
/**
* Tests for the WebAssembly/JavaScript implementation of VideoPlayerState
*/
class VideoPlayerStateTest {
-
/**
* Test the creation of VideoPlayerState
*/
@@ -27,8 +26,6 @@ class VideoPlayerStateTest {
assertFalse(playerState.loop)
assertEquals("00:00", playerState.positionText)
assertEquals("00:00", playerState.durationText)
- assertEquals(0f, playerState.leftLevel)
- assertEquals(0f, playerState.rightLevel)
assertFalse(playerState.isFullscreen)
// Clean up
@@ -140,11 +137,12 @@ class VideoPlayerStateTest {
assertTrue(playerState.availableSubtitleTracks.isEmpty())
// Create a test subtitle track
- val testTrack = SubtitleTrack(
- label = "English",
- language = "en",
- src = "test.vtt"
- )
+ val testTrack =
+ SubtitleTrack(
+ label = "English",
+ language = "en",
+ src = "test.vtt",
+ )
// Select the subtitle track
playerState.selectSubtitleTrack(testTrack)
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt
deleted file mode 100644
index 3a126a7c..00000000
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioLevelProcessor.kt
+++ /dev/null
@@ -1,138 +0,0 @@
-package io.github.kdroidfilter.composemediaplayer
-
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Severity
-import io.github.kdroidfilter.composemediaplayer.jsinterop.AnalyserNode
-import io.github.kdroidfilter.composemediaplayer.jsinterop.AudioContext
-import io.github.kdroidfilter.composemediaplayer.jsinterop.ChannelSplitterNode
-import io.github.kdroidfilter.composemediaplayer.jsinterop.MediaElementAudioSourceNode
-import org.khronos.webgl.Uint8Array
-import org.khronos.webgl.get
-import org.w3c.dom.HTMLVideoElement
-
-/**
- * Logger for WebAssembly audio level processor
- */
-internal val wasmAudioLogger = Logger.withTag("WasmAudioProcessor")
- .apply { Logger.setMinSeverity(Severity.Warn) }
-
-internal class AudioLevelProcessor(private val video: HTMLVideoElement) {
-
- private var audioContext: AudioContext? = null
- private var sourceNode: MediaElementAudioSourceNode? = null
- private var splitterNode: ChannelSplitterNode? = null
-
- private var leftAnalyser: AnalyserNode? = null
- private var rightAnalyser: AnalyserNode? = null
-
- private var leftData: Uint8Array? = null
- private var rightData: Uint8Array? = null
-
- // Audio properties
- private var _audioChannels: Int = 0
- private var _audioSampleRate: Int = 0
-
- // Getters for audio properties
- val audioChannels: Int get() = _audioChannels
- val audioSampleRate: Int get() = _audioSampleRate
-
- /**
- * Initializes Web Audio (creates a source, a splitter, etc.)
- * In case of error (CORS), we simply return false => the video remains managed by HTML
- * and audio levels will be set to 0
- *
- * @return true if initialization was successful, false if there was a CORS error
- */
- fun initialize(): Boolean {
- if (audioContext != null) return true // already initialized?
-
- val ctx = AudioContext()
- audioContext = ctx
-
- val source = try {
- ctx.createMediaElementSource(video)
- } catch (e: Throwable) {
- wasmAudioLogger.w { "CORS/format error: Video doesn't have CORS headers. Audio levels will be set to 0. Error: ${e.message}" }
- // Clean up the audio context since we won't be using it
- audioContext = null
- return false
- }
-
- sourceNode = source
- splitterNode = ctx.createChannelSplitter(2)
-
- leftAnalyser = ctx.createAnalyser().apply { fftSize = 256 }
- rightAnalyser = ctx.createAnalyser().apply { fftSize = 256 }
-
- // Chaining
- source.connect(splitterNode!!)
- splitterNode!!.connect(leftAnalyser!!, 0, 0)
- splitterNode!!.connect(rightAnalyser!!, 1, 0)
-
- // To hear the sound via Web Audio
- splitterNode!!.connect(ctx.destination)
-
- val size = leftAnalyser!!.frequencyBinCount
- leftData = Uint8Array(size)
- rightData = Uint8Array(size)
-
- // Extract audio properties
- _audioSampleRate = ctx.sampleRate
- _audioChannels = source.channelCount
-
-
- wasmAudioLogger.d { "Web Audio successfully initialized and capturing audio. Sample rate: $_audioSampleRate Hz, Channels: $_audioChannels" }
- return true
- }
-
- /**
- * Returns (left%, right%) in range 0..100
- *
- * Uses a logarithmic scale to match the Mac implementation:
- * 1. Calculate average level from frequency data
- * 2. Normalize to 0..1 range
- * 3. Convert to decibels: 20 * log10(level)
- * 4. Normalize: ((db + 60) / 60).coerceIn(0f, 1f)
- * 5. Convert to percentage: normalized * 100f
- */
- fun getAudioLevels(): Pair {
- val la = leftAnalyser ?: return 0f to 0f
- val ra = rightAnalyser ?: return 0f to 0f
- val lb = leftData ?: return 0f to 0f
- val rb = rightData ?: return 0f to 0f
-
- la.getByteFrequencyData(lb)
- ra.getByteFrequencyData(rb)
-
- var sumLeft = 0
- for (i in 0 until lb.length) {
- sumLeft += lb[i].toInt()
- }
- var sumRight = 0
- for (i in 0 until rb.length) {
- sumRight += rb[i].toInt()
- }
-
- val avgLeft = sumLeft.toFloat() / lb.length
- val avgRight = sumRight.toFloat() / rb.length
-
- // Normalize to 0..1 range
- val normalizedLeft = avgLeft / 255f
- val normalizedRight = avgRight / 255f
-
- // Convert to logarithmic scale (same as Mac implementation)
- fun convertToPercentage(level: Float): Float {
- if (level <= 0f) return 0f
- // Conversion to decibels: 20 * log10(level)
- val db = 20 * kotlin.math.log10(level)
- // Assume that -60 dB corresponds to silence and 0 dB to maximum level.
- val normalized = ((db + 60) / 60).coerceIn(0f, 1f)
- return normalized * 100f
- }
-
- val leftPercent = convertToPercentage(normalizedLeft)
- val rightPercent = convertToPercentage(normalizedRight)
-
- return leftPercent to rightPercent
- }
-}
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt
index 121c020c..03d1057a 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/FullscreenManager.kt
@@ -35,7 +35,10 @@ object FullscreenManager {
* @param isCurrentlyFullscreen Whether the player is currently in fullscreen mode
* @param onFullscreenChange Callback to update the fullscreen state
*/
- fun toggleFullscreen(isCurrentlyFullscreen: Boolean, onFullscreenChange: (Boolean) -> Unit) {
+ fun toggleFullscreen(
+ isCurrentlyFullscreen: Boolean,
+ onFullscreenChange: (Boolean) -> Unit,
+ ) {
if (!isCurrentlyFullscreen) {
requestFullScreen()
CoroutineScope(Dispatchers.Default).launch {
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt
index 19defb21..ee0d5178 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt
@@ -33,8 +33,7 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState(
* and error handling.
*/
@Stable
-open class DefaultVideoPlayerState: VideoPlayerState {
-
+open class DefaultVideoPlayerState : VideoPlayerState {
// Variable to store the last opened URI for potential replay
private var lastUri: String? = null
@@ -68,7 +67,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
// Media metadata
override val metadata = VideoMetadata()
- override val aspectRatio : Float = 16f / 9f //TO DO: Get from video source
+ override val aspectRatio: Float = 16f / 9f // TO DO: Get from video source
// Subtitle management
override var subtitlesEnabled by mutableStateOf(false)
@@ -79,8 +78,8 @@ open class DefaultVideoPlayerState: VideoPlayerState {
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
- textAlign = TextAlign.Center
- )
+ textAlign = TextAlign.Center,
+ ),
)
override var subtitleBackgroundColor by mutableStateOf(Color.Black.copy(alpha = 0.5f))
@@ -113,12 +112,6 @@ open class DefaultVideoPlayerState: VideoPlayerState {
override var isFullscreen by mutableStateOf(false)
- // Audio level indicators
- private var _leftLevel by mutableStateOf(0f)
- private var _rightLevel by mutableStateOf(0f)
- override val leftLevel: Float get() = _leftLevel
- override val rightLevel: Float get() = _rightLevel
-
// Time display properties
private var _positionText by mutableStateOf("00:00")
private var _durationText by mutableStateOf("00:00")
@@ -127,7 +120,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
// Current duration of the media
private var _currentDuration: Float = 0f
-
+
// Current time of the media in seconds
private var _currentTime: Double = 0.0
override val currentTime: Double get() = _currentTime
@@ -171,11 +164,12 @@ open class DefaultVideoPlayerState: VideoPlayerState {
if (timeSinceLastChange < 100.milliseconds) {
// If changes are coming too rapidly, schedule them with a delay
- pendingVolumeChange = playerScope.launch {
- delay(100.milliseconds.minus(timeSinceLastChange).inWholeMilliseconds)
- applyVolumeCallback?.invoke(value)
- lastVolumeChangeTime = TimeSource.Monotonic.markNow()
- }
+ pendingVolumeChange =
+ playerScope.launch {
+ delay(100.milliseconds.minus(timeSinceLastChange).inWholeMilliseconds)
+ applyVolumeCallback?.invoke(value)
+ lastVolumeChangeTime = TimeSource.Monotonic.markNow()
+ }
} else {
// Apply immediately if we're not throttling
applyVolumeCallback?.invoke(value)
@@ -195,11 +189,12 @@ open class DefaultVideoPlayerState: VideoPlayerState {
if (timeSinceLastChange < 100.milliseconds) {
// If changes are coming too rapidly, schedule them with a delay
- pendingSpeedChange = playerScope.launch {
- delay(100.milliseconds.minus(timeSinceLastChange).inWholeMilliseconds)
- applyPlaybackSpeedCallback?.invoke(value)
- lastSpeedChangeTime = TimeSource.Monotonic.markNow()
- }
+ pendingSpeedChange =
+ playerScope.launch {
+ delay(100.milliseconds.minus(timeSinceLastChange).inWholeMilliseconds)
+ applyPlaybackSpeedCallback?.invoke(value)
+ lastSpeedChangeTime = TimeSource.Monotonic.markNow()
+ }
} else {
// Apply immediately if we're not throttling
applyPlaybackSpeedCallback?.invoke(value)
@@ -231,7 +226,10 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param uri The URI of the media to open
* @param initializeplayerState Controls whether playback should start automatically after opening
*/
- override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
+ override fun openUri(
+ uri: String,
+ initializeplayerState: InitialPlayerState,
+ ) {
playerScope.coroutineContext.cancelChildren()
// Store the URI for potential replay after stop
@@ -239,7 +237,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
_sourceUri = uri
_hasMedia = true
- _isLoading = true // Set initial loading state
+ _isLoading = true // Set initial loading state
_error = null
_isPlaying = false
_playbackSpeed = 1.0f
@@ -251,10 +249,11 @@ open class DefaultVideoPlayerState: VideoPlayerState {
_isPlaying = initializeplayerState == InitialPlayerState.PLAY
} catch (e: Exception) {
_isLoading = false
- _error = when (e) {
- is IOException -> VideoPlayerError.NetworkError(e.message ?: "Network error")
- else -> VideoPlayerError.UnknownError(e.message ?: "Unknown error")
- }
+ _error =
+ when (e) {
+ is IOException -> VideoPlayerError.NetworkError(e.message ?: "Network error")
+ else -> VideoPlayerError.UnknownError(e.message ?: "Unknown error")
+ }
}
}
}
@@ -265,7 +264,10 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param file The file to open
* @param initializeplayerState Controls whether playback should start automatically after opening
*/
- override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) {
+ override fun openFile(
+ file: PlatformFile,
+ initializeplayerState: InitialPlayerState,
+ ) {
val fileUri = file.getUri()
openUri(fileUri, initializeplayerState)
}
@@ -343,17 +345,6 @@ open class DefaultVideoPlayerState: VideoPlayerState {
_error = error
}
- /**
- * Updates the audio level indicators.
- *
- * @param left The left channel audio level
- * @param right The right channel audio level
- */
- fun updateAudioLevels(left: Float, right: Float) {
- _leftLevel = left
- _rightLevel = right
- }
-
/**
* Updates the position and duration display.
*
@@ -361,18 +352,26 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param duration The total duration of the media in seconds
* @param forceUpdate If true, bypasses the rate limiting check (useful for tests)
*/
- fun updatePosition(currentTime: Float, duration: Float, forceUpdate: Boolean = false) {
+ fun updatePosition(
+ currentTime: Float,
+ duration: Float,
+ forceUpdate: Boolean = false,
+ ) {
val now = TimeSource.Monotonic.markNow()
if (forceUpdate || now - lastUpdateTime >= 1.seconds) {
// Calculate a dynamic threshold based on video duration (10% of duration or at least 0.5 seconds)
- val threshold = if (duration > 0f && !duration.isNaN()) {
- maxOf(duration * 0.1f, 0.5f)
- } else {
- 0.5f
- }
+ val threshold =
+ if (duration > 0f && !duration.isNaN()) {
+ maxOf(duration * 0.1f, 0.5f)
+ } else {
+ 0.5f
+ }
// Check if we're very close to the end of the video
- val isNearEnd = duration > 0f && !duration.isNaN() && !currentTime.isNaN() &&
+ val isNearEnd =
+ duration > 0f &&
+ !duration.isNaN() &&
+ !currentTime.isNaN() &&
(duration - currentTime < threshold)
// If we're near the end, use the duration as the current time
@@ -380,16 +379,17 @@ open class DefaultVideoPlayerState: VideoPlayerState {
_positionText = if (displayTime.isNaN()) "00:00" else formatTime(displayTime)
_durationText = if (duration.isNaN()) "00:00" else formatTime(duration)
-
+
// Update the current time property
_currentTime = displayTime.toDouble()
if (!userDragging && duration > 0f && !duration.isNaN() && !_isLoading) {
- sliderPos = if (isNearEnd) {
- PERCENTAGE_MULTIPLIER // Set to 100% if near end
- } else {
- (currentTime / duration) * PERCENTAGE_MULTIPLIER
- }
+ sliderPos =
+ if (isNearEnd) {
+ PERCENTAGE_MULTIPLIER // Set to 100% if near end
+ } else {
+ (currentTime / duration) * PERCENTAGE_MULTIPLIER
+ }
}
_currentDuration = duration
lastUpdateTime = now
@@ -403,7 +403,11 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param duration The total duration of the media in seconds
* @param forceUpdate If true, bypasses the rate limiting check (useful for tests)
*/
- fun onTimeUpdate(currentTime: Float, duration: Float, forceUpdate: Boolean = false) {
+ fun onTimeUpdate(
+ currentTime: Float,
+ duration: Float,
+ forceUpdate: Boolean = false,
+ ) {
updatePosition(currentTime, duration, forceUpdate)
}
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt
index 4160de1e..03465558 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt
@@ -13,33 +13,31 @@ 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.TaggedLogger
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) }
+internal val webVideoLogger = TaggedLogger("WebVideoPlayerSurface")
// 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"
-)
+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() {
@@ -78,7 +76,7 @@ internal fun HTMLVideoElement.addEventListeners(
scope: CoroutineScope,
playerState: VideoPlayerState,
events: Map Unit>,
- loadingEvents: Map = emptyMap()
+ loadingEvents: Map = emptyMap(),
) {
events.forEach { (event, handler) ->
addEventListener(event, handler)
@@ -93,52 +91,59 @@ internal fun HTMLVideoElement.addEventListeners(
}
}
-fun Modifier.videoRatioClip(videoRatio: Float?, contentScale: ContentScale = ContentScale.Fit): Modifier =
- drawBehind { videoRatio?.let { drawVideoRatioRect(it, contentScale) } }
+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) {
+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 (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)
+ 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 (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)
+ size = Size(rectWidth, rectHeight),
)
}
ContentScale.FillWidth -> {
@@ -149,7 +154,7 @@ private fun DrawScope.drawVideoRatioRect(ratio: Float, contentScale: ContentScal
color = Color.Transparent,
blendMode = BlendMode.Clear,
topLeft = Offset(0f, yOffset),
- size = Size(width, height)
+ size = Size(width, height),
)
}
ContentScale.FillHeight -> {
@@ -160,7 +165,7 @@ private fun DrawScope.drawVideoRatioRect(ratio: Float, contentScale: ContentScal
color = Color.Transparent,
blendMode = BlendMode.Clear,
topLeft = Offset(xOffset, 0f),
- size = Size(width, height)
+ size = Size(width, height),
)
}
ContentScale.FillBounds -> {
@@ -168,26 +173,27 @@ private fun DrawScope.drawVideoRatioRect(ratio: Float, contentScale: ContentScal
color = Color.Transparent,
blendMode = BlendMode.Clear,
topLeft = Offset(0f, 0f),
- size = Size(containerWidth, containerHeight)
+ 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 (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)
+ size = Size(rectWidth, rectHeight),
)
}
}
@@ -199,13 +205,15 @@ internal fun SubtitleOverlay(playerState: VideoPlayerState) {
return
}
- val durationMs = remember(playerState.durationText) {
- playerState.durationText.toTimeMs()
- }
+ val durationMs =
+ remember(playerState.durationText) {
+ playerState.durationText.toTimeMs()
+ }
- val currentTimeMs = remember(playerState.sliderPos, durationMs) {
- ((playerState.sliderPos / 1000f) * durationMs).toLong()
- }
+ val currentTimeMs =
+ remember(playerState.sliderPos, durationMs) {
+ ((playerState.sliderPos / 1000f) * durationMs).toLong()
+ }
ComposeSubtitleLayer(
currentTimeMs = currentTimeMs,
@@ -214,7 +222,7 @@ internal fun SubtitleOverlay(playerState: VideoPlayerState) {
subtitleTrack = playerState.currentSubtitleTrack,
subtitlesEnabled = true,
textStyle = playerState.subtitleTextStyle,
- backgroundColor = playerState.subtitleBackgroundColor
+ backgroundColor = playerState.subtitleBackgroundColor,
)
}
@@ -224,13 +232,14 @@ internal fun VideoBox(
videoRatio: Float?,
contentScale: ContentScale,
isFullscreenMode: Boolean,
- overlay: @Composable () -> Unit
+ overlay: @Composable () -> Unit,
) {
Box(
- modifier = Modifier
- .fillMaxSize()
- .background(if (isFullscreenMode) Color.Black else Color.Transparent)
- .videoRatioClip(videoRatio, contentScale)
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(if (isFullscreenMode) Color.Black else Color.Transparent)
+ .videoRatioClip(videoRatio, contentScale),
) {
SubtitleOverlay(playerState)
Box(modifier = Modifier.fillMaxSize()) {
@@ -246,14 +255,14 @@ internal fun VideoContentLayout(
videoRatio: Float?,
contentScale: ContentScale,
overlay: @Composable () -> Unit,
- videoElementContent: @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
+ contentAlignment = Alignment.Center,
) {
VideoBox(playerState, videoRatio, contentScale, true, overlay)
}
@@ -279,7 +288,10 @@ internal fun HTMLVideoElement.applyInteropBehindCanvas() {
}
}
-internal fun HTMLVideoElement.applyContentScale(contentScale: ContentScale, videoRatio: Float?) {
+internal fun HTMLVideoElement.applyContentScale(
+ contentScale: ContentScale,
+ videoRatio: Float?,
+) {
style.apply {
backgroundColor = "black"
setProperty("pointer-events", "none")
@@ -325,8 +337,8 @@ internal fun HTMLVideoElement.applyContentScale(contentScale: ContentScale, vide
}
}
-internal fun createVideoElement(useCors: Boolean = true): HTMLVideoElement {
- return (document.createElement("video") as HTMLVideoElement).apply {
+internal fun createVideoElement(useCors: Boolean = true): HTMLVideoElement =
+ (document.createElement("video") as HTMLVideoElement).apply {
controls = false
style.width = "100%"
style.height = "100%"
@@ -345,70 +357,49 @@ internal fun createVideoElement(useCors: Boolean = true): HTMLVideoElement {
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
- }
- }
- }
- }
-
if (playerState is DefaultVideoPlayerState) {
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
- )
+ 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 }
- )
+ 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 (playerState is DefaultVideoPlayerState && condition()) {
playerState._isLoading = false
@@ -423,37 +414,11 @@ internal fun setupVideoElement(
}
}
- var audioLevelJob: Job? = null
-
- video.addEventListener("play") {
- if (enableAudioDetection && !corsErrorDetected && initializationJob?.isActive != true) {
- initAudioAnalyzer()
- }
-
- if (playerState is DefaultVideoPlayerState && 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 {
- if (playerState is DefaultVideoPlayerState)
+ if (playerState is DefaultVideoPlayerState) {
playerState._isLoading = false
+ }
corsErrorDetected = true
val error = video.error
@@ -462,11 +427,12 @@ internal fun setupVideoElement(
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"
- }
+ 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"
+ }
if (playerState is DefaultVideoPlayerState) {
playerState.setError(VideoPlayerError.SourceError(errorMsg))
}
@@ -490,7 +456,7 @@ internal fun DefaultVideoPlayerState.onTimeUpdateEvent(event: Event) {
internal fun HTMLVideoElement.setupMetadataListener(
playerState: VideoPlayerState,
- onVideoRatioChange: (Float) -> Unit
+ onVideoRatioChange: (Float) -> Unit,
) {
addEventListener("loadedmetadata") {
val width = videoWidth
@@ -543,7 +509,7 @@ internal fun VideoPlayerEffects(
onLastPlaybackSpeedChange: (Float) -> Unit,
lastPosition: Double,
wasPlaying: Boolean,
- lastPlaybackSpeed: Float
+ lastPlaybackSpeed: Float,
) {
// Handle fullscreen
LaunchedEffect(playerState.isFullscreen) {
@@ -567,10 +533,13 @@ internal fun VideoPlayerEffects(
}
}
- val fullscreenEvents = listOf(
- "fullscreenchange", "webkitfullscreenchange",
- "mozfullscreenchange", "MSFullscreenChange"
- )
+ val fullscreenEvents =
+ listOf(
+ "fullscreenchange",
+ "webkitfullscreenchange",
+ "mozfullscreenchange",
+ "MSFullscreenChange",
+ )
fullscreenEvents.forEach { event ->
document.addEventListener(event, fullscreenChangeListener)
@@ -643,7 +612,7 @@ internal fun VideoPlayerEffects(
// Handle seeking
LaunchedEffect(playerState.sliderPos) {
- if (playerState is DefaultVideoPlayerState && !playerState.userDragging && playerState.hasMedia) {
+ if (playerState is DefaultVideoPlayerState && !playerState.userDragging && playerState.hasMedia) {
playerState.seekJob?.cancel()
videoElement?.let { video ->
@@ -653,9 +622,10 @@ internal fun VideoPlayerEffects(
val currentTime = video.currentTime
if (abs(currentTime - newTime) > 0.5) {
- playerState.seekJob = scope.launch {
- video.safeSetCurrentTime(newTime.toDouble())
- }
+ playerState.seekJob =
+ scope.launch {
+ video.safeSetCurrentTime(newTime.toDouble())
+ }
}
}
}
@@ -687,7 +657,7 @@ internal fun VideoPlayerEffects(
@Composable
internal fun VideoVolumeAndSpeedEffects(
playerState: VideoPlayerState,
- videoElement: HTMLVideoElement?
+ videoElement: HTMLVideoElement?,
) {
var pendingVolumeChange by remember { mutableStateOf(null) }
var pendingPlaybackSpeedChange by remember { mutableStateOf(null) }
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt
index f83dbe00..42f6533a 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/AudioContextApi.kt
@@ -2,11 +2,11 @@
package io.github.kdroidfilter.composemediaplayer.jsinterop
-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.JsAny
import kotlin.js.definedExternally
/**
@@ -15,14 +15,19 @@ import kotlin.js.definedExternally
@OptIn(ExperimentalWasmJsInterop::class)
external class AudioContext : JsAny {
constructor()
+
val destination: AudioDestinationNode
val state: String
val sampleRate: Int
fun createMediaElementSource(mediaElement: HTMLMediaElement): MediaElementAudioSourceNode
+
fun createChannelSplitter(numberOfOutputs: Int = definedExternally): ChannelSplitterNode
+
fun createAnalyser(): AnalyserNode
+
fun resume()
+
fun close()
}
@@ -31,7 +36,12 @@ external class AudioContext : JsAny {
*/
@OptIn(ExperimentalWasmJsInterop::class)
open external class AudioNode : JsAny {
- fun connect(destination: AudioNode, output: Int = definedExternally, input: Int = definedExternally): AudioNode
+ fun connect(
+ destination: AudioNode,
+ output: Int = definedExternally,
+ input: Int = definedExternally,
+ ): AudioNode
+
fun disconnect()
}
@@ -96,5 +106,6 @@ external class AnalyserNode : AudioNode {
* Additional methods if needed: retrieval in Float32.
*/
fun getFloatFrequencyData(array: Float32Array)
+
fun getFloatTimeDomainData(array: Float32Array)
}
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt
index 92814c66..c4a3dafc 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/subtitle/SubtitleLoader.web.kt
@@ -1,7 +1,6 @@
package io.github.kdroidfilter.composemediaplayer.subtitle
-import co.touchlab.kermit.Logger
-import co.touchlab.kermit.Severity
+import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger
import kotlinx.browser.window
import kotlinx.coroutines.suspendCancellableCoroutine
import org.w3c.dom.url.URL
@@ -15,76 +14,76 @@ 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)
-}
+private val webSubtitleLogger = TaggedLogger("WebSubtitleLoader")
-actual suspend fun loadSubtitleContent(src: String): String = suspendCancellableCoroutine { continuation ->
- try {
- // Handle different types of URLs
- val url = when {
- // Handle HTTP/HTTPS URLs directly
- src.startsWith("http://") || src.startsWith("https://") -> src
+actual suspend fun loadSubtitleContent(src: String): String =
+ suspendCancellableCoroutine { continuation ->
+ try {
+ // Handle different types of URLs
+ val url =
+ when {
+ // Handle HTTP/HTTPS URLs directly
+ src.startsWith("http://") || src.startsWith("https://") -> src
- // Handle blob: URLs directly
- src.startsWith("blob:") -> src
+ // Handle blob: URLs directly
+ src.startsWith("blob:") -> src
- // Handle data: URLs directly
- src.startsWith("data:") -> src
+ // Handle data: URLs directly
+ src.startsWith("data:") -> src
- // Handle file: URLs
- src.startsWith("file:") -> {
- webSubtitleLogger.d { "File URLs are not directly supported in browser. Using as-is: $src" }
- src
- }
+ // Handle file: URLs
+ src.startsWith("file:") -> {
+ webSubtitleLogger.d { "File URLs are not directly supported in browser. Using as-is: $src" }
+ src
+ }
- // For any other format, assume it's a relative path
- else -> {
- try {
- // Try to resolve relative to the current page
- URL(src, window.location.href).toString()
- } catch (e: Exception) {
- webSubtitleLogger.e { "Failed to resolve URL: $src - ${e.message}" }
- src // Use as-is if resolution fails
+ // For any other format, assume it's a relative path
+ else -> {
+ try {
+ // Try to resolve relative to the current page
+ URL(src, window.location.href).toString()
+ } catch (e: Exception) {
+ webSubtitleLogger.e { "Failed to resolve URL: $src - ${e.message}" }
+ src // Use as-is if resolution fails
+ }
+ }
}
- }
- }
- // Log the URL we're fetching
- webSubtitleLogger.d { "Fetching subtitle content from: $url" }
+ // Log the URL we're fetching
+ webSubtitleLogger.d { "Fetching subtitle content from: $url" }
- // Use XMLHttpRequest to fetch the content
- val xhr = XMLHttpRequest()
- xhr.open("GET", url, true)
- // We want text response, which is the default, so no need to set responseType
+ // Use XMLHttpRequest to fetch the content
+ val xhr = XMLHttpRequest()
+ xhr.open("GET", url, true)
+ // We want text response, which is the default, so no need to set responseType
- xhr.onload = {
- if (xhr.status.toInt() in 200..299) {
- val content = xhr.responseText
- continuation.resume(content)
- } else {
- webSubtitleLogger.e { "Failed to fetch subtitle content: ${xhr.status} ${xhr.statusText}" }
- continuation.resume("")
+ xhr.onload = {
+ if (xhr.status.toInt() in 200..299) {
+ val content = xhr.responseText
+ continuation.resume(content)
+ } else {
+ webSubtitleLogger.e { "Failed to fetch subtitle content: ${xhr.status} ${xhr.statusText}" }
+ continuation.resume("")
+ }
}
- }
- xhr.onerror = {
- webSubtitleLogger.e { "Error fetching subtitle content" }
- continuation.resume("")
- }
+ xhr.onerror = {
+ webSubtitleLogger.e { "Error fetching subtitle content" }
+ continuation.resume("")
+ }
- xhr.send()
+ xhr.send()
- // Register cancellation handler
- continuation.invokeOnCancellation {
- try {
- xhr.abort()
- } catch (_: Exception) {
- // Ignore abort errors
+ // Register cancellation handler
+ continuation.invokeOnCancellation {
+ try {
+ xhr.abort()
+ } catch (_: Exception) {
+ // Ignore abort errors
+ }
}
+ } catch (e: Exception) {
+ webSubtitleLogger.e { "Error loading subtitle content: ${e.message}" }
+ continuation.resume("")
}
- } catch (e: Exception) {
- 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 a8e84c10..98a572c2 100644
--- a/sample/composeApp/build.gradle.kts
+++ b/sample/composeApp/build.gradle.kts
@@ -1,5 +1,6 @@
@file:OptIn(ExperimentalWasmDsl::class)
+import org.apache.tools.ant.taskdefs.condition.Os
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
@@ -50,27 +51,27 @@ kotlin {
}
binaries.executable()
}
- listOf(
- iosX64(),
- iosArm64(),
- iosSimulatorArm64()
- ).forEach {
- it.binaries.framework {
- baseName = "ComposeApp"
- isStatic = true
+ if (Os.isFamily(Os.FAMILY_MAC)) {
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = "ComposeApp"
+ isStatic = true
+ }
}
}
sourceSets {
commonMain.dependencies {
- implementation(compose.runtime)
- implementation(compose.foundation)
- implementation(compose.material3)
- implementation(compose.components.uiToolingPreview)
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material.icons.extended)
implementation(project(":mediaplayer"))
- implementation(compose.materialIconsExtended)
implementation(libs.filekit.dialogs.compose)
- implementation(libs.platformtools.darkmodedetector)
}
androidMain.dependencies {
@@ -83,15 +84,10 @@ kotlin {
}
webMain.dependencies {
implementation(libs.kotlinx.browser)
-
}
}
}
-dependencies {
- debugImplementation(compose.uiTooling)
-}
-
android {
namespace = "sample.app"
compileSdk = 36
@@ -106,6 +102,10 @@ android {
}
}
+dependencies {
+ debugImplementation(libs.compose.ui.tooling)
+}
+
compose.desktop {
application {
mainClass = "sample.app.MainKt"
@@ -126,26 +126,4 @@ compose.desktop {
}
}
-// Task to run the iOS app
-tasks.register("runIos") {
- group = "run"
- description = "Run the iOS app in a simulator"
-
- // Set the working directory to the iosApp directory
- workingDir = file("${project.rootDir}/sample/iosApp")
-
- // Command to execute the run_ios.sh script
- commandLine("bash", "./run_ios.sh")
-
- // Make the task depend on building the iOS framework
- dependsOn(tasks.named("linkDebugFrameworkIosSimulatorArm64"))
-
- doFirst {
- println("Running iOS app in simulator...")
- }
-}
-
-dependencies {
- debugImplementation(compose.uiTooling)
-}
diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt
index 40e2f622..7fa389a3 100644
--- a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt
+++ b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt
@@ -11,12 +11,12 @@ import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
-import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode
+import androidx.compose.foundation.isSystemInDarkTheme
import sample.app.singleplayer.SinglePlayerScreen
@Composable
fun App() {
- MaterialTheme(colorScheme = if(isSystemInDarkMode()) darkColorScheme() else lightColorScheme()) {
+ MaterialTheme(colorScheme = if(isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
// Navigation state
var currentScreen by remember { mutableStateOf(Screen.SinglePlayer) }
diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt
index d752dd46..cfd71500 100644
--- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt
+++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt
@@ -399,22 +399,6 @@ fun VideoUrlInput(
)
}
-@Composable
-fun AudioLevelDisplay(
- leftLevel: Float,
- rightLevel: Float
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(2.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text("Left: ${leftLevel.toInt()}%")
- Text("Right: ${rightLevel.toInt()}%")
- }
-}
-
@Composable
fun MetadataDisplay(
playerState: VideoPlayerState
@@ -585,10 +569,6 @@ fun ControlsCard(
}
}
- AudioLevelDisplay(
- leftLevel = playerState.leftLevel,
- rightLevel = playerState.rightLevel
- )
}
}
diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
index 72ab80ac..e872d7b5 100644
--- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
+++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
@@ -74,7 +74,6 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
// Launcher for selecting a local video file
val videoFileLauncher = rememberFilePickerLauncher(
type = FileKitType.Video,
- title = "Select a video"
) { file ->
file?.let {
playerState.openFile(it, initialPlayerState)
@@ -84,7 +83,6 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
// Launcher for selecting a local subtitle file (VTT format)
val subtitleFileLauncher = rememberFilePickerLauncher(
type = FileKitType.File("vtt", "srt"),
- title = "Select a subtitle file"
) { file ->
file?.let {
val subtitleUri = it.getUri()
diff --git a/sample/composeApp/src/jvmMain/kotlin/sample/app/main.kt b/sample/composeApp/src/jvmMain/kotlin/sample/app/main.kt
index ada650ce..d79e8647 100644
--- a/sample/composeApp/src/jvmMain/kotlin/sample/app/main.kt
+++ b/sample/composeApp/src/jvmMain/kotlin/sample/app/main.kt
@@ -4,8 +4,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
-import io.github.kdroidfilter.platformtools.darkmodedetector.windows.setWindowsAdaptiveTitleBar
-
fun main() {
application {
val windowState = rememberWindowState(width = 720.dp, height = 1000.dp)
@@ -14,7 +12,6 @@ fun main() {
title = "Compose Media Player",
state = windowState
) {
- window.setWindowsAdaptiveTitleBar()
App()
}
}
diff --git a/sample/iosApp/run_ios.sh b/sample/iosApp/run_ios.sh
deleted file mode 100755
index 22ba1f91..00000000
--- a/sample/iosApp/run_ios.sh
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Script to compile and launch the iOS application in a simulator
-# Usage: ./run_ios.sh [SIMULATOR_UDID]
-#
-# If no UDID is provided, the script will attempt to use an available iPhone simulator.
-# To list available simulators: xcrun simctl list devices available
-
-### — Parameters —
-SCHEME="iosApp" # Scheme name
-CONFIG="Debug"
-# If UDID is provided, use it; otherwise find the latest iOS version and use an iPhone from that version
-if [[ -n "${1:-}" ]]; then
- UDID="$1"
-else
- # Get the list of available devices
- DEVICES_LIST=$(xcrun simctl list devices available)
-
- # Find the latest iOS version by extracting all iOS version numbers and sorting them
- LATEST_IOS_VERSION=$(echo "$DEVICES_LIST" | grep -E -e "-- iOS [0-9]+\.[0-9]+ --" |
- sed -E 's/.*-- iOS ([0-9]+\.[0-9]+) --.*/\1/' |
- sort -t. -k1,1n -k2,2n |
- tail -1)
-
- echo "🔍 Latest iOS version found: $LATEST_IOS_VERSION"
-
- # Find the first iPhone in the latest iOS version section
- UDID=$(echo "$DEVICES_LIST" |
- awk -v version="-- iOS $LATEST_IOS_VERSION --" 'BEGIN {found=0}
- $0 ~ version {found=1; next}
- /-- iOS/ {found=0}
- found && /iPhone/ {print; exit}' |
- sed -E 's/.*\(([A-Z0-9-]+)\).*/\1/' |
- head -1)
-
- # If no iPhone is found in the latest iOS version, fall back to any simulator
- if [[ -z "$UDID" ]]; then
- echo "⚠️ No iPhone found for iOS $LATEST_IOS_VERSION, falling back to any available simulator"
- UDID=$(echo "$DEVICES_LIST" | grep -E '\([A-Z0-9-]+\)' | head -1 | sed -E 's/.*\(([A-Z0-9-]+)\).*/\1/')
- fi
-fi
-
-# Check if a simulator was found
-if [[ -z "$UDID" ]]; then
- echo "❌ No available iOS simulator found. Please create one in Xcode."
- echo " You can also specify a UDID manually as the first argument of the script:"
- echo " ./run_ios.sh SIMULATOR_UDID"
- echo ""
- echo " To list available simulators:"
- echo " xcrun simctl list devices available"
- exit 1
-fi
-
-echo "🔍 Using simulator with UDID: $UDID"
-DERIVED_DATA="$(pwd)/build"
-BUNDLE_ID="sample.app.iosApp"
-
-### — Detecting project/workspace in current directory —
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-
-WORKSPACE=$(find "$ROOT_DIR" -maxdepth 1 -name "*.xcworkspace" | head -n1)
-XCODEPROJ=$(find "$ROOT_DIR" -maxdepth 1 -name "*.xcodeproj" | head -n1)
-
-if [[ -n "$WORKSPACE" ]]; then
- BUILD_BASE=(xcodebuild -workspace "$WORKSPACE")
-elif [[ -n "$XCODEPROJ" ]]; then
- BUILD_BASE=(xcodebuild -project "$XCODEPROJ")
-else
- echo "❌ No .xcworkspace or .xcodeproj found in $ROOT_DIR"
- exit 1
-fi
-
-### — Compilation —
-echo "⏳ Compiling for simulator..."
-BUILD_CMD=("${BUILD_BASE[@]}"
- -scheme "$SCHEME"
- -configuration "$CONFIG"
- -sdk iphonesimulator
- -destination "id=$UDID"
- -derivedDataPath "$DERIVED_DATA"
- build)
-
-if command -v xcpretty &>/dev/null; then
- "${BUILD_CMD[@]}" | xcpretty
-else
- "${BUILD_CMD[@]}"
-fi
-
-echo "🔍 Searching for the application in the build folder..."
-APP_DIR="$DERIVED_DATA/Build/Products/${CONFIG}-iphonesimulator"
-# Search for the .app application in the build folder
-APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" | head -n1)
-[[ -n "$APP_PATH" ]] || { echo "❌ No .app application found in $APP_DIR"; exit 1; }
-echo "✅ Application found: $APP_PATH"
-
-# Extract the bundle ID from the application
-echo "🔍 Extracting bundle ID..."
-EXTRACTED_BUNDLE_ID=$(defaults read "$APP_PATH/Info" CFBundleIdentifier 2>/dev/null)
-if [[ -n "$EXTRACTED_BUNDLE_ID" ]]; then
- echo "✅ Bundle ID extracted: $EXTRACTED_BUNDLE_ID"
- BUNDLE_ID="$EXTRACTED_BUNDLE_ID"
-else
- echo "⚠️ Unable to extract bundle ID, using default value: $BUNDLE_ID"
-fi
-
-### — Simulator, installation, launch —
-echo "🚀 Booting simulator..."
-xcrun simctl boot "$UDID" 2>/dev/null || true # idempotent
-
-# Open the Simulator.app application to display the simulator window
-echo "🖥️ Opening Simulator application..."
-open -a Simulator
-
-# Wait for the simulator to be fully booted
-echo "⏳ Waiting for simulator to fully boot..."
-MAX_WAIT=30
-WAIT_COUNT=0
-while ! xcrun simctl list devices | grep "$UDID" | grep -q "(Booted)"; do
- sleep 1
- WAIT_COUNT=$((WAIT_COUNT + 1))
- if [[ $WAIT_COUNT -ge $MAX_WAIT ]]; then
- echo "❌ Timeout waiting for simulator to boot"
- exit 1
- fi
- echo -n "."
-done
-echo ""
-echo "✅ Simulator started"
-
-echo "📲 Installing the app..."
-xcrun simctl install booted "$APP_PATH"
-
-echo "▶️ Launching with logs..."
-echo " Bundle ID: $BUNDLE_ID"
-xcrun simctl launch --console booted "$BUNDLE_ID"
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e45b71b4..7d591136 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -3,11 +3,11 @@ rootProject.name = "Compose-Media-Player"
pluginManagement {
repositories {
google {
- content {
- includeGroupByRegex("com\\.android.*")
- includeGroupByRegex("com\\.google.*")
- includeGroupByRegex("androidx.*")
- includeGroupByRegex("android.*")
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ includeGroupByRegex("android.*")
}
}
gradlePluginPortal()
@@ -18,11 +18,11 @@ pluginManagement {
dependencyResolutionManagement {
repositories {
google {
- content {
- includeGroupByRegex("com\\.android.*")
- includeGroupByRegex("com\\.google.*")
- includeGroupByRegex("androidx.*")
- includeGroupByRegex("android.*")
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ includeGroupByRegex("android.*")
}
}
mavenCentral()
@@ -31,4 +31,3 @@ dependencyResolutionManagement {
}
include(":mediaplayer")
include(":sample:composeApp")
-