Skip to content

iOS 27 remote now playing - #5690

Draft
gbyo wants to merge 9 commits into
home-assistant:mainfrom
gbyo:ios27-remote-now-playing
Draft

iOS 27 remote now playing#5690
gbyo wants to merge 9 commits into
home-assistant:mainfrom
gbyo:ios27-remote-now-playing

Conversation

@gbyo

@gbyo gbyo commented Sep 8, 2026

Copy link
Copy Markdown

AI Policy

Select exactly one option that describes AI usage in this contribution:

  • I have not used AI for this contribution.
  • AI assistance was used for this contribution.
  • AI fully generated the code for this contribution, but I've reviewed and understood it before submitting and will respond without AI during review.

Summary

Adds Remote Now Playing support on iOS 27.

A media_player can be followed from its Add to... menu, which makes it appear in the native iOS Now Playing interface on the Lock Screen and in Control Center. Only one media player can be followed at a time. The current player can be stopped either from its add to menu or from the companion app settings

The session continues receiving metadata, progress, artwork, and playback state updates while the app is not open.

Screenshots

Follow / stop following

Screenshot 2026-09-07 at 23 28 14

Lock Screen / Control Center

IMG_5815-2

Link to pull request in Documentation repository

Documentation: home-assistant/companion.home-assistant#1420

Any other notes

Tested on a physical iPhone running iOS 27, including metadata and artwork updates with the Companion app force-closed and playback controls from the native Now Playing UI.

The RemoteMedia-focused test suites pass 56/56. SwiftFormat, SwiftLint, and RuboCop also pass.

Copilot AI lite review requested due to automatic review settings September 8, 2026 03:36

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gbyo
gbyo marked this pull request as draft September 8, 2026 03:41
@gbyo gbyo changed the title iOS 27 remote now playing (WIP) iOS 27 remote now playing Sep 8, 2026
@gbyo gbyo changed the title (WIP) iOS 27 remote now playing iOS 27 remote now playing Sep 8, 2026

Copilot AI left a comment

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.

🟡 Changes recommended

Multiple unresolved critical security, duplicate-command, and memory-safety issues block approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Sources/RemoteMediaCore/RemoteMediaSnapshotMapper.swift:91

  • Bool also bridges to NSNumber in this integer path, so supported_features: true becomes the pause capability on-device while the server mapper rejects it. Reject booleans before converting numeric feature flags.
  • Files reviewed: 100/104 changed files
  • Comments generated: 11
  • Review effort level: Balanced

Comment on lines +40 to +44
public static func isFetchable(_ url: URL) -> Bool {
guard url.scheme?.lowercased() == "https" else { return false }
guard url.user == nil, url.password == nil else { return false }
guard let host = url.host, !host.isEmpty else { return false }
return true

@gbyo gbyo Sep 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I don’t think rejecting all private/LAN addresses would be the best solution since local entity picture URLs can be used legitimately in some setups (if you're self-hosting music). I'm going to change it to still allow LAN hosted artwork and just prevent redirects from bypassing the checks

// Belt and braces: `ephemeral` already holds nothing, and this says so at the request too.
request.httpShouldHandleCookies = false

guard let (data, response) = try? await session.data(for: request) else {
Comment on lines +213 to +214
case is URLError:
return true
Comment on lines +64 to +67
public static func send(
server: Server,
baseURL: URL,
method: Method = .get,
Comment on lines +85 to +89
Current.settingsStore.remoteMediaSelection = selection
// Each Follow is its own relationship. Re-following the same player reuses the session
// identifier, so this is what lets the server retire the token the last one registered and
// know which of the two came later.
Current.settingsStore.startRemoteMediaFollowLifetime(following: selection)
Comment on lines +24 to +26
/// One client and a cached context, refreshed when the host delivers new attributes so route
/// changes are picked up without making every command build the app's networking stack.
private let client = RemoteMediaWebhookClient()
Comment on lines +83 to +85
// The host app is authoritative when it is running, but a reconciliation already in flight
// is answering a command the user just pressed, so it is not thrown away here.
apply(attributes.snapshot)
Comment on lines +323 to +327
} catch {
RemoteMediaLog.logger.error(
"command \(command.rawValue, privacy: .public) failed: \(error.localizedDescription, privacy: .public)"
)
throw error
Comment on lines +79 to +81
switch value {
case let number as NSNumber: candidate = number.doubleValue
case let text as String: candidate = Double(text)
Comment on lines +128 to +130
} catch {
lastError = error
guard Self.shouldTryNextCandidate(after: error) else { throw error }
@gbyo
gbyo force-pushed the ios27-remote-now-playing branch from 606e61c to 772be0c Compare September 8, 2026 12:26
@gbyo
gbyo force-pushed the ios27-remote-now-playing branch from 772be0c to 176a9c7 Compare September 8, 2026 12:31
gbyo and others added 2 commits September 8, 2026 08:41
Validate a followed entity id against the character set Home Assistant
slugifies to, so it cannot escape the quoted literal it sits in inside the
render_template query. Fall through to the next webhook route on statuses
that prove a service call never arrived, which is what a deleted cloudhook
returns. Clamp seek and volume through the command so the settle condition
waits for the value that was sent. Block up artwork bytes before appending
them, parse a space-separated timestamp with no fractional seconds, and drop
some dead code.

Co-authored-by: Claude <noreply@anthropic.com>

@bgoncal bgoncal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please split this PR into smaller iterations, it's already over 8k lines added + 110 file changes.

Also coordinate with your core PR to have it ready first, core PRs can change quite a lot during review so I would advise keeping it simple around here until it's not done there.

Also, there is currently a PR open in core that implements entity state subscription reported via silent push notifications, that one could help you a lot in here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants