Recetas

Cada receta hace tres cosas: pedir /playback, reproducir hls_url, recuperarse con la regla de recargar. Reemplazá showError y showReload por tu propia UI.

Tu backend (Strict o videos privados)

La secret key vive en tu servidor. Tu app le pregunta a tu backend, tu backend le pregunta a XonaPlay.

js
// Node 18+ / Bun / Deno
export async function getPlayback(videoId, viewerId) {
  const url = new URL(`https://api.xonaplay.com/v1/videos/${videoId}/playback`)
  if (viewerId) url.searchParams.set("viewer_id", viewerId)
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.XONAPLAY_SECRET_KEY}` },
  })
  const body = await res.json()
  if (body.status !== "ok") throw body // { code, message }: pásaselo al player tal cual
  return body.data
}
python
# Python 3 + requests
import os, requests
 
def get_playback(video_id, viewer_id=None):
    r = requests.get(
        f"https://api.xonaplay.com/v1/videos/{video_id}/playback",
        params={"viewer_id": viewer_id} if viewer_id else None,
        headers={"Authorization": f"Bearer {os.environ['XONAPLAY_SECRET_KEY']}"},
        timeout=10,
    )
    body = r.json()
    if body["status"] != "ok":
        raise RuntimeError(body)  # {"code", "message"}
    return body["data"]
php
// PHP 8
function xonaplay_playback(string $videoId): array {
    $ch = curl_init("https://api.xonaplay.com/v1/videos/{$videoId}/playback");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('XONAPLAY_SECRET_KEY')],
        CURLOPT_TIMEOUT => 10,
    ]);
    $body = json_decode(curl_exec($ch), true);
    if (($body['status'] ?? '') !== 'ok') throw new RuntimeException(json_encode($body));
    return $body['data'];
}

Web (hls.js y Safari)

js
import Hls from "hls.js"
 
// Video público fuera de Strict: llamá a XonaPlay directo. Si no, tu ruta de backend.
const playbackUrl = (id) => `https://api.xonaplay.com/v1/videos/${id}/playback`
let hls = null
let lastRetry = 0
 
async function load(video, id, at = 0) {
  const body = await fetch(playbackUrl(id)).then((r) => r.json())
  if (body.status !== "ok") throw body
  hls?.destroy()
  hls = null
  if (Hls.isSupported()) {
    hls = new Hls({ startPosition: at })
    hls.loadSource(body.data.hls_url)
    hls.attachMedia(video)
    hls.on(Hls.Events.ERROR, (_, e) => e.fatal && recover(video, id))
  } else {
    video.src = body.data.hls_url // Safari: HLS nativo
    video.addEventListener("loadedmetadata", () => (video.currentTime = at), { once: true })
    video.onerror = () => recover(video, id)
  }
  video.play().catch(() => {})
}
 
function recover(video, id) {
  const at = video.currentTime
  if (Date.now() - lastRetry < 60_000) return showReload(() => load(video, id, at).catch(showError))
  lastRetry = Date.now()
  load(video, id, at).catch(showError)
}
 
load(document.querySelector("video"), "9KfR2mQx").catch(showError)

showError(err) muestra err.message; ofrecé Recargar solo para los códigos reintentables.

iOS (AVPlayer)

swift
import AVFoundation
import Combine
 
final class XonaPlayback {
  let player = AVPlayer()
  let videoId: String
  private var lastRetry = Date.distantPast
  private var subs = Set<AnyCancellable>()
  init(videoId: String) { self.videoId = videoId }
 
  func reload(at time: CMTime = .zero) {
    Task { @MainActor in
      do {
        let p = try await getPlayback(videoId) // tu llamado a /playback (backend o directo)
        load(URL(string: p.hlsUrl)!, at: time)
      } catch { showError(error) }
    }
  }
 
  private func load(_ url: URL, at time: CMTime) {
    let item = AVPlayerItem(url: url)
    subs = []
    item.publisher(for: \.status).receive(on: DispatchQueue.main).sink { [weak self] status in
      if status == .readyToPlay, time > .zero { self?.player.seek(to: time) }
      if status == .failed { self?.recover() }
    }.store(in: &subs)
    NotificationCenter.default.publisher(for: .AVPlayerItemFailedToPlayToEndTime, object: item)
      .receive(on: DispatchQueue.main)
      .sink { [weak self] _ in self?.recover() } // falla a mitad de camino, p. ej. un 403 en un segmento
      .store(in: &subs)
    player.replaceCurrentItem(with: item)
    player.play()
  }
 
  private func recover() {
    subs = []
    let at = player.currentTime()
    if Date().timeIntervalSince(lastRetry) < 60 { return showReload { self.reload(at: at) } }
    lastRetry = Date()
    reload(at: at)
  }
}

Decodificá el JSON con keyDecodingStrategy = .convertFromSnakeCase así hls_url se convierte en hlsUrl.

Android (Media3 / ExoPlayer)

kotlin
// deps: androidx.media3:media3-exoplayer, media3-exoplayer-hls, media3-ui
class XonaPlayback(context: Context, private val videoId: String, private val scope: CoroutineScope) {
    val player = ExoPlayer.Builder(context).build() // playerView.player = player
    private var lastRetry = -60_000L
 
    init {
        player.addListener(object : Player.Listener {
            override fun onPlayerError(error: PlaybackException) = recover()
        })
    }
 
    fun reload(atMs: Long = 0L) {
        scope.launch {
            runCatching { getPlayback(videoId) } // tu llamado a /playback
                .onSuccess { p ->
                    player.setMediaItem(MediaItem.fromUri(p.hlsUrl), atMs)
                    player.prepare()
                    player.play()
                }
                .onFailure { showError(it) }
        }
    }
 
    private fun recover() {
        val at = player.currentPosition
        val now = SystemClock.elapsedRealtime()
        if (now - lastRetry < 60_000) return showReload { reload(at) }
        lastRetry = now
        reload(at)
    }
}

Con kotlinx.serialization, mapeá hls_url con @SerialName("hls_url") o fijá namingStrategy = JsonNamingStrategy.SnakeCase.

React Native (react-native-video)

tsx
import Video, { VideoRef } from "react-native-video"
 
export function XonaVideo({ videoId }: { videoId: string }) {
  const ref = useRef<VideoRef>(null)
  const [src, setSrc] = useState<string | null>(null)
  const at = useRef(0)
  const lastRetry = useRef(0)
 
  const reload = useCallback(async () => {
    try {
      const p = await getPlayback(videoId) // tu backend o el endpoint público
      setSrc(p.hls_url)
    } catch (e) {
      showError(e)
    }
  }, [videoId])
 
  useEffect(() => {
    reload()
  }, [reload])
 
  const onError = () => {
    if (Date.now() - lastRetry.current < 60_000) return showReload(reload)
    lastRetry.current = Date.now()
    reload()
  }
 
  if (!src) return null
  return (
    <Video
      ref={ref}
      source={{ uri: src }}
      onProgress={(e) => (at.current = e.currentTime)}
      onLoad={() => at.current > 0 && ref.current?.seek(at.current)}
      onError={onError}
      controls
      style={{ width: "100%", aspectRatio: 16 / 9 }}
    />
  )
}

Flutter (video_player)

dart
Future<void> reload({Duration at = Duration.zero}) async {
  final p = await getPlayback(videoId); // tu llamado a /playback
  final next = VideoPlayerController.networkUrl(Uri.parse(p['hls_url']));
  await next.initialize();
  await next.seekTo(at);
  next.addListener(() {
    if (next.value.hasError) recover();
  });
  await controller?.dispose();
  setState(() => controller = next..play());
}
 
void recover() {
  final at = controller?.value.position ?? Duration.zero;
  if (DateTime.now().difference(lastRetry).inSeconds < 60) return showReload(() => reload(at: at));
  lastRetry = DateTime.now();
  reload(at: at);
}

Electron

El renderer es un navegador: usá la receta web. Guardá la secret key en el proceso principal y exponé getPlayback al renderer vía IPC.

Subir un video

Las subidas van directo al storage; XonaPlay nunca proxea tus bytes.

bash
# 1. Abrí una subida: te dan URLs de parte para hacer PUT
curl -X POST https://api.xonaplay.com/v1/videos/uploads \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Lesson 1","file_name":"lesson-1.mp4","size_bytes":734003200,"content_type":"video/mp4","delivery":"hls"}'

La respuesta es así:

