Skip to content

ios: native iOS client (radio, podcasts, library, SSH/SFTP provider) - #17

Draft
ptgamr wants to merge 83 commits into
cliamp:mainfrom
ptgamr:ios
Draft

ptgamr wants to merge 83 commits into
cliamp:mainfrom
ptgamr:ios

Conversation

@ptgamr

@ptgamr ptgamr commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Native iOS client for cliamp

A Swift 6 + SwiftUI port of the Android client: iOS 18 minimum, universal iPhone/iPad, portrait and landscape. The project is one XcodeGen manifest plus two local Swift packages — CliampCore (product logic, IO, parsers, 173 tests) and CliampDesign (tokens and components) — with an unsigned simulator CI job.

Behaviour is ported against the Android source as the reference (never edited from an iOS task) and docs/design.md as the design source of truth. docs/ios-parity.md owns scope, task IDs and evidence; this description is a summary of that tracker.

How to build and test

cd ios
brew install xcodegen
xcodegen generate

# Package logic (macOS, no simulator)
swift test --package-path CliampCore        # 173 tests

# App build and hosted unit tests (unsigned)
xcodebuild test -project Cliamp.xcodeproj -scheme Cliamp \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
  CODE_SIGNING_ALLOWED=NO                   # 10 tests

CI runs the same unsigned build/test on macos-26 with the pinned toolchain (.github/workflows/ios.yml).

Live SFTP tests are opt-in against a real server (CLIAMP_SFTP_LIVE=1 swift test --package-path CliampCore --filter SftpLiveTests); the assertions adapt to any server via CLIAMP_SFTP_* env vars, and CLIAMP_ART_FILE=/path/track.mp3 exercises embedded-artwork extraction against one real file. Details in ios/README.md.

Achieved

Foundation and CI

  • FND-01 — XcodeGen project, iOS 18 floor, universal iPhone/iPad, Swift 6, local packages, unsigned CI job, documented commands.
  • DEC-07 — HTTP transport decided and recorded (blanket ATS exception; the media-only key does not cover AVPlayerItem on iOS 26).

Radio — phase 2, 12/12 implemented

  • RAD-01 cliamp channels from the playlist endpoint with fallback.
  • RAD-02 Radio Browser directory: 60-item pages, top-voted/trending, tag/country filters, counts, retry, exhausted state, mirror failover, dedupe, click reporting, HLS filter.
  • RAD-03 independent list/grid choices per section, persisted.
  • RAD-04 add/remove custom stations with the same URL/name validation as Android.
  • RAD-05 M3U/PLS resolution with bounded redirect handling and fatal malformed media.
  • RAD-06 full player surfaces (source line, artwork, ICY titles, format details, speed).
  • RAD-07 one playback state behind mini player and full player; transport, history and fallback ring.
  • RAD-08 streaming rule, seek/elapsed/remaining.
  • RAD-09 artwork pipeline (branding discovery, bounded reads, atomic disk cache, embedded tags, never blocking audio).
  • RAD-10 reconnect: 1/2/4/8/15/30 s backoff, 20 s stall watchdog, network-return resume.
  • RAD-11 background and locked-device playback, lock-screen metadata and transport.
  • RAD-12 last station, favourites and history; cold launch is silent by default.

