1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:57:40 +03:00

be sync, migration, featuring pre-live web cast

This commit is contained in:
Anton Afanasyeu
2026-06-20 19:18:34 +02:00
parent b9f94fe005
commit 254d989e28
27 changed files with 2178 additions and 12 deletions

View File

@@ -69,9 +69,11 @@ public class DiscoveryManager {
public final String pinAuth; public final String pinAuth;
/** Shown from {@link PinnedReceiversStore} at top of sender list. */ /** Shown from {@link PinnedReceiversStore} at top of sender list. */
public boolean pinnedRecent; 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) { 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, 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, public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth) { 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.name = name;
this.host = host; this.host = host;
this.port = port; this.port = port;
@@ -91,6 +100,7 @@ public class DiscoveryManager {
this.placeholderId = placeholderId; this.placeholderId = placeholderId;
this.protoVersion = protoVersion; this.protoVersion = protoVersion;
this.pinAuth = pinAuth != null ? pinAuth : ""; this.pinAuth = pinAuth != null ? pinAuth : "";
this.deviceUuid = deviceUuid != null ? deviceUuid : "";
} }
public boolean pinMatches(String pin) { public boolean pinMatches(String pin) {
@@ -431,7 +441,8 @@ public class DiscoveryManager {
String pinAuth = json.optString("pin_auth", ""); String pinAuth = json.optString("pin_auth", "");
String key = host + ":" + port; String key = host + ":" + port;
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, 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); devices.put(key, device);
notifyListener(); notifyListener();
} catch (Exception ignored) { } catch (Exception ignored) {

View File

@@ -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 "";
}
}
}

View File

@@ -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 "";
}
}
}

View File

@@ -44,6 +44,7 @@ import com.foxx.androidcast.media.codec.VideoCodecBackend;
import com.foxx.androidcast.media.codec.VideoDecoderFactory; import com.foxx.androidcast.media.codec.VideoDecoderFactory;
import com.foxx.androidcast.media.codec.VideoDecoderSink; import com.foxx.androidcast.media.codec.VideoDecoderSink;
import com.foxx.androidcast.discovery.DiscoveryManager; 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.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge; import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
@@ -433,6 +434,9 @@ public class ReceiverCastService extends Service {
sessionStatsRecorder.setCodecs(videoMime, "AAC"); sessionStatsRecorder.setCodecs(videoMime, "AAC");
} }
negotiatedVideoMime = videoMime; negotiatedVideoMime = videoMime;
String audioCodec = remoteSettings != null && remoteSettings.isAudioRequested() ? "AAC" : "none";
LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec,
settings.getBitrateMode().name().toLowerCase());
remoteCastSettings = remoteSettings; remoteCastSettings = remoteSettings;
streamIdle = false; streamIdle = false;
streamMetrics.reset(); streamMetrics.reset();
@@ -882,6 +886,10 @@ public class ReceiverCastService extends Service {
} }
private void finishSessionStatsAsync() { 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; final SessionStatsRecorder recorder = sessionStatsRecorder;
sessionStatsRecorder = null; sessionStatsRecorder = null;
if (recorder == null) { if (recorder == null) {

View File

@@ -23,8 +23,10 @@ import com.foxx.androidcast.R;
import com.foxx.androidcast.discovery.DiscoveryManager; import com.foxx.androidcast.discovery.DiscoveryManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set; import java.util.Set;
/** Device list with single or multi selection. */ /** 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 final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private int selectedPosition = -1; private int selectedPosition = -1;
private final Set<Integer> selectedPositions = new LinkedHashSet<>(); 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) { public DeviceListAdapter(Context context, boolean multiSelect) {
this.context = context; this.context = context;
@@ -99,6 +103,25 @@ public class DeviceListAdapter extends BaseAdapter {
return list.isEmpty() ? null : list.get(0); 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 @Override
public int getCount() { public int getCount() {
return devices.size(); return devices.size();
@@ -129,6 +152,11 @@ public class DeviceListAdapter extends BaseAdapter {
row.setActivated(selected); row.setActivated(selected);
row.setSelected(selected); row.setSelected(selected);
label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL); 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; return row;
} }
} }

View File

@@ -62,6 +62,7 @@ import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.VideoCodecFactory; import com.foxx.androidcast.media.codec.VideoCodecFactory;
import com.foxx.androidcast.media.codec.VideoEncoderSink; import com.foxx.androidcast.media.codec.VideoEncoderSink;
import com.foxx.androidcast.media.codec.AudioEncoderSink; import com.foxx.androidcast.media.codec.AudioEncoderSink;
import com.foxx.androidcast.live.LiveCastControlPlane;
import com.foxx.androidcast.stats.SessionStatsContext; import com.foxx.androidcast.stats.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder; import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore; import com.foxx.androidcast.stats.SessionStatsStore;
@@ -298,6 +299,10 @@ public class ScreenCastService extends Service implements
casting.set(true); casting.set(true);
CastActiveState.setSenderCasting(true); CastActiveState.setSenderCasting(true);
CastTrayNotifier.refresh(this); CastTrayNotifier.refresh(this);
LiveCastControlPlane.onCastStart(this, "android_sender",
settings.getNegotiatedVideoMime(),
PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)),
settings.getBitrateMode().name().toLowerCase());
activeSettings = settings; activeSettings = settings;
if (AppPreferences.isGrabSessionStats(this)) { if (AppPreferences.isGrabSessionStats(this)) {
if (sessionStatsRecorder == null) { if (sessionStatsRecorder == null) {
@@ -1053,6 +1058,14 @@ public class ScreenCastService extends Service implements
} }
private void finishSessionStatsAsync() { 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; final SessionStatsRecorder recorder = sessionStatsRecorder;
sessionStatsRecorder = null; sessionStatsRecorder = null;
if (recorder == null) { if (recorder == null) {

View File

@@ -40,6 +40,7 @@ import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.PlatformCastSupport; import com.foxx.androidcast.PlatformCastSupport;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.live.LiveCastPeerTracker;
import com.foxx.androidcast.discovery.DiscoveryManager; import com.foxx.androidcast.discovery.DiscoveryManager;
import com.foxx.androidcast.discovery.PinnedReceiversStore; import com.foxx.androidcast.discovery.PinnedReceiversStore;
import com.foxx.androidcast.discovery.ReceiverCompatibility; import com.foxx.androidcast.discovery.ReceiverCompatibility;
@@ -218,6 +219,11 @@ public class SenderActivity extends DrawerHostActivity {
findViewById(R.id.panel_preview).post(this::maybeRequestPreviewConsent); findViewById(R.id.panel_preview).post(this::maybeRequestPreviewConsent);
} }
PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled()); PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
LiveCastPeerTracker.start(this, (deviceIds, usernames) -> runOnUiThread(() -> {
if (deviceAdapter != null) {
deviceAdapter.setLiveIndicators(deviceIds, usernames);
}
}));
} }
private void publishFilteredDevices() { private void publishFilteredDevices() {
@@ -277,6 +283,7 @@ public class SenderActivity extends DrawerHostActivity {
@Override @Override
protected void onPause() { protected void onPause() {
LiveCastPeerTracker.stop();
if (capturePreview != null) { if (capturePreview != null) {
capturePreview.pause(); capturePreview.pause();
} }

View 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>

View File

@@ -2,6 +2,7 @@
<resources> <resources>
<color name="ic_launcher_background">#1565C0</color> <color name="ic_launcher_background">#1565C0</color>
<color name="device_item_selected">#331565C0</color> <color name="device_item_selected">#331565C0</color>
<color name="device_live_border">#FF34D399</color>
<!-- Section headers: blue (left) → dark (right) --> <!-- Section headers: blue (left) → dark (right) -->
<color name="settings_header_gradient_start">#FF1565C0</color> <color name="settings_header_gradient_start">#FF1565C0</color>
<color name="settings_header_gradient_end">#FF212121</color> <color name="settings_header_gradient_end">#FF212121</color>

View 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.

View 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);

View File

@@ -2217,3 +2217,147 @@ button.report-tag--filter:hover {
max-width: 72ch; max-width: 72ch;
line-height: 1.5; 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;
}

View File

@@ -18,6 +18,12 @@
} }
function enabled() { 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); return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId);
} }
@@ -120,17 +126,23 @@
function init() { function init() {
if (!enabled()) { if (!enabled()) {
log('disabled (no measurement id)'); log('disabled (no measurement id or cookie consent)');
return; return;
} }
pageView(); pageView();
if (platform === 'crashes') { if (platform === 'crashes' || String(platform).indexOf('live_') === 0) {
bindConsoleView(); bindConsoleView();
} else if (platform === 'hub') { } else if (platform === 'hub') {
bindHubInteractions(); bindHubInteractions();
} }
} }
global.addEventListener('ac-cookie-consent', function () {
if (enabled()) {
init();
}
});
global.AndroidCastAnalytics = { global.AndroidCastAnalytics = {
enabled: enabled, enabled: enabled,
pageView: pageView, pageView: pageView,

View File

@@ -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);

View File

@@ -19,9 +19,18 @@
'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' }, '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' }, 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' }, '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 graphScopes = { user: null, slug: null, platform: null };
let liveCastScope = null;
let detailState = null; let detailState = null;
function basePath() { function basePath() {
@@ -84,12 +93,20 @@
function parseCanvasId(id) { function parseCanvasId(id) {
const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/); const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/);
if (!m) return null; if (m) {
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null }; 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) { function scopeData(scopeKey) {
if (scopeKey === 'live_cast') return liveCastScope;
if (scopeKey === 'user') return graphScopes.user; if (scopeKey === 'user') return graphScopes.user;
if (scopeKey === 'slug') return graphScopes.slug; if (scopeKey === 'slug') return graphScopes.slug;
return graphScopes.platform; return graphScopes.platform;
@@ -446,6 +463,24 @@
drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version); 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) { function setScopeVisibility(viewer) {
document.querySelectorAll('[data-graph-scope]').forEach((section) => { document.querySelectorAll('[data-graph-scope]').forEach((section) => {
const need = section.getAttribute('data-graph-scope'); const need = section.getAttribute('data-graph-scope');
@@ -536,6 +571,9 @@
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
const def = BRICK_DEFS[m[2]]; const def = BRICK_DEFS[m[2]];
empty = !fieldHasData(def, scopeData(scopeKey)); empty = !fieldHasData(def, scopeData(scopeKey));
} else if (brickId.startsWith('live-')) {
const def = BRICK_DEFS[brickId];
empty = !fieldHasData(def, liveCastScope);
} else if (canvas && canvas.id) { } else if (canvas && canvas.id) {
const parsed = parseCanvasId(canvas.id); const parsed = parseCanvasId(canvas.id);
empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey)); empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey));
@@ -1013,10 +1051,12 @@
graphScopes.user = data.user || null; graphScopes.user = data.user || null;
graphScopes.slug = data.slug_admin || null; graphScopes.slug = data.slug_admin || null;
graphScopes.platform = data.platform_admin || null; graphScopes.platform = data.platform_admin || null;
liveCastScope = data.live_cast || null;
setScopeVisibility(viewer); setScopeVisibility(viewer);
renderScope('user', graphScopes.user); renderScope('user', graphScopes.user);
if (graphScopes.slug) renderScope('slug', graphScopes.slug); if (graphScopes.slug) renderScope('slug', graphScopes.slug);
if (graphScopes.platform) renderScope('platform', graphScopes.platform); if (graphScopes.platform) renderScope('platform', graphScopes.platform);
renderLiveCast(liveCastScope);
markGraphBricks(); markGraphBricks();
hideEmptyBricks(); hideEmptyBricks();
applyBrickOrder(); applyBrickOrder();

View File

@@ -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();
}
})();

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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) + '&amp;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();
}
})();

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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();
}
})();

View File

@@ -105,6 +105,21 @@ if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_ac
exit; 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')) { if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) {
require __DIR__ . '/api/short_links.php'; require __DIR__ . '/api/short_links.php';
exit; exit;
@@ -375,7 +390,8 @@ if ($view === 'ticket' && isset($_GET['id'])) {
$pageTitle = match ($view) { $pageTitle = match ($view) {
'home' => 'Home', 'home' => 'Home',
'tickets' => 'Tickets', 'tickets' => 'Tickets',
'graphs' => 'Graphs', 'graphs' => 'Analytics',
'live_sessions' => 'Live sessions',
'remote_access' => 'Remote access', 'remote_access' => 'Remote access',
'short_links' => 'Short links', 'short_links' => 'Short links',
'rbac' => 'Access control', 'rbac' => 'Access control',

View 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"

View File

@@ -302,6 +302,7 @@ final class GraphRepository {
'viewer' => $viewer, 'viewer' => $viewer,
'links' => self::serviceLinks(), 'links' => self::serviceLinks(),
]; ];
$payload['live_cast'] = LiveCastRepository::analytics($days, $user ?? []);
$payload['user'] = self::buildScope($days, $activeCompanyId, null); $payload['user'] = self::buildScope($days, $activeCompanyId, null);
if ($viewer === 'user') { if ($viewer === 'user') {
return $payload; return $payload;

View 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;
}
}

View File

@@ -64,6 +64,7 @@ require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php'; require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php'; require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php'; require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/LiveCastRepository.php';
require_once __DIR__ . '/UrlShortenerDatabase.php'; require_once __DIR__ . '/UrlShortenerDatabase.php';
require_once __DIR__ . '/ShortLinksRepository.php'; require_once __DIR__ . '/ShortLinksRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php'; require_once __DIR__ . '/WireGuardPeerProvisioner.php';

View File

@@ -29,6 +29,7 @@
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script> <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/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.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> <script src="<?= h(Auth::basePath()) ?>/assets/js/ticket_create.js" defer></script>
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?> <?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script> <script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
@@ -36,6 +37,9 @@
<?php if (($view ?? '') === 'graphs'): ?> <?php if (($view ?? '') === 'graphs'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script> <script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
<?php endif; ?> <?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'): ?> <?php if (($view ?? '') === 'remote_access'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script> <script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
<?php endif; ?> <?php endif; ?>
@@ -106,15 +110,39 @@
<li> <li>
<a href="/app/androidcast_project/graphs/" <a href="/app/androidcast_project/graphs/"
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>" class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
aria-label="Graphs" aria-label="Analytics"
title="Graphs"> title="Analytics">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span> <span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<span class="nav-text"> <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 class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
</span> </span>
</a> </a>
</li> </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')): ?> <?php if (Rbac::can('remote_access_view')): ?>
<li> <li>
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access" <a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
@@ -325,7 +353,7 @@
<?php elseif (($view ?? '') === 'graphs'): ?> <?php elseif (($view ?? '') === 'graphs'): ?>
<div id="graphs-app" class="reports-app graphs-app"> <div id="graphs-app" class="reports-app graphs-app">
<div class="toolbar reports-toolbar"> <div class="toolbar reports-toolbar">
<h1>AndroidCast graphs</h1> <h1>AndroidCast analytics</h1>
<div class="toolbar-actions"> <div class="toolbar-actions">
<label class="toolbar-select"> <label class="toolbar-select">
<span>Theme</span> <span>Theme</span>
@@ -401,6 +429,20 @@
</div> </div>
</section> </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 id="graph-detail-overlay" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title"> <div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
<header class="graph-detail-header"> <header class="graph-detail-header">
@@ -420,6 +462,44 @@
</div> </div>
</div> </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'): ?> <?php elseif (($view ?? '') === 'remote_access'): ?>
<div id="remote-access-app" class="reports-app"> <div id="remote-access-app" class="reports-app">
<div class="toolbar reports-toolbar"> <div class="toolbar reports-toolbar">
@@ -739,6 +819,7 @@
</footer> </footer>
</form> </form>
</dialog> </dialog>
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
<?php platform_render_footer(); ?> <?php platform_render_footer(); ?>
</body> </body>
</html> </html>

View 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>

View 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>

View File

@@ -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>