Skip to content

Implement clip share feature with spin-based creation#219

Open
briankeane wants to merge 11 commits into
developfrom
briankeane/clip-share
Open

Implement clip share feature with spin-based creation#219
briankeane wants to merge 11 commits into
developfrom
briankeane/clip-share

Conversation

@briankeane

Copy link
Copy Markdown
Contributor

Summary

Completes the clip share feature allowing listeners to download and share audio clips of their Q&A airings. Uses spin-based clip identity instead of time ranges for natural de-duplication. Includes new MyAiringsPage, push notification handling, homepage integration, and comprehensive design system documentation.

Changes

  • Clip model & API: Added firstSpinId/lastSpinId/prerollMS/postrollMS; new getAiringSpins endpoint
  • MyAiringsPage: Full page with create/download/share actions, polling for clip completion
  • Push notifications: clip_ready navigation guard and airing reminder scheduling
  • HomePage: Past airing tile personalization with curator names
  • Design system: Comprehensive DESIGN.md with colors, typography, spacing, components

Test Coverage

All new functionality covered with 27 tests across MyAiringsPageTests and related modules.

briankeane and others added 4 commits March 14, 2026 17:17
* Add setup script to copy secrets from original repo

* Fix arithmetic exit code bug and add dest dir guard

Use $((var + 1)) instead of ((var++)) to avoid set -e
aborting when post-increment evaluates to 0. Add a guard
for the destination directory.

* Fix DEST_DIR resolution to not abort before error guard

Resolve only the script directory with cd, then append the
relative path so the dest dir guard can print a friendly
error if PlayolaRadio/Config is missing.
* Add search bar to station list page

Filter stations by curator name or station name. Search bar
pinned to bottom of screen matching library page style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address PR feedback: autocapitalization, cornerRadius, segment clear

- Disable autocapitalization on search TextField
- Replace deprecated cornerRadius with clipShape
- Clear searchText when switching segments to avoid stale results
- Add test for segment-switch clearing behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Add firstSpinId, lastSpinId, prerollMS, postrollMS to Clip model
- Add SpinSummary and AiringSpinsResponse models
- Add getAiringSpins endpoint to fetch spins for an airing
- Update createClipForAiring to use spin IDs instead of airing IDs
- Update MyAiringsPageModel to fetch spins before creating clips
- Update tests for new API signatures
@greptile-apps

greptile-apps Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements the end-to-end clip share feature, allowing listeners to create, poll for, download, and share audio clips of their Q&A airings. It introduces the Clip model, four new API endpoints, a new MyAiringsPage with full MVVM wiring, clip_ready push notification navigation, airing reminder scheduling, and homepage personalization for past airings. The architecture follows established project patterns (TCA-style dependencies, @Shared state, @Observable view models) and includes 27 new tests with solid coverage.

Key changes:

  • Clip.swift: New Clip, ClipTrack, SpinSummary, and AiringSpinsResponse models with spin-based identity (replacing time-range approach)
  • MyAiringsPageModel.swift: Loads airings + clips in parallel, maps clips to airings via track metadata, manages clip creation and background polling — has two notable bugs (see comments)
  • PushNotifications.swift: clip_ready navigates to MyAiringsPage; listener_question_scheduled is a no-op; new scheduleAiringReminders shared helper called from both HomePageModel and MyAiringsPageModel
  • HomePageModel.swift: Past/upcoming airing split, personalized curator name in pastAiringTileModel, and push notification scheduling integrated into the existing viewAppeared flow
  • DESIGN.md: New design system documentation covering colors, typography, spacing, buttons, and component patterns

Issues found:

  • pollingTask is a single property but pollForClipCompletion can be called concurrently for different airings. Any task started before the last one loses its cancellation handle, causing it to run indefinitely after viewDisappeared() is called.
  • Silent polling timeout: After 60 iterations (~3 minutes), the polling loop ends without presenting an alert. The UI silently reverts to .noClip, which may confuse the user into thinking they need to re-create the clip.
  • downloadTapped passes a URL String to ShareSheetModel rather than a URL object, which may degrade the share sheet experience for audio file saving on iOS.
  • formattedAirtime allocates three DateFormatter instances on every call — these should be cached as static properties.

Confidence Score: 3/5

  • Safe to merge with the pollingTask and timeout bugs addressed before release.
  • The feature is well-structured with good test coverage and consistent use of the project's architecture patterns. However, the single pollingTask property creates a real memory/lifecycle bug when multiple clips are created concurrently, and the silent polling timeout produces a confusing UX regression. These two issues in MyAiringsPageModel lower confidence and should be resolved before this ships to users.
  • PlayolaRadio/Views/Pages/MyAiringsPage/MyAiringsPageModel.swift — concurrent polling task management and timeout feedback need fixes.

Important Files Changed