Podcasts — phase 4, 7/8 (built ahead on the radio player's source lists)

  • POD-01 iTunes directory browse/search with country and category selection.
  • POD-02 subscribe/unsubscribe, cached feeds, show pages with description and episodes.
  • POD-03 episodes in displayed order; show switches replace the source, end-of-item advances.
  • POD-04 progress by canonical media URL at the 5 s cadence, resume on reopen.
  • POD-05 manual downloads with progress, cancellation, failure states, cellular gate.
  • POD-06 offline playback prefers the downloaded file without changing history.
  • POD-07 auto-download opt-in keeps the latest three full unplayed episodes with retention rules.
  • POD-08 downloads smart list and subscribed-episode search — lands with LIB-02/SRC-02.

Library — phase 3, LIB-01LIB-04

  • DEC-02 local library decided: managed Documents folder with Files/Finder sharing; snapshot cache, metadata, folder covers, grouping, four sorts, identity/removal handling.
  • LIB-01 local library scanning (AVFoundation metadata, folder grouping, local:<path> identity, removed files drop out).
  • LIB-02 smart lists: recently played, downloads, favorites (with all/local/stations/podcasts scopes), local songs with folder filter and sort chips; counts, empty states, active indicators.
  • LIB-03 playlists: create/rename/pin/unpin/delete, mixed-source members with snapshots, add-songs picker (local/stations/podcasts).
  • LIB-04 playlist membership/sorts/pins persisted; per-list sort preferences.
  • LIB-05 song info, favourite action and play statistics — next.

Providers — phase 5, first provider (SSH/SFTP) + shared account layer

  • PRV-01 spec-driven wizard: conditional fields, pickers, test-then-save, edit/remove, multiple accounts.
  • PRV-02 Keychain secrets (split out of the JSON store, deleted with the account), opaque cliamp-sftp:// playback references resolved at play time.
  • PRV-03 browse: albums → artist albums → album tracks, a-z/newest sort, filter, rescan, empty/error states, all-provider songs pane, album queue identity, embedded cover art per track.
  • PRV-11 SSH setup: password, pasted ed25519 key and passphrase, Tailscale none; TOFU host-key pinning with a per-host fingerprint; clear errors for refused credentials, bad keys and unreachable hosts.
  • PRV-12 progressive filename-derived index with scan status and rescan, commit-on-success (failed/cancelled walks keep the old index), and streaming/seeking over SFTP through an AVAssetResourceLoader with range reads — playback and indexing share one connection without disturbing each other.
  • PRV-04PRV-10 — Navidrome, Subsonic, Jellyfin, Emby, Plex, Audiobookshelf, Lyrion.

Search, settings, audio, design system

  • SRC-01 fuzzy search page with all six chips, source labels, highlighting, resident-first ranking and stale-query protection (local/tags/providers scopes await their stores).
  • SRC-02 shows, cached subscribed episodes, favourites and history search with per-list queue identity.
  • SRC-03 search keeps its query and scope, resets the pager stack on show open instead of overwriting browse state.
  • VIS-01VIS-07 palette roles and 27 themes, bundled Poppins/JetBrains Mono, rows/hairlines/headers/chips/toggles/sliders, mechanical keys, the three-tab shell with swipe paging, player/settings overlays, immediate theme persistence.
  • SET-01 every setting exposed and persisted with immediate effect where wired (auto-resume, cellular, mono, buffer and resume-local behaviour land with AUD).
  • AUD-01 real playback-driven spectrum (post-effects MTAudioProcessingTap + vDSP FFT) in player, mini player and the meter.
  • DAT-01 relaunch restores favourites, history, custom stations, playlists, podcasts, the local-library snapshot and the SFTP index; no signed provider URLs are persisted.

Evidence

  • 173 CliampCore tests, 10 app tests, plus 10 opt-in live SFTP tests run against both an asyncssh fixture and a Docker OpenSSH server with a real 125-track library.
  • Simulator verification of every shipped screen, and a device build installed on an iPad Pro for review.
  • Each tracker row carries its own evidence with commit SHAs; several passes of advisarial review (Codex) were run per slice and findings fixed before merge-size commits (radio, podcasts, library, providers, artwork).

What's left

Grouped by phase, mirroring docs/ios-parity.md.

Phase 3 — queue and leftover library/search

  • QUE-01QUE-04 — the ordered upcoming sequence with explicit source identity, transport consumption, drag reorder/swipe removal/Clear, shuffle with base-order restore, 130-track boundary behaviour.
  • LIB-05 — song info, favourite action, play statistics, resume-local-songs behaviour.
  • POD-08 — downloads smart list and subscribed-episode search reflecting changes immediately.
  • SRC-01/SRC-02 — fill the local, tags and providers scopes and verify the blank-query/error/loading states.

Phase 5 — remaining providers

  • PRV-04 Navidrome · PRV-05 generic Subsonic · PRV-06 Jellyfin · PRV-07 Emby · PRV-08 Plex · PRV-09 Audiobookshelf · PRV-10 Lyrion — each with its auth branches, browse, playback headers, artwork and seek verified against a real server.
  • PRV-03 starred results and provider artwork for the API providers.

Phase 6 — audio character, settings, system surfaces

  • AUD-02 seven-band EQ with all 17 presets · AUD-03 live mono/speed and buffer verification · SET-02 counts, history purge, directory stats, about surface · SCB-01 ListenBrainz wizard · SYS-01SYS-03 widgets, quick entry and share intake.

Foundation gaps

  • FND-02 Android capture set · FND-03 codec/container matrix and locked-device proof beyond the first ICY MP3 spike · FND-04 device proofs for Keychain, file access and LAN · FND-05 stable-ID fixtures including the 130-item queue and #80445566.

Decisions to close

  • DEC-01 audio architecture and format matrix · DEC-03 widget/control availability · DEC-04 cellular policy · DEC-05 LAN permission and suspended/terminated behaviour · DEC-02 follow-ups if synced libraries or external folders are wanted later · DEC-06 ongoing reference-drift checks.

Verification and acceptance

  • QA-01QA-05 and the J-01J-14 journey matrix on the declared devices; QA-05 also owns the App Review note for the ATS exception.

Known limitations (recorded, not blockers)

  • Opus/Ogg files are not indexed on iOS — AVFoundation cannot decode them (Android plays them). This is visible with real libraries and is recorded as a provider/local-library difference.
  • SSH keys: ed25519 and passwords work; RSA and ECDSA keys are refused with a clear message because the SSH stack signs SHA-1 ssh-rsa, which modern servers reject.
  • Search scopes local, tags and providers return nothing until their stores land (phases 3/5).
  • W status means working, not verified. Most radio/podcast/library behaviour is unit-tested but awaits the device and visual review the tracker schedules; this PR does not claim verified parity.

Review notes

  • Biggest files to start with: ios/CliampCore/Sources/CliampCore/ (logic and parsers), ios/Cliamp/Sources/RadioPlayer.swift, ios/Cliamp/Sources/Screens/, ios/Cliamp/Sources/Providers/.
  • The tracker (docs/ios-parity.md) is the durable artifact: task rows, phase summary and the activity log with per-commit evidence.
  • No android/ or cliamp (TUI) files are touched.
  • ios/Cliamp.xcodeproj is generated and ignored; edit ios/project.yml.

iOS 18 floor, universal iPhone/iPad, SwiftUI. project.yml is the source
of truth; the generated xcodeproj is ignored. CliampCore owns shared
product identity so the app target proves package wiring.
macos-26 with Xcode 26.6 and XcodeGen 2.45.4, unsigned simulator
build and tests. README, evidence record, and AGENTS routing updated
for the second client.
Enforce warnings-as-errors in CliampCore via the manifest, correct the
Clang key to GCC_TREAT_WARNINGS_AS_ERRORS, move the device matrix into
the README, and refresh the stale tracker prose.
The design system first: all 32 roles plus dark, five hand-built
palettes with the Android values, the Poppins/JetBrains Mono scale, the
SVG path parser the icon set shares with Android, and the shared chrome
and controls.
Station and its source contract, the twelve-channel seed, m3u parsing
with a deterministic fallback id, and the shared clock/compact
formatters.
Android already ships Poppins but the notices only named JetBrains
Mono; the iOS bundle ships both, so the OFL text and the attribution
travel with them.
The permanent chrome (mini player, bottom tabs, landscape rail), the
Stations tab on the live cliamp playlist with the twelve-channel
fallback, Now Playing over a real AVPlayer radio transport, and the
Settings page with the six built-in themes switching live. Fonts are
bundled and registered; persistence for these preferences lands with
SET-01.
The signature meter: three layers anchored to the bottom, the shared
attack/release and peak-fall math, the three geometry presets, and the
log-spaced dB band folding.
A post-effects MTAudioProcessingTap hands vDSP the PCM AVPlayer is
actually playing; a display-link model pushes it through MeterCore at
frame rate. Player and mini player both wear the meter, the player is
reordered to the Android layout (status above meta), and the preview
hooks gain a play-without-opening variant.
Mirror discovery and failover with a pinned mirror, 60-item pages with
the source HLS filter and url dedupe, stats/tags/countries, play
reporting, and the custom-station validation and JSON store.
Top/trending/tag/country filters, paged loading that keeps rows and
scroll while a query loads, votes, retry and exhausted states, the
custom add form and remove menu, and persisted grid/list choices.
Directory stations still stream over plain HTTP, and the media-only ATS
exception is not honoured for AVPlayerItem on iOS 26, so DEC-07 takes
the blanket exception and QA-05 owns the review justification. The
player now logs the underlying failure code.
Retain the processor for the tap's lifetime and release it in finalize
(P1 dangling pointer); publish bands with a non-blocking preallocated
handoff instead of a blocking locked copy; average both channels in the
interleaved layout; reject non-float32 PCM; correct the vDSP real-FFT
scale to 2/N; stop the display links when spectrum is off.
Share one discovery sweep so reentrant callers cannot clobber mirror
state; retry fetch and decode together; count server records rather
than displayed rows for pagination offsets; start the first directory
page before the other startup fetches so a user filter cannot be
overwritten; paginate in grid mode too.
og:image / apple-touch-icon / favicon discovery with a capped scrape,
scaled ImageIO decodes, a week-long disk cache and a 60-second miss
backoff. Rows, tiles, the mini player and the player plate use it;
cliamp channels keep the generated plate, and the player follows the
source's fill-versus-letterbox rule.
A second Icy-MetaData connection feeds a tested block parser, so the
now-playing line shows the real artist/title like Android; the meta
block is left-aligned and the transport reports buffered seconds. The
queue count stays 0 until the queue lands.
Bounded delegate reads stop at the scrape/image caps instead of
buffering first; cancellation no longer publishes stale art or starts
the miss backoff; disk writes are atomic; grid tiles load full-size
art; and known cover URLs are honoured. RAD-09 is marked W with the
local-embedded gap owned by LIB-01.
The hook lets a capture or UI test play an arbitrary stream URL, and
the evidence record for the first shell/screens slice was never
committed.
The generated Omarchy theme map joins the resolver, Settings lists all
28 rows, and AppState writes every preference to defaults so a relaunch
keeps the theme, grid choices and toggles. A preview hook selects a
palette for captures.
The icon is generated from the same geometry as the Android adaptive
icon: oxide bevel ground with the six-bar mark in cream, via a checked
in generator. The launch screen uses the flat oxide ground colour, so
the first frame is the app's own background.
@ptgamr
ptgamr marked this pull request as draft September 17, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant