Add iOS 27 remote media session and system player extension

This commit is contained in:
2026-09-14 00:17:07 +03:00
parent 2fbf3bf859
commit e2902e6031
16 changed files with 667 additions and 30 deletions
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>MusicBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.nowplaying.remote-media</string>
</dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
<key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>172.16.0.0/12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.0.0/16</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+68
View File
@@ -0,0 +1,68 @@
import ExtensionFoundation
import NowPlaying
import Observation
import Foundation
@main
struct MusicBridgeRemote: RemoteMediaSessionExtension {
var configuration: RemoteMediaSessionExtensionConfiguration<Self> {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession {
_ = try SystemPlayerCredentials.load(attributes.id)
return PCMediaSession(attributes)
}
}
@MainActor @Observable
final class PCMediaSession: RemoteMediaSessionRepresentable {
let id: String
var attributes: SystemPlayerAttributes
private let transport = SystemPlayerTransport()
init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes }
func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes }
var content: (any MediaContentRepresentable)? {
guard attributes.hasSession else { return nil }
let artwork = attributes.artworkID.map { artworkID in
Artwork(id: artworkID) { _ in
let saved = try SystemPlayerCredentials.load(self.id)
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
return try ArtworkRepresentation(data: await client.artwork(artworkID))
}
}
return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist,
songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title,
artistName: attributes.artist, albumName: "", type: .audio,
duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: attributes.rate) : .paused,
elapsedTime: attributes.position, timestamp: attributes.timestamp)
}
var commands: [MediaCommand] {
[
.play { try await self.send(.play) }.enabled(attributes.capabilities.play),
.pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause),
.next { try await self.send(.next) }.enabled(attributes.capabilities.next),
.previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous),
.seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek)
]
}
var devices: [MediaDevice] {
let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in
[.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }]
} ?? []
return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)]
}
private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws {
let state = try await transport.send(action, value: value, sessionID: id)
attributes = SystemPlayerAttributes(id: id, state: state)
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</array>
</dict>
</plist>