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/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
iosApp/Pods/
node_modules/
composeApp/release/

View File

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

View File

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

View File

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

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 = {
/* 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 = "<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 */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -35,6 +38,8 @@
};
E265D0F6D10F52F5C1374EEB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Configuration;
sourceTree = "<group>";
};
@ -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 = "<group>";
};
835615636B5FE86AA850B7DB /* Products */ = {
isa = PBXGroup;
children = (
@ -69,6 +65,35 @@
name = Products;
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 */
/* 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 */;
}

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 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
}
@ -166,77 +159,32 @@ 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 }
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)
}
}
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
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,11 +192,12 @@ 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 {
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) {
DispatchQueue.main.async(execute: work)