Repository navigation
Architecture
Foldman is a single-module Android app in Kotlin with Jetpack Compose. The UI is drawn almost entirely on Canvas, so the hardware looks and moves like hardware.
flowchart LR
subgraph UI["Jetpack Compose UI"]
Screen["FoldmanScreen<br/>posture from the hinge"] --> Deck["Cassette · Keys · Gauges<br/>Canvas-drawn, physics-driven"]
Screen --> Panels["Library · Now playing · Tape shelf"]
end
VM["DeckViewModel<br/>transport state machine<br/>REW/FF winding"] --> Screen
VM --> Local["LocalBackend<br/>Media3 controller"]
VM --> Remote["RemoteBackend<br/>other apps' media sessions"]
VM --> Kit["AppleKitBackend<br/>MusicKit client"]
Local --> Service["PlaybackService<br/>ExoPlayer + media notification"]
Kit -- "Messenger IPC" --> ApplePlayer["ApplePlayerService<br/>own :apple process"]
Remote --> Apps["YT Music · Apple Music apps"]
Kit --> API["Apple Music API<br/>library playlists"]
Every source implements DeckBackend, which reports a BackendState flow (now playing, playing or not, queue, next/previous available) and offers play, pause, seekTo, skip and playQueueItem.
| Backend | Plays through | Notes |
|---|---|---|
LocalBackend |
PlaybackService: Media3 ExoPlayer in a MediaSessionService
|
A TeeAudioProcessor taps the PCM to drive the VU needles. |
RemoteBackend |
Another app's MediaController, found through MediaSessionManager
|
Needs a notification listener (MediaListenerService), which does nothing else. |
AppleKitBackend |
ApplePlayerService in the :apple process |
Reaches MusicKit by reflection (MusicKit.kt), so the app builds without Apple's AARs. Restores the queue after a crash. |
DeckViewModel turns key presses into one of five Transport states: STOPPED, PLAYING, PAUSED, REWINDING and FAST_FORWARDING.
- STOP and PAUSE both pause the player. They're told apart by a latch: after STOP the deck shows STOPPED, after PAUSE it shows PAUSED.
-
Winding pauses the player and moves a virtual tape position at 12× real time, seeking as it goes. For local music, it crosses into neighbouring songs. At the end of the tape it fires a
TapeEndevent, which plays the auto-stop thunk and haptic. -
The UI only reads
DeckUi, which the ViewModel rebuilds ten times a second, or thirty while winding.
ReelMotion is a small physics model, run every frame while the tape moves:
- Linear tape speed is the standard 4.76 cm/s, eased towards its target with different time constants for spin-up, winding and coast-down.
- Each reel's angular speed is v ÷ r.
- Pack radii come from conservation of tape area: r = √(r_hub² + f·(r_full² − r_hub²)).
- The counter accumulates take-up-reel revolutions.
- Hub spokes are drawn several times across each frame's sweep, as motion blur, once they turn fast enough.
Cassette.kt draws the cassette in millimetres, to the real 100.4 × 63.8 mm Compact Cassette, and crops it to the window in the case. On the cover screen it's turned a quarter-turn with a custom layout modifier.
MainActivity collects WindowInfoTracker layout info. FoldmanScreen picks a layout from the FoldingFeature:
- half-open with a horizontal hinge: tabletop;
- a vertical hinge, or a wide window: unfolded;
- anything else: the cover-screen player.
Animation state such as reel angles, the counter and the cassette's insert animation lives in DeckAnimations, above the layouts, so switching posture doesn't reset it. On the cover screen the system bars are hidden with WindowInsetsControllerCompat.
Foldman · Apache 2.0 · © 2026 Devesh Rai · Not affiliated with Sony, Apple, Google or Samsung.