mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:38:52 +03:00
be sync, migration, featuring pre-live web cast
This commit is contained in:
@@ -69,9 +69,11 @@ public class DiscoveryManager {
|
||||
public final String pinAuth;
|
||||
/** Shown from {@link PinnedReceiversStore} at top of sender list. */
|
||||
public boolean pinnedRecent;
|
||||
/** LAN discovery device UUID when advertised by peer. */
|
||||
public final String deviceUuid;
|
||||
|
||||
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
|
||||
this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "");
|
||||
this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "", "");
|
||||
}
|
||||
|
||||
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
|
||||
@@ -81,6 +83,13 @@ public class DiscoveryManager {
|
||||
|
||||
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
|
||||
int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth) {
|
||||
this(name, host, port, transport, lastSeenMs, videoCodecs, audioCodecs, placeholderId, protoVersion,
|
||||
pinAuth, "");
|
||||
}
|
||||
|
||||
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
|
||||
int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth,
|
||||
String deviceUuid) {
|
||||
this.name = name;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
@@ -91,6 +100,7 @@ public class DiscoveryManager {
|
||||
this.placeholderId = placeholderId;
|
||||
this.protoVersion = protoVersion;
|
||||
this.pinAuth = pinAuth != null ? pinAuth : "";
|
||||
this.deviceUuid = deviceUuid != null ? deviceUuid : "";
|
||||
}
|
||||
|
||||
public boolean pinMatches(String pin) {
|
||||
@@ -431,7 +441,8 @@ public class DiscoveryManager {
|
||||
String pinAuth = json.optString("pin_auth", "");
|
||||
String key = host + ":" + port;
|
||||
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
|
||||
System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth);
|
||||
System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth,
|
||||
remoteUuid);
|
||||
devices.put(key, device);
|
||||
notifyListener();
|
||||
} catch (Exception ignored) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.foxx.androidcast.live;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.BuildConfig;
|
||||
import com.foxx.androidcast.DeviceInfo;
|
||||
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||
import com.foxx.androidcast.network.BackendEndpoints;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Keeps BE live cast session state updated (intent/live/closed + 60s keepalive). */
|
||||
public final class LiveCastControlPlane {
|
||||
private static final String TAG = "LiveCastControl";
|
||||
private static final int HTTP_TIMEOUT_MS = 20_000;
|
||||
private static final long HEARTBEAT_INTERVAL_SEC = 60L;
|
||||
private static final AtomicReference<String> SESSION_ID = new AtomicReference<>("");
|
||||
private static final ScheduledExecutorService HEARTBEAT = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "live-cast-heartbeat");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
static {
|
||||
HEARTBEAT.scheduleAtFixedRate(() -> {
|
||||
String sid = SESSION_ID.get();
|
||||
if (sid == null || sid.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
postHeartbeat(sid, "active", "", "", "");
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "heartbeat failed: " + e.getMessage());
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_SEC, HEARTBEAT_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private LiveCastControlPlane() {}
|
||||
|
||||
public static void onCastStart(Context context, String platform, String codecVideo, String codecAudio, String bwMode) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String sid = SESSION_ID.get();
|
||||
if (sid == null || sid.isEmpty()) {
|
||||
sid = createIntent(context.getApplicationContext(), platform, codecVideo, codecAudio, bwMode);
|
||||
if (sid != null && !sid.isEmpty()) {
|
||||
SESSION_ID.set(sid);
|
||||
}
|
||||
} else {
|
||||
postHeartbeat(sid, "active", codecVideo, codecAudio, bwMode);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "onCastStart failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void onCastStop(String codecVideo, String codecAudio, String bwMode) {
|
||||
String sid = SESSION_ID.getAndSet("");
|
||||
if (sid == null || sid.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
postHeartbeat(sid, "ended", codecVideo, codecAudio, bwMode);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "onCastStop failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String createIntent(Context app, String platform, String codecVideo, String codecAudio, String bwMode)
|
||||
throws Exception {
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("action", "create_intent");
|
||||
req.put("platform", platform == null || platform.isEmpty() ? "android" : platform);
|
||||
req.put("device_id", DeviceInfo.getDeviceUuid(app));
|
||||
req.put("max_duration_s", 300);
|
||||
if (codecVideo != null && !codecVideo.isEmpty()) {
|
||||
req.put("codec_video", codecVideo);
|
||||
}
|
||||
if (codecAudio != null && !codecAudio.isEmpty()) {
|
||||
req.put("codec_audio", codecAudio);
|
||||
}
|
||||
if (bwMode != null && !bwMode.isEmpty()) {
|
||||
req.put("bw_mode", bwMode);
|
||||
}
|
||||
JSONObject resp = postJson(resolveLiveCastUrl(app), req.toString());
|
||||
if (!resp.optBoolean("ok", false)) {
|
||||
return "";
|
||||
}
|
||||
JSONObject session = resp.optJSONObject("session");
|
||||
return session != null ? session.optString("session_id", "") : "";
|
||||
}
|
||||
|
||||
private static void postHeartbeat(String sid, String status, String codecVideo, String codecAudio, String bwMode)
|
||||
throws Exception {
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("action", "heartbeat");
|
||||
req.put("session_id", sid);
|
||||
req.put("status", status);
|
||||
if (codecVideo != null && !codecVideo.isEmpty()) {
|
||||
req.put("codec_video", codecVideo);
|
||||
}
|
||||
if (codecAudio != null && !codecAudio.isEmpty()) {
|
||||
req.put("codec_audio", codecAudio);
|
||||
}
|
||||
if (bwMode != null && !bwMode.isEmpty()) {
|
||||
req.put("bw_mode", bwMode);
|
||||
}
|
||||
JSONObject resp = postJson(resolveLiveCastUrl(null), req.toString());
|
||||
if (!resp.optBoolean("ok", false)) {
|
||||
throw new IllegalStateException("heartbeat rejected");
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveLiveCastUrl(Context context) {
|
||||
String crashesUpload = context != null ? CrashSettingsStore.load(context).uploadUrl : "";
|
||||
if (crashesUpload != null && !crashesUpload.trim().isEmpty()) {
|
||||
String base = BackendEndpoints.normalizeCrashesUrl(crashesUpload.trim());
|
||||
if (base.contains("/api/upload.php")) {
|
||||
return base.replace("/api/upload.php", "/api/live_cast.php");
|
||||
}
|
||||
if (base.endsWith("/")) {
|
||||
return base + "api/live_cast.php";
|
||||
}
|
||||
return base + "/api/live_cast.php";
|
||||
}
|
||||
return BackendEndpoints.CRASHES_BASE + "/api/live_cast.php";
|
||||
}
|
||||
|
||||
private static JSONObject postJson(String url, String body) throws Exception {
|
||||
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
|
||||
c.setRequestMethod("POST");
|
||||
c.setConnectTimeout(HTTP_TIMEOUT_MS);
|
||||
c.setReadTimeout(HTTP_TIMEOUT_MS);
|
||||
c.setDoOutput(true);
|
||||
c.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
if (BuildConfig.GIT_COMMIT_SHORT != null) {
|
||||
c.setRequestProperty("X-AndroidCast", BuildConfig.GIT_COMMIT_SHORT);
|
||||
}
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
try (OutputStream os = c.getOutputStream()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
int code = c.getResponseCode();
|
||||
String text = readBody(c);
|
||||
c.disconnect();
|
||||
if (code < 200 || code >= 300) {
|
||||
throw new IllegalStateException("http_" + code + ":" + text);
|
||||
}
|
||||
return new JSONObject(text);
|
||||
}
|
||||
|
||||
private static String readBody(HttpURLConnection c) {
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
sb.append(line);
|
||||
if (sb.length() > 8192) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.foxx.androidcast.live;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||
import com.foxx.androidcast.network.BackendEndpoints;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Polls BE for active live cast sessions to highlight casting peers in discovery UI. */
|
||||
public final class LiveCastPeerTracker {
|
||||
private static final String TAG = "LiveCastPeerTracker";
|
||||
private static final long POLL_INTERVAL_SEC = 45L;
|
||||
private static final int HTTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
public interface Listener {
|
||||
void onLivePeersUpdated(Set<String> liveDeviceIds, Set<String> liveUsernames);
|
||||
}
|
||||
|
||||
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "live-cast-peer-poll");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private static final AtomicReference<ScheduledFuture<?>> POLL_TASK = new AtomicReference<>();
|
||||
private static final AtomicReference<Listener> LISTENER = new AtomicReference<>();
|
||||
private static final AtomicReference<String> API_URL = new AtomicReference<>("");
|
||||
|
||||
private LiveCastPeerTracker() {}
|
||||
|
||||
public static void start(Context context, Listener listener) {
|
||||
if (context == null || listener == null) {
|
||||
return;
|
||||
}
|
||||
LISTENER.set(listener);
|
||||
API_URL.set(resolveActiveUrl(context.getApplicationContext()));
|
||||
ScheduledFuture<?> prev = POLL_TASK.getAndSet(
|
||||
EXECUTOR.scheduleAtFixedRate(LiveCastPeerTracker::pollOnce, 0L, POLL_INTERVAL_SEC, TimeUnit.SECONDS));
|
||||
if (prev != null) {
|
||||
prev.cancel(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
ScheduledFuture<?> prev = POLL_TASK.getAndSet(null);
|
||||
if (prev != null) {
|
||||
prev.cancel(false);
|
||||
}
|
||||
LISTENER.set(null);
|
||||
}
|
||||
|
||||
private static void pollOnce() {
|
||||
Listener listener = LISTENER.get();
|
||||
String url = API_URL.get();
|
||||
if (listener == null || url == null || url.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<String> deviceIds = new HashSet<>();
|
||||
Set<String> usernames = new HashSet<>();
|
||||
JSONObject payload = getJson(url + "?action=active");
|
||||
if (payload != null && payload.optBoolean("ok", false)) {
|
||||
JSONArray sessions = payload.optJSONArray("sessions");
|
||||
if (sessions != null) {
|
||||
for (int i = 0; i < sessions.length(); i++) {
|
||||
JSONObject row = sessions.optJSONObject(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String status = row.optString("status", "");
|
||||
if (!"live".equals(status) && !"intent".equals(status)) {
|
||||
continue;
|
||||
}
|
||||
String deviceId = row.optString("device_id", "");
|
||||
if (!deviceId.isEmpty()) {
|
||||
deviceIds.add(deviceId);
|
||||
}
|
||||
String username = row.optString("owner_username", "");
|
||||
if (!username.isEmpty()) {
|
||||
usernames.add(username.toLowerCase(Locale.US));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.onLivePeersUpdated(Collections.unmodifiableSet(deviceIds),
|
||||
Collections.unmodifiableSet(usernames));
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "poll failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveActiveUrl(Context context) {
|
||||
String upload = CrashSettingsStore.load(context).uploadUrl;
|
||||
if (upload != null && !upload.trim().isEmpty()) {
|
||||
String base = BackendEndpoints.normalizeCrashesUrl(upload.trim());
|
||||
if (base.contains("/api/upload.php")) {
|
||||
return base.replace("/api/upload.php", "/api/live_cast.php");
|
||||
}
|
||||
if (base.endsWith("/")) {
|
||||
return base + "api/live_cast.php";
|
||||
}
|
||||
return base + "/api/live_cast.php";
|
||||
}
|
||||
return BackendEndpoints.CRASHES_BASE + "/api/live_cast.php";
|
||||
}
|
||||
|
||||
private static JSONObject getJson(String url) throws Exception {
|
||||
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
|
||||
c.setRequestMethod("GET");
|
||||
c.setConnectTimeout(HTTP_TIMEOUT_MS);
|
||||
c.setReadTimeout(HTTP_TIMEOUT_MS);
|
||||
int code = c.getResponseCode();
|
||||
String text = readBody(c);
|
||||
c.disconnect();
|
||||
if (code < 200 || code >= 300) {
|
||||
throw new IllegalStateException("http_" + code);
|
||||
}
|
||||
return new JSONObject(text);
|
||||
}
|
||||
|
||||
private static String readBody(HttpURLConnection c) {
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
sb.append(line);
|
||||
if (sb.length() > 16384) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import com.foxx.androidcast.media.codec.VideoCodecBackend;
|
||||
import com.foxx.androidcast.media.codec.VideoDecoderFactory;
|
||||
import com.foxx.androidcast.media.codec.VideoDecoderSink;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
import com.foxx.androidcast.live.LiveCastControlPlane;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
@@ -433,6 +434,9 @@ public class ReceiverCastService extends Service {
|
||||
sessionStatsRecorder.setCodecs(videoMime, "AAC");
|
||||
}
|
||||
negotiatedVideoMime = videoMime;
|
||||
String audioCodec = remoteSettings != null && remoteSettings.isAudioRequested() ? "AAC" : "none";
|
||||
LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec,
|
||||
settings.getBitrateMode().name().toLowerCase());
|
||||
remoteCastSettings = remoteSettings;
|
||||
streamIdle = false;
|
||||
streamMetrics.reset();
|
||||
@@ -882,6 +886,10 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
|
||||
private void finishSessionStatsAsync() {
|
||||
String video = negotiatedVideoMime != null ? negotiatedVideoMime : "";
|
||||
String audio = Boolean.TRUE.equals(audioAccepted) ? "AAC" : "none";
|
||||
String bwMode = settings != null ? settings.getBitrateMode().name().toLowerCase() : "";
|
||||
LiveCastControlPlane.onCastStop(video, audio, bwMode);
|
||||
final SessionStatsRecorder recorder = sessionStatsRecorder;
|
||||
sessionStatsRecorder = null;
|
||||
if (recorder == null) {
|
||||
|
||||
@@ -23,8 +23,10 @@ import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** Device list with single or multi selection. */
|
||||
@@ -35,6 +37,8 @@ public class DeviceListAdapter extends BaseAdapter {
|
||||
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
||||
private int selectedPosition = -1;
|
||||
private final Set<Integer> selectedPositions = new LinkedHashSet<>();
|
||||
private Set<String> liveDeviceIds = Collections.emptySet();
|
||||
private Set<String> liveUsernames = Collections.emptySet();
|
||||
|
||||
public DeviceListAdapter(Context context, boolean multiSelect) {
|
||||
this.context = context;
|
||||
@@ -99,6 +103,25 @@ public class DeviceListAdapter extends BaseAdapter {
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
public void setLiveIndicators(Set<String> deviceIds, Set<String> usernames) {
|
||||
liveDeviceIds = deviceIds != null ? deviceIds : Collections.emptySet();
|
||||
liveUsernames = usernames != null ? usernames : Collections.emptySet();
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
private boolean isLiveCasting(DiscoveryManager.DiscoveredDevice d) {
|
||||
if (d == null) {
|
||||
return false;
|
||||
}
|
||||
if (d.deviceUuid != null && !d.deviceUuid.isEmpty() && liveDeviceIds.contains(d.deviceUuid)) {
|
||||
return true;
|
||||
}
|
||||
if (d.name != null && !d.name.isEmpty()) {
|
||||
return liveUsernames.contains(d.name.toLowerCase(Locale.US));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return devices.size();
|
||||
@@ -129,6 +152,11 @@ public class DeviceListAdapter extends BaseAdapter {
|
||||
row.setActivated(selected);
|
||||
row.setSelected(selected);
|
||||
label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL);
|
||||
if (isLiveCasting(d)) {
|
||||
label.setBackgroundResource(R.drawable.device_item_live_border);
|
||||
} else {
|
||||
label.setBackgroundResource(R.drawable.device_item_background);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.media.codec.VideoCodecFactory;
|
||||
import com.foxx.androidcast.media.codec.VideoEncoderSink;
|
||||
import com.foxx.androidcast.media.codec.AudioEncoderSink;
|
||||
import com.foxx.androidcast.live.LiveCastControlPlane;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
@@ -298,6 +299,10 @@ public class ScreenCastService extends Service implements
|
||||
casting.set(true);
|
||||
CastActiveState.setSenderCasting(true);
|
||||
CastTrayNotifier.refresh(this);
|
||||
LiveCastControlPlane.onCastStart(this, "android_sender",
|
||||
settings.getNegotiatedVideoMime(),
|
||||
PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)),
|
||||
settings.getBitrateMode().name().toLowerCase());
|
||||
activeSettings = settings;
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
if (sessionStatsRecorder == null) {
|
||||
@@ -1053,6 +1058,14 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
|
||||
private void finishSessionStatsAsync() {
|
||||
String vCodec = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : "";
|
||||
String aCodec = activeSettings != null
|
||||
? PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(activeSettings))
|
||||
: "";
|
||||
String bwMode = activeSettings != null
|
||||
? activeSettings.getBitrateMode().name().toLowerCase()
|
||||
: "";
|
||||
LiveCastControlPlane.onCastStop(vCodec, aCodec, bwMode);
|
||||
final SessionStatsRecorder recorder = sessionStatsRecorder;
|
||||
sessionStatsRecorder = null;
|
||||
if (recorder == null) {
|
||||
|
||||
@@ -40,6 +40,7 @@ import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.PermissionHelper;
|
||||
import com.foxx.androidcast.PlatformCastSupport;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.live.LiveCastPeerTracker;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
import com.foxx.androidcast.discovery.PinnedReceiversStore;
|
||||
import com.foxx.androidcast.discovery.ReceiverCompatibility;
|
||||
@@ -218,6 +219,11 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
findViewById(R.id.panel_preview).post(this::maybeRequestPreviewConsent);
|
||||
}
|
||||
PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
|
||||
LiveCastPeerTracker.start(this, (deviceIds, usernames) -> runOnUiThread(() -> {
|
||||
if (deviceAdapter != null) {
|
||||
deviceAdapter.setLiveIndicators(deviceIds, usernames);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void publishFilteredDevices() {
|
||||
@@ -277,6 +283,7 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
LiveCastPeerTracker.stop();
|
||||
if (capturePreview != null) {
|
||||
capturePreview.pause();
|
||||
}
|
||||
|
||||
14
app/src/main/res/drawable/device_item_live_border.xml
Normal file
14
app/src/main/res/drawable/device_item_live_border.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="3dp"
|
||||
android:color="@color/device_live_border" />
|
||||
<corners android:radius="6dp" />
|
||||
<padding
|
||||
android:left="11dp"
|
||||
android:top="11dp"
|
||||
android:right="11dp"
|
||||
android:bottom="11dp" />
|
||||
</shape>
|
||||
@@ -2,6 +2,7 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1565C0</color>
|
||||
<color name="device_item_selected">#331565C0</color>
|
||||
<color name="device_live_border">#FF34D399</color>
|
||||
<!-- Section headers: blue (left) → dark (right) -->
|
||||
<color name="settings_header_gradient_start">#FF1565C0</color>
|
||||
<color name="settings_header_gradient_end">#FF212121</color>
|
||||
|
||||
40
docs/DRs/20260620_live_cast_control_plane.md
Normal file
40
docs/DRs/20260620_live_cast_control_plane.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Live Cast Control Plane (P0-P2 kickoff)
|
||||
|
||||
## Scope implemented
|
||||
|
||||
- New BE API `POST/GET .../api/live_cast.php`:
|
||||
- `create_intent`, `heartbeat` (60s KA), `join_event`
|
||||
- `GET action=active|analytics|session|list`
|
||||
- Public (no login): `session`, `join_event`, education `create_intent`/`heartbeat`
|
||||
- New BE repository `LiveCastRepository` with auto-schema:
|
||||
- `live_cast_sessions`, `live_cast_join_events`
|
||||
- Expiry rule: sessions without heartbeat for 60s become `expired`.
|
||||
- Short links + QR per session (join + direct) via internal short-links service.
|
||||
- Analytics payload includes `live_cast` section; shell label **Analytics**.
|
||||
- Web pages:
|
||||
- `/live/join` — viewer entry, join_event tracking, player placeholder
|
||||
- `/live/education` — 5-minute browser screen-share demo
|
||||
- `?view=live_sessions` — session tree (read-only, auto-refresh)
|
||||
- Cookie consent banner (`cookie_consent.js`) gates GA4 until user accepts.
|
||||
- Mobile:
|
||||
- `LiveCastControlPlane` — start/heartbeat/stop from sender/receiver services
|
||||
- `LiveCastPeerTracker` — polls `active` sessions; green border on discovery list
|
||||
|
||||
## Non-goals (remaining)
|
||||
|
||||
- WebRTC/SFU media plane and real HTML5 playback.
|
||||
- Push notification delivery (Web Push / FCM).
|
||||
- Gravatar / refined avatar chrome on web lists.
|
||||
- Short-link click counter migration in url-shortener submodule.
|
||||
|
||||
## Data model notes
|
||||
|
||||
`live_cast_sessions` stores owner/device/platform, state transitions, short URLs, codec/BW hints.
|
||||
`live_cast_join_events` stores open/join/leave events with platform/channel/direct flags and basic anon dimensions.
|
||||
|
||||
## Follow-up phases
|
||||
|
||||
1. Integrate SFU/web viewer when media-plane branch is ready.
|
||||
2. Browser/mobile notification fan-out on cast start.
|
||||
3. Gravatar + green-border on web peer lists.
|
||||
4. Shortener click stats joined into live session analytics.
|
||||
114
examples/crash_reporter/backend/public/api/live_cast.php
Normal file
114
examples/crash_reporter/backend/public/api/live_cast.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$action = '';
|
||||
$body = [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = trim((string) ($_GET['action'] ?? 'active'));
|
||||
} else {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
$action = trim((string) ($body['action'] ?? ''));
|
||||
}
|
||||
|
||||
$public = ($method === 'GET' && $action === 'session')
|
||||
|| ($method === 'POST' && $action === 'join_event');
|
||||
|
||||
if ($method === 'POST' && in_array($action, ['create_intent', 'heartbeat'], true)) {
|
||||
$platform = strtolower(trim((string) ($body['platform'] ?? '')));
|
||||
if ($action === 'create_intent' && str_starts_with($platform, 'education')) {
|
||||
$public = true;
|
||||
}
|
||||
if ($action === 'heartbeat') {
|
||||
$sid = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sid !== '') {
|
||||
$existing = LiveCastRepository::sessionById($sid);
|
||||
$existingPlatform = strtolower((string) ($existing['platform'] ?? ''));
|
||||
if ($existing !== null && str_starts_with($existingPlatform, 'education')) {
|
||||
$public = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$public) {
|
||||
Auth::check();
|
||||
}
|
||||
$actor = Auth::user() ?? [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($action === 'active') {
|
||||
$platform = trim((string) ($_GET['platform'] ?? ''));
|
||||
$rows = LiveCastRepository::activeSessions($actor, $platform !== '' ? $platform : null);
|
||||
json_out(['ok' => true, 'sessions' => $rows]);
|
||||
}
|
||||
if ($action === 'analytics') {
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
$payload = LiveCastRepository::analytics($days, $actor);
|
||||
json_out(['ok' => true, 'data' => $payload]);
|
||||
}
|
||||
if ($action === 'list') {
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
$limit = (int) ($_GET['limit'] ?? 200);
|
||||
$rows = LiveCastRepository::listSessions($days, $actor, $limit);
|
||||
json_out(['ok' => true, 'sessions' => $rows]);
|
||||
}
|
||||
if ($action === 'session') {
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$row = LiveCastRepository::publicSession($sessionId);
|
||||
if ($row === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
if ($action === 'create_intent') {
|
||||
$row = LiveCastRepository::createIntent($body, $actor);
|
||||
if ($row === []) {
|
||||
json_out(['ok' => false, 'error' => 'create_failed'], 500);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
|
||||
if ($action === 'heartbeat') {
|
||||
$sessionId = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$row = LiveCastRepository::heartbeat($sessionId, $body, $actor);
|
||||
if ($row === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
|
||||
if ($action === 'join_event') {
|
||||
$sessionId = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$ok = LiveCastRepository::registerJoin($sessionId, $body, $actor);
|
||||
if (!$ok) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
@@ -2217,3 +2217,147 @@ button.report-tag--filter:hover {
|
||||
max-width: 72ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Live cast pages */
|
||||
.live-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-shell, #0f1419);
|
||||
}
|
||||
.live-shell {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
.live-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.live-brand {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
text-decoration: none;
|
||||
}
|
||||
.live-card h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.live-meta-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.live-meta-list li {
|
||||
padding: 4px 0;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.live-player-wrap,
|
||||
.live-player-shell {
|
||||
margin-top: 16px;
|
||||
min-height: 200px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
padding: 16px;
|
||||
}
|
||||
.live-player-placeholder {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.live-preview {
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
border-radius: 10px;
|
||||
background: #000;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.live-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.live-timer-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Cookie consent */
|
||||
.cookie-consent {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1200;
|
||||
padding: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cookie-consent-inner {
|
||||
pointer-events: auto;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--card-bg, rgba(30, 41, 59, 0.96));
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.cookie-consent-title {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cookie-consent-text {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cookie-consent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Nav icons: live + education */
|
||||
.nav-icon--live::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 7px 11px 11px 7px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nav-icon--live::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
box-shadow: 0 0 0 2px var(--nav-bg, #111827);
|
||||
}
|
||||
.nav-icon--education::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
height: 10px;
|
||||
border: 2px solid currentColor;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.nav-icon--education::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.tag-pill--ok {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
}
|
||||
|
||||
function enabled() {
|
||||
if (global.AndroidCastCookieConsent && !global.AndroidCastCookieConsent.allowsAnalytics()) {
|
||||
var choice = global.AndroidCastCookieConsent.getChoice && global.AndroidCastCookieConsent.getChoice();
|
||||
if (choice && choice !== 'all') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId);
|
||||
}
|
||||
|
||||
@@ -120,17 +126,23 @@
|
||||
|
||||
function init() {
|
||||
if (!enabled()) {
|
||||
log('disabled (no measurement id)');
|
||||
log('disabled (no measurement id or cookie consent)');
|
||||
return;
|
||||
}
|
||||
pageView();
|
||||
if (platform === 'crashes') {
|
||||
if (platform === 'crashes' || String(platform).indexOf('live_') === 0) {
|
||||
bindConsoleView();
|
||||
} else if (platform === 'hub') {
|
||||
bindHubInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
global.addEventListener('ac-cookie-consent', function () {
|
||||
if (enabled()) {
|
||||
init();
|
||||
}
|
||||
});
|
||||
|
||||
global.AndroidCastAnalytics = {
|
||||
enabled: enabled,
|
||||
pageView: pageView,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Cookie consent banner — stores choice in localStorage (ac_cookie_consent).
|
||||
* Values: all | necessary | reject
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'ac_cookie_consent';
|
||||
var banner = null;
|
||||
|
||||
function getChoice() {
|
||||
try {
|
||||
var v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === 'all' || v === 'necessary' || v === 'reject') {
|
||||
return v;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function setChoice(value) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
} catch (e) { /* ignore */ }
|
||||
global.dispatchEvent(new CustomEvent('ac-cookie-consent', { detail: { choice: value } }));
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
function allowsAnalytics() {
|
||||
return getChoice() === 'all';
|
||||
}
|
||||
|
||||
function allowsPreferenceCookies() {
|
||||
var c = getChoice();
|
||||
return c === 'all' || c === 'necessary';
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
if (banner) {
|
||||
banner.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBanner() {
|
||||
if (banner || getChoice()) {
|
||||
return;
|
||||
}
|
||||
banner = document.getElementById('cookie-consent-banner');
|
||||
if (!banner) {
|
||||
return;
|
||||
}
|
||||
banner.hidden = false;
|
||||
var allBtn = document.getElementById('cookie-consent-all');
|
||||
var necBtn = document.getElementById('cookie-consent-necessary');
|
||||
var rejBtn = document.getElementById('cookie-consent-reject');
|
||||
if (allBtn) {
|
||||
allBtn.addEventListener('click', function () { setChoice('all'); });
|
||||
}
|
||||
if (necBtn) {
|
||||
necBtn.addEventListener('click', function () { setChoice('necessary'); });
|
||||
}
|
||||
if (rejBtn) {
|
||||
rejBtn.addEventListener('click', function () { setChoice('reject'); });
|
||||
}
|
||||
}
|
||||
|
||||
global.AndroidCastCookieConsent = {
|
||||
getChoice: getChoice,
|
||||
allowsAnalytics: allowsAnalytics,
|
||||
allowsPreferenceCookies: allowsPreferenceCookies,
|
||||
setChoice: setChoice
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ensureBanner);
|
||||
} else {
|
||||
ensureBanner();
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -19,9 +19,18 @@
|
||||
'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' },
|
||||
fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top issue fingerprints' },
|
||||
'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' },
|
||||
'live-casts': { type: 'line', field: 'casts_per_day', title: 'Live casts / day', color: COLORS[0] },
|
||||
'live-avg-length': { type: 'line', field: 'avg_length_s_per_day', title: 'Avg cast length (s) / day', color: COLORS[2] },
|
||||
'live-platform-pie': { type: 'pie', field: 'platform_mix', title: 'Cast platforms' },
|
||||
'live-join-platform-pie': { type: 'pie', field: 'joins_platform_mix', title: 'Join platforms' },
|
||||
'live-video-codec-pie': { type: 'pie', field: 'video_codec_mix', title: 'Video codecs' },
|
||||
'live-audio-codec-pie': { type: 'pie', field: 'audio_codec_mix', title: 'Audio codecs' },
|
||||
'live-bw-pie': { type: 'pie', field: 'bw_mode_mix', title: 'Bandwidth modes' },
|
||||
'live-top-users': { type: 'toplist', field: 'top_users', title: 'Top casters' },
|
||||
};
|
||||
|
||||
let graphScopes = { user: null, slug: null, platform: null };
|
||||
let liveCastScope = null;
|
||||
let detailState = null;
|
||||
|
||||
function basePath() {
|
||||
@@ -84,12 +93,20 @@
|
||||
|
||||
function parseCanvasId(id) {
|
||||
const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/);
|
||||
if (!m) return null;
|
||||
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
|
||||
return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null };
|
||||
if (m) {
|
||||
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
|
||||
return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null };
|
||||
}
|
||||
const live = String(id || '').match(/^graph-live-(.+)$/);
|
||||
if (live) {
|
||||
const suffix = 'live-' + live[1];
|
||||
return { scopeKey: 'live_cast', suffix: suffix, def: BRICK_DEFS[suffix] || null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scopeData(scopeKey) {
|
||||
if (scopeKey === 'live_cast') return liveCastScope;
|
||||
if (scopeKey === 'user') return graphScopes.user;
|
||||
if (scopeKey === 'slug') return graphScopes.slug;
|
||||
return graphScopes.platform;
|
||||
@@ -446,6 +463,24 @@
|
||||
drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version);
|
||||
}
|
||||
|
||||
function renderLiveCast(scope) {
|
||||
if (!scope) {
|
||||
const section = document.getElementById('graphs-live-cast-section');
|
||||
if (section) section.hidden = true;
|
||||
return;
|
||||
}
|
||||
const section = document.getElementById('graphs-live-cast-section');
|
||||
if (section) section.hidden = false;
|
||||
drawLineChart(el('graph-live-casts'), scope.casts_per_day, COLORS[0]);
|
||||
drawLineChart(el('graph-live-avg-length'), scope.avg_length_s_per_day, COLORS[2]);
|
||||
drawPie(el('graph-live-platform-pie'), scope.platform_mix);
|
||||
drawPie(el('graph-live-join-platform-pie'), scope.joins_platform_mix);
|
||||
drawPie(el('graph-live-video-codec-pie'), scope.video_codec_mix);
|
||||
drawPie(el('graph-live-audio-codec-pie'), scope.audio_codec_mix);
|
||||
drawPie(el('graph-live-bw-pie'), scope.bw_mode_mix);
|
||||
renderTopList(el('graph-live-top-users'), scope.top_users);
|
||||
}
|
||||
|
||||
function setScopeVisibility(viewer) {
|
||||
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
|
||||
const need = section.getAttribute('data-graph-scope');
|
||||
@@ -536,6 +571,9 @@
|
||||
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
|
||||
const def = BRICK_DEFS[m[2]];
|
||||
empty = !fieldHasData(def, scopeData(scopeKey));
|
||||
} else if (brickId.startsWith('live-')) {
|
||||
const def = BRICK_DEFS[brickId];
|
||||
empty = !fieldHasData(def, liveCastScope);
|
||||
} else if (canvas && canvas.id) {
|
||||
const parsed = parseCanvasId(canvas.id);
|
||||
empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey));
|
||||
@@ -1013,10 +1051,12 @@
|
||||
graphScopes.user = data.user || null;
|
||||
graphScopes.slug = data.slug_admin || null;
|
||||
graphScopes.platform = data.platform_admin || null;
|
||||
liveCastScope = data.live_cast || null;
|
||||
setScopeVisibility(viewer);
|
||||
renderScope('user', graphScopes.user);
|
||||
if (graphScopes.slug) renderScope('slug', graphScopes.slug);
|
||||
if (graphScopes.platform) renderScope('platform', graphScopes.platform);
|
||||
renderLiveCast(liveCastScope);
|
||||
markGraphBricks();
|
||||
hideEmptyBricks();
|
||||
applyBrickOrder();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MAX_DEMO_S = 300;
|
||||
var timer = null;
|
||||
var secondsLeft = MAX_DEMO_S;
|
||||
var mediaStream = null;
|
||||
var sessionId = '';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function detectPlatform() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Firefox\//i.test(ua)) return 'firefox';
|
||||
if (/Edg\//i.test(ua)) return 'edge';
|
||||
if (/Chrome\//i.test(ua)) return 'chrome';
|
||||
if (/Safari\//i.test(ua)) return 'safari';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
return basePath() + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function postJson(body, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(), true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
cb(payload);
|
||||
};
|
||||
xhr.onerror = function () { cb(null); };
|
||||
xhr.send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var node = el('edu-status');
|
||||
if (node) node.textContent = text;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
var node = el('edu-timer');
|
||||
if (!node) return;
|
||||
var m = Math.floor(secondsLeft / 60);
|
||||
var s = secondsLeft % 60;
|
||||
node.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
function stopDemo() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(function (t) { t.stop(); });
|
||||
mediaStream = null;
|
||||
}
|
||||
var preview = el('edu-preview');
|
||||
if (preview) preview.srcObject = null;
|
||||
if (sessionId) {
|
||||
postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {});
|
||||
sessionId = '';
|
||||
}
|
||||
el('edu-start').disabled = false;
|
||||
el('edu-stop').disabled = true;
|
||||
setStatus('Demo ended. Thanks for trying AndroidCast education mode.');
|
||||
}
|
||||
|
||||
function startDemo() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
setStatus('Screen sharing is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
setStatus('Requesting screen share permission…');
|
||||
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||
.then(function (stream) {
|
||||
mediaStream = stream;
|
||||
var preview = el('edu-preview');
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
preview.srcObject = stream;
|
||||
preview.play().catch(function () {});
|
||||
}
|
||||
stream.getVideoTracks()[0].addEventListener('ended', stopDemo);
|
||||
setStatus('Creating demo session…');
|
||||
postJson({
|
||||
action: 'create_intent',
|
||||
platform: 'education_' + detectPlatform(),
|
||||
max_duration_s: MAX_DEMO_S,
|
||||
codec_video: 'browser_screen',
|
||||
codec_audio: 'none',
|
||||
bw_mode: 'demo'
|
||||
}, function (resp) {
|
||||
if (!resp || !resp.ok || !resp.session) {
|
||||
setStatus('Could not register demo session. Sign in or try again later.');
|
||||
stopDemo();
|
||||
return;
|
||||
}
|
||||
sessionId = resp.session.session_id || '';
|
||||
secondsLeft = MAX_DEMO_S;
|
||||
updateTimer();
|
||||
el('edu-start').disabled = true;
|
||||
el('edu-stop').disabled = false;
|
||||
setStatus('Live demo — max ' + MAX_DEMO_S / 60 + ' minutes.');
|
||||
timer = setInterval(function () {
|
||||
secondsLeft -= 1;
|
||||
updateTimer();
|
||||
if (sessionId && secondsLeft % 60 === 0) {
|
||||
postJson({ action: 'heartbeat', session_id: sessionId, status: 'active' }, function () {});
|
||||
}
|
||||
if (secondsLeft <= 0) {
|
||||
stopDemo();
|
||||
}
|
||||
}, 1000);
|
||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||
window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() });
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
setStatus('Screen share permission denied or cancelled.');
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!el('live-education-app')) return;
|
||||
updateTimer();
|
||||
var startBtn = el('edu-start');
|
||||
var stopBtn = el('edu-stop');
|
||||
if (startBtn) startBtn.addEventListener('click', startDemo);
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.addEventListener('click', stopDemo);
|
||||
}
|
||||
var notifBtn = el('edu-notify');
|
||||
if (notifBtn && 'Notification' in window) {
|
||||
notifBtn.addEventListener('click', function () {
|
||||
Notification.requestPermission().then(function (perm) {
|
||||
setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.');
|
||||
});
|
||||
});
|
||||
} else if (notifBtn) {
|
||||
notifBtn.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
171
examples/crash_reporter/backend/public/assets/js/live_join.js
Normal file
171
examples/crash_reporter/backend/public/assets/js/live_join.js
Normal file
@@ -0,0 +1,171 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function detectPlatform() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||
if (/Firefox\//i.test(ua)) return 'firefox';
|
||||
if (/Edg\//i.test(ua)) return 'edge';
|
||||
if (/Chrome\//i.test(ua)) return 'chrome';
|
||||
if (/Safari\//i.test(ua)) return 'safari';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function anonId() {
|
||||
var key = 'ac_live_anon_id';
|
||||
try {
|
||||
var existing = localStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
localStorage.setItem(key, id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
return 'anon-' + Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
return basePath() + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function postJoinEvent(sessionId, eventType, direct) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(), true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr.send(JSON.stringify({
|
||||
action: 'join_event',
|
||||
session_id: sessionId,
|
||||
event_type: eventType,
|
||||
platform: detectPlatform(),
|
||||
channel: direct ? 'direct_link' : 'viewer_link',
|
||||
direct: !!direct,
|
||||
anon_id: anonId()
|
||||
}));
|
||||
}
|
||||
|
||||
function fetchSession(sessionId, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true);
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
cb(payload && payload.ok ? payload.session : null);
|
||||
};
|
||||
xhr.onerror = function () { cb(null); };
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function formatStatus(session) {
|
||||
if (!session) return 'Unknown';
|
||||
var st = session.status || '';
|
||||
if (st === 'live' || st === 'intent') return 'Live';
|
||||
if (st === 'closed') return 'Ended';
|
||||
if (st === 'expired') return 'Expired';
|
||||
return st;
|
||||
}
|
||||
|
||||
function boot() {
|
||||
var root = el('live-join-app');
|
||||
if (!root) return;
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || '';
|
||||
var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1';
|
||||
var role = params.get('role') || (direct ? 'direct' : 'viewer');
|
||||
|
||||
if (!sessionId) {
|
||||
el('live-join-status').textContent = 'Missing session_id in URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
postJoinEvent(sessionId, 'open', direct);
|
||||
|
||||
var statusEl = el('live-join-status');
|
||||
var metaEl = el('live-join-meta');
|
||||
var playerEl = el('live-join-player');
|
||||
|
||||
function render(session) {
|
||||
if (!session) {
|
||||
statusEl.textContent = 'Session not found or no longer available.';
|
||||
return;
|
||||
}
|
||||
var live = session.status === 'live' || session.status === 'intent';
|
||||
statusEl.textContent = formatStatus(session) + (live ? ' — waiting for stream' : '');
|
||||
var owner = session.owner_username || 'host';
|
||||
var lines = [
|
||||
'Host: ' + owner,
|
||||
'Platform: ' + (session.platform || '—'),
|
||||
'Video: ' + (session.codec_video || '—'),
|
||||
'Audio: ' + (session.codec_audio || '—'),
|
||||
'Bandwidth: ' + (session.bw_mode || '—')
|
||||
];
|
||||
metaEl.innerHTML = lines.map(function (l) {
|
||||
return '<li>' + escapeHtml(l) + '</li>';
|
||||
}).join('');
|
||||
|
||||
if (direct && /android|ios/i.test(detectPlatform())) {
|
||||
playerEl.innerHTML =
|
||||
'<p class="muted">Open in the AndroidCast app to join this P2P session.</p>' +
|
||||
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1</code></p>';
|
||||
postJoinEvent(sessionId, 'join', true);
|
||||
} else if (role === 'viewer' || !direct) {
|
||||
playerEl.innerHTML =
|
||||
'<div class="live-player-shell">' +
|
||||
'<p class="live-player-placeholder">HTML5 viewer placeholder</p>' +
|
||||
'<p class="muted">WebRTC media plane is not wired yet. Session control plane is active.</p>' +
|
||||
'</div>';
|
||||
postJoinEvent(sessionId, 'join', false);
|
||||
} else {
|
||||
playerEl.innerHTML = '<p class="muted">Use a mobile device with direct=1 for P2P join.</p>';
|
||||
}
|
||||
|
||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||
window.AndroidCastAnalytics.trackEvent('live_join_view', {
|
||||
session_id: sessionId,
|
||||
direct: direct ? 1 : 0,
|
||||
platform: detectPlatform(),
|
||||
status: session.status || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchSession(sessionId, render);
|
||||
setInterval(function () {
|
||||
fetchSession(sessionId, function (session) {
|
||||
if (session && (session.status === 'live' || session.status === 'intent')) {
|
||||
statusEl.textContent = formatStatus(session) + ' — waiting for stream';
|
||||
} else if (session) {
|
||||
statusEl.textContent = formatStatus(session);
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (!ms) return '—';
|
||||
try {
|
||||
return new Date(ms).toLocaleString();
|
||||
} catch (e) {
|
||||
return String(ms);
|
||||
}
|
||||
}
|
||||
|
||||
function durationS(startMs, endMs) {
|
||||
if (!startMs) return '—';
|
||||
var end = endMs || Date.now();
|
||||
var sec = Math.max(0, Math.round((end - startMs) / 1000));
|
||||
if (sec < 60) return sec + 's';
|
||||
return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok';
|
||||
if (status === 'expired') return 'tag-pill tag-pill--warn';
|
||||
return 'tag-pill';
|
||||
}
|
||||
|
||||
function renderRows(sessions) {
|
||||
var tbody = el('live-sessions-tbody');
|
||||
if (!tbody) return;
|
||||
if (!sessions || !sessions.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">No sessions in this window.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = sessions.map(function (s) {
|
||||
var joinUrl = s.join_short_url || '';
|
||||
var joinCell = joinUrl
|
||||
? '<a href="' + escapeHtml(joinUrl) + '" target="_blank" rel="noopener">Open</a>'
|
||||
: '—';
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td><span class="' + statusClass(s.status) + '">' + escapeHtml(s.status) + '</span></td>' +
|
||||
'<td>' + escapeHtml(s.owner_username || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.platform || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.codec_video || '—') + '</td>' +
|
||||
'<td>' + durationS(s.started_at_ms, s.ended_at_ms) + '</td>' +
|
||||
'<td>' + formatMs(s.last_heartbeat_ms) + '</td>' +
|
||||
'<td>' + Number(s.join_opens || 0) + '</td>' +
|
||||
'<td>' + joinCell + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function load() {
|
||||
var status = el('live-sessions-status');
|
||||
var daysSel = el('live-sessions-days');
|
||||
var days = daysSel && daysSel.value ? daysSel.value : '14';
|
||||
if (status) status.textContent = 'Loading…';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true);
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
if (status) status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.ok) {
|
||||
if (status) status.textContent = (payload && payload.error) || 'Failed';
|
||||
return;
|
||||
}
|
||||
renderRows(payload.sessions || []);
|
||||
if (status) {
|
||||
status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (status) status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!el('live-sessions-app')) return;
|
||||
var daysSel = el('live-sessions-days');
|
||||
if (daysSel) daysSel.addEventListener('change', load);
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -105,6 +105,21 @@ if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_ac
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php')) {
|
||||
require __DIR__ . '/api/live_cast.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/live/join' || str_ends_with($route, '/live/join')) {
|
||||
require __DIR__ . '/../views/live_join.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/live/education' || str_ends_with($route, '/live/education')) {
|
||||
require __DIR__ . '/../views/live_education.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) {
|
||||
require __DIR__ . '/api/short_links.php';
|
||||
exit;
|
||||
@@ -375,7 +390,8 @@ if ($view === 'ticket' && isset($_GET['id'])) {
|
||||
$pageTitle = match ($view) {
|
||||
'home' => 'Home',
|
||||
'tickets' => 'Tickets',
|
||||
'graphs' => 'Graphs',
|
||||
'graphs' => 'Analytics',
|
||||
'live_sessions' => 'Live sessions',
|
||||
'remote_access' => 'Remote access',
|
||||
'short_links' => 'Short links',
|
||||
'rbac' => 'Access control',
|
||||
|
||||
80
examples/crash_reporter/backend/scripts/test_live_cast_api.sh
Executable file
80
examples/crash_reporter/backend/scripts/test_live_cast_api.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test live cast API (public join + authenticated list).
|
||||
#
|
||||
# ./scripts/test_live_cast_api.sh
|
||||
# COOKIE='ac_crash_sess=…' CRASH_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_live_cast_api.sh
|
||||
set -euo pipefail
|
||||
|
||||
CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
|
||||
API_URL="${CRASH_BASE}/api/live_cast.php"
|
||||
COOKIE_JAR="${TMPDIR:-/tmp}/live_cast_cookies_$$.txt"
|
||||
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||
|
||||
CURL_AUTH=()
|
||||
if [[ -n "${COOKIE:-}" ]]; then
|
||||
CURL_AUTH=(-H "Cookie: ${COOKIE}")
|
||||
else
|
||||
user="${CRASH_USER:-admin}"
|
||||
pass="${CRASH_PASS:-admin}"
|
||||
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
|
||||
-d "username=${user}&password=${pass}" \
|
||||
"${CRASH_BASE}/login" -o /dev/null
|
||||
CURL_AUTH=(-b "$COOKIE_JAR")
|
||||
fi
|
||||
|
||||
sid="smoke-live-$(date +%s)"
|
||||
echo "POST create_intent (${sid})"
|
||||
create_code="$(curl -sS -o /tmp/live_create.json -w '%{http_code}' \
|
||||
"${CURL_AUTH[@]}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"action\":\"create_intent\",\"platform\":\"android\",\"device_id\":\"smoke-device\",\"max_duration_s\":120}" \
|
||||
"${API_URL}")"
|
||||
echo "create HTTP ${create_code}"
|
||||
cat /tmp/live_create.json
|
||||
echo
|
||||
|
||||
session_id="$(python3 - <<'PY'
|
||||
import json
|
||||
try:
|
||||
d=json.load(open("/tmp/live_create.json"))
|
||||
print(d.get("session",{}).get("session_id",""))
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
)"
|
||||
if [[ -z "${session_id}" ]]; then
|
||||
echo "FAIL: no session_id in create response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GET public session"
|
||||
pub_code="$(curl -sS -o /tmp/live_session.json -w '%{http_code}' \
|
||||
"${API_URL}?action=session&session_id=${session_id}")"
|
||||
echo "session HTTP ${pub_code}"
|
||||
head -c 300 /tmp/live_session.json
|
||||
echo
|
||||
|
||||
echo "POST public join_event"
|
||||
join_code="$(curl -sS -o /tmp/live_join.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"action\":\"join_event\",\"session_id\":\"${session_id}\",\"event_type\":\"open\",\"platform\":\"chrome\"}" \
|
||||
"${API_URL}")"
|
||||
echo "join HTTP ${join_code}"
|
||||
cat /tmp/live_join.json
|
||||
echo
|
||||
|
||||
echo "GET list (auth)"
|
||||
list_code="$(curl -sS -o /tmp/live_list.json -w '%{http_code}' \
|
||||
"${CURL_AUTH[@]}" \
|
||||
"${API_URL}?action=list&days=7")"
|
||||
echo "list HTTP ${list_code}"
|
||||
head -c 300 /tmp/live_list.json
|
||||
echo
|
||||
|
||||
for code in "${create_code}" "${pub_code}" "${join_code}" "${list_code}"; do
|
||||
if [[ "${code}" != "200" ]]; then
|
||||
echo "FAIL: expected HTTP 200" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK"
|
||||
@@ -302,6 +302,7 @@ final class GraphRepository {
|
||||
'viewer' => $viewer,
|
||||
'links' => self::serviceLinks(),
|
||||
];
|
||||
$payload['live_cast'] = LiveCastRepository::analytics($days, $user ?? []);
|
||||
$payload['user'] = self::buildScope($days, $activeCompanyId, null);
|
||||
if ($viewer === 'user') {
|
||||
return $payload;
|
||||
|
||||
583
examples/crash_reporter/backend/src/LiveCastRepository.php
Normal file
583
examples/crash_reporter/backend/src/LiveCastRepository.php
Normal file
@@ -0,0 +1,583 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LiveCastRepository {
|
||||
private static bool $schemaEnsured = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
Database::withMigrationLock($pdo, 'live_cast_sessions', static function () use ($pdo): void {
|
||||
self::createTables($pdo);
|
||||
self::ensureIndexes($pdo);
|
||||
});
|
||||
self::$schemaEnsured = true;
|
||||
}
|
||||
|
||||
private static function createTables(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'live_cast_sessions')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
owner_user_id INT UNSIGNED NULL,
|
||||
owner_username VARCHAR(64) NULL,
|
||||
device_id VARCHAR(128) NULL,
|
||||
platform VARCHAR(24) NOT NULL DEFAULT 'android',
|
||||
status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent',
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
last_heartbeat_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NULL,
|
||||
max_duration_s INT NOT NULL DEFAULT 300,
|
||||
join_short_url TEXT NULL,
|
||||
join_qr_url TEXT NULL,
|
||||
join_slug VARCHAR(32) NULL,
|
||||
direct_short_url TEXT NULL,
|
||||
direct_qr_url TEXT NULL,
|
||||
direct_slug VARCHAR(32) NULL,
|
||||
codec_video VARCHAR(32) NULL,
|
||||
codec_audio VARCHAR(32) NULL,
|
||||
bw_mode VARCHAR(24) NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_live_cast_session_id (session_id),
|
||||
KEY idx_live_cast_company_status (company_id, status),
|
||||
KEY idx_live_cast_last_hb (last_heartbeat_ms),
|
||||
KEY idx_live_cast_owner (owner_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
owner_user_id INTEGER NULL,
|
||||
owner_username TEXT NULL,
|
||||
device_id TEXT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'android',
|
||||
status TEXT NOT NULL DEFAULT 'intent',
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
last_heartbeat_ms INTEGER NOT NULL,
|
||||
ended_at_ms INTEGER NULL,
|
||||
max_duration_s INTEGER NOT NULL DEFAULT 300,
|
||||
join_short_url TEXT NULL,
|
||||
join_qr_url TEXT NULL,
|
||||
join_slug TEXT NULL,
|
||||
direct_short_url TEXT NULL,
|
||||
direct_qr_url TEXT NULL,
|
||||
direct_slug TEXT NULL,
|
||||
codec_video TEXT NULL,
|
||||
codec_audio TEXT NULL,
|
||||
bw_mode TEXT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Database::tableExists($pdo, 'live_cast_join_events')) {
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_join_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open',
|
||||
platform VARCHAR(24) NOT NULL DEFAULT 'unknown',
|
||||
channel VARCHAR(24) NOT NULL DEFAULT 'short_link',
|
||||
direct TINYINT(1) NOT NULL DEFAULT 0,
|
||||
user_id INT UNSIGNED NULL,
|
||||
anon_id VARCHAR(96) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
ip_hash CHAR(64) NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_live_join_session_time (session_id, created_at_ms),
|
||||
KEY idx_live_join_company_time (company_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE live_cast_join_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
event_type TEXT NOT NULL DEFAULT 'open',
|
||||
platform TEXT NOT NULL DEFAULT 'unknown',
|
||||
channel TEXT NOT NULL DEFAULT 'short_link',
|
||||
direct INTEGER NOT NULL DEFAULT 0,
|
||||
user_id INTEGER NULL,
|
||||
anon_id TEXT NULL,
|
||||
user_agent TEXT NULL,
|
||||
ip_hash TEXT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureIndexes(PDO $pdo): void {
|
||||
if (Database::isMysql()) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function createIntent(array $input, array $actor): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$userId = (int) ($actor['id'] ?? 0);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$ownerUsername = trim((string) ($actor['username'] ?? ''));
|
||||
$platform = strtolower(trim((string) ($input['platform'] ?? 'android')));
|
||||
if ($platform === '') {
|
||||
$platform = 'android';
|
||||
}
|
||||
$deviceId = trim((string) ($input['device_id'] ?? ''));
|
||||
$maxDuration = (int) ($input['max_duration_s'] ?? 300);
|
||||
$maxDuration = max(60, min(1800, $maxDuration));
|
||||
if (str_starts_with($platform, 'education')) {
|
||||
$maxDuration = min($maxDuration, 300);
|
||||
}
|
||||
|
||||
$sessionId = self::newSessionId($now, $companyId, $userId);
|
||||
$joinLongUrl = self::joinLongUrl($sessionId, false);
|
||||
$directLongUrl = self::joinLongUrl($sessionId, true);
|
||||
|
||||
$links = self::makeShortLinks($joinLongUrl, $directLongUrl);
|
||||
$meta = is_array($input['meta'] ?? null) ? $input['meta'] : [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_sessions (
|
||||
session_id, company_id, owner_user_id, owner_username, device_id, platform, status,
|
||||
started_at_ms, last_heartbeat_ms, max_duration_s,
|
||||
join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug,
|
||||
codec_video, codec_audio, bw_mode, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$userId > 0 ? $userId : null,
|
||||
$ownerUsername !== '' ? $ownerUsername : null,
|
||||
$deviceId !== '' ? $deviceId : null,
|
||||
$platform,
|
||||
'intent',
|
||||
$now,
|
||||
$now,
|
||||
$maxDuration,
|
||||
$links['join_short_url'],
|
||||
$links['join_qr_url'],
|
||||
$links['join_slug'],
|
||||
$links['direct_short_url'],
|
||||
$links['direct_qr_url'],
|
||||
$links['direct_slug'],
|
||||
trim((string) ($input['codec_video'] ?? '')) ?: null,
|
||||
trim((string) ($input['codec_audio'] ?? '')) ?: null,
|
||||
trim((string) ($input['bw_mode'] ?? '')) ?: null,
|
||||
safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
return self::sessionById($sessionId) ?? [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function heartbeat(string $sessionId, array $input, array $actor): ?array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$status = strtolower(trim((string) ($input['status'] ?? 'active')));
|
||||
if ($status !== 'ended' && $status !== 'active') {
|
||||
$status = 'active';
|
||||
}
|
||||
$nextState = $status === 'ended' ? 'closed' : 'live';
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE live_cast_sessions
|
||||
SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END,
|
||||
codec_video = COALESCE(NULLIF(?, \'\'), codec_video),
|
||||
codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio),
|
||||
bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode)
|
||||
WHERE session_id = ? AND company_id = ?'
|
||||
);
|
||||
$stmt->execute([
|
||||
$nextState,
|
||||
$now,
|
||||
$nextState,
|
||||
'closed',
|
||||
$now,
|
||||
trim((string) ($input['codec_video'] ?? '')),
|
||||
trim((string) ($input['codec_audio'] ?? '')),
|
||||
trim((string) ($input['bw_mode'] ?? '')),
|
||||
$sessionId,
|
||||
$companyId,
|
||||
]);
|
||||
if ($stmt->rowCount() < 1) {
|
||||
return null;
|
||||
}
|
||||
return self::sessionById($sessionId);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function activeSessions(array $actor, ?string $platform = null): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$pdo = Database::pdo();
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$where = ['company_id = ?', "status IN ('intent','live')"];
|
||||
$params = [$companyId];
|
||||
if ($platform !== null && trim($platform) !== '') {
|
||||
$where[] = 'platform = ?';
|
||||
$params[] = strtolower(trim($platform));
|
||||
}
|
||||
$sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status,
|
||||
started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms,
|
||||
join_short_url, join_qr_url, direct_short_url, direct_qr_url,
|
||||
codec_video, codec_audio, bw_mode
|
||||
FROM live_cast_sessions
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY last_heartbeat_ms DESC LIMIT 200';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = self::normalizeSessionRow($row);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function analytics(int $days, array $actor): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$days = max(1, min(90, $days));
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$pdo = Database::pdo();
|
||||
|
||||
$castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId);
|
||||
$avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId);
|
||||
$platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId);
|
||||
$videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId);
|
||||
$audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId);
|
||||
$bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId);
|
||||
$joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId);
|
||||
|
||||
$topUsersStmt = $pdo->prepare(
|
||||
'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c
|
||||
FROM live_cast_sessions
|
||||
WHERE company_id = ? AND started_at_ms >= ?
|
||||
GROUP BY u ORDER BY c DESC LIMIT 20'
|
||||
);
|
||||
$topUsersStmt->execute([$companyId, $startMs]);
|
||||
$topUsers = [];
|
||||
foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
|
||||
return [
|
||||
'window_days' => $days,
|
||||
'casts_per_day' => $castsPerDay,
|
||||
'avg_length_s_per_day' => $avgDurationPerDay,
|
||||
'platform_mix' => $platformMix,
|
||||
'joins_platform_mix' => $joinsPerPlatform,
|
||||
'video_codec_mix' => $videoCodecMix,
|
||||
'audio_codec_mix' => $audioCodecMix,
|
||||
'bw_mode_mix' => $bwModeMix,
|
||||
'top_users' => $topUsers,
|
||||
];
|
||||
}
|
||||
|
||||
public static function registerJoin(string $sessionId, array $payload, array $actor): bool {
|
||||
self::ensureSchema();
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '') {
|
||||
return false;
|
||||
}
|
||||
$session = self::sessionById($sessionId);
|
||||
if ($session === null) {
|
||||
return false;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId());
|
||||
$eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open')));
|
||||
if (!in_array($eventType, ['open', 'join', 'leave'], true)) {
|
||||
$eventType = 'open';
|
||||
}
|
||||
$platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown')));
|
||||
if ($platform === '') {
|
||||
$platform = 'unknown';
|
||||
}
|
||||
$channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link')));
|
||||
if ($channel === '') {
|
||||
$channel = 'short_link';
|
||||
}
|
||||
$direct = !empty($payload['direct']) ? 1 : 0;
|
||||
$userId = (int) ($actor['id'] ?? 0);
|
||||
$anon = trim((string) ($payload['anon_id'] ?? ''));
|
||||
$ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO live_cast_join_events
|
||||
(session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$eventType,
|
||||
$platform,
|
||||
$channel,
|
||||
$direct,
|
||||
$userId > 0 ? $userId : null,
|
||||
$anon !== '' ? $anon : null,
|
||||
$ua !== '' ? substr($ua, 0, 255) : null,
|
||||
$ipHash,
|
||||
$now,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function sessionById(string $sessionId): ?array {
|
||||
self::ensureSchema();
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$sessionId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
return self::normalizeSessionRow($row);
|
||||
}
|
||||
|
||||
private static function expireStale(): void {
|
||||
$pdo = Database::pdo();
|
||||
$now = (int) floor(microtime(true) * 1000);
|
||||
$staleBefore = $now - 60_000;
|
||||
$sql = "UPDATE live_cast_sessions
|
||||
SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?)
|
||||
WHERE status IN ('intent','live')
|
||||
AND last_heartbeat_ms < ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$now, $staleBefore]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function normalizeSessionRow(array $row): array {
|
||||
return [
|
||||
'session_id' => (string) ($row['session_id'] ?? ''),
|
||||
'company_id' => (int) ($row['company_id'] ?? 0),
|
||||
'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null,
|
||||
'owner_username' => (string) ($row['owner_username'] ?? ''),
|
||||
'device_id' => (string) ($row['device_id'] ?? ''),
|
||||
'platform' => (string) ($row['platform'] ?? ''),
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'started_at_ms' => (int) ($row['started_at_ms'] ?? 0),
|
||||
'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0),
|
||||
'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null,
|
||||
'max_duration_s' => (int) ($row['max_duration_s'] ?? 0),
|
||||
'join_short_url' => (string) ($row['join_short_url'] ?? ''),
|
||||
'join_qr_url' => (string) ($row['join_qr_url'] ?? ''),
|
||||
'direct_short_url' => (string) ($row['direct_short_url'] ?? ''),
|
||||
'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''),
|
||||
'codec_video' => (string) ($row['codec_video'] ?? ''),
|
||||
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
|
||||
'bw_mode' => (string) ($row['bw_mode'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function newSessionId(int $nowMs, int $companyId, int $userId): string {
|
||||
$seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6));
|
||||
return 'live-' . substr(hash('sha256', $seed), 0, 20);
|
||||
}
|
||||
|
||||
private static function joinLongUrl(string $sessionId, bool $direct): string {
|
||||
$base = rtrim((string) cfg('base_path', '/app/androidcast_project/crashes'), '/');
|
||||
$qs = 'session_id=' . rawurlencode($sessionId);
|
||||
if ($direct) {
|
||||
$qs .= '&direct=1';
|
||||
}
|
||||
return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs;
|
||||
}
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array {
|
||||
$out = [
|
||||
'join_short_url' => null,
|
||||
'join_qr_url' => null,
|
||||
'join_slug' => null,
|
||||
'direct_short_url' => null,
|
||||
'direct_qr_url' => null,
|
||||
'direct_slug' => null,
|
||||
];
|
||||
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||
return $out;
|
||||
}
|
||||
$bearers = ShortLinksRepository::listBearers();
|
||||
if ($bearers === []) {
|
||||
return $out;
|
||||
}
|
||||
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
||||
if ($bearerId <= 0) {
|
||||
return $out;
|
||||
}
|
||||
$join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600);
|
||||
if (!empty($join['ok'])) {
|
||||
$out['join_short_url'] = (string) ($join['short_url'] ?? '');
|
||||
$out['join_qr_url'] = (string) ($join['qr_url'] ?? '');
|
||||
$out['join_slug'] = (string) ($join['slug'] ?? '');
|
||||
}
|
||||
$direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600);
|
||||
if (!empty($direct['ok'])) {
|
||||
$out['direct_short_url'] = (string) ($direct['short_url'] ?? '');
|
||||
$out['direct_qr_url'] = (string) ($direct['qr_url'] ?? '');
|
||||
$out['direct_slug'] = (string) ($direct['slug'] ?? '');
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array{x:string,y:int}> */
|
||||
private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array {
|
||||
$day = Database::isMysql()
|
||||
? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))'
|
||||
: "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')";
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . '
|
||||
WHERE company_id = ? AND ' . $msColumn . ' >= ?
|
||||
GROUP BY d ORDER BY d'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array{x:string,y:float}> */
|
||||
private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array {
|
||||
$day = Database::isMysql()
|
||||
? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))'
|
||||
: "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')";
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT ' . $day . ' AS d,
|
||||
AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms
|
||||
THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v
|
||||
FROM live_cast_sessions
|
||||
WHERE company_id = ? AND started_at_ms >= ?
|
||||
GROUP BY d ORDER BY d'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function listSessions(int $days, array $actor, int $limit = 200): array {
|
||||
self::ensureSchema();
|
||||
self::expireStale();
|
||||
$days = max(1, min(90, $days));
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
$companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId();
|
||||
$limit = max(1, min(500, $limit));
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status,
|
||||
s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s,
|
||||
s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode,
|
||||
(SELECT COUNT(*) FROM live_cast_join_events j
|
||||
WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens
|
||||
FROM live_cast_sessions s
|
||||
WHERE s.company_id = ? AND s.started_at_ms >= ?
|
||||
ORDER BY s.last_heartbeat_ms DESC
|
||||
LIMIT ' . (int) $limit
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$normalized = self::normalizeSessionRow($row);
|
||||
$normalized['join_opens'] = (int) ($row['join_opens'] ?? 0);
|
||||
$out[] = $normalized;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function publicSession(string $sessionId): ?array {
|
||||
$row = self::sessionById($sessionId);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'session_id' => $row['session_id'],
|
||||
'owner_username' => $row['owner_username'],
|
||||
'platform' => $row['platform'],
|
||||
'status' => $row['status'],
|
||||
'started_at_ms' => $row['started_at_ms'],
|
||||
'last_heartbeat_ms' => $row['last_heartbeat_ms'],
|
||||
'ended_at_ms' => $row['ended_at_ms'],
|
||||
'max_duration_s' => $row['max_duration_s'],
|
||||
'codec_video' => $row['codec_video'],
|
||||
'codec_audio' => $row['codec_audio'],
|
||||
'bw_mode' => $row['bw_mode'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array{label:string,value:int}> */
|
||||
private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array {
|
||||
$allowedTables = ['live_cast_sessions', 'live_cast_join_events'];
|
||||
if (!in_array($table, $allowedTables, true)) {
|
||||
return [];
|
||||
}
|
||||
$allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode'];
|
||||
if ($table === 'live_cast_join_events') {
|
||||
$allowedCols = ['platform'];
|
||||
}
|
||||
if (!in_array($column, $allowedCols, true)) {
|
||||
return [];
|
||||
}
|
||||
$timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms';
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c
|
||||
FROM ' . $table . '
|
||||
WHERE company_id = ? AND ' . $timeColumn . ' >= ?
|
||||
GROUP BY k ORDER BY c DESC'
|
||||
);
|
||||
$stmt->execute([$companyId, $startMs]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ require_once __DIR__ . '/UserRepository.php';
|
||||
require_once __DIR__ . '/TicketRepository.php';
|
||||
require_once __DIR__ . '/GraphRepository.php';
|
||||
require_once __DIR__ . '/RemoteAccessRepository.php';
|
||||
require_once __DIR__ . '/LiveCastRepository.php';
|
||||
require_once __DIR__ . '/UrlShortenerDatabase.php';
|
||||
require_once __DIR__ . '/ShortLinksRepository.php';
|
||||
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/cookie_consent.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/ticket_create.js" defer></script>
|
||||
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
|
||||
@@ -36,6 +37,9 @@
|
||||
<?php if (($view ?? '') === 'graphs'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php if (($view ?? '') === 'live_sessions'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/live_sessions.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php if (($view ?? '') === 'remote_access'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
|
||||
<?php endif; ?>
|
||||
@@ -106,15 +110,39 @@
|
||||
<li>
|
||||
<a href="/app/androidcast_project/graphs/"
|
||||
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
|
||||
aria-label="Graphs"
|
||||
title="Graphs">
|
||||
aria-label="Analytics"
|
||||
title="Analytics">
|
||||
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||
<span class="nav-text">
|
||||
<span class="nav-label" data-i18n="nav.graphs">Graphs</span>
|
||||
<span class="nav-label" data-i18n="nav.graphs">Analytics</span>
|
||||
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/?view=live_sessions"
|
||||
class="nav-link <?= ($view ?? '') === 'live_sessions' ? 'active' : '' ?>"
|
||||
aria-label="Live sessions"
|
||||
title="Live sessions">
|
||||
<span class="nav-icon nav-icon--live" aria-hidden="true"></span>
|
||||
<span class="nav-text">
|
||||
<span class="nav-label">Live sessions</span>
|
||||
<span class="nav-desc">Cast intents, join stats, and session history.</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/live/education"
|
||||
class="nav-link"
|
||||
aria-label="Education demo"
|
||||
title="Education demo">
|
||||
<span class="nav-icon nav-icon--education" aria-hidden="true"></span>
|
||||
<span class="nav-text">
|
||||
<span class="nav-label">Education demo</span>
|
||||
<span class="nav-desc">Browser screen-share trial (5 min).</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php if (Rbac::can('remote_access_view')): ?>
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
|
||||
@@ -325,7 +353,7 @@
|
||||
<?php elseif (($view ?? '') === 'graphs'): ?>
|
||||
<div id="graphs-app" class="reports-app graphs-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
<h1>AndroidCast graphs</h1>
|
||||
<h1>AndroidCast analytics</h1>
|
||||
<div class="toolbar-actions">
|
||||
<label class="toolbar-select">
|
||||
<span>Theme</span>
|
||||
@@ -401,6 +429,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graphs-scope" id="graphs-live-cast-section">
|
||||
<h2 class="graphs-scope-title">Live cast</h2>
|
||||
<div class="cards cards--lift graphs-grid">
|
||||
<article class="card card--lift"><h3>Live casts / day</h3><canvas id="graph-live-casts" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Avg cast length (s) / day</h3><canvas id="graph-live-avg-length" width="520" height="160"></canvas></article>
|
||||
<article class="card card--lift"><h3>Cast platforms</h3><canvas id="graph-live-platform-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Join platforms</h3><canvas id="graph-live-join-platform-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Video codecs</h3><canvas id="graph-live-video-codec-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Audio codecs</h3><canvas id="graph-live-audio-codec-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Bandwidth modes</h3><canvas id="graph-live-bw-pie" width="520" height="180"></canvas></article>
|
||||
<article class="card card--lift"><h3>Top casters</h3><div id="graph-live-top-users" class="graph-breakdown"></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
|
||||
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
|
||||
<header class="graph-detail-header">
|
||||
@@ -420,6 +462,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (($view ?? '') === 'live_sessions'): ?>
|
||||
<div id="live-sessions-app" class="reports-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
<h1>Live cast sessions</h1>
|
||||
<div class="toolbar-actions">
|
||||
<label class="toolbar-select">
|
||||
<span>Window</span>
|
||||
<select id="live-sessions-days" aria-label="Sessions window">
|
||||
<option value="1">1d</option>
|
||||
<option value="7">7d</option>
|
||||
<option value="14" selected>14d</option>
|
||||
<option value="30">30d</option>
|
||||
</select>
|
||||
</label>
|
||||
<a class="btn" href="<?= h(Auth::basePath()) ?>/live/education">Education demo</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||
<p id="live-sessions-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||
<p class="muted graphs-footnote">Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).</p>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table reports-table--cols" id="live-sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Platform</th>
|
||||
<th>Video</th>
|
||||
<th>Duration</th>
|
||||
<th>Last heartbeat</th>
|
||||
<th>Join opens</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="live-sessions-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (($view ?? '') === 'remote_access'): ?>
|
||||
<div id="remote-access-app" class="reports-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
@@ -739,6 +819,7 @@
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||
<?php platform_render_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
47
examples/crash_reporter/backend/views/live_education.php
Normal file
47
examples/crash_reporter/backend/views/live_education.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$bp = Auth::basePath();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Education demo — <?= h(cfg('app_name')) ?></title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('crash_console_theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
||||
<?php AnalyticsHead::render('live_education'); ?>
|
||||
<script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script>
|
||||
</head>
|
||||
<body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education">
|
||||
<main id="live-education-app" class="live-shell">
|
||||
<header class="live-header">
|
||||
<a href="<?= h($bp) ?>/" class="live-brand"><?= h(cfg('app_name')) ?></a>
|
||||
<span class="tag-pill">Education demo</span>
|
||||
</header>
|
||||
<section class="card card--lift live-card">
|
||||
<h1>Try screen sharing in your browser</h1>
|
||||
<p class="muted">Free demo up to 5 minutes. No install required — great for first-time visitors.</p>
|
||||
<p id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
|
||||
<p class="live-timer-label">Time left: <strong id="edu-timer">5:00</strong></p>
|
||||
<video id="edu-preview" class="live-preview" playsinline muted autoplay hidden></video>
|
||||
<div class="live-actions">
|
||||
<button type="button" class="btn btn--primary" id="edu-start">Start screen share</button>
|
||||
<button type="button" class="btn" id="edu-stop">Stop</button>
|
||||
<button type="button" class="btn btn--ghost" id="edu-notify">Enable notifications</button>
|
||||
</div>
|
||||
<p class="muted graphs-footnote">
|
||||
Media is previewed locally only until the WebRTC viewer ships. Session metadata is recorded for analytics.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||
<?php platform_render_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
45
examples/crash_reporter/backend/views/live_join.php
Normal file
45
examples/crash_reporter/backend/views/live_join.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$bp = Auth::basePath();
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
$direct = isset($_GET['direct']) && (string) $_GET['direct'] === '1';
|
||||
$role = trim((string) ($_GET['role'] ?? ''));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Join live cast — <?= h(cfg('app_name')) ?></title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('crash_console_theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
||||
<?php AnalyticsHead::render('live_join'); ?>
|
||||
<script src="<?= h($bp) ?>/assets/js/live_join.js" defer></script>
|
||||
</head>
|
||||
<body class="live-page" data-base-path="<?= h($bp) ?>"
|
||||
data-view="live_join">
|
||||
<main id="live-join-app" class="live-shell"
|
||||
data-session-id="<?= h($sessionId) ?>"
|
||||
data-direct="<?= $direct ? '1' : '0' ?>"
|
||||
data-role="<?= h($role) ?>">
|
||||
<header class="live-header">
|
||||
<a href="<?= h($bp) ?>/" class="live-brand"><?= h(cfg('app_name')) ?></a>
|
||||
<span class="tag-pill tag-pill--ok">Live viewer</span>
|
||||
</header>
|
||||
<section class="card card--lift live-card">
|
||||
<h1>Join live cast</h1>
|
||||
<p id="live-join-status" class="reports-status muted" aria-live="polite">Loading session…</p>
|
||||
<ul id="live-join-meta" class="live-meta-list"></ul>
|
||||
<div id="live-join-player" class="live-player-wrap"></div>
|
||||
</section>
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||
<?php platform_render_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php declare(strict_types=1); ?>
|
||||
<div id="cookie-consent-banner" class="cookie-consent" hidden role="dialog" aria-labelledby="cookie-consent-title" aria-modal="false">
|
||||
<div class="cookie-consent-inner">
|
||||
<p id="cookie-consent-title" class="cookie-consent-title">Cookies on AndroidCast</p>
|
||||
<p class="cookie-consent-text muted">
|
||||
We use necessary session cookies to sign you in. With your consent we also load analytics to improve the service.
|
||||
You can change this later in your browser by clearing site data.
|
||||
</p>
|
||||
<div class="cookie-consent-actions">
|
||||
<button type="button" class="btn btn--primary" id="cookie-consent-all">Accept all</button>
|
||||
<button type="button" class="btn" id="cookie-consent-necessary">Necessary only</button>
|
||||
<button type="button" class="btn btn--ghost" id="cookie-consent-reject">Reject optional</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user