Files
QueueCube/ios/QueueCube/Backend/API.swift
buzzert 8723955705 ios: add share extension to queue and play video links
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.
2026-10-20 22:08:12 -07:00

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)!
}
}