From e02c1b73b45abe84d2dada899f98882c8a7f20c3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 3 Nov 2026 17:57:55 +0600 Subject: [PATCH] fix(ios): use MobileVLCKit from CocoaPods instead of a git Swift package Android Studio couldn't build the iOS app: resolving the videolan/vlckit Swift package fails inside it (Xcode's source control service reports "Communication with the service was interrupted"), ending in "Missing package product 'VLCKit'". VLCKit's README only documents CocoaPods and Carthage anyway. - Podfile with MobileVLCKit 3.7.5 (iOS-only build, 233 MB instead of the 922 MB cross-platform VLCKit 4 binary); build iosApp.xcworkspace. - VlcPlayerEngine ported to the MobileVLCKit 3 API: it has a real "ended" state, so the stop bookkeeping and the post-resume seek workaround needed for VLCKit 4 are gone. Buffering/ES-added notifications keep arriving while playing, so "playing" is reported whenever VLC is playing. - NativePlayerListener.onBuffered removed: MobileVLCKit has no buffering progress callback. - Pods/ is ignored; AGENTS.md notes the pod install + workspace setup. --- .gitignore | 1 + AGENTS.md | 1 + .../core/audioplayer/AudioPlayer.ios.kt | 11 +- .../core/audioplayer/NativePlayerEngine.kt | 14 +- iosApp/Podfile | 9 + iosApp/Podfile.lock | 16 + iosApp/iosApp.xcodeproj/project.pbxproj | 363 ++++++++++-------- .../xcshareddata/swiftpm/Package.resolved | 14 - .../contents.xcworkspacedata | 10 + iosApp/iosApp/VlcPlayerEngine.swift | 133 ++----- 10 files changed, 288 insertions(+), 284 deletions(-) create mode 100644 iosApp/Podfile create mode 100644 iosApp/Podfile.lock delete mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iosApp/iosApp.xcworkspace/contents.xcworkspacedata diff --git a/.gitignore b/.gitignore index 68c4954a..b7c03908 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ captures !*.xcodeproj/project.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings +iosApp/Pods/ node_modules/ composeApp/release/ diff --git a/AGENTS.md b/AGENTS.md index dc6bc52f..0bd47d04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). - Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`. - iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. +- iOS audio: MobileVLCKit from CocoaPods (`iosApp/Podfile`), driven by `iosApp/iosApp/VlcPlayerEngine.swift` through `NativePlayerEngine` (iosMain). Run `pod install` in `iosApp/` first, then build `iosApp/iosApp.xcworkspace` (not the `.xcodeproj`, which can't build the Pods). Android Studio's iOS run config must point at the workspace too. ## High-value commands - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index d5df44b4..a60372f5 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -34,7 +34,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** - * iOS player. Playback is done by VLCKit through [NativePlayerEngine]; AVPlayer misreports the + * iOS player. Playback is done by MobileVLCKit through [NativePlayerEngine]; AVPlayer misreports the * duration of some fragmented MP4 streams (doubling it, which leaves the second half silent). * The engine plays a single URL, so the playlist, loop, shuffle and auto-advance live here. */ @@ -110,15 +110,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface _duration.tryEmit(durationMs.milliseconds) } - override fun onBuffered(percent: Float) { - if (disposed) return - val durationMs = _duration.value.inWholeMilliseconds - _bufferingPosition.tryEmit( - if (durationMs > 0) (durationMs * (percent.coerceIn(0f, 100f) / 100f)).toLong().milliseconds - else Duration.ZERO - ) - } - override fun onError(message: String) { if (disposed) return _playerState.tryEmit(PlayerState.IDLE) diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt index b8ce4d1a..95da43ee 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt @@ -18,14 +18,15 @@ package org.spotube.spotube.core.audioplayer /** - * Single-media playback engine implemented in Swift on top of VLCKit (`iosApp/iosApp/VlcPlayerEngine.swift`). + * Single-media playback engine implemented in Swift on top of MobileVLCKit + * (`iosApp/iosApp/VlcPlayerEngine.swift`). * - * VLCKit ships as a SwiftPM binary target, so it can't be bound with cinterop from Gradle. Swift - * implements this contract instead and registers it in [NativePlayerRegistry] before the Compose - * view controller is created. The playlist, loop and shuffle logic stays in Kotlin + * MobileVLCKit comes from CocoaPods into the Xcode project, so Gradle can't bind it with cinterop. + * Swift implements this contract instead and registers it in [NativePlayerRegistry] before the + * Compose view controller is created. The playlist, loop and shuffle logic stays in Kotlin * ([AudioPlayer]); the engine only ever plays one URL at a time. * - * Implementations may be called from any thread and must hop to the thread VLCKit needs + * Implementations may be called from any thread and must hop to the thread VLC needs * themselves. [NativePlayerListener] callbacks may arrive on any thread. */ interface NativePlayerEngine { @@ -53,9 +54,6 @@ interface NativePlayerListener { fun onState(state: NativePlayerState) fun onTime(positionMs: Long) fun onDuration(durationMs: Long) - - /** Network cache fill of the current media, 0..100. */ - fun onBuffered(percent: Float) fun onError(message: String) } diff --git a/iosApp/Podfile b/iosApp/Podfile new file mode 100644 index 00000000..dbb2699c --- /dev/null +++ b/iosApp/Podfile @@ -0,0 +1,9 @@ +# MobileVLCKit plays audio on iOS (iosApp/iosApp/VlcPlayerEngine.swift). +# After `pod install`, open and build iosApp.xcworkspace, not iosApp.xcodeproj. +platform :ios, '18.2' + +target 'iosApp' do + use_frameworks! + + pod 'MobileVLCKit', '3.7.5' +end diff --git a/iosApp/Podfile.lock b/iosApp/Podfile.lock new file mode 100644 index 00000000..8d7d9112 --- /dev/null +++ b/iosApp/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - MobileVLCKit (3.7.5) + +DEPENDENCIES: + - MobileVLCKit (= 3.7.5) + +SPEC REPOS: + trunk: + - MobileVLCKit + +SPEC CHECKSUMS: + MobileVLCKit: 9fb4475cd820b2d271ac394d5d7ba18c525332ab + +PODFILE CHECKSUM: 6dfb07dbc8a64d0c6fe7231a81687c56f08e8250 + +COCOAPODS: 1.16.2 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ed20c29c..222b2336 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,11 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - 9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1B2C3D4E5F60718293A4B3 /* VLCKit */; }; + EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; + 3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -35,6 +38,8 @@ }; E265D0F6D10F52F5C1374EEB /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Configuration; sourceTree = ""; }; @@ -45,22 +50,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */, + EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - EFA169D322B90F32841D602F = { - isa = PBXGroup; - children = ( - E265D0F6D10F52F5C1374EEB /* Configuration */, - 5BB730BF90365122B74925FF /* iosApp */, - 835615636B5FE86AA850B7DB /* Products */, - ); - sourceTree = ""; - }; 835615636B5FE86AA850B7DB /* Products */ = { isa = PBXGroup; children = ( @@ -69,6 +65,35 @@ name = Products; sourceTree = ""; }; + 85BADDD666F59900213B22FE /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 92A495F3BC0A87A606BB8FD4 /* Pods */ = { + isa = PBXGroup; + children = ( + 34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */, + BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + EFA169D322B90F32841D602F = { + isa = PBXGroup; + children = ( + E265D0F6D10F52F5C1374EEB /* Configuration */, + 5BB730BF90365122B74925FF /* iosApp */, + 835615636B5FE86AA850B7DB /* Products */, + 92A495F3BC0A87A606BB8FD4 /* Pods */, + 85BADDD666F59900213B22FE /* Frameworks */, + ); + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -76,10 +101,12 @@ isa = PBXNativeTarget; buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */; buildPhases = ( + FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */, 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */, 314349EF3C4149F4A4A72378 /* Sources */, EFCF243BC7D8246E008EF23D /* Frameworks */, ED9557BFD7438D37A5E9513D /* Resources */, + 19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -89,9 +116,6 @@ 5BB730BF90365122B74925FF /* iosApp */, ); name = iosApp; - packageProductDependencies = ( - 9A1B2C3D4E5F60718293A4B3 /* VLCKit */, - ); productName = iosApp; productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */; productType = "com.apple.product-type.application"; @@ -120,9 +144,6 @@ ); mainGroup = EFA169D322B90F32841D602F; minimizedProjectReferenceProxies = 1; - packageReferences = ( - 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */, - ); preferredProjectObjectVersion = 77; productRefGroup = 835615636B5FE86AA850B7DB /* Products */; projectDirPath = ""; @@ -144,6 +165,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -163,6 +201,28 @@ shellPath = /bin/sh; shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; }; + FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -176,6 +236,122 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 1C42200356BD14712FAE1CFA /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 4FB276594B17F1A0D71D2462 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + AC996E9458BD3DAE9610EA49 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; AF5828948B37FEEF5EF7F345 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; @@ -241,132 +417,9 @@ }; name = Debug; }; - 1C42200356BD14712FAE1CFA /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; - baseConfigurationReferenceRelativePath = Config.xcconfig; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - AC996E9458BD3DAE9610EA49 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 4FB276594B17F1A0D71D2462 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - AF5828948B37FEEF5EF7F345 /* Debug */, - 1C42200356BD14712FAE1CFA /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -376,26 +429,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF5828948B37FEEF5EF7F345 /* Debug */, + 1C42200356BD14712FAE1CFA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/videolan/vlckit"; - requirement = { - kind = revision; - revision = 6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 9A1B2C3D4E5F60718293A4B3 /* VLCKit */ = { - isa = XCSwiftPackageProductDependency; - package = 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */; - productName = VLCKit; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = B056E79C138AE849968965F1 /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index c30addc3..00000000 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b", - "pins" : [ - { - "identity" : "vlckit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/videolan/vlckit", - "state" : { - "revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f" - } - } - ], - "version" : 3 -} diff --git a/iosApp/iosApp.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..c009e7d7 --- /dev/null +++ b/iosApp/iosApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/iosApp/iosApp/VlcPlayerEngine.swift b/iosApp/iosApp/VlcPlayerEngine.swift index 2653c968..32eaddfa 100644 --- a/iosApp/iosApp/VlcPlayerEngine.swift +++ b/iosApp/iosApp/VlcPlayerEngine.swift @@ -16,9 +16,9 @@ import AVFoundation import ComposeApp import Foundation -import VLCKit +import MobileVLCKit -/// Registers the VLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first +/// Registers the MobileVLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first /// `ComposeView` is created. func registerNativePlayerEngine() { NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory() @@ -30,30 +30,23 @@ final class VlcPlayerEngineFactory: NSObject, NativePlayerEngineFactory { } } -/// Plays one URL at a time with VLCKit. The Kotlin `AudioPlayer` owns the playlist; see +/// Plays one URL at a time with MobileVLCKit. The Kotlin `AudioPlayer` owns the playlist; see /// `NativePlayerEngine.kt` for the contract. /// -/// Every VLCKit call is made on the main queue, in the order the Kotlin side issued it. +/// Every VLC call is made on the main queue, in the order the Kotlin side issued it. final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate { private let player = VLCMediaPlayer() private var listener: NativePlayerListener? - - // VLCKit 4 has no "ended" state: reaching the end of a media goes Stopping -> Stopped, the - // same as stop(). `stopRequested` marks the stops this class asked for, so any other stop of - // a media that had started playing is a natural end. - private var stopRequested = false - private var hasStarted = false - private var finished = false private var released = false private var rate: Float = 1 private var volumePercent: Int32 = 100 + private var lastReportedDurationMs: Int64 = 0 + private var lastReportedState: NativePlayerState? override init() { super.init() player.delegate = self - player.timeChangeUpdateInterval = 0.25 - player.minimalTimePeriod = 250_000 try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) } @@ -66,19 +59,18 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat func load(url: String, autoPlay: Bool, startPositionMs: Int64) { onMain { guard !self.released else { return } - guard let mediaUrl = URL(string: url), let media = VLCMedia(url: mediaUrl) else { + guard let mediaUrl = URL(string: url) else { self.listener?.onError(message: "Invalid URL: \(url)") return } - self.stopCurrentMedia() - self.hasStarted = false - self.finished = false + let media = VLCMedia(url: mediaUrl) if startPositionMs > 0 { media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)") } + self.lastReportedDurationMs = 0 self.player.media = media - self.listener?.onState(state: .opening) + self.report(.opening) if autoPlay { self.startPlayback() } } } @@ -100,7 +92,7 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat func stop() { onMain { guard !self.released else { return } - self.stopCurrentMedia() + self.player.stop() } } @@ -139,25 +131,26 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat // MARK: VLCMediaPlayerDelegate - func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { + func mediaPlayerStateChanged(_ aNotification: Notification) { onMain { guard !self.released else { return } - switch newState { + switch self.player.state { case .opening: - self.listener?.onState(state: .opening) + self.report(.opening) + case .buffering, .esAdded: + // MobileVLCKit keeps sending these while it plays; only a stall counts as buffering. + self.report(self.player.isPlaying ? .playing : .buffering) case .playing: - self.hasStarted = true - self.applyPlaybackSettings() - self.reportDuration() - self.listener?.onState(state: .playing) + self.report(.playing) case .paused: - self.listener?.onState(state: .paused) - case .stopping, .stopped: - self.handleStop(isFinalStop: newState == .stopped) + self.report(.paused) + case .ended: + self.report(.ended) + case .stopped: + self.report(.idle) case .error: - self.finished = true self.listener?.onError(message: "VLC could not play this media") - self.listener?.onState(state: .idle) + self.report(.idle) default: break } @@ -165,29 +158,13 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat } func mediaPlayerTimeChanged(_ aNotification: Notification) { - onMain { - guard !self.released, let ms = self.player.time.value?.int64Value, ms >= 0 else { return } - self.listener?.onTime(positionMs: ms) - } - } - - func mediaPlayerLengthChanged(_ length: Int64) { onMain { guard !self.released else { return } - self.listener?.onDuration(durationMs: length) - } - } - - func mediaPlayerBufferingChanged(_ progress: Float) { - onMain { - guard !self.released else { return } - self.listener?.onBuffered(percent: progress * 100) - // VLC always reports 0 and 1 before the first frame; only mid-playback stalls matter. - guard self.hasStarted else { return } - if progress < 1 { - self.listener?.onState(state: .buffering) - } else if self.player.isPlaying { - self.listener?.onState(state: .playing) + // Time only advances while playing, whatever the last state notification said. + if self.player.isPlaying { self.report(.playing) } + self.reportDuration() + if let ms = self.player.time.value?.int64Value, ms >= 0 { + self.listener?.onTime(positionMs: ms) } } } @@ -196,47 +173,18 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat private func startPlayback() { try? AVAudioSession.sharedInstance().setActive(true) - let wasPaused = player.state == .paused player.play() - if wasPaused { kickTimeReporting() } } - /// After a pause, VLCKit 4 withholds time updates until libvlc reports a fresh time point, and - /// on iOS that report doesn't come after a resume: audio plays but the position stays frozen. - /// A seek goes through a different callback that does reset VLCKit's time tracking, so seek - /// to where we already are. - private func kickTimeReporting() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self, !self.released, self.player.isPlaying, self.player.isSeekable else { return } - self.player.time = self.player.time - } - } - - /// Stops the current media and marks the resulting stop events as ours. - private func stopCurrentMedia() { - switch player.state { - case .opening, .playing, .paused, .stopping: - stopRequested = true - player.stop() - default: - break - } - } - - private func handleStop(isFinalStop: Bool) { - if stopRequested { - if isFinalStop { - stopRequested = false - listener?.onState(state: .idle) - } - return - } - if hasStarted && !finished { - finished = true - listener?.onState(state: .ended) - } else if isFinalStop && !finished { - listener?.onState(state: .idle) + /// Sends [state] to Kotlin unless it was the last one sent. + private func report(_ state: NativePlayerState) { + guard state != lastReportedState else { return } + lastReportedState = state + if state == .playing { + applyPlaybackSettings() + reportDuration() } + listener?.onState(state: state) } private func applyPlaybackSettings() { @@ -244,10 +192,11 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat if player.rate != rate { player.rate = rate } } + /// MobileVLCKit has no length callback, so the length is read from the media as it plays. private func reportDuration() { - if let ms = player.media?.length.value?.int64Value, ms > 0 { - listener?.onDuration(durationMs: ms) - } + guard let ms = player.media?.length.value?.int64Value, ms > 0, ms != lastReportedDurationMs else { return } + lastReportedDurationMs = ms + listener?.onDuration(durationMs: ms) } private func onMain(_ work: @escaping () -> Void) {