mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 18:23:59 +00:00
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.
15 KiB
15 KiB
AGENTS
Repo shape (KMP + modules)
- Gradle multi-module:
:composeApp(main app),:plugin_interfaces(plugin API contracts),:js_plugin_example(Zipline JS plugin template). :composeAppuses custom KMP source sets (mobileMain,androidJvmMain) with explicitdependsOn; Gradle prints "Default Kotlin Hierarchy Template Not Applied Correctly" warning. Treat as known/expected.
Real app entrypoints
- 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.ktandiosApp/iosApp/ContentView.swift. - iOS audio: MobileVLCKit from CocoaPods (
iosApp/Podfile), driven byiosApp/iosApp/VlcPlayerEngine.swiftthroughNativePlayerEngine(iosMain). Runpod installiniosApp/first, then buildiosApp/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 (
./gradlewor./gradlew.bat) only. - Run desktop:
:composeApp:run - Build Android debug:
:composeApp:assembleStableDebug(orassembleNightlyDebug). Android hasstable/nightlyproduct flavors (nightly = beta channel:.nightlyapplicationId suffix, own icons/name incomposeApp/src/nightly/res). Desktop icons come fromcomposeApp/icons/{stable,nightly}, chosen with-Pnightly=true. - Module checks:
:composeApp:check,:plugin_interfaces:check,:js_plugin_example:check - Focused tests:
:composeApp:jvmTest,:composeApp:iosSimulatorArm64Test,:plugin_interfaces:jvmTest,:plugin_interfaces:jsTest,:js_plugin_example:jsTest - No lint/typecheck/formatter tasks are configured;
:composeApp:checkis the only aggregated check.
Dependencies
gradle/libs.versions.tomlis the single source of truth for all version pins and library declarations.- Kotlin:
2.4.21, JVM target:11(compile/target compatibility in bothcomposeApp/build.gradle.ktsandplugin_interfaces/build.gradle.kts). - compose-webview (desktop) dispatcher hijack:
dev.nucleusframework:composewebviewtransitively pullsnucleus.decorated-window-tao, which registers aTaoMainDispatcherFactory(loadPriority = 100) viaMETA-INF/servicesthat hijacksDispatchers.Mainaway from the Swing EDT. This breaks lifecycle'senforceMainThreadIfNeeded(window fails with "Method addObserver must be called on the main thread"). The app overrides it withSwingMainDispatcherFactory(composeApp/src/jvmMain/.../core/coroutines/,loadPriority = Int.MAX_VALUE) which pinsDispatchers.Mainback to the Swing EDT. Keep that factory + itsMETA-INF/services/kotlinx.coroutines.internal.MainDispatcherFactoryresource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority).
Networking
- Never call
HttpClient(...)directly. InjectHttpClientFactory(commonMain/.../core/network/HttpClientFactory.kt, a Koin single) and use.default/.json(shared, never close them) or.create { ... }for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). - Do not add
ktor-client-cio: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
Group Jam
- Room server: separate repository, https://github.com/team-spotube/jam-server (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in its
src/protocol.ts; keep it in sync with the wire frames incore/jam/JamRoomClient.kt(bumpPROTOCOL_VERSIONon both sides for breaking changes, and land both changes together). - App side:
core/jam/—JamRoomClient(Ktor WebSocket, reconnects, keep-alives, clock pings),JamClock/JamDriftCorrector(sync math),JamRoomService(host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off). - Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See the jam-server README.
- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (
JamDevice.kttraits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it throughJamMediaItem.sanitizedbefore loading it. See "Security" in the jam-server README. - A network drop must never crash the app:
JamRoomClient.pumpturns socket failures into a reconnect.JamSessionSimulationTest.roomSurvivesNetworkLosscuts the network withTcpProxy. - Server URL setting:
UserSettings.jamServerUrl(defaultDEFAULT_JAM_SERVER_URLinSettingsModels.kt). - Tests:
JamSyncTest(pure), plusJamRoomClientIntegrationTestandJamSessionSimulationTest, which only run withJAM_TEST_URLset (runnpm run devin a jam-server checkout).
GraalVM native image (desktop)
- Enabled via
graalvm { }insidenucleus.applicationincomposeApp/build.gradle.kts. Nucleus downloads GraalVM CE itself (or uses$GRAALVM_HOME). Linux needsgcc+patchelf. - Build:
:composeApp:createGraalvmNativeDistributable(outputcomposeApp/build/compose/binaries/main/graalvm-app/), installers viapackageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}. A build takes ~10 min and uses all cores and ~15 GB RAM. - Hand-maintained + agent-recorded metadata lives in
composeApp/graalvm/reachability-metadata.json. After adding features that use reflection/JNI/JNA (vlcj, Zipline, plugins), run:composeApp:runWithNativeAgent, exercise the feature, then close the window normally (the merge only happens on a clean exit). uk.co.caprica.vlcj.binding.lib.LibVlcmust stay registered (allDeclaredMethods+jniAccessible): JNA direct mapping finds itsnativemethods by reflection.- The
*UberJarForCurrentOStask excludes other OSes'/CPUs' native libs and stale CEF binaries, because native-image embeds every registered resource into the binary. On Linux,copyGraalvmJvmSois disabled andcopyGraalvmAwtSoLibsprefers native-image's own libs (10 KBlibjvm.so/libjava.soshims instead of the JDK's 28 MBlibjvm.so). isProductionBuild(top ofcomposeApp/build.gradle.kts) is true forpackage*/create*/notarize*/nativeImageCompiletasks, or with-Pproduction=true|false. It gates the Rust release profile (GobleyjvmVariant) so:composeApp:runkeeps fast debug Rust builds. Put new slow, ship-only optimizations behind it.- Full user-facing build docs are in
README.md("Building from source").
Codegen and plugin packaging
- JS plugin bundles:
:js_plugin_example:packageDevelopmentPluginand:js_plugin_example:packageProductionPlugin. Output is.smplugfiles injs_plugin_example/build/distributions/. - Zipline plugin entrypoint:
mainFunction = "org.spotube.js_plugin_example.main"injs_plugin_example/build.gradle.kts. Plugin metadata fromjs_plugin_example/plugin.json.
Plugin architecture
plugin_interfacesexportszipline.coreandsemveras API.composeAppdepends on it for the plugin system.plugin_interfacesalso has a JS target (browser()), used by the plugin system.
UI component patterns
- ALL user-facing strings MUST come from
composeApp/src/commonMain/composeResources/values/strings.xmlviastringResource(Res.string.xxx). Never hardcode a literal string in aText(...), dialog title, button label, content description, placeholder, or error message inside@Composablecode. If the string you need doesn't exist yet, add a new<string name="...">entry tostrings.xml(grouped near related keys, following the existingfeature_context_purposenaming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not justmodules/settings.strings.xmlis the single source of truth for copy, not a showcase file for one screen. - Outside composables (ViewModels, services, snackbars), import
getString/getPluralString/getStringArrayfromorg.spotube.spotube.core.i18n, never fromorg.jetbrains.compose.resources. The library versions ask Skiko for the system theme on every call, which on Linux is a blocking D-Bus call that segfaulted the desktop app from background threads (#3183).NonComposableStringLookupTestenforces this. - ViewModels own ALL state and logic. Composables are dumb renderers: they collect a single
StateFlow<UiState>from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, orLaunchedEffect-based state syncing belongs in a composable — it goes in the ViewModel. Thecombine/stateInflow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediaterememberderivations ormutableStateListOfmirrors. - AdaptiveDropdownBottomSheet (
commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt): switches betweenDropdownMenu(large screen) andModalBottomSheet(small screen) viacurrentWindowAdaptiveInfo(). Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - AdaptiveDialogBottomSheet (
commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt): switches betweenThemedDialog(large screen) andModalBottomSheet(small screen) viacurrentWindowAdaptiveInfo(). Same rule — keep in commonMain unless platform-specific APIs are required. - Use
expect/actualonly when the component MUST use platform-specific APIs (e.g.WindowStatefor desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain. - JavaFX is required;
--add-opensflags incompose.desktop.application.jvmArgsmust be preserved:javafx.graphics/javafx.scene,javafx.graphics/com.sun.javafx.sg.prism,javafx.graphics/com.sun.javafx.scene,javafx.web/com.sun.webkit,javafx.media/com.sun.media.jfxmedia,javafx.media/com.sun.media.jfxmedia.events. - JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via
System.getProperty("os.name").
Tooling
- Gradle config cache enabled (
gradle.properties); prefer module-scoped tasks. - Gradle daemon JVM pinned to JetBrains JDK 21 via
gradle/gradle-daemon-jvm.properties. - Gradle 8.14.3 (from
gradle/wrapper/gradle-wrapper.properties). - Foojay toolchain resolver in use (
plugins { id("org.gradle.toolchains.foojay-resolver-convention") }).
Current testing reality
- No committed
*Test*.ktfiles; test tasks may run zero tests unless new tests are added.
Release workflow (.github/workflows/release.yml)
- Triggered by
workflow_dispatchwith arelease_typechoice input (stable|nightly). prepare-depsjob checks out & publisheskdroidFilter/ComposeNativeWebview(compose-webview) tomavenLocal. The spotube gradle plugin and vlcj-bundler now come from Maven Central.- Build jobs per platform (Android, Linux, Windows, macOS), all
needs: prepare-deps. Desktop jobs ship GraalVM native installers (packageGraalvm{Deb,Rpm,Exe,Dmg}, output incomposeApp/build/compose/binaries/main/graalvm-<format>/), not jpackage/JVM ones. - Stable: reads
versionNamefromcomposeApp/build.gradle.kts, tag =v{version}, draft release. - Nightly: builds with
-PversionName=nightly, tag =nightly(updates existing), prerelease. - Android signing: decodes
secrets.KEYSTORE(base64) →composeApp/upload-keystore.jks, writes signing config intolocal.propertiesfrom secrets. create-releasejob (depends on all builds) usessoftprops/action-gh-release@v2to create the GitHub release with all artifacts attached.
Package managers (packaging/, .github/workflows/spotube-publish-binary.yml)
packaging/<target>(linux,aur,flatpak,winget,chocolatey) holds JMustache templates (*.mustache) plus files copied as is.:composeApp:renderPackaging<Target>renders them intocomposeApp/build/packaging/<target>(RenderPackagingTaskin buildSrc documents the template data). Never hand-write versions, checksums or release notes into these files.- Checksums come from release assets in
-PpackagingArtifactsDir=<dir>; a template using a missing value fails the render. Release notes come fromgit-cliff --contextwithcliff.toml(GitCliffContextTask): needsgit-cliffon the PATH and full git history.gradle.propertiesdefaults tonightly=true, which leaves commits unreleased: pass-Pnightly=falsefor a stable render. packaging/package-linux-tarball.shpacksspotube-linux-<version>-<arch>.tar.xz(the AUR and Flathub source) from the GraalVM distributable and the renderedlinuxdir; the release workflow builds it before the AppImage step.- The publish workflow (
workflow_dispatch, inputtag) renders once, then pushes to Flathub (team-spotube/org.spotube.spotube), the AUR (spotube-bin), WinGet (PR fromteam-spotube/winget-pkgs) and Chocolatey.
OpenCode Agent MCP Execution Directives
1. Code Discovery & Context Search
- Use
android-studio-mcpsearch/symbol tools to locate declarations before making edits across:composeApp,:plugin_interfaces, or:js_plugin_example[cite: 1, 2]. - Keep
gradle/libs.versions.tomlas the single source of truth for dependencies and version updates.
2. UI Architecture Constraints
- Strict ViewModel Separation: UI composables in
commonMainmust only collectStateFlow<UiState>and emit event callbacks. Do NOT write business logic, filtering, ormutableStateListOfdirectly inside@Composablefunctions. - Adaptive Components: Use
AdaptiveDropdownBottomSheetorAdaptiveDialogBottomSheetinsidecommonMainfor adaptive layouts[cite: 2]. Do NOT create platform-specificexpect/actualvariants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2].
3. Verification & Diagnostic Loops
- Post-Edit Verification: After editing Kotlin code, validate compilation by running
./gradlew :composeApp:checkor./gradlew :composeApp:assembleStableDebugvia MCP[cite: 2]. - Dispatcher Integrity: Never remove or bypass
SwingMainDispatcherFactoryincomposeApp/src/jvmMain/.../core/coroutines/[cite: 2]. It is required to preventcompose-webviewfrom hijackingDispatchers.Mainaway from the Swing EDT[cite: 2]. - Live Preview Iteration: Use
compose-hot-reloadto evaluate UI layout changes headlessly without forcing full Gradle rebuilds.