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) {