Nesta página
A receita, em qualquer plataforma
- Peça uma sessão nova ao seu backend
Sempre logo antes de abrir a tela (a
launchUrlvale 60 s e uma vez só). Veja Criar uma sessão. - Acrescente os parâmetros de embed e abra a URL
embed=1liga o modo embutido;closable=1mostra um botão de fechar dentro da Astorya (use se a sua tela não tiver um). - Configure a WebView para áudio
JavaScript e armazenamento local (DOM storage) ligados, reprodução de mídia sem exigir toque e vídeo/áudio inline.
- Escute os eventos da ponte
Recarregue com uma sessão nova em
astorya:session-expired, feche a tela emastorya:closee abra links externos no navegador do sistema.
Parâmetros da URL
| Parâmetro | Efeito |
|---|---|
token | Já vem na launchUrl. O Astorya Web troca por uma sessão e apaga da barra de endereço. |
redirect | Já vem na launchUrl quando você envia redirectPath. Não monte à mão. |
embed=1 | Liga o modo embutido: eventos para o app hospedeiro e links externos repassados a você em vez de abertos pela página. Também é ligado sozinho quando a página detecta a ponte (window.ReactNativeWebView) — mas envie sempre, por clareza. |
closable=1 | Mostra um botão de fechar; ao tocar, a Astorya envia astorya:close. |
A launchUrl sempre tem query string (?token=…), então basta acrescentar &embed=1&closable=1 — ou usar o construtor de URL da sua plataforma, como nos exemplos.
Eventos da ponte
A Astorya avisa o app hospedeiro com mensagens JSON que têm sempre um campo type. Elas chegam por dois canais, quando existem:
- WebView nativa:
window.ReactNativeWebView.postMessage(string). O valor é uma string JSON — façaJSON.parse. O React Native cria esse objeto sozinho; em Flutter, iOS e Android você o cria com o nomeReactNativeWebView(veja os exemplos). - iframe (site do parceiro):
window.parent.postMessage(objeto, '*'). O valor já é um objeto. Confira sempreevent.origin === 'https://play.astorya.com.br'.
| Evento | Dados | O que o app deve fazer |
|---|---|---|
astorya:ready | — | A Astorya terminou de carregar. Esconda o seu indicador de carregamento. |
astorya:session-expired | reason: "invalid_launch_token" ou "refresh_failed" | O link expirou/foi usado ou a sessão acabou. Peça uma nova sessão ao seu backend e carregue a nova launchUrl na mesma WebView. |
astorya:close | — | O membro tocou em fechar (só existe com closable=1). Feche a tela da WebView. |
astorya:open-external | url: string | Abra a URL no navegador do sistema (ou num navegador in-app). No modo embutido a Astorya nunca navega para fora sozinha. |
{ "type": "astorya:ready" }
{ "type": "astorya:session-expired", "reason": "invalid_launch_token" }
{ "type": "astorya:close" }
{ "type": "astorya:open-external", "url": "mailto:suporte@exemplo.com.br" }Ignore tipos que você não conhece: novos eventos podem ser adicionados sem mudança de versão.
React Native
Instale react-native-webview (npx expo install react-native-webview no Expo). O window.ReactNativeWebView já existe; basta usar onMessage.
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Linking, StyleSheet, View } from 'react-native';
import { WebView } from 'react-native-webview';
import { api } from './api'; // cliente do SEU backend
const PLAY_BASE_URL = 'https://play.astorya.com.br';
async function fetchLaunchUrl() {
// Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
const { launchUrl } = await api.post('/astorya/session');
return `${launchUrl}&embed=1&closable=1`;
}
export function AstoryaScreen({ navigation }) {
const [uri, setUri] = useState(null);
const [ready, setReady] = useState(false);
const openSession = useCallback(async () => {
setReady(false);
setUri(await fetchLaunchUrl());
}, []);
useEffect(() => {
openSession();
}, [openSession]);
const onMessage = useCallback(
(event) => {
let message;
try {
message = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
switch (message.type) {
case 'astorya:ready':
setReady(true);
break;
case 'astorya:session-expired':
openSession();
break;
case 'astorya:close':
navigation.goBack();
break;
case 'astorya:open-external':
Linking.openURL(message.url);
break;
default:
break;
}
},
[navigation, openSession],
);
return (
<View style={styles.root}>
{uri && (
<WebView
source={{ uri }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
setSupportMultipleWindows={false}
onShouldStartLoadWithRequest={(request) => {
if (request.url.startsWith(PLAY_BASE_URL)) return true;
Linking.openURL(request.url);
return false;
}}
/>
)}
{!ready && (
<View style={styles.loader} pointerEvents="none">
<ActivityIndicator size="large" color="#7747F3" />
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#fff' },
loader: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
});Flutter
Dependências: webview_flutter (4.x), webview_flutter_android, webview_flutter_wkwebview e url_launcher. O canal JavaScript chamado ReactNativeWebView cria exatamente a ponte que a Astorya procura.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import 'api.dart'; // cliente do SEU backend
const playBaseUrl = 'https://play.astorya.com.br';
class AstoryaPage extends StatefulWidget {
const AstoryaPage({super.key});
@override
State<AstoryaPage> createState() => _AstoryaPageState();
}
class _AstoryaPageState extends State<AstoryaPage> {
late final WebViewController _controller;
bool _ready = false;
@override
void initState() {
super.initState();
final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
_controller = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('ReactNativeWebView', onMessageReceived: _onMessage)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (request) {
if (request.url.startsWith(playBaseUrl)) {
return NavigationDecision.navigate;
}
launchUrl(Uri.parse(request.url), mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
},
));
final platform = _controller.platform;
if (platform is AndroidWebViewController) {
platform.setMediaPlaybackRequiresUserGesture(false);
}
_openSession();
}
Future<void> _openSession() async {
if (_ready) setState(() => _ready = false);
// Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
final String sessionUrl = await Api.createAstoryaSession();
if (!mounted) return;
final uri = Uri.parse(sessionUrl);
await _controller.loadRequest(uri.replace(queryParameters: {
...uri.queryParameters,
'embed': '1',
'closable': '1',
}));
}
void _onMessage(JavaScriptMessage message) {
final Map<String, dynamic> event;
try {
event = jsonDecode(message.message) as Map<String, dynamic>;
} catch (_) {
return;
}
switch (event['type']) {
case 'astorya:ready':
setState(() => _ready = true);
case 'astorya:session-expired':
_openSession();
case 'astorya:close':
Navigator.of(context).pop();
case 'astorya:open-external':
launchUrl(Uri.parse(event['url'] as String), mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
children: [
WebViewWidget(controller: _controller),
if (!_ready) const Center(child: CircularProgressIndicator()),
],
),
),
);
}
}iOS (Swift, WKWebView)
O WKUserScript cria window.ReactNativeWebView antes da página carregar e repassa as mensagens para o handler nativo astorya. O WeakMessageHandler evita o ciclo de referência clássico do WKUserContentController.
import UIKit
import WebKit
final class AstoryaViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
private static let playBaseUrl = "https://play.astorya.com.br"
private var webView: WKWebView!
private let loader = UIActivityIndicatorView(style: .large)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// Ponte: a Astorya chama window.ReactNativeWebView.postMessage(stringJSON).
let bridge = WKUserScript(
source: """
window.ReactNativeWebView = {
postMessage: function (data) { window.webkit.messageHandlers.astorya.postMessage(data); }
};
""",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
let contentController = WKUserContentController()
contentController.addUserScript(bridge)
contentController.add(WeakMessageHandler(self), name: "astorya")
let config = WKWebViewConfiguration()
config.userContentController = contentController
config.websiteDataStore = .default()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
loader.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loader)
loader.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
loader.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
openSession()
}
private func openSession() {
loader.startAnimating()
Task {
do {
// Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
let launchUrl = try await PartnerAPI.createAstoryaSession()
guard var components = URLComponents(string: launchUrl) else { return }
components.queryItems = (components.queryItems ?? []) + [
URLQueryItem(name: "embed", value: "1"),
URLQueryItem(name: "closable", value: "1"),
]
if let url = components.url {
webView.load(URLRequest(url: url))
}
} catch {
loader.stopAnimating()
// Mostre uma mensagem de erro com um botão "Tentar de novo".
}
}
}
// MARK: WKScriptMessageHandler
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let text = message.body as? String,
let data = text.data(using: .utf8),
let event = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let type = event["type"] as? String else { return }
switch type {
case "astorya:ready":
loader.stopAnimating()
case "astorya:session-expired":
openSession()
case "astorya:close":
dismiss(animated: true)
case "astorya:open-external":
if let value = event["url"] as? String, let url = URL(string: value) {
UIApplication.shared.open(url)
}
default:
break
}
}
// MARK: WKNavigationDelegate — links de fora da Astorya vão para o sistema.
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if url.absoluteString.hasPrefix(Self.playBaseUrl) || url.scheme == "about" || url.scheme == "blob" {
decisionHandler(.allow)
} else {
UIApplication.shared.open(url)
decisionHandler(.cancel)
}
}
}
/// Evita que o WKUserContentController segure o view controller para sempre.
private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
private weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) {
self.target = target
}
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
target?.userContentController(userContentController, didReceive: message)
}
}Android (Kotlin, WebView)
O addJavascriptInterface com o nome ReactNativeWebView expõe um postMessage(String) que a Astorya chama diretamente. Ele roda fora da thread principal, por isso o runOnUiThread.
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
class AstoryaActivity : AppCompatActivity() {
private lateinit var webView: WebView
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
}
// Ponte: a Astorya chama window.ReactNativeWebView.postMessage(stringJSON).
webView.addJavascriptInterface(AstoryaBridge(), "ReactNativeWebView")
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (request.url.toString().startsWith(PLAY_BASE_URL)) return false
startActivity(Intent(Intent.ACTION_VIEW, request.url))
return true
}
}
onBackPressedDispatcher.addCallback(this) {
if (webView.canGoBack()) webView.goBack() else finish()
}
openSession()
}
private fun openSession() {
lifecycleScope.launch {
// Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
val launchUrl = PartnerApi.createAstoryaSession()
val url = Uri.parse(launchUrl).buildUpon()
.appendQueryParameter("embed", "1")
.appendQueryParameter("closable", "1")
.build()
.toString()
webView.loadUrl(url)
}
}
private fun handleEvent(event: JSONObject) {
when (event.optString("type")) {
"astorya:ready" -> { /* esconda o seu loader */ }
"astorya:session-expired" -> openSession()
"astorya:close" -> finish()
"astorya:open-external" ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.getString("url"))))
}
}
private inner class AstoryaBridge {
@JavascriptInterface
fun postMessage(message: String) {
val event = try {
JSONObject(message)
} catch (e: JSONException) {
return
}
runOnUiThread { handleEvent(event) }
}
}
override fun onDestroy() {
webView.destroy()
super.onDestroy()
}
companion object {
private const val PLAY_BASE_URL = "https://play.astorya.com.br"
}
}Site do parceiro (iframe)
Também dá para embutir num site. A sessão é pedida do mesmo jeito (pelo seu backend); os eventos chegam por postMessage como objetos.
const PLAY_BASE_URL = 'https://play.astorya.com.br';
const frame = document.querySelector('#astorya'); // <iframe id="astorya" allow="autoplay; fullscreen">
async function openAstorya() {
const res = await fetch('/astorya/session', { method: 'POST', credentials: 'include' }); // SUA rota
const { launchUrl } = await res.json();
frame.src = `${launchUrl}&embed=1&closable=1`;
}
window.addEventListener('message', (event) => {
if (event.origin !== PLAY_BASE_URL) return;
const message = event.data; // objeto, não string
if (message.type === 'astorya:session-expired') openAstorya();
if (message.type === 'astorya:close') frame.hidden = true;
if (message.type === 'astorya:open-external') window.open(message.url, '_blank', 'noopener');
});
openAstorya();Limitações da versão web
O que a WebView não faz tão bem quanto o app nativo da Astorya:
- Áudio em segundo plano e na tela bloqueada é limitado, principalmente no iOS: o sistema pode pausar a reprodução quando o app vai para segundo plano ou a tela apaga, mesmo com
UIBackgroundModes = audio. No Android, o comportamento varia por fabricante e economia de bateria. - Sem downloads offline. A versão web precisa de internet para tocar e ler.
- Os controles de mídia da tela de bloqueio e do fone podem não aparecer ou não responder.
Para membros que ouvem muito (no treino, no carro, sem sinal), recomendamos um botão “Abrir no app Astorya” ao lado da WebView, levando à loja do app. A forma de acesso desses membros ao app nativo é combinada com a equipe Astorya na implantação.
Checklist antes de publicar
- A sessão é pedida a cada abertura da tela, nunca reaproveitada.
astorya:session-expiredrecarrega com uma sessão nova (teste abrindo a mesmalaunchUrlduas vezes).- Links externos saem para o navegador do sistema; a WebView só navega em
https://play.astorya.com.br. - O áudio começa a tocar sem toque extra no iOS e no Android.
- Nenhum segredo da Astorya está no código do app.