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.
This commit is contained in:
Kingkor Roy Tirtho 2026-11-03 17:57:55 +06:00
parent f9c040015e
commit e02c1b73b4
10 changed files with 288 additions and 284 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ captures
!*.xcodeproj/project.xcworkspace/ !*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
iosApp/Pods/
node_modules/ node_modules/
composeApp/release/ composeApp/release/

View File

@ -8,6 +8,7 @@
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). - 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`. - 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 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 ## High-value commands
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.

View File

@ -34,7 +34,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds 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). * 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. * 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) _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) { override fun onError(message: String) {
if (disposed) return if (disposed) return
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)

View File

@ -18,14 +18,15 @@
package org.spotube.spotube.core.audioplayer 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 * MobileVLCKit comes from CocoaPods into the Xcode project, so Gradle can't bind it with cinterop.
* implements this contract instead and registers it in [NativePlayerRegistry] before the Compose * Swift implements this contract instead and registers it in [NativePlayerRegistry] before the
* view controller is created. The playlist, loop and shuffle logic stays in Kotlin * 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. * ([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. * themselves. [NativePlayerListener] callbacks may arrive on any thread.
*/ */
interface NativePlayerEngine { interface NativePlayerEngine {
@ -53,9 +54,6 @@ interface NativePlayerListener {
fun onState(state: NativePlayerState) fun onState(state: NativePlayerState)
fun onTime(positionMs: Long) fun onTime(positionMs: Long)
fun onDuration(durationMs: Long) fun onDuration(durationMs: Long)
/** Network cache fill of the current media, 0..100. */
fun onBuffered(percent: Float)
fun onError(message: String) fun onError(message: String)
} }

9
iosApp/Podfile Normal file
View File

@ -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

16
iosApp/Podfile.lock Normal file
View File

@ -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

View File

@ -7,11 +7,14 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -35,6 +38,8 @@
}; };
E265D0F6D10F52F5C1374EEB /* Configuration */ = { E265D0F6D10F52F5C1374EEB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Configuration; path = Configuration;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -45,22 +50,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */, EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
EFA169D322B90F32841D602F = {
isa = PBXGroup;
children = (
E265D0F6D10F52F5C1374EEB /* Configuration */,
5BB730BF90365122B74925FF /* iosApp */,
835615636B5FE86AA850B7DB /* Products */,
);
sourceTree = "<group>";
};
835615636B5FE86AA850B7DB /* Products */ = { 835615636B5FE86AA850B7DB /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -69,6 +65,35 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
85BADDD666F59900213B22FE /* Frameworks */ = {
isa = PBXGroup;
children = (
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
92A495F3BC0A87A606BB8FD4 /* Pods */ = {
isa = PBXGroup;
children = (
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */,
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
EFA169D322B90F32841D602F = {
isa = PBXGroup;
children = (
E265D0F6D10F52F5C1374EEB /* Configuration */,
5BB730BF90365122B74925FF /* iosApp */,
835615636B5FE86AA850B7DB /* Products */,
92A495F3BC0A87A606BB8FD4 /* Pods */,
85BADDD666F59900213B22FE /* Frameworks */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -76,10 +101,12 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */; buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = ( buildPhases = (
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */,
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */, 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */,
314349EF3C4149F4A4A72378 /* Sources */, 314349EF3C4149F4A4A72378 /* Sources */,
EFCF243BC7D8246E008EF23D /* Frameworks */, EFCF243BC7D8246E008EF23D /* Frameworks */,
ED9557BFD7438D37A5E9513D /* Resources */, ED9557BFD7438D37A5E9513D /* Resources */,
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -89,9 +116,6 @@
5BB730BF90365122B74925FF /* iosApp */, 5BB730BF90365122B74925FF /* iosApp */,
); );
name = iosApp; name = iosApp;
packageProductDependencies = (
9A1B2C3D4E5F60718293A4B3 /* VLCKit */,
);
productName = iosApp; productName = iosApp;
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */; productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -120,9 +144,6 @@
); );
mainGroup = EFA169D322B90F32841D602F; mainGroup = EFA169D322B90F32841D602F;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 835615636B5FE86AA850B7DB /* Products */; productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -144,6 +165,23 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase 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 */ = { 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -163,6 +201,28 @@
shellPath = /bin/sh; 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"; 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 */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -176,6 +236,122 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration 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 */ = { AF5828948B37FEEF5EF7F345 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
@ -241,132 +417,9 @@
}; };
name = Debug; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList 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" */ = { 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@ -376,26 +429,16 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AF5828948B37FEEF5EF7F345 /* Debug */,
1C42200356BD14712FAE1CFA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* 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 */; rootObject = B056E79C138AE849968965F1 /* Project object */;
} }

View File

@ -1,14 +0,0 @@
{
"originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b",
"pins" : [
{
"identity" : "vlckit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/videolan/vlckit",
"state" : {
"revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f"
}
}
],
"version" : 3
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:iosApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -16,9 +16,9 @@
import AVFoundation import AVFoundation
import ComposeApp import ComposeApp
import Foundation 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. /// `ComposeView` is created.
func registerNativePlayerEngine() { func registerNativePlayerEngine() {
NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory() 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. /// `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 { final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate {
private let player = VLCMediaPlayer() private let player = VLCMediaPlayer()
private var listener: NativePlayerListener? 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 released = false
private var rate: Float = 1 private var rate: Float = 1
private var volumePercent: Int32 = 100 private var volumePercent: Int32 = 100
private var lastReportedDurationMs: Int64 = 0
private var lastReportedState: NativePlayerState?
override init() { override init() {
super.init() super.init()
player.delegate = self player.delegate = self
player.timeChangeUpdateInterval = 0.25
player.minimalTimePeriod = 250_000
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) 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) { func load(url: String, autoPlay: Bool, startPositionMs: Int64) {
onMain { onMain {
guard !self.released else { return } 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)") self.listener?.onError(message: "Invalid URL: \(url)")
return return
} }
self.stopCurrentMedia() let media = VLCMedia(url: mediaUrl)
self.hasStarted = false
self.finished = false
if startPositionMs > 0 { if startPositionMs > 0 {
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)") media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
} }
self.lastReportedDurationMs = 0
self.player.media = media self.player.media = media
self.listener?.onState(state: .opening) self.report(.opening)
if autoPlay { self.startPlayback() } if autoPlay { self.startPlayback() }
} }
} }
@ -100,7 +92,7 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat
func stop() { func stop() {
onMain { onMain {
guard !self.released else { return } guard !self.released else { return }
self.stopCurrentMedia() self.player.stop()
} }
} }
@ -139,25 +131,26 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat
// MARK: VLCMediaPlayerDelegate // MARK: VLCMediaPlayerDelegate
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { func mediaPlayerStateChanged(_ aNotification: Notification) {
onMain { onMain {
guard !self.released else { return } guard !self.released else { return }
switch newState { switch self.player.state {
case .opening: 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: case .playing:
self.hasStarted = true self.report(.playing)
self.applyPlaybackSettings()
self.reportDuration()
self.listener?.onState(state: .playing)
case .paused: case .paused:
self.listener?.onState(state: .paused) self.report(.paused)
case .stopping, .stopped: case .ended:
self.handleStop(isFinalStop: newState == .stopped) self.report(.ended)
case .stopped:
self.report(.idle)
case .error: case .error:
self.finished = true
self.listener?.onError(message: "VLC could not play this media") self.listener?.onError(message: "VLC could not play this media")
self.listener?.onState(state: .idle) self.report(.idle)
default: default:
break break
} }
@ -166,77 +159,32 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat
func mediaPlayerTimeChanged(_ aNotification: Notification) { func mediaPlayerTimeChanged(_ aNotification: Notification) {
onMain { onMain {
guard !self.released, let ms = self.player.time.value?.int64Value, ms >= 0 else { return } guard !self.released else { return }
// 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) 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)
}
}
} }
// MARK: Internals // MARK: Internals
private func startPlayback() { private func startPlayback() {
try? AVAudioSession.sharedInstance().setActive(true) try? AVAudioSession.sharedInstance().setActive(true)
let wasPaused = player.state == .paused
player.play() player.play()
if wasPaused { kickTimeReporting() }
} }
/// After a pause, VLCKit 4 withholds time updates until libvlc reports a fresh time point, and /// Sends [state] to Kotlin unless it was the last one sent.
/// on iOS that report doesn't come after a resume: audio plays but the position stays frozen. private func report(_ state: NativePlayerState) {
/// A seek goes through a different callback that does reset VLCKit's time tracking, so seek guard state != lastReportedState else { return }
/// to where we already are. lastReportedState = state
private func kickTimeReporting() { if state == .playing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in applyPlaybackSettings()
guard let self, !self.released, self.player.isPlaying, self.player.isSeekable else { return } reportDuration()
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)
} }
listener?.onState(state: state)
} }
private func applyPlaybackSettings() { private func applyPlaybackSettings() {
@ -244,11 +192,12 @@ final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegat
if player.rate != rate { player.rate = rate } 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() { private func reportDuration() {
if let ms = player.media?.length.value?.int64Value, ms > 0 { guard let ms = player.media?.length.value?.int64Value, ms > 0, ms != lastReportedDurationMs else { return }
lastReportedDurationMs = ms
listener?.onDuration(durationMs: ms) listener?.onDuration(durationMs: ms)
} }
}
private func onMain(_ work: @escaping () -> Void) { private func onMain(_ work: @escaping () -> Void) {
DispatchQueue.main.async(execute: work) DispatchQueue.main.async(execute: work)