Receitas

Cada receita faz três coisas: pedir /playback, reproduzir hls_url, recuperar-se com a regra de recarregar. Substitua showError e showReload pela sua própria UI.

Seu backend (Strict ou vídeos privados)

A secret key vive no seu servidor. Seu app pergunta ao seu backend, seu backend pergunta à 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 }: repasse ao player como está
  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 e Safari)

js
import Hls from "hls.js"
 
// Vídeo público fora do Strict: chame a XonaPlay direto. Senão, a rota do seu 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) mostra err.message; ofereça Recarregar só para os códigos reexecutáveis.

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) // sua chamada a /playback (backend ou direta)
        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() } // falha no meio do caminho, ex. um 403 em um 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)
  }
}

Decodifique o JSON com keyDecodingStrategy = .convertFromSnakeCase para que hls_url vire 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) } // sua chamada 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)
    }
}

Com kotlinx.serialization, mapeie hls_url com @SerialName("hls_url") ou defina 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) // seu backend ou o 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); // sua chamada 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

O renderer é um navegador: use a receita web. Mantenha a secret key no processo principal e exponha getPlayback ao renderer via IPC.

Enviar um vídeo

Os envios vão direto para o armazenamento; a XonaPlay nunca faz proxy dos seus bytes.

bash
# 1. Abra um envio: você recebe URLs de parte para fazer 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"}'

A resposta é assim:

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: divida seu arquivo em pedaços desse tamanho em bytes (o último pedaço costuma ser menor).
  • urls: uma URL de PUT pré-assinada por parte, em ordem — faça PUT do pedaço N (começando em 1) em urls[N - 1]. Com a concorrência que quiser.
  • max_height: o teto de resolução do seu plano; o pipeline reduz qualquer coisa maior que isso.

No request, delivery é opcional: hls (padrão) renderiza HLS adaptativo; original mantém o arquivo intacto. Veja Original ou HLS.

js
// Node 18+: dividir o arquivo e fazer PUT de cada parte
import { open } from "node:fs/promises"
 
async function uploadParts(filePath, { partSize, urls }) {
  const file = await open(filePath, "r")
  const etags = [] // guarde-os: a chamada de complete os usa 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. Faça PUT de cada parte do arquivo na sua URL (o snippet de Node acima faz isso)
# 3. Feche o envio; com delivery "hls", a codificação começa na hora
curl -X POST https://api.xonaplay.com/v1/videos/uploads/{SESSION_ID}/complete \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY"

Se o seu armazenamento não conseguir listar as partes de um envio multiparte, envie em vez disso o header ETag de resposta que você coletou em 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…\""}]}'

Fechar o envio retorna um status: queued quando delivery é hls (a renderização começa), ou ready quando é original (reproduzível na hora). Com hls, chega um webhook video.ready quando a codificação termina.

Importar de uma URL

Você passa uma URL e a XonaPlay baixa o arquivo. Os bytes nunca passam pelo seu servidor. É ideal para geradores de IA que devolvem um link do clipe.

bash
curl -X POST https://api.xonaplay.com/v1/videos/imports \
  -H "Authorization: Bearer $XONAPLAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Clipe gerado","source_url":"https://files.example.com/clip.mp4","delivery":"original"}'
json
{
  "status": "ok",
  "data": {
    "video_id": "0f8e2c7a-…",
    "import_id": "c3a9f1d2-…",
    "status": "uploading"
  }
}
  • source_url: uma URL pública https. Redirecionamentos são seguidos.
  • file_name (opcional): o nome do arquivo original.
  • delivery (opcional): hls (padrão) ou original, como nos envios.

A XonaPlay baixa o arquivo e o guarda intacto. Espere o webhook video.ready (ou video.failed) antes de reproduzir. Os limites do seu plano se aplicam (402 PLAN_LIMIT). Erros transitórios são repetidos, em até 3 tentativas.

Clipes gerados por IA: chame a importação assim que o gerador devolver a URL, enquanto o link ainda funciona. A maioria dos geradores devolve um mp4: com delivery: "original", ele fica reproduzível assim que o download termina.

Baixar o original

Baixe o arquivo original exatamente como foi enviado. Exige secret key ou sessão; 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
  }
}

O url vale por uma hora e entrega o arquivo com o seu file_name.

  • 409 VIDEO_NOT_READY: o envio ainda está em andamento. Tente de novo mais tarde.
  • 404 ORIGINAL_NOT_AVAILABLE: o original foi excluído.
  • 422 NOT_SUPPORTED_FOR_BYO: o vídeo está no seu próprio servidor.

Verificar um 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)
}

Use o body bruto da requisição, antes de qualquer parsing de JSON.