Merge pull request #3207 from team-spotube/ios-mobilevlckit-cocoapods

fix(ios): use MobileVLCKit from CocoaPods instead of a git Swift package
This commit is contained in:
Kingkor Roy Tirtho 2026-11-03 18:21:57 +06:00 committed by GitHub
commit 3a856d6d03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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.
*/
@ -130,15 +130,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
}
@ -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) {