json
{
  "status": "ok",
  "data": {
    "video_id": "0f8e2c7a-…",
    "session_id": "6b1d9a4e-…",
    "part_size": 52428800,
    "urls": ["https://…/part-1?X-Amz-…", "https://…/part-2?X-Amz-…"],
    "max_height": 1080
  }
}
  • part_size: partí tu archivo en chunks de esta cantidad de bytes (el último chunk suele ser más chico).
  • urls: una URL de PUT pre-firmada por parte, en orden — hacé PUT del chunk N (empezando en 1) a urls[N - 1]. Con la concurrencia que quieras.
  • max_height: el tope de resolución de tu plan; el pipeline recorta cualquier cosa más alta que esto.

En el request, delivery es opcional: hls (default) renderiza HLS adaptativo; original deja el archivo sin tocar. Ver Original o HLS.

js
// Node 18+: partir el archivo y hacer PUT de cada parte
import { open } from "node:fs/promises"
 
async function uploadParts(filePath, { partSize, urls }) {
  const file = await open(filePath, "r")
  const etags = [] // guardalos: el llamado a complete los necesita como respaldo
  try {
    for (let i = 0; i < urls.length; i++) {
      const { buffer, bytesRead } = await file.read({
        buffer: Buffer.alloc(partSize),
        position: i * partSize,
      })
      const res = await fetch(urls[i], { method: "PUT", body: buffer.subarray(0, bytesRead) })
      etags.push({ part_number: i + 1, etag: res.headers.get("etag") })
    }
  } finally {
    await file.close()
  }
  return etags
}
bash
# 2. Hacé PUT de cada parte del archivo a su URL (el snippet de Node de arriba lo hace)
# 3. Cerrala; con delivery "hls", la codificación arranca enseguida
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY"

Si tu storage no puede listar las partes de una subida multiparte, mandá en su lugar el header ETag de respuesta que juntaste en cada PUT:

bash
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"parts":[{"part_number":1,"etag":"\"9bb58f26…\""},{"part_number":2,"etag":"\"c2e897…\""}]}'

Cerrar la subida devuelve un status: queued si delivery es hls (empieza el renderizado), o ready si es original (reproducible al toque). Con hls, te llega un webhook video.ready cuando termina la codificación.

Importar desde una URL

Le pasás una URL a XonaPlay y ella descarga el archivo por vos. Los bytes nunca pasan por tu servidor. Sirve para generadores de IA que devuelven un link al clip.

bash
curl -X POST https://api.xonaplay.com/v1/videos/imports \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Clip generado","source_url":"https://files.example.com/clip.mp4","delivery":"original"}'
json
{
  "status": "ok",
  "data": {
    "video_id": "0f8e2c7a-…",
    "import_id": "c3a9f1d2-…",
    "status": "uploading"
  }
}
  • source_url: una URL pública https. Se siguen los redirects.
  • file_name (opcional): el nombre del archivo original.
  • delivery (opcional): hls (default) u original, igual que en las subidas.

XonaPlay descarga el archivo y lo guarda sin tocar. Esperá el webhook video.ready (o video.failed) antes de reproducirlo. Aplican los límites de tu plan (402 PLAN_LIMIT). Los errores transitorios se reintentan, hasta 3 intentos.

Clips generados por IA: llamá a la importación apenas el generador te devuelva la URL, mientras el link todavía funciona. La mayoría de los generadores devuelve un mp4: con delivery: "original", queda reproducible apenas termina la descarga.

Descargar el original

Obtené el archivo original tal cual se subió. Requiere secret key o sesión; nunca funciona de forma anónima.

bash
curl https://api.xonaplay.com/v1/videos/{VIDEO_ID}/download \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY"
json
{
  "status": "ok",
  "data": {
    "url": "https://…",
    "expires_at": "2026-10-10T01:00:00.000Z",
    "file_name": "lesson-1.mp4",
    "size_bytes": 734003200
  }
}

El url vale una hora y entrega el archivo con su file_name.

  • 409 VIDEO_NOT_READY: la subida todavía está en curso. Reintentá más tarde.
  • 404 ORIGINAL_NOT_AVAILABLE: el original fue borrado.
  • 422 NOT_SUPPORTED_FOR_BYO: el video vive en tu propio servidor.

Verificar un webhook

js
import { createHmac, timingSafeEqual } from "node:crypto"
 
export function verifyXonaplay(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  const want = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest()
  const got = Buffer.from(parts.v1 ?? "", "hex")
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  return fresh && got.length === want.length && timingSafeEqual(got, want)
}

Usá el body crudo del request, antes de cualquier parseo de JSON.