Skip to content

Architecture

Devesh Rai edited this page Oct 21, 2026 · 1 revision

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"]
Loading

Backends

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.

The transport state machine

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 TapeEnd event, 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.

The reels

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.

Postures

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.

Clone this wiki locally