Filename Overview
PlayolaRadio/Views/Pages/MyAiringsPage/MyAiringsPageModel.swift New model for clip creation/polling; has two bugs: single pollingTask is overwritten on concurrent clip creation causing leaked background tasks, and the polling timeout silently reverts UI state with no user feedback.
PlayolaRadio/Views/Pages/MyAiringsPage/MyAiringsPage.swift New SwiftUI view rendering upcoming/past airings with clip create/download/share buttons; well-structured with proper accessibility labels and empty/loading states.
PlayolaRadio/Views/Pages/MyAiringsPage/MyAiringsPageTests.swift Comprehensive tests for MyAiringsPageModel covering load, empty state, clip state machine, create/retry flows, and notification scheduling; no test for concurrent multi-airing polling.
PlayolaRadio/Models/Clip.swift New Clip, ClipTrack, SpinSummary, and AiringSpinsResponse models with clean Codable/Sendable conformances and a complete mock helper.
PlayolaRadio/Core/API/APIClient.swift Added four new API endpoints (getAiringSpins, createClipForAiring, getClip, getUserClips) with well-documented signatures and safe no-op defaults.
PlayolaRadio/Core/API/APIClient+Live.swift Live implementations for the four new clip/spins endpoints; createClipForAiring correctly conditionally omits zero-value preroll/postroll parameters.
PlayolaRadio/Core/PushNotifications/PushNotifications.swift Added clip_ready navigation guard and listener_question_scheduled no-op, plus a new scheduleAiringReminders helper; all additions are clean and well-placed in the existing flow.
PlayolaRadio/Views/Pages/HomePage/HomePageModel.swift Past vs. upcoming airing split, personalized pastAiringTileModel, and push notification scheduling for upcoming airings — all cleanly integrated alongside existing logic.
PlayolaRadio/Core/Navigation/MainContainerNavigationCoordinator.swift Minimal, correct addition of myAiringsPage case to the navigation destination enum and corresponding view builder entry.
PlayolaRadio/PlayolaRadioApp.swift Foreground notification display extended to listener_question_scheduled, listener_question_airing_soon, and clip_ready types; straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant MyAiringsPage
    participant MyAiringsPageModel
    participant API
    participant PushNotifications

    User->>MyAiringsPage: viewAppeared
    MyAiringsPage->>MyAiringsPageModel: viewAppeared()
    MyAiringsPageModel->>API: getMyListenerQuestionAirings()
    MyAiringsPageModel->>API: getUserClips()
    API-->>MyAiringsPageModel: [ListenerQuestionAiring]
    API-->>MyAiringsPageModel: [Clip]
    MyAiringsPageModel->>MyAiringsPageModel: matchClipsToAirings()
    MyAiringsPageModel->>PushNotifications: scheduleAiringReminders(upcomingAirings)

    User->>MyAiringsPage: Tap "Create Clip"
    MyAiringsPage->>MyAiringsPageModel: createClipTapped(airing)
    MyAiringsPageModel->>API: getAiringSpins(airingId)
    API-->>MyAiringsPageModel: AiringSpinsResponse
    MyAiringsPageModel->>MyAiringsPageModel: find firstSpin / lastSpin by airtime
    MyAiringsPageModel->>API: createClipForAiring(firstSpinId, lastSpinId, 0, 0)
    API-->>MyAiringsPageModel: Clip (pending/processing)

    loop Poll every 3s, up to 60 times
        MyAiringsPageModel->>API: getClip(clipId)
        API-->>MyAiringsPageModel: Clip
        alt status == completed
            MyAiringsPageModel-->>MyAiringsPage: UI state → .ready(clip)
        else status == failed
            MyAiringsPageModel-->>MyAiringsPage: Show clipFailed alert
        end
    end

    User->>MyAiringsPage: Tap "Download"
    MyAiringsPageModel->>MyAiringsPageModel: shareTapped / downloadTapped
    MyAiringsPageModel-->>MyAiringsPage: present ShareSheet

    Note over PushNotifications,MyAiringsPageModel: clip_ready push notification
    PushNotifications->>MyAiringsPageModel: push .myAiringsPage(model)
Loading

Last reviewed commit: "Update iOS API clien..."

Comment on lines +238 to +241
}

pollingAiringIds.remove(airingId)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent polling timeout leaves the user with no feedback

After the polling loop exhausts its 60 iterations (~3 minutes), pollingAiringIds.remove(airingId) is called without presenting any alert. The UI will silently revert to the .noClip state (showing the "Create Clip" button again), with no indication to the user that the clip timed out. They may click "Create Clip" again, not knowing the original request is still being processed server-side.

Consider showing an alert here so the user knows the wait exceeded the expected time:

pollingAiringIds.remove(airingId)
presentedAlert = .clipTimeout  // new alert case

Comment on lines +139 to +153
func formattedAirtime(_ date: Date) -> String {
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEEE"
let dayOfWeek = dayFormatter.string(from: date)

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM d"
let dateStr = dateFormatter.string(from: date)

let hourFormatter = DateFormatter()
hourFormatter.dateFormat = "h:mma"
let hour = hourFormatter.string(from: date).lowercased()

return "\(dayOfWeek), \(dateStr) at \(hour)"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Three DateFormatter instances created on every call

DateFormatter is an expensive object to allocate and is not thread-safe, but allocating it fresh on each call inside a hot path (called once per visible row during layout) is wasteful. These should be static let properties or cached elsewhere.

Suggested change
func formattedAirtime(_ date: Date) -> String {
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEEE"
let dayOfWeek = dayFormatter.string(from: date)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM d"
let dateStr = dateFormatter.string(from: date)
let hourFormatter = DateFormatter()
hourFormatter.dateFormat = "h:mma"
let hour = hourFormatter.string(from: date).lowercased()
return "\(dayOfWeek), \(dateStr) at \(hour)"
}
func formattedAirtime(_ date: Date) -> String {
let dayFormatter = Self.dayFormatter
let dayOfWeek = dayFormatter.string(from: date)
let dateFormatter = Self.monthDayFormatter
let dateStr = dateFormatter.string(from: date)
let hourFormatter = Self.hourFormatter
let hour = hourFormatter.string(from: date).lowercased()
return "\(dayOfWeek), \(dateStr) at \(hour)"
}

Add the static formatters near the top of the class:

private static let dayFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateFormat = "EEEE"
    return f
}()
private static let monthDayFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateFormat = "MMM d"
    return f
}()
private static let hourFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateFormat = "h:mma"
    return f
}()

Comment on lines +87 to +95
func downloadTapped(_ airing: ListenerQuestionAiring) {
guard let clip = clips[airing.id] else { return }
guard let urlString = clip.url else {
presentedAlert = .errorDownloadingClip
return
}
let shareModel = ShareSheetModel(items: [urlString])
mainContainerNavigationCoordinator.presentedSheet = .share(shareModel)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 downloadTapped opens a share sheet with a URL string, not a downloaded file

The "Download" button's handler passes urlString (a String) to ShareSheetModel. On iOS, sharing a string URL via UIActivityViewController will offer "Copy" and web-browser-oriented actions, but typically won't trigger a "Save to Files" or "Save to Music Library" offer that users expect when tapping a button labelled "Download". Passing a URL instead of a String enables proper file-download behaviors in the share sheet:

Suggested change
func downloadTapped(_ airing: ListenerQuestionAiring) {
guard let clip = clips[airing.id] else { return }
guard let urlString = clip.url else {
presentedAlert = .errorDownloadingClip
return
}
let shareModel = ShareSheetModel(items: [urlString])
mainContainerNavigationCoordinator.presentedSheet = .share(shareModel)
}
func downloadTapped(_ airing: ListenerQuestionAiring) {
guard let clip = clips[airing.id] else { return }
guard let urlString = clip.url, let url = URL(string: urlString) else {
presentedAlert = .errorDownloadingClip
return
}
let shareModel = ShareSheetModel(items: [url])
mainContainerNavigationCoordinator.presentedSheet = .share(shareModel)
}

Comment on lines +41 to +52
private var pollingTask: Task<Void, Never>?

// MARK: - User Actions

func viewAppeared() async {
await loadData()
await pushNotifications.scheduleAiringReminders(upcomingAirings)
}

func viewDisappeared() {
pollingTask?.cancel()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Single pollingTask does not support concurrent polling for multiple airings

pollingTask is a single stored property, but pollForClipCompletion can be called concurrently for different airings. When a user taps "Create Clip" on two different past airings simultaneously, the second call overwrites pollingTask with a new Task. The reference to the first task is permanently lost — so when viewDisappeared() calls pollingTask?.cancel(), only the second task is cancelled. The first polling task keeps running (and retaining self) in the background for up to 3 minutes (60 iterations × 3 s each) after the view has been dismissed.

To fix this, store tasks keyed by airingId:

private var pollingTasks: [String: Task<Void, Never>] = [:]

func viewDisappeared() {
    pollingTasks.values.forEach { $0.cancel() }
    pollingTasks.removeAll()
}

Then in pollForClipCompletion, assign to pollingTasks[airingId] instead of pollingTask, and remove the entry once the task finishes.

Add Clip.swift, MyAiringsPage files to pbxproj so they compile.
Add rbenv + xcodeproj gem installation to Makefile setup-conductor
target so Conductor agents can programmatically add files to the
Xcode project.
- Replace single pollingTask with pollingTasks dictionary keyed by
  airingId to support concurrent polling for multiple airings
- Show clipTimeout alert when polling exhausts 60 iterations instead
  of silently reverting to noClip state
- Pass URL object instead of String to ShareSheetModel in downloadTapped
  for proper iOS file-save behavior
- Cache DateFormatter instances as static properties instead of
  allocating per call
Tests calling viewAppeared() need date.now (accessed via upcomingAirings)
and scheduleNotification (called by scheduleAiringReminders).
Revert URL change since ShareSheetModel.items is [String], not [Any].
Also add pre-PR checklist to CLAUDE.md.
Tests calling viewAppeared() that provide getMyListenerQuestionAirings
also need date.now and pushNotifications.scheduleNotification since
checkForUpcomingQuestionAirings uses both.

Also update TESTING.md with guidance on providing all dependencies
reached by a code path, not just the one under test.
…rings

Test returns a future airing, so scheduleAiringReminders tries to
schedule a notification for it.
@briankeane
briankeane changed the base branch from release/5.6.0 to develop March 19, 2026 22:23
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