NexusIRL is a free, open-source IRL live streaming app for Android: SRTLA bonding of mobile data and Wi-Fi, adaptive bitrate, SRT and RTMP, Twitch and Kick chat, OBS remote control. An Android port of Moblin.
This file maps upstream Moblin sources (upstream/, pinned at 97e26d2) to their Kotlin ports.
When the submodule is bumped, check every row for upstream changes:
git -C upstream diff <old>..<new> -- <path>
Upstream reviews (changes checked without bumping the pin):
| Date | Range | Result |
|---|---|---|
| 2026-10-25 | 97e26d2..e4e8ebc (30 commits) |
Nothing to port for compatibility. Remote control: Moblin Cloud tips and wheel of luck only (not ported). Settings: new zoomSlider, per-mic gainDb and controller button functions, songRequests removed (kept or ignored by the forgiving decoder). SRTLA, Moblink and remote control streamer/assistant: refactoring only. Possible features: per-mic gain, zoom slider. |
| 2026-10-27 | e4e8ebc..1f0fe63 (16 commits) |
Remote control: bitrateInfo and bondingInfo in the status and zoom state changes at most every 500 ms (ported); identify may carry appAttest and a new icon event exists, both for Moblin Cloud (decoded or skipped by the assistant, tested). Moblin Cloud and App Attest: not ported (§8.1). Also recorder audio channel handling, screen capture audio as mic, ElevenLabs TTS, and the frontend’s dependency bumps (no asset resync needed). |
| Upstream (Swift) | Kotlin port | Notes |
|---|---|---|
Moblin/Media/WrappingTimestamp.swift |
core/clock/.../WrappingTimestamp.kt |
CMTime → MediaTime |
MoblinTests/Moblin/Media/WrappingTimestampSuite.swift |
core/clock/src/test/.../WrappingTimestampTest.kt |
Vectors copied verbatim |
Swift JSONEncoder/Codable conventions |
core/codable/ |
Golden vectors from RemoteControlSuite.swift and MoblinkSuite.swift |
Common/Various/CommonUtils.swift (decode(key, Type, default)) |
SwiftJson config (ignoreUnknownKeys, defaults) |
Per-field forgiveness came with settings in Phase 3: every field has a default, and SwiftPreservingSerializer (core/codable/.../SwiftPreserving.kt, used by settings/.../Database.kt) keeps unknown keys so iOS settings round-trip |
Moblin/Media/HaishinKit/Util/ByteReader.swift, ByteWriter.swift |
media/mux/.../Bytes.kt |
ByteWriter is append-only (the overwrite mode is unused by ported code) |
Moblin/Media/HaishinKit/Mpeg/Crc32.swift |
media/mux/.../Crc32.kt |
|
Mpeg/MpegTsPacket.swift, MpegTsAdaptationField.swift |
media/mux/.../ts/MpegTsPacket.kt |
Includes TSTimestamp, and PCR coding with upstream’s reserved-bit quirk kept |
Mpeg/MpegTsProgramSpecificInformation.swift, ElementaryStreamSpecificData.swift |
media/mux/.../ts/MpegTsProgram.kt |
Encode only; the PSI decoder arrives with MpegTsReader (post-v1 ingest) |
Mpeg/MpegTsPacketizedElementaryStream.swift |
media/mux/.../ts/MpegTsPes.kt |
PTS ticks = (seconds * 90000).toLong(), truncating like upstream |
Mpeg/MpegTsAudioConfig.swift, Adts.swift, Opus.swift, Avc/MpegTsVideoConfigAvc.swift, Hevc/MpegTsVideoConfigHevc.swift |
media/mux/.../ts/MpegTsConfigs.kt |
CoreMedia format-description bridges are not ported |
Mpeg/MpegTsTimecodeGenerator.swift |
media/mux/.../ts/MpegTsTimecodeGenerator.kt |
Clock kept as epoch seconds (Double) |
Mpeg/MpegTsWriter.swift |
media/mux/.../ts/MpegTsWriter.kt |
Takes Annex-B input (MediaCodec) instead of AVCC. newSrt becomes OutputMode.BLOCKS/CHUNKED. The global payloadSize becomes a constructor property. TrueTime becomes TimecodeClock. |
Mpeg/NalUnitReader.swift, NalUnitWriter.swift, NalUnitStream.swift |
media/mux/.../nal/NalUnits.kt |
Adds annexBToLengthPrefixed for FLV/RTMP |
Mpeg/Avc/*.swift, Mpeg/Hevc/*.swift |
media/mux/.../nal/AvcHevc.kt |
Header, raw parameter sets and timecode SEI |
MpegTsSuite, TSTimestampSuite, NalUnitReaderSuite, NalUnitWriterSuite, NalUnitSeiSuite, MpegTsTimecodeGeneratorSuite |
media/mux/src/test/.../Upstream*Test.kt |
All vectors copied verbatim |
| — | MpegTsWriterTest, FfmpegInteropTest |
New: an independent demuxer, plus an ffmpeg strict-decode check (skipped when ffmpeg is absent) |
Moblin/Media/Srtla/Common/Srt.swift, Srtla.swift, HaishinKit/Srt/SrtPerformanceData.swift |
transport/srt/.../Srt.kt |
32-bit sequence numbers held in Long |
Moblin/Media/HaishinKit/Srt/SrtSender.swift |
transport/srt/.../SrtSender.kt |
Injected HostClock and SerialExecutor (instead of srtlaClientQueue) for deterministic tests. Quirks kept: no shutdown is sent; a received shutdown is only logged. |
Moblin/Media/HaishinKit/Srt/SrtStreamMoblin.swift + SRT parts of Various/Media.swift |
transport/srt/.../SrtStream.kt |
Rejects passphrase until SRT encryption lands (plan §9.5) |
Moblin/Various/BondingStatisticsFormatter.swift |
transport/srt/.../srtla/BondingStatistics.kt; bonding line in ui/LiveStatus.kt; srtla, srtlaRtts and bondingInfo in the remote control status |
Texts only (the pie chart is not ported). SRTLA streams only, as upstream’s isBonding. |
Moblin/Media/Srtla/Client/RemoteConnection.swift |
transport/srt/.../srtla/RemoteConnection.kt |
NWConnection replaced by UdpLink/BondingInterface (Android binds the socket to a Network) |
Moblin/Media/Srtla/Client/SrtlaClient.swift |
transport/srt/.../srtla/SrtlaClient.kt |
NWPathMonitor replaced by setInterfaces(). The interface sort is a stable rank, because upstream’s comparator isn’t a strict ordering. The libsrt LocalListener path is deferred to post-v1. |
SrtSenderSuite, SrtSuite |
transport/srt/src/test/.../SrtSenderTest.kt |
Golden handshake vectors copied verbatim, plus pacing, retransmit, ACK and drop tests |
| — | SrtInteropTest |
New: a live stream into ffmpeg’s libsrt listener, with a strict decode of the recording |
Moblin/Media/Srtla/Server/SrtlaServer.swift, SrtlaServerClient.swift, SrtlaServerClientConnection.swift |
transport/srt/.../srtla/server/SrtlaServer.kt |
SRTLA relay only. One DatagramSocket demuxed by endpoint replaces NWListener. Any SRT listener can sit behind it (libsrt via ffmpeg in tests); the libsrt-backed ingest arrives post-v1. |
| — | SrtlaInteropTest |
New: bonded stream over 2 paths → ported SRTLA server → ffmpeg libsrt, checking per-path usage and a strict decode |
Moblin/Media/AdaptiveBitrate/AdaptiveBitrate.swift, AdaptiveBitrateSrtFight.swift, AdaptiveBitrateSrtBelabox.swift |
transport/abr/ |
Injected HostClock. The 20/200 ms driver and the transport-bitrate EWMA (from Media.swift) arrive with the Phase 2 pipeline. The RIST experiment comes post-v1. |
AdaptiveBitrateSuite |
transport/abr/src/test/.../AdaptiveBitrateTest.kt |
Vectors kept; a fake clock replaces the 20 ms sleeps. Adds behaviour tests for both algorithms. |
Moblin/Media/HaishinKit/Rtmp/Amf/Amf.swift |
transport/rtmp/.../Amf.kt |
AsObject keeps insertion order (LinkedHashMap); upstream uses a hash-ordered Swift Dictionary |
Rtmp/RtmpChunk.swift, RtmpChunkReader.swift, Rtmp/Message/*.swift |
transport/rtmp/.../RtmpChunk.kt |
Messages are plain type + payload, with typed command/user-control helpers |
Rtmp/RtmpStream.swift, RtmpConnection.swift, RtmpSocket.swift, RtmpHandshake.swift |
transport/rtmp/.../RtmpStream.kt |
One class with a blocking socket plus a writer thread (replacing NWConnection). Handshake consumes exactly S2. RTMPS adds hostname verification. Annex-B is converted to AVCC. |
Flv/Flv.swift + FLV tag building in RtmpStream.swift |
transport/rtmp/.../Flv.kt |
Adds avcC/hvcC builders, because MediaCodec gives raw parameter sets |
Rtmp/RtmpStreamInfo.swift, Util/BitrateStats.swift |
transport/rtmp/.../RtmpStreamInfo.kt |
Injected clock |
AmfSuite, RtmpSuite, RtmpStreamInfoSuite |
transport/rtmp/src/test/.../UpstreamRtmpSuitesTest.kt |
The SRS _result vector is abridged; the others are verbatim |
| — | RtmpInteropTest |
New: publish H.264 and enhanced-RTMP HEVC to ffmpeg’s RTMP server, then strict decode; all 90 frames must arrive |
| — (not in upstream’s own sender) | transport/srt/.../SrtCrypto.kt + SrtSender KMREQ/KMRSP |
New: SRT AES-CTR encryption (HaiCrypt scheme: PBKDF2-HMAC-SHA1, RFC 3394 key wrap, even key). Tested with RFC vectors and live against libsrt (AES-128/256, wrong passphrase, wire tap). No key refresh yet (libsrt refreshes after 2^25 packets). |
Various/Media.swift (updateAdaptiveBitrate*, updateSrtTransportBitrate) + 20 ms/1 s timers in Model.swift |
transport/srt/.../SrtAdaptiveBitrateDriver.kt |
Belabox every 20 ms, others every 200 ms; transport-bitrate EWMA (0.7/0.3) every 1 s; no transport cap in CBR mode |
| — | SrtAdaptiveBitrateInteropTest + ShapingUdpRelay |
New: synthetic encoder → SRT → 2 Mbit/s shaped relay → libsrt; both algorithms back off below the cap and recover after it lifts, with no disconnects |
| — | tools/push |
New desktop CLI: ffmpeg pre-encode, then real-time streaming over SRT/SRTLA/RTMP(S) with ABR stats; secrets redacted. Covers the Phase 1 gate tool. |
Media/HaishinKit/Media/Video/VideoUnit.swift (built-in camera path), VideoCaptureSession.swift |
media/pipeline/.../VideoPipeline.kt, CameraSource.kt, gl/* |
Camera2 → SurfaceTexture → GLES. Upstream’s frame timer: one output frame per interval on an exact time grid (FrameClock.kt, with upstream’s one-frame nudge), showing the latest camera image, black frames when there is no source, and a monotonic PTS guard. Camera REALTIME timestamps are shifted to CLOCK_MONOTONIC. Content is rotated upright by the SurfaceTexture transform plus the display rotation. Encoders are created and released off the GL thread (Handoff.kt). Android addition: a camera taken by another app is reopened when it is free again (CameraManager.AvailabilityCallback), as upstream’s capture session resumes after an interruption. |
Codec/Video/VideoEncoder.swift, VideoEncoderSettings.swift |
media/pipeline/.../VideoEncoder.kt |
MediaCodec with Surface input. VBR, priority 0, no B-frames, 2 s GOP. Live bitrate via setParameters, and on-demand sync frames. |
Media/Audio/AudioUnit.swift, Codec/Audio/AudioEncoder.swift |
media/pipeline/.../AudioPipeline.kt |
AudioRecord (CAMCORDER source, 48 kHz) → MediaCodec AAC-LC, with PTS from AudioRecord.getTimestamp(MONOTONIC). Gain, mute and peak dBFS meter. Single source, no mixer, as upstream. |
Various/Model/ModelAudio.swift (mic selection, autoSwitchMicIfNeededAfterRouteChange) |
media/pipeline/.../MicrophoneManager.kt |
AudioManager input devices plus AudioDeviceCallback. The highest-priority connected mic wins (USB > wired > Bluetooth > built-in, overridable). Routed with AudioRecord.setPreferredDevice. |
| (none: VideoToolbox needs no probe) | media/pipeline/.../EncoderCapabilities.kt |
MediaCodecList probe. Prefers hardware encoders and reports bitrate modes and resolution support. HEVC is offered only with a hardware encoder. |
Various/Model/Model.swift updateThermalState() |
app/.../stream/StreamSession.kt ThermalState |
PowerManager thermal-status listener mapped to normal/fair/serious/critical, with a toast on critical (upstream makeFlameRedToast). |
Various/Media.swift (stream start/connect, startEncoding on connect) |
app/.../stream/StreamSession.kt, StreamingService.kt |
Destination joins the encoder fan-out when connected (formats replayed, keyframe requested). Foreground service of type camera|microphone. |
Various/Media.swift multi streaming (rtmpStreams, getNumberOfDestinations, streamTotal), SettingsStreamMultiStreaming, mediaOnRtmpDestination* toasts, StreamMultiStreamingSettingsView.swift |
settings/.../SettingsStream.kt; app/.../stream/{StreamSession,StreamConfig}.kt ExtraRtmpDestination; stream editor; remote/StatusFormat.kt speedAndTotal |
Extra RTMP servers fed by the same encoders when the stream is RTMP; status “5.0 Mbps x3 (total of all)”; upstream’s toasts. Difference: each destination connects and retries after 5 s on its own instead of all restarting with the main one. Destination URLs are secrets like stream URLs. |
Media/Srtla/Client/SrtlaClient.swift path monitoring (NWPathMonitor, requiredInterface) |
net/.../MultiNetworkManager.kt |
requestNetwork per transport (cellular/Wi-Fi/Ethernet) keeps all paths up; Network.bindSocket and per-network DNS. Verified on the emulator: a cellular + Wi-Fi bonded stream into the desktop SRTLA receiver, zero drops. With stock libsrt behind the relay, cross-path reordering causes ~1% NAK/retransmit; upstream’s ingest uses libsrt with srtlaPatches for this. |
Various/Settings/Settings.swift (Database, storage, archive) |
settings/.../Database.kt, SettingsArchive.kt; app/.../settings/SettingsStore.kt |
v1 subset of the settings document. Unmodelled keys are kept (SwiftPreserving) so iOS exports round-trip. Stream URL secrets live in SecretStore, not settings.json. .moblinSettings read (zip or JSON) and written stored, like upstream. Images (image widgets, LUT files) is exported and, from an archive, replaces the app’s images as upstream’s extractArchive does; only Images/<UUID> entries are extracted, with size limits, into a staging folder swapped in at the end. Other media folders (alerts, PNGTuber, VTuber, media player, replay transitions) are ignored. An import adds missing quick buttons, all off (upstream Database.fromString). |
Various/Settings/SettingsStream.swift |
settings/.../SettingsStream.kt |
Stream, SRT, ABR and RTMP settings with upstream keys, raw values and defaults. |
SettingsStreamSrtConnectionPriorities, View/Settings/Streams/Stream/Srt/StreamSrtConnectionPriority2View.swift, ModelStream.updateSrtlaPriorities, handleIpStatusUpdate (wired interfaces) |
settings/.../SettingsStream.kt, Database.kt; app/.../ui/ConnectionPrioritiesScreen.kt (stream editor and the Connection priorities quick button); StreamController.updateConnectionPriorities, StreamSession.setConnectionPriorities |
Same JSON and defaults (Cellular and WiFi, priority 1, off); changes apply while live. Entries match the SRTLA client’s path names: “Cellular”, “WiFi”, the system interface name of a wired network (e.g. eth0), or a Moblink relay’s id. Differences: a nil relay id is left out instead of written as null (module convention, both decode); wired interfaces get their entry when an SRTLA stream sees them, not from a global IP monitor; deleting is a button instead of a swipe. |
Various/Model/Model.swift updateAdaptiveBitrateSrt, Media.swift srtSetAdaptiveBitrateAlgorithm |
app/.../stream/StreamConfig.kt |
Saved stream → session config (kbps → bps, Belabox vs SrtFight, none when ABR is off). |
View/Settings/Streams/**, View/Settings/ImportExport/** |
app/.../ui/StreamsScreen.kt, StreamEditorScreen.kt, ImportExportScreen.kt |
v1 subset. Export defaults to “without stream keys”; “with keys” needs a warning. Import shows a confirmation and is blocked while live. |
| — | tools/srtla-receiver |
New desktop CLI: the ported SRTLA relay forwarding to a local SRT listener |
Various/MoblinSettingsUrl.swift, Various/Model/ModelSettingsUrl.swift, Common/Various/Validate.swift |
settings/.../MoblinSettingsUrl.kt, StreamUrls.kt; app/.../ui/SettingsLinkDialog.kt |
Streams in moblin:// links are applied as upstream (ranges, name collisions, selection). OBS, quick buttons, web browser, remote control and Moblin Cloud parts are ignored and listed in the confirmation. |
Various/Model/ModelCamera.swift (zoom, torch, focus point, camera picker), View/Stream/StreamView.swift gestures |
media/pipeline/.../CameraSource.kt, VideoPipeline.kt; app/.../ui/CameraPreview.kt |
Camera2 CONTROL_ZOOM_RATIO (crop region on API 29), torch, AF/AE regions with an AF trigger, continuous video AF on long press. The camera pipeline runs while the app is visible or live; going live attaches an encoder. |
Various/Model/ModelRecording.swift, Media/HaishinKit/Media/Recorder.swift |
media/pipeline/.../Mp4Recorder.kt; app/.../stream/StreamController.kt, SharedAudio.kt |
MP4 via MediaMuxer into Movies/NexusIRL (MediaStore, no storage permission). Own video encoder on the shared camera pipeline (recording codec and bitrate; “auto” bitrate is about 0.15 bit/pixel for H.264, 0.09 for H.265) and the shared microphone. Starts at the first key frame. Resolution override, auto start/stop, custom path and replay are not ported yet. |
Various/Model/ModelSnapshot.swift |
media/pipeline/.../VideoPipeline.kt takeSnapshot; app/.../stream/StreamController.kt |
Current frame rendered offscreen at canvas size, JPEG quality 0.9 to Pictures/NexusIRL (app/.../stream/Snapshots.kt). Discord upload with upstream’s multipart body to the stream’s webhook (quick button) or chat bot webhook, only when live if set; webhook URLs are secrets. The chat bot can fetch the picture from another phone’s web remote control (<url>/snapshot, upstream’s source and mode); for a NexusIRL phone its ?t= token is sent as the login cookie. Snapshot age, the countdown, snapshot widgets and cat printers come later. |
Various/Model/ModelCamera.swift (manual focus/exposure/ISO/white balance, exposure bias), Various/Utils/CameraUtils.swift factor mappings |
media/pipeline/.../CameraFactors.kt, CameraSource.kt; app/.../ui/CameraSettingsDialog.kt |
Same 0..1 slider factors and shutter-speed ladder as upstream. Camera2: AE off with exposure/ISO/frame duration, AF off with focus distance, AWB off with gains from a colour temperature on the sensor’s own colour matrix. Stabilization is saved in videoStabilizationMode (any mode but Off turns on EIS/OIS). Manual values are not remembered per camera yet. |
Common/Localizable.xcstrings |
tools/import-translations.py → app/src/main/res/values-*/strings.xml, xml/locales_config.xml |
Strings whose English text matches an upstream source string get upstream’s “translated” entries (pseudo-locale eo skipped). Others fall back to English; lint MissingTranslation is off for that reason. |
Various/Settings/SettingsScene.swift (scenes, scene widgets, text/QR widgets) |
settings/.../SettingsScene.kt |
Raw cameraPosition kept so iOS-only positions round-trip; Android shows back/front/none. Unknown widget types decode as text like upstream. |
VideoEffects/Text/TextFormatStringLoader.swift, TextEffectFormatter.swift |
effects/.../TextFormat.kt, TextFormatter.kt |
All placeholders parsed in upstream order. Values Android does not have yet (weather, workouts, Tesla, location) show “-“. |
VideoEffects/EffectUtils.swift (layout) |
effects/.../WidgetLayout.kt |
layoutScale, layoutPosition (top-left origin), font scale by long side / 1920. |
VideoEffects/Text/TextEffect.swift, ImageEffect.swift, QrCodeEffect.swift |
app/.../scene/WidgetBitmaps.kt, SceneEngine.kt |
Widgets drawn into bitmaps with Canvas (QR via ZXing), redrawn only when content changes. Images live in Images/<widget id> like upstream. Widget effects (SettingsVideoEffect) are kept in settings; enabled LUT effects are applied to image widgets on the CPU when the bitmap is drawn. Other widget effects come later. A scene whose camera is “None” shows black under its widgets, as upstream. |
Processor effect chain (Media/HaishinKit/Media/Video/VideoUnit.swift) |
media/pipeline/.../VideoPipeline.kt, gl/GlPrograms.kt (Framebuffer), gl/TextureRenderer.kt |
One composite per frame into a canvas framebuffer (camera, then overlays with premultiplied alpha), copied to every encoder, the preview and snapshots. |
View/Settings/Scenes/**, scene picker |
app/.../ui/ScenesScreens.kt |
v1: scenes (name, camera, widgets, order, position/size/alignment) and text/image/QR widget editors. |
VideoEffects/{GrayScale,Sepia,Pixellate,FourThree,Whirlpool,Pinch}Effect.swift, VideoEffects/Crt/* |
media/pipeline/.../gl/EffectRenderer.kt, VideoEffect.kt |
GLSL passes between the camera and the widgets, in upstream’s order (whirlpool, pinch, 4:3, gray scale, sepia, pixellate, CRT). Colour matrices and parameters from upstream (MetalPetal matrices; CRT from the Core Image path: barrel 0.1, scanlines every height/240, saturation 0.7, contrast 1.05, brightness -0.02, vignette). |
VideoEffects/Browser/BrowserEffect.swift, moblin.js |
app/.../scene/BrowserWidgets.kt; media/pipeline/.../gl/OesRectRenderer.kt (surface overlays) |
The page renders in a WebView on a private VirtualDisplay (160 dpi, so 1 CSS px = 1 px) straight into an OES texture in the compositor. Viewport and style sheet injected after load. Differences: https only (cleartext is blocked app-wide), the page renders at display rate instead of fps, page audio is not mixed into the stream, and mode/speech-to-text are not ported yet. |
Media/HaishinKit/Media/Video/VideoEffectsProcessor.swift (scene switch transition) |
media/pipeline/.../VideoPipeline.kt, gl/EffectRenderer.kt blur |
“Blur”: from a camera switch until the new camera’s first frame, the last frame is blurred with radius (15 + 15 per second, up to 2 s) x long side / 1920, under the widgets. Two-pass separable Gaussian. |
VideoEffects/LutEffect.swift, Various/Settings/Settings.swift SettingsColor, LUTs.bundle |
effects/.../Lut.kt; app/.../scene/LutLoader.kt, ui/LutsScreen.kt; media/pipeline/.../gl/EffectRenderer.kt applyLut |
Upstream image layout, trilinear resampling to at most 64³, and order (bundled, .cube, image). Own .cube parser instead of the SC3DLut package. GLES 2 has no 3D textures, so each LUT is a N²×N strip interpolated between blue slices. Bundled LUT PNGs copied from upstream (MIT). |
StreamingPlatforms/Twitch/TwitchChat.swift (IRC part), Various/WebSocketClient.swift |
chat/.../twitch/{TwitchChat,TwitchChatMessage}.kt, chat/.../net/ReconnectingWebSocket.kt |
Anonymous read-only chat (justinfan login). Reconnect backoff 0.5 s doubling to 10 s, ping every 10 s. Sending and EventSub need OAuth and come later. |
StreamingPlatforms/Kick/KickPusher.swift, KickApi.swift (channel lookup), alert texts from ModelKick.swift |
chat/.../kick/KickChat.kt; settings/.../SettingsKickAlerts.kt; app/.../chat/{ChatController,TwitchAlertFilter}.kt |
Chat messages, replies, deletions and bans. Ids from settings, else from kick.com/api/v1/channels/<slug> (retrying with _ as -). Alerts: subscriptions, gifted subscriptions, rewards, hosts, bans and Kicks, filtered by kickChatAlerts / kickToastAlerts (bans to chat only, as upstream). |
StreamingPlatforms/Kick/KickAuth.swift, KickApi.swift (signed-in calls), ModelKick.swift (login, send, title, category) |
app/.../kick/{KickLoginActivity,KickAccount,KickChannel}.kt, chat/.../kick/KickApi.kt, ui/KickSections.kt |
Kick’s website in a WebView, as upstream, but in its own process (:kick) with its own data directory, cleared before and after; the session_token cookie goes to the secret store. Sending chat (kickSendMessagesTo), chat bot answers, title and category. Addition: the chat bot’s stream title/category also change Kick (upstream: Twitch only). |
StreamingPlatforms/YouTube/YouTubeLiveChat.swift, fetchYouTubeVideoId |
chat/.../youtube/YouTubeLiveChat.kt |
innertube get_live_chat polling, delay 200 ms to 3 s, restart after 5 s on errors. Text, emoji, Super Chat/Sticker, memberships, gifted memberships and Jewels. |
Various/Model/ModelChat.swift, ModelTwitch/Kick/YouTube.swift (chat parts), View/Stream/Overlay/StreamOverlayChatView.swift |
app/.../chat/{ChatController,EmoteImages}.kt, app/.../ui/ChatOverlay.kt |
One newest-first list of 50 posts from the selected stream’s channels; reconnects when the channels or the chat switch change; maximum age pruning. YouTube video id looked up from the handle on reload and when going live (60 s). Overlay: colours, size, shadow, background, bold, mirrored and highlight bars from SettingsChat; still emote images only. |
VideoEffects/ChatEffect.swift (ChatRenderer) |
app/.../scene/ChatWidgetBitmap.kt, SceneEngine.kt |
Newest maximumNumberOfMessages posts, oldest at the top, 20 x font size wide, 1 px apart, 1.5 px border, background at 60 %, 3 px highlight bars; cut to height of the canvas keeping the newest. Redrawn on each new post. No badges, platform or highlight icons yet. |
VideoEffects/Browser/BrowserEffectServer.swift (chat) |
chat/.../BrowserChatProtocol.kt, app/.../scene/BrowserWidgets.kt |
subscribe({chat: {prefix}}) and the {"message":{"data":{"chat":{"message":{user, segments}}}}} messages, base64 into moblin.handleMessage, only for widgets with Moblin access. Speech to text and videoPlaying come later. |
Integrations/Emotes/{Emotes,Bttv,Ffz,Seventv}.swift |
chat/.../ThirdPartyEmotes.kt |
Same endpoints, URL choices and precedence (BTTV, then FFZ through BTTV’s cache, then 7TV; channel over shared over global). Retry after 30 s doubling to 1 h until all enabled providers answer. Difference: anonymous Twitch chat takes the channel id from ROOMSTATE room-id, so channel emotes work without signing in. Error toasts come later. |
View/Settings/Chat/ChatSettingsView.swift (appearance) |
app/.../ui/ChatSettingsScreen.kt |
Enabled, font size, width, height, colours, background, border, bold, mirrored and maximum age. Filters, text to speech and fonts come later. |
Various/ChatBotCommand.swift, Various/Model/ModelChatBot.swift, chat bot queue in ModelChat.swift, SettingsChatBot* in SettingsChat.swift, View/Settings/Chat/ChatBotSettingsView.swift |
chat/.../bot/{ChatBotCommand,ChatBotPermissions}.kt, settings/.../SettingsChatBot.kt, app/.../chat/ChatBot.kt, ui/ChatBotSettings.kt |
Same parsing (quoted words, aliases, typo-tolerant arguments), permissions (owner, moderators, subscribers, others, cooldowns, chat answers) and queue (25, one command per 100 ms, not for a forwarded history batch). Commands: scene, mute, unmute, zoom, torch on/off, filter (the six ported effects), snapshot (this phone or another phone’s web remote control, saved and uploaded to Discord), stream start/stop/title/category, help (links to this project’s docs). Not looked up: Twitch subscriber tiers above 1. Others (text to speech, OBS, map, location, alert, fax, say, Tesla, reaction, widget, AI, raid, gimbal, macro, send, music, vibrate, custom, torch level and the snapshot countdown) come later. |
FuzzyMatchingSwift 0.11.1 FuzzyMatching.swift (Apache-2.0, see NOTICE) |
chat/.../bot/FuzzyMatching.kt |
fuzzyMatchPattern only, quirks kept (the substring search does not lower the threshold, the text’s last character is not scanned). |
Various/Settings/SettingsQuickButtons.swift, addMissingQuickButtons (Settings.swift), View/ControlBar/QuickButtonsView.swift |
settings/.../QuickButtons.kt; app/.../ui/QuickButtons.kt; StreamController quick button functions |
All 64 types and upstream’s 61 default buttons (icons, pages) generated from upstream, stored under globalButtons; all start off on launch, as upstream. Android acts on torch, mute, stream, record, snapshot, scene widgets, LUTs, local overlays, stealth mode, the seven effects and connection priorities (opens the selected stream’s screen); buttons are shown by name (no SF Symbols). The rest are kept for iOS round trips. Layout settings (quickButtons: columns, sizes) stay in extra. |
VideoEffectsProcessor.swift (applySceneSwitchTransition, calcBlurRadius, calcBlurScale), SettingsSceneSwitchTransition, forceSceneSwitchTransition |
media/pipeline/.../SceneSwitchTransitionKind.kt, VideoPipeline.kt; Scenes screen |
Blur, Freeze and Blur & zoom with upstream’s radii and zoom, scaled by long side / 1920. Force restarts the camera on every scene switch, as upstream’s re-attach does. |
Various/Settings/SettingsLocation.swift, Various/Model/ModelLocation.swift, LocationUtils.swift, text widget speed/altitude/distance |
settings/.../SettingsLocation.kt; app/.../location/LocationController.kt, ui/LocationScreen.kt; effects/.../LocationFormat.kt |
Upstream’s totals rules (distance beyond horizontal accuracy, altitude changes of max(vertical accuracy, 3 m), average speed), saved every 30 s instead of continuously. Fused (AOSP) or GPS + network provider at high accuracy, no Play services. Privacy regions are added around the current position (no map picker) and hide the position from maps; upstream shows an empty location there. Units approximate Foundation’s formatters. Accuracy and distance filter settings, slope and RealtimeIRL come later. |
VideoEffects/MapEffect.swift, SettingsWidgetMap |
effects/.../MapCamera.kt; app/.../scene/MapWidgets.kt; widget editor |
MapLibre MapSnapshotter instead of MapKit, upstream’s 30 px position dot (a 22 px blue disc) on any map size, style URL per widget (OpenFreeMap by default), with the tile attribution drawn on the image. Upstream’s camera rules: course up unless north up, zoom out by 150 m per m/s above 4 m/s, dot moved down up to half of half the side at 20 m/s; MapKit camera distance turned into the zoom showing the same ground width. Delay and size as upstream. Inside privacy regions the map is hidden. The chat bot’s temporary zoom-out comes later. |
RemoteControl/RemoteControl.swift (protocol), RemoteControlStreamer.swift |
remotecontrol/.../{RemoteControl,RemoteControlCrypto,RemoteControlStreamer}.kt |
Same JSON (Swift Codable enums, [RemoteControlFilter: Bool] as a flat array), password hash, AES-GCM twitchStart token and 30 s ping. v1 requests: status, settings (with the current stream’s SRT connection priorities), stream, scene, record, live, zoom, mute, stealth mode, torch, effects, SRT connection priority and priorities enabled, status loop, preview, send message, and chat and Twitch EventSub messages forwarded by an assistant (RemoteControlChat.kt; app/.../remote/ForwardedChat.kt maps them to chat posts, skipping ids already shown; forwarded EventSub takes notifications only, never session messages). Differences: unknown requests are answered unknownRequest (upstream fails to decode them), and importSettings is refused unless “Allow settings import from the assistant” is on, and then applied only after the user accepts it on the phone (answered when they decide, rejected after 30 s or when live). |
Various/Model/ModelRemoteControl.swift (streamer), VideoLowFpsImage.swift, SettingsRemoteControl |
app/.../remote/RemoteControlController.kt, ui/RemoteControlScreen.kt; settings/.../SettingsRemoteControl.kt |
Preview: 400 px long side, JPEG 0.3, every 0.2–1 s. The password is kept in the secret store and defaults to 20 random characters. The streamer id is per device, as upstream. Assistant, web and relay settings are kept for iOS round trips. |
Various/Network/WebSocketClient.swift |
core/websocket/.../ReconnectingWebSocket.kt, CleartextPolicy.kt |
Moved out of :chat for remote control. Cleartext rules live in the HTTP clients (see network_security_config.xml). |
Moblink/MoblinkProtocol.swift, MoblinkRelay.swift |
moblink/.../{MoblinkProtocol,MoblinkRelay}.kt |
Same JSON and password hash; one relay per mobile data and Ethernet network with upstream’s ids (UUID.add) and names; UDP passed through unchanged; the cellular relay reports battery and thermal state; 5 s reconnect. Unknown requests answered unknownRequest. |
Moblink/MoblinkScanner.swift, Various/Model/ModelMoblink.swift (relay), SettingsMoblink |
app/.../moblink/MoblinkController.kt, ui/MoblinkScreen.kt; settings/.../SettingsMoblink.kt |
mDNS _moblink._tcp with NsdManager (and a multicast lock), or a manual URL. The control connection is bound to Wi-Fi, tunnels to mobile data (MultiNetworkManager keeps it up). The foreground service keeps the relay running with the screen off (type connectedDevice). The password is a secret with no default (upstream: “1234”). The streamer side comes later. |
StreamingPlatforms/Twitch/TwitchAuth.swift |
chat/.../twitch/TwitchAuth.kt; app/.../twitch/TwitchAccount.kt, ui/TwitchSignInSection.kt |
Replaced, not ported: OAuth Device Code flow with the project’s own public client id (no secret, no redirect through the author’s site). 11 scopes instead of upstream’s 28 (read-only where upstream also manages polls, predictions and shoutouts). Tokens per stream in the secret store, refreshed on expiry (single-use refresh tokens saved at once), revoked on sign-out, never exported. |
StreamingPlatforms/Twitch/TwitchEventSub.swift, alert texts and the hype train, poll, prediction, ad break and shoutout handlers in ModelTwitch.swift, TwitchApi.swift (EventSub, streams, chat) |
chat/.../twitch/{TwitchEventSub,TwitchBanners,TwitchApi}.kt; app/.../chat/ChatController.kt, ui/ChatOverlay.kt |
Upstream’s subscriptions, versions and conditions except channel.moderate and raids from this channel (both only drive upstream’s outgoing raid banner, not ported; channel.moderate needs eight moderator scopes). channel.chat.notification: subs, Prime subs, resubs, gift and community gifts, Prime and gift upgrades, watch streaks and shared-chat partners’ subs, gifts and raids, with the viewer’s message (Twitch emotes as images) and colour. Follows, cheers, raids and channel points as before. Hype trains, polls and predictions: upstream’s chat lines (by the channel’s display name instead of the configured channel name) and banners; ad breaks: upstream’s toast; shoutouts: a chat line. Upstream treats a refused (403) subscription as signed out; here the others keep working, and a sign-in without user:read:chat (from an earlier version) falls back to channel.subscribe/subscription.message/subscription.gift and is told once to sign in again. 2 s start, reconnect doubling to 120 s, session_reconnect, keepalive timeout. Viewers polled once a minute; send field when signed in. Alert sounds, the ads remaining timer, badges and shared-chat channel icons on alerts, and third-party emotes in alert messages come later. |
RemoteControl/RemoteControlAssistant.swift, assistant parts of ModelRemoteControl.swift, RemoteControlAssistantView.swift |
remotecontrol/.../RemoteControlAssistant.kt; app/.../remote/RemoteControlAssistantController.kt, ui/RemoteAssistantScreen.kt |
WebSocket server on Java-WebSocket (MIT): one streamer, upstream’s hello/identify, requests and events, 30 s WebSocket ping and 60 s app-ping timeout, retry listening after 5 s. Additions: only on LAN, hotspot and Tailscale addresses and never on a mobile data interface; 5 wrong passwords lock an address out for 60 s; constant-time hash comparison; going live or stopping asks first. The Twitch relay through the assistant and chat history come later. |
RemoteControl/RemoteControlWeb.swift, Moblin/RemoteControl/Web/ (built frontend) |
remotecontrol/.../RemoteControlWeb.kt; app/src/main/assets/web/ (copied unchanged); web section of ui/RemoteControlScreen.kt |
Same frontend, /js/config.mjs, WebSocket on port + 1 and requests (shared handle() with the streamer). Differences: port 8080 instead of 80 (Android cannot listen below 1024); an access token (link ?t= sets an HttpOnly SameSite=Strict cookie, which browsers also send to the WebSocket port, so the frontend is unchanged); LAN/hotspot/Tailscale only; settings import refused; /snapshot is JPEG. Recordings and scoreboards come later. Resync the assets from the submodule when upstream changes them. |
Moblink/MoblinkStreamer.swift, streamer parts of ModelMoblink.swift, SrtlaClient.addMoblink |
moblink/.../MoblinkStreamer.kt; app/.../moblink/MoblinkStreamerController.kt, streamer section of ui/MoblinkScreen.kt; StreamSession Moblink hooks |
Same protocol, mDNS service (per-device name, TXT name), startTunnel on every SRTLA connect, relay replaced on reconnect, status every 10 s, 10 s WebSocket ping. Additions: LAN/hotspot/Tailscale only, 5 wrong passwords lock an address for 60 s, constant-time hash check; relay paths are bound to Wi-Fi so they work while mobile data is the default network. A new relay adds (or renames) its connection priority entry with its relay id, as upstream. |
ModelTwitch.swift (makeToast for EventSub alerts), SettingsTwitchAlerts in SettingsStream.swift |
chat/.../ChatPost.kt (TwitchAlert), chat/.../twitch/TwitchEventSub.kt, app/.../chat/TwitchAlertFilter.kt, ChatController, ui/AlertToasts.kt, stream editor |
Toasts and chat alerts filtered per kind with upstream’s defaults and keys, including the minimum watch streak (isWatchStreakEnabled) and shared-chat alerts (isTwitchSharedChatAlertEnabled). Hype train, poll, prediction and shoutout lines and the ad break toast have no setting, as upstream. |
View/Utils/BannersView.swift (hype train, poll, prediction) |
chat/.../twitch/TwitchBanners.kt; app/.../ui/TwitchBanners.kt, ChatController.twitchBanners |
Same texts, countdowns (every second), percentages, outcome colours and background; each closable until its next update, tap to minimize. Shown under the live status instead of over the preview (the preview’s gesture layer takes taps); minimized as names instead of SF Symbols. The raid banner comes with outgoing raids. |
TwitchApi.swift (getChannelInformation, searchCategories, modifyChannelInformation), setTwitchStreamTitle/setTwitchStreamCategory in ModelTwitch.swift |
chat/.../twitch/TwitchApi.kt, app/.../twitch/TwitchChannel.kt, ui/TwitchChannelSection.kt |
Same Helix calls. Additions: the update asks for confirmation first (it is public at once), and a missing channel:manage:broadcast permission says to sign in again instead of failing as an expired sign-in. |