Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success. Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
320 lines
8.5 KiB
Swift
320 lines
8.5 KiB
Swift
//
|
|
// API.swift
|
|
// QueueCube
|
|
//
|
|
// Created by James Magahern on 3/3/25.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
struct MediaItem: Codable
|
|
{
|
|
let filename: String?
|
|
let title: String?
|
|
let id: Int
|
|
|
|
let current: Bool?
|
|
let playing: Bool?
|
|
let metadata: Metadata?
|
|
let playbackError: String?
|
|
|
|
var displayTitle: String {
|
|
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
|
|
}
|
|
|
|
private var displayFilename: String? {
|
|
guard let filename else { return nil }
|
|
if let url = URL(string: filename) {
|
|
return url.lastPathComponent
|
|
}
|
|
|
|
return filename
|
|
}
|
|
|
|
// MARK: - Types
|
|
|
|
struct Metadata: Codable
|
|
{
|
|
let title: String?
|
|
let description: String?
|
|
let siteName: String?
|
|
}
|
|
}
|
|
|
|
struct SearchResultItem: Codable
|
|
{
|
|
var type: String
|
|
var title: String
|
|
var author: String
|
|
var mediaUrl: String
|
|
var thumbnailUrl: String
|
|
}
|
|
|
|
struct FetchResult<T: Codable>: Codable
|
|
{
|
|
let success: Bool
|
|
let results: T?
|
|
let error: String?
|
|
}
|
|
|
|
struct NowPlayingInfo: Codable
|
|
{
|
|
let playingItem: MediaItem?
|
|
let isPaused: Bool
|
|
let volume: Int
|
|
let timePosition: Double?
|
|
let duration: Double?
|
|
let seekable: Bool?
|
|
}
|
|
|
|
actor API
|
|
{
|
|
let baseURL: URL
|
|
private let requestTimeout: TimeInterval
|
|
|
|
private var pingTask: Task<(), any Swift.Error>? = nil
|
|
|
|
init(baseURL: URL, requestTimeout: TimeInterval = 60) {
|
|
self.baseURL = baseURL
|
|
self.requestTimeout = requestTimeout
|
|
}
|
|
|
|
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
|
try await request()
|
|
.path("/nowplaying")
|
|
.json()
|
|
}
|
|
|
|
public func fetchPlaylist() async throws -> [MediaItem] {
|
|
try await request()
|
|
.path("/playlist")
|
|
.json()
|
|
}
|
|
|
|
public func fetchFavorites() async throws -> [MediaItem] {
|
|
try await request()
|
|
.path("/favorites")
|
|
.json()
|
|
}
|
|
|
|
public func play() async throws {
|
|
try await request()
|
|
.path("/play")
|
|
.post()
|
|
}
|
|
|
|
public func pause() async throws {
|
|
try await request()
|
|
.path("/pause")
|
|
.post()
|
|
}
|
|
|
|
public func stop() async throws {
|
|
try await request()
|
|
.path("/stop")
|
|
.post()
|
|
}
|
|
|
|
public func skip(_ to: Int? = nil) async throws {
|
|
let path = if let to { "/skip/\(to)" } else { "/skip" }
|
|
try await request()
|
|
.path(path)
|
|
.post()
|
|
}
|
|
|
|
public func previous() async throws {
|
|
try await request()
|
|
.path("/previous")
|
|
.post()
|
|
}
|
|
|
|
public func add(mediaURL: String) async throws {
|
|
try await request()
|
|
.path("/playlist")
|
|
.body([ "url" : mediaURL ])
|
|
.post()
|
|
}
|
|
|
|
public func replace(mediaURL: String) async throws {
|
|
try await request()
|
|
.path("/playlist/replace")
|
|
.body([ "url" : mediaURL ])
|
|
.post()
|
|
}
|
|
|
|
public func addFavorite(mediaURL: String) async throws {
|
|
try await request()
|
|
.path("/favorites")
|
|
.body([ "filename" : mediaURL ])
|
|
.post()
|
|
}
|
|
|
|
public func deleteFavorite(mediaURL: String) async throws {
|
|
try await request()
|
|
.pathString("/favorites/\(mediaURL.uriEncoded())")
|
|
.method(.delete)
|
|
.execute()
|
|
}
|
|
|
|
public func renameFavorite(mediaURL: String, title: String) async throws {
|
|
try await request()
|
|
.pathString("/favorites/\(mediaURL.uriEncoded())/title")
|
|
.body([ "title": title ])
|
|
.method(.put)
|
|
.execute()
|
|
}
|
|
|
|
public func delete(index: Int) async throws {
|
|
try await request()
|
|
.path("/playlist/\(index)")
|
|
.method(.delete)
|
|
.execute()
|
|
}
|
|
|
|
public func setVolume(_ value: Double) async throws {
|
|
try await request()
|
|
.path("/volume")
|
|
.body([ "volume" : Int(value * 100) ])
|
|
.post()
|
|
}
|
|
|
|
public func seek(to time: Double) async throws {
|
|
try await request()
|
|
.path("/player/seek")
|
|
.body([ "time" : time ])
|
|
.post()
|
|
}
|
|
|
|
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
|
|
try await request()
|
|
.pathString("/search?q=\(query.uriEncoded())")
|
|
.json()
|
|
}
|
|
|
|
public func events() async throws -> AsyncStream<StreamEvent> {
|
|
let requestBuilder: () -> RequestBuilder = request
|
|
|
|
return AsyncStream { continuation in
|
|
let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation)
|
|
|
|
Task {
|
|
var pingLoopEnabled = true
|
|
while pingLoopEnabled {
|
|
try await Task.sleep(for: .seconds(5))
|
|
|
|
websocketTask.sendPing { error in
|
|
if let error {
|
|
API.notifyError(error, continuation: continuation)
|
|
pingLoopEnabled = false
|
|
} else {
|
|
continuation.yield(.event(Event(type: .receivedWebsocketPong)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func spawnWebsocketTask(
|
|
requestBuilder: () -> RequestBuilder,
|
|
with continuation: AsyncStream<StreamEvent>.Continuation
|
|
) -> URLSessionWebSocketTask
|
|
{
|
|
let url = requestBuilder()
|
|
.path("/events")
|
|
.websocket()
|
|
|
|
let websocketTask = URLSession.shared.webSocketTask(with: url)
|
|
websocketTask.resume()
|
|
|
|
Task {
|
|
do {
|
|
let event = { (data: Data) in
|
|
try JSONDecoder().decode(Event.self, from: data)
|
|
}
|
|
|
|
while websocketTask.state == .running {
|
|
switch try await websocketTask.receive() {
|
|
case .string(let string):
|
|
let event = try event(string.data(using: .utf8)!)
|
|
continuation.yield(.event(event))
|
|
case .data(let data):
|
|
let event = try event(data)
|
|
continuation.yield(.event(event))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
notifyError(error, continuation: continuation)
|
|
}
|
|
}
|
|
|
|
return websocketTask
|
|
}
|
|
|
|
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
|
|
print("Websocket Error: \(error)")
|
|
|
|
// Always notify observers of WebSocket errors so reconnection can happen
|
|
// The UI layer can decide whether to show the error to the user
|
|
continuation.yield(.error(.websocketError(error)))
|
|
}
|
|
|
|
private func request() -> RequestBuilder {
|
|
RequestBuilder(url: baseURL, timeout: requestTimeout)
|
|
}
|
|
|
|
// MARK: - Types
|
|
|
|
enum Error: Swift.Error, LocalizedError
|
|
{
|
|
case apiNotConfigured
|
|
case websocketError(Swift.Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .apiNotConfigured:
|
|
"No server is configured."
|
|
case .websocketError(let error):
|
|
error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
enum StreamEvent {
|
|
case event(Event)
|
|
case error(API.Error)
|
|
}
|
|
|
|
struct Event: Decodable
|
|
{
|
|
let type: EventType
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case type = "event"
|
|
}
|
|
|
|
enum EventType: String, Decodable {
|
|
case playlistUpdate = "playlist_update"
|
|
case nowPlayingUpdate = "now_playing_update"
|
|
case volumeUpdate = "volume_update"
|
|
case favoritesUpdate = "favorites_update"
|
|
case metadataUpdate = "metadata_update"
|
|
case mpdUpdate = "mpd_update"
|
|
case playbackError = "playback_error"
|
|
|
|
// Private UI events
|
|
case receivedWebsocketPong
|
|
case websocketReconnected
|
|
}
|
|
}
|
|
}
|
|
|
|
extension String
|
|
{
|
|
func uriEncoded() -> Self {
|
|
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
|
|
}
|
|
}
|