Mobile apps: embedding the player
Research, October 2026. No code yet; the tracker entry is
PROJECT.md §28, which holds the
status, the open issues (MOB-…) and what's left to do.
architecture.md draws the player as it is today — the packages, the clocks and threads, the backing track. This is what would have to change to put it on a phone.
The goal set for this: native desktop and mobile apps are thin shells that embed one reusable player and bridge it to the system. The two questions asked were what the best approach is, and whether the existing wasm code can be repurposed for it (and if not, whether to delete it).
Short answers: compile the player natively for each platform, with SDL3 supplying the window, input and audio on mobile; and no, wasm is not the way in, but keep it anyway — it's the web version, and it's the reason the mobile port is cheap.
1. What already ports
Measured on odin dev-2026-05, not guessed.
Odin has first-class mobile subtargets: -target:darwin_arm64 -subtarget:iphone (and iphonesimulator), and -target:linux_arm64 -subtarget:android.
Every package in the repo that doesn't import raylib type-checks for iOS
unchanged — vct, player, align, score, sheet, chordpro,
stagesync, qr and lsp — and so does cmd/player. Android needs nothing
but ODIN_ANDROID_NDK set.
A static library holding the parser, timeline builder, engine and driver builds for iOS today:
odin build <pkg> -target:darwin_arm64 -subtarget:iphone \
-build-mode:static -no-entry-point -o:speed -out:libplayer.a
That gives a 324 KB arm64 Mach-O archive — LC_BUILD_VERSION platform 2,
minos 17.4 — exporting an @(export) C-ABI symbol, ready to link into an
Xcode target. -subtarget:iphonesimulator gives platform 7. So feature 12's
"export the core with a C ABI" is a formality rather than a port.
The themes and clock-lock work (features 9 and 11) didn't change that:
player/theme.odin, player/look.odin, player/ttf.odin and
player/lock.odin are all platform-free — no raylib — and check for iOS with
the rest. config_dir (os.user_config_dir, where settings.json5 lives)
resolves inside the app container on iOS, as documents_dir does.
The audio-thread mixer (feature 11) helps more than anything else since:
every stem now goes out through one stream whose callback (backing_mix,
a proc "c" that takes no lock and allocates nothing) sums them from a frame
cursor, and player/lock.odin counts the frames that mixer is asked for. A
single pull callback plus a frame count is exactly the shape SDL3's audio
callback wants, so the mobile backend inherits the mixer and the clock lock
rather than reimplementing them.
Two other pieces land better than expected:
cmd/player/midi_darwin.odinuses only the plain C CoreMIDI API (MIDIClientCreate,MIDIInputPortCreate,MIDIPortConnectSource,MIDISend,MIDIObjectGetStringProperty), which iOS has as well. The Launchkey pads could work on an iPad over USB-C or Bluetooth MIDI from nearly the same code.documents_dir($HOME/Documents,player/file.odin) is already the right folder inside an iOS sandbox; withUIFileSharingEnabledthe library shows up in the Files app.
Why it ports: the wasm build did the hard part
None of this is luck. The web version (feature 20) is what got core:os out
of the shared code — files and paths behind player/file.odin vs
player/file_js.odin, player.Arena instead of core:mem/virtual
directly — and what split the shared startup and per-frame step
(cmd/player/main.odin) away from the platform entry point
(main_desktop.odin, main_web.odin).
A mobile shell wants exactly that shape: a third entry point beside those two.
mise run vet type-checking the wasm target is what keeps it true.
2. The blocker is raylib, not the player
raylib has no iOS backend. There is no rcore_ios.c, and the request for
one was closed in 2018. The only public working iOS raylib
(jlt-commons/raylib-ios,
raylib 6.0 + SDL 2.32.10) reaches iOS through raylib's SDL platform backend
with GRAPHICS_API_OPENGL_ES2 — and turns raylib's audio off, because
enabling it pulls Objective-C headers into C translation units. It also notes
the iOS simulator hasn't displayed OpenGL ES since 17.5. Audio is not
optional for this app, so that path is out.
Odin's vendor:raylib couldn't link a mobile raylib as it stands anyway: the
ODIN_OS == .Darwin branch hard-codes macos/libraylib.a plus the Cocoa,
OpenGL and IOKit frameworks, and iOS is Darwin, so the system:raylib
fallback branch is unreachable. Prebuilt libraries ship for macOS, Linux,
Windows and wasm only.
raylib's Android support is official (PLATFORM_ANDROID, NativeActivity,
GLES2), but the same binding problem applies, and standing on it would mean
two different mobile backends.
Backends compared
| iOS | Android | Web | In vendor: |
Zero-install | |
|---|---|---|---|---|---|
| raylib (today) | ✗ no backend; unofficial SDL route, audio off | ✓ official | ✓ (in use) | ✓ | ✓ prebuilt libs |
| SDL3 | ✓ official, xcframework + Metal | ✓ official, Gradle/NDK | ✓ official | ✓ vendor:sdl3 |
✗ system:SDL3 |
| sokol | ✓ in the C headers | ✓ in the C headers | ✓ | ✗ third-party sokol-odin |
✗ desktop clib scripts only |
SDL3 is the one to stand on for mobile. It's already in vendor:sdl3
(video, gpu, render, audio, main, plus ttf, mixer and image), both Apple and
Google platforms are officially supported, it exposes UIKit and Android window
properties (PROP_WINDOW_UIKIT_WINDOW_POINTER,
PROP_WINDOW_ANDROID_WINDOW_POINTER,
PROP_WINDOW_CREATE_EXTERNAL_GRAPHICS_CONTEXT_BOOLEAN) so it can sit inside a
host-owned view, and it supports Emscripten as well — so one backend could
eventually cover the web too.
It is not zero-install the way vendor:raylib is: the bindings link
system:SDL3, so each platform supplies its own build. That's the documented
path on iOS (SDL3.xcframework) and Android (Gradle), but it would be a new
requirement on desktop. Hence: raylib stays on desktop and web, SDL3 is
added for mobile only.
sokol was the other serious candidate. The C headers do support iOS and
Android, but floooh/sokol-odin is
third-party rather than in vendor:, and ships clib build scripts for
Windows, macOS and Linux only — mobile means writing those yourself.
3. Why wasm is not the way in
In a WebView (WKWebView, Android WebView)
- WebKit has no Web MIDI, on macOS or iOS, and no roadmap for it (Apple cites device-ID fingerprinting; the tracking bug is still open). Every browser on iOS is WebKit underneath. So the Launchkey pads and MIDI Go/Next/song-change are gone for good, not merely unimplemented.
- A page can't listen on UDP or TCP, so the sync server and phone link stay out — already recorded as WEB-I2.
- WKWebView's canvas/WebGL has a history of performance regressions, and a single page is memory-capped far below what decoded stems want (see MOB-I1 below).
- Web Audio is suspended when the app backgrounds or the screen locks, which on stage is fatal.
- A thin web wrapper also invites an App Store guideline 4.2 ("minimum functionality") rejection, which is survivable but is work.
In a native wasm runtime (wasmtime, WAMR, wasm3)
iOS allows no JIT, so interpretation or AOT only — and the shell would have to expose graphics, input and audio across the wasm boundary, i.e. reimplement raylib's backend as host calls, in order to run code that already compiles to native arm64. Strictly more work, strictly slower, no upside.
So should the wasm code go?
No. Keep it, for reasons that have nothing to do with mobile:
- It is the web version — a shipped way to run the whole player (library, stage, editors) with no install, on a Chromebook or a borrowed laptop.
- It is why the shared code is portable enough for iOS to type-check
today (§1). Deleting it removes the only continuous check that
core:osand desktop assumptions stay out ofplayer/and the shared half ofcmd/player/. mise run vetalready type-checks the wasm target, so that check costs nothing to keep.
Its one real cost is WEB-I1: mise run build needs emcc on PATH, so a
machine without emsdk fails at the build:web step. That's worth fixing by
making the step skippable — not by deleting the target.
4. Recommended architecture
┌─────────────────────────────────────────────┐
│ vct/ player/ align/ score/ sheet/ ... │ pure Odin, no I/O
│ parser · timeline · engine · driver · view │ compiles for iOS today
└─────────────────────────────────────────────┘
▲
┌─────────────────┴───────────────────────────┐
│ gfx seam (~40 procs, from ui.odin) │ draw · input · audio · files
└──────┬───────────────────────┬──────────────┘
gfx_raylib gfx_sdl3
│ │
┌────────────┴──────┐ ┌───────────┴───────────┐
│ macOS/Win/Linux │ │ iOS shell │ Android │ thin: lifecycle, pickers,
│ web (Emscripten) │ │ (UIKit) │ (Kotlin)│ MIDI, audio session, paths
└───────────────────┘ └─────────────┴─────────┘
The shells stay thin because the player draws its own UI; what they bridge is the list in §6: file pickers, MIDI, the audio session, the sandbox paths, the idle timer and the lifecycle events.
Port the stage, not the app
raylib call sites cluster in the desktop-only screens — edit.odin 149,
set_edit.odin 99, home.odin 83 — while the stage path is 48
(stage.odin) plus 83 (ui.odin's primitives) plus 12
(cmd/player/look.odin): 143 of 758 in total. Porting the stage and the
pre-play list first is under a fifth of the work, and it's what a musician
actually needs on a phone or tablet.
The editor should stay desktop-first regardless: it's driven by Enter, Tab,
E, N, Ctrl+T/S/Z/D and friends, with no touch design at all.
5. Feature-by-feature compatibility
How each existing feature stands on iOS and Android under the recommended architecture.
| Feature | iOS | Android | Notes |
|---|---|---|---|
| Parse, timeline, engine, driver | ✓ | ✓ | Builds today (§1) |
| Stage display, layouts, lyrics/karaoke | ✓ | ✓ | Needs the gfx seam; layout already scales to any screen shape |
| Themes and settings | ✓ | ~ | theme.odin, look.odin, ttf.odin are platform-free; Android needs a config path (MOB-Q2) |
| Metronome mode | ✓ | ✓ | Draws from the same view state |
| Operator controls | ~ | ~ | Keyboard-only today; Start/Next need touch targets (MOB-Q1) |
| Backing tracks, mixer | ~ | ~ | Works, but decode is whole-file in memory (MOB-I1) |
| Stage clock locked to the track | ✓ | ✓ | player/lock.odin counts frames from our own mixer, so it ports with it (MOB-I2) |
| Audio while backgrounded / screen locked | ~ | ~ | Needs the playback session and audio background mode (MOB-I5) |
| MIDI pads (Launchkey) | ✓ | ~ | iOS: same CoreMIDI C API. Android: AMidi + JNI (MOB-I4) |
| Library, sets, pre-play | ✓ | ~ | iOS paths already right; Android needs a path from the shell (MOB-Q2) |
| Import (ChordPro, sheets, MusicXML, MuseScore, MIDI) | ✓ | ✓ | Importers are pure Odin and already compile; pickers need a bridge (MOB-I7) |
| Export | ✓ | ✓ | Same: share sheet / SAF behind the existing seam |
| Song and set editors | ✗ | ✗ | Keyboard-driven; desktop-first by choice (MOB-Q1) |
| Stage sync server + web client | ✓ | ✓ | TCP listening is unrestricted on iOS |
vct-sync broadcast discovery |
~ | ✓ | iOS UDP broadcast needs an Apple entitlement (MOB-I3); QR code already covers it |
| Phone link / QR | ✓ | ✓ | |
| Extra stage displays, a layout each | ✗ | ✗ | SDL3 on iOS is single-window; use AirPlay or a synced screen (MOB-I6) |
--render to video |
✗ | ✗ | Shells out to ffmpeg (MOB-I7) |
| CLI tools, LSP | ✗ | ✗ | Desktop only by nature; they do compile for iOS |
✓ works · ~ works with the noted work · ✗ not going to mobile
6. What the shells have to bridge
- Audio session. iOS: playback category and the
audiobackground mode, or the click stops on the silent switch or a screen lock. If miniaudio is used directly it must be compiled as Objective-C, with runtime linking off and CoreAudio/AudioToolbox linked, for notarisation. Android: AAudio works out of the box. Either backend only has to callbacking_mixfrom its own audio callback: the frame count the clock lock needs comes from that call, not from the device. One raylib-specific thing must not be carried over —BACKING_PAN_CENTREdivides raylib's own pan law back out of the stream, and SDL3 would need its own figure or none. - Screen.
isIdleTimerDisabledon iOS,FLAG_KEEP_SCREEN_ONon Android — a stage display must not sleep. - Lifecycle. SDL3 gives 5 seconds on backgrounding to save state; stop
drawing on
SDL_EVENT_WILL_ENTER_BACKGROUND. - File pickers.
UIDocumentPickerViewControllerand Android SAF, behind the existingplayer.pick_files/cmd/player/open_*.odinseam. - Paths. iOS needs nothing: both the library (
documents_dir) andsettings.json5(config_dir) land in the app container. Android has neither$HOMEnor an XDG config dir: the shell passesgetExternalFilesDirandgetFilesDirdown (MOB-Q2). - MIDI. iOS: link
CoreMIDI.framework, nothing else. Android:AMidi(NDK, API 29+) with theMidiDevicehanded down fromandroid.media.midiover JNI (MOB-I4). - Local network. iOS wants
NSLocalNetworkUsageDescription; UDP broadcast additionally wantscom.apple.developer.networking.multicast(MOB-I3).
7. The cheap alternative, for the record
A native sync client — a phone app that follows a desktop player — needs no player port at all: the protocol is specified in sync.md and a web client already exists. It's a genuinely useful app, but it requires a desktop player on the network, so it complements an embedded player rather than replacing one.
8. Sources
Platform claims above come from these, checked October 2026.
- raylib: README, raylib-ios
- SDL3: iOS, Android, Emscripten
- sokol-odin
- miniaudio manual (iOS Objective-C requirement, Android AAudio)
- Web MIDI: browser support 2026, Safari-WebMIDI extension
- WKWebView: WebGL performance regression, Construct on WKWebView performance
- App Store guideline 4.2 and webview wrappers
- Apple forums: UDP broadcast and the multicast entitlement, EACCES on UDP broadcast with the entitlement
- Android Native MIDI (AMidi)