diff --git a/examples/crash_reporter/backend/public/api/live_cast_signaling.php b/examples/crash_reporter/backend/public/api/live_cast_signaling.php new file mode 100644 index 0000000..da26ea9 --- /dev/null +++ b/examples/crash_reporter/backend/public/api/live_cast_signaling.php @@ -0,0 +1,84 @@ + false, 'error' => 'invalid_json'], 400); + } + $sessionId = trim((string) ($body['session_id'] ?? $sessionId)); +} + +if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); +} + +$session = LiveCastRepository::sessionById($sessionId); +if ($session === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); +} + +$platform = strtolower((string) ($session['platform'] ?? '')); +$status = strtolower((string) ($session['status'] ?? '')); +$isPublic = str_starts_with($platform, 'education') + || in_array($status, ['intent', 'live'], true); + +if (!$isPublic) { + Auth::check(); +} + +if ($method === 'GET') { + $action = trim((string) ($_GET['action'] ?? 'poll')); + if ($action === 'latest') { + $latest = LiveCastSignalingRepository::latest( + $sessionId, + $msgType !== '' ? $msgType : 'offer', + $fromRole !== '' ? $fromRole : null + ); + json_out(['ok' => true, 'message' => $latest]); + } + $messages = LiveCastSignalingRepository::poll( + $sessionId, + $sinceId, + $msgType !== '' ? $msgType : null, + $fromRole !== '' ? $fromRole : null + ); + $lastId = $sinceId; + foreach ($messages as $m) { + $lastId = max($lastId, (int) ($m['id'] ?? 0)); + } + json_out(['ok' => true, 'messages' => $messages, 'since_id' => $lastId]); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +$action = trim((string) ($body['action'] ?? 'post')); +if ($action !== 'post') { + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +$peerRole = trim((string) ($body['peer_role'] ?? 'caster')); +$type = trim((string) ($body['msg_type'] ?? '')); +$payload = $body['payload'] ?? null; +if (!is_array($payload)) { + $payload = ['sdp' => (string) ($body['sdp'] ?? ''), 'type' => (string) ($body['type'] ?? '')]; +} +if ($type === '') { + json_out(['ok' => false, 'error' => 'missing_msg_type'], 400); +} + +$id = LiveCastSignalingRepository::post($sessionId, $peerRole, $type, $payload); +json_out(['ok' => true, 'id' => $id]); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 8406f61..e8cda5e 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -2383,3 +2383,41 @@ button.report-tag--filter:hover { margin-top: 8px; font-size: 0.88rem; } + +.live-share { + margin-top: 16px; + padding: 16px; +} +.live-share h2 { + margin: 0 0 8px; + font-size: 1.05rem; +} +.live-stats-nerds { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.72); + border: 1px solid rgba(148, 163, 184, 0.25); + font-size: 0.78rem; + line-height: 1.45; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +[data-theme="light"] .live-stats-nerds { + background: rgba(241, 245, 249, 0.95); + border-color: rgba(100, 116, 139, 0.35); +} +.live-player-wrap video.live-preview { + width: 100%; + max-height: 60vh; + background: #000; + border-radius: 8px; +} +.platform-footer__left a { + color: var(--accent, #5b8def); + text-decoration: none; +} +.platform-footer__left a:hover, +.platform-footer__left a:focus-visible { + text-decoration: underline; +} diff --git a/examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js b/examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js new file mode 100644 index 0000000..4e98a09 --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js @@ -0,0 +1,256 @@ +(function (global) { + 'use strict'; + + var STUN = [{ urls: 'stun:stun.l.google.com:19302' }]; + + function signalingUrl(basePath) { + return basePath + '/api/live_cast_signaling.php'; + } + + function castApiUrl(basePath) { + return basePath + '/api/live_cast.php'; + } + + function xhrJson(method, url, body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + if (body != null) { + 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(body != null ? JSON.stringify(body) : null); + } + + function postSignal(basePath, sessionId, peerRole, msgType, payload, cb) { + xhrJson('POST', signalingUrl(basePath), { + action: 'post', + session_id: sessionId, + peer_role: peerRole, + msg_type: msgType, + payload: payload + }, cb); + } + + function pollSignals(basePath, sessionId, sinceId, fromRole, cb) { + var qs = '?action=poll&session_id=' + encodeURIComponent(sessionId) + + '&since_id=' + encodeURIComponent(String(sinceId || 0)); + if (fromRole) { + qs += '&from_role=' + encodeURIComponent(fromRole); + } + xhrJson('GET', signalingUrl(basePath) + qs, null, cb); + } + + function collectStats(pc, videoEl) { + var stats = { + ice_state: pc ? pc.iceConnectionState : 'new', + signaling_state: pc ? pc.signalingState : 'stable', + fps: 0, + inbound_kbps: 0, + outbound_kbps: 0, + rtt_ms: 0, + frames_decoded: 0 + }; + if (videoEl && videoEl.videoWidth) { + stats.frame_w = videoEl.videoWidth; + stats.frame_h = videoEl.videoHeight; + } + return stats; + } + + function enrichStatsFromRtc(pc, stats, cb) { + if (!pc || !pc.getStats) { + cb(stats); + return; + } + pc.getStats().then(function (report) { + report.forEach(function (r) { + if (r.type === 'inbound-rtp' && r.kind === 'video') { + stats.frames_decoded = r.framesDecoded || stats.frames_decoded; + if (r.bytesReceived && r.timestamp) { + stats.inbound_kbps = Math.round((r.bytesReceived * 8) / 1000); + } + } + if (r.type === 'outbound-rtp' && r.kind === 'video') { + if (r.bytesSent) { + stats.outbound_kbps = Math.round((r.bytesSent * 8) / 1000); + } + } + if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime) { + stats.rtt_ms = Math.round(r.currentRoundTripTime * 1000); + } + }); + cb(stats); + }).catch(function () { cb(stats); }); + } + + function sendHeartbeat(basePath, sessionId, status, stats, cb) { + var body = { action: 'heartbeat', session_id: sessionId, status: status || 'active' }; + if (stats) body.stats = stats; + xhrJson('POST', castApiUrl(basePath), body, cb || function () {}); + } + + /** + * @param {object} opts + * @param {string} opts.basePath + * @param {string} opts.sessionId + * @param {'caster'|'viewer'|'mobile'} opts.role + * @param {MediaStream|null} opts.localStream + * @param {function(MediaStream)} [opts.onRemoteStream] + * @param {function(object)} [opts.onStats] + * @param {function(string)} [opts.onStatus] + */ + function connect(opts) { + var basePath = opts.basePath || ''; + var sessionId = opts.sessionId || ''; + var role = opts.role || 'viewer'; + var localStream = opts.localStream || null; + var onRemoteStream = opts.onRemoteStream || function () {}; + var onStats = opts.onStats || function () {}; + var onStatus = opts.onStatus || function () {}; + var remoteRole = role === 'caster' ? 'viewer' : 'caster'; + var sinceId = 0; + var pc = new RTCPeerConnection({ iceServers: STUN }); + var pollTimer = null; + var hbTimer = null; + var remoteVideo = null; + var stopped = false; + + function status(msg) { + onStatus(msg); + } + + function teardown() { + stopped = true; + if (pollTimer) clearInterval(pollTimer); + if (hbTimer) clearInterval(hbTimer); + pollTimer = null; + hbTimer = null; + try { pc.close(); } catch (e) { /* ignore */ } + } + + pc.onicecandidate = function (ev) { + if (!ev.candidate) return; + postSignal(basePath, sessionId, role, 'candidate', { + candidate: ev.candidate.candidate, + sdpMid: ev.candidate.sdpMid, + sdpMLineIndex: ev.candidate.sdpMLineIndex + }, function () {}); + }; + + pc.ontrack = function (ev) { + var stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]); + onRemoteStream(stream); + }; + + pc.oniceconnectionstatechange = function () { + status('ICE: ' + pc.iceConnectionState); + }; + + if (localStream) { + localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); }); + } + + function applyRemoteDescription(payload, cb) { + if (!payload || !payload.type || !payload.sdp) { + cb(false); + return; + } + pc.setRemoteDescription(payload).then(function () { + cb(true); + }).catch(function () { cb(false); }); + } + + function handlePollMessages(messages) { + if (!messages || !messages.length) return; + messages.forEach(function (msg) { + sinceId = Math.max(sinceId, msg.id || 0); + var payload = msg.payload || {}; + if (msg.msg_type === 'offer' && role === 'viewer') { + applyRemoteDescription(payload, function (ok) { + if (!ok) return; + pc.createAnswer().then(function (answer) { + return pc.setLocalDescription(answer); + }).then(function () { + postSignal(basePath, sessionId, role, 'answer', pc.localDescription, function () {}); + status('Answer sent — connecting…'); + }).catch(function () { + status('Could not create WebRTC answer.'); + }); + }); + } else if (msg.msg_type === 'answer' && role === 'caster') { + applyRemoteDescription(payload, function (ok) { + if (ok) status('Viewer connected — streaming.'); + }); + } else if (msg.msg_type === 'candidate' && payload.candidate) { + pc.addIceCandidate({ + candidate: payload.candidate, + sdpMid: payload.sdpMid, + sdpMLineIndex: payload.sdpMLineIndex + }).catch(function () {}); + } + }); + } + + pollTimer = setInterval(function () { + if (stopped) return; + pollSignals(basePath, sessionId, sinceId, remoteRole, function (resp) { + if (!resp || !resp.ok) return; + handlePollMessages(resp.messages || []); + if (typeof resp.since_id === 'number') { + sinceId = Math.max(sinceId, resp.since_id); + } + }); + }, 800); + + hbTimer = setInterval(function () { + if (stopped) return; + enrichStatsFromRtc(pc, collectStats(pc, remoteVideo), function (stats) { + onStats(stats); + sendHeartbeat(basePath, sessionId, 'active', stats); + }); + }, 5000); + + if (role === 'caster') { + status('Publishing offer…'); + pc.createOffer().then(function (offer) { + return pc.setLocalDescription(offer); + }).then(function () { + postSignal(basePath, sessionId, role, 'offer', pc.localDescription, function (resp) { + if (resp && resp.ok) { + status('Waiting for viewer…'); + } else { + status('Signaling failed.'); + } + }); + }).catch(function () { + status('Could not create WebRTC offer.'); + }); + } else { + status('Waiting for caster offer…'); + } + + return { + setRemoteVideo: function (el) { remoteVideo = el; }, + stop: function () { + sendHeartbeat(basePath, sessionId, 'ended', null); + teardown(); + } + }; + } + + global.LiveCastWebRtc = { + connect: connect, + sendHeartbeat: sendHeartbeat, + collectStats: collectStats + }; +})(typeof window !== 'undefined' ? window : this); diff --git a/examples/crash_reporter/backend/public/assets/js/live_education.js b/examples/crash_reporter/backend/public/assets/js/live_education.js index 0e16f6d..ddd51c0 100644 --- a/examples/crash_reporter/backend/public/assets/js/live_education.js +++ b/examples/crash_reporter/backend/public/assets/js/live_education.js @@ -6,6 +6,7 @@ var secondsLeft = MAX_DEMO_S; var mediaStream = null; var sessionId = ''; + var webrtc = null; function basePath() { return document.body.getAttribute('data-base-path') || ''; @@ -59,11 +60,31 @@ node.textContent = m + ':' + (s < 10 ? '0' : '') + s; } + function renderShare(session) { + var box = el('edu-share'); + if (!box || !session) return; + var joinUrl = session.join_short_url || ''; + var qrUrl = session.join_qr_url || ''; + var longJoin = basePath() + '/live/join?session_id=' + encodeURIComponent(session.session_id || sessionId); + if (!joinUrl) joinUrl = longJoin; + var html = '
Viewers open this link in another browser (same LAN or online).
'; + html += ''; + if (qrUrl) { + html += 'WebRTC is not available in this browser.
'; + return null; + } + playerEl.innerHTML = + '' + + ''; + var video = el('live-remote-video'); + var nerds = el('live-join-stats'); + var conn = window.LiveCastWebRtc.connect({ + basePath: basePath(), + sessionId: sessionId, + role: 'viewer', + onStatus: function (msg) { + statusEl.textContent = formatStatus(session) + ' — ' + msg; + }, + onRemoteStream: function (stream) { + if (video) { + video.srcObject = stream; + video.play().catch(function () {}); + } + statusEl.textContent = formatStatus(session) + ' — playing stream'; + }, + onStats: function (stats) { + renderStats(nerds || statsEl, stats); + } + }); + if (conn && video) conn.setRemoteVideo(video); + return conn; + } + function boot() { var root = el('live-join-app'); if (!root) return; @@ -94,6 +138,7 @@ 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'); + var webrtcConn = null; if (!sessionId) { el('live-join-status').textContent = 'Missing session_id in URL.'; @@ -105,6 +150,7 @@ var statusEl = el('live-join-status'); var metaEl = el('live-join-meta'); var playerEl = el('live-join-player'); + var statsEl = el('live-join-stats-panel'); function render(session) { if (!session) { @@ -112,7 +158,7 @@ return; } var live = session.status === 'live' || session.status === 'intent'; - statusEl.textContent = formatStatus(session) + (live ? ' — waiting for stream' : ''); + statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : ''); var owner = session.owner_username || 'host'; var lines = [ 'Host: ' + owner, @@ -121,24 +167,24 @@ 'Audio: ' + (session.codec_audio || '—'), 'Bandwidth: ' + (session.bw_mode || '—') ]; + if (session.join_short_url) { + lines.push('Viewer link: ' + session.join_short_url); + } metaEl.innerHTML = lines.map(function (l) { return 'Open in the AndroidCast app to join this P2P session.
' + - 'androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1
Open in the AndroidCast app for direct P2P on LAN.
' + + 'androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1
Mobile WebRTC signaling uses the same REST API when the app transport is wired.
'; postJoinEvent(sessionId, 'join', true); - } else if (role === 'viewer' || !direct) { - playerEl.innerHTML = - 'HTML5 viewer placeholder
' + - 'WebRTC media plane is not wired yet. Session control plane is active.
' + - 'Use a mobile device with direct=1 for P2P join.
'; + playerEl.innerHTML = 'Session is not live.
'; } if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { @@ -154,13 +200,20 @@ 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) { + if (!session) return; + if (!(session.status === 'live' || session.status === 'intent')) { statusEl.textContent = formatStatus(session); + if (webrtcConn) { + webrtcConn.stop(); + webrtcConn = null; + } } }); }, 30000); + + window.addEventListener('beforeunload', function () { + if (webrtcConn) webrtcConn.stop(); + }); } if (document.readyState === 'loading') { diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index c7f6ef4..2f5cdfa 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -107,6 +107,11 @@ if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php exit; } +if ($route === '/api/live_cast_signaling.php' || str_ends_with($route, '/api/live_cast_signaling.php')) { + require __DIR__ . '/api/live_cast_signaling.php'; + exit; +} + if ($route === '/live/join' || str_ends_with($route, '/live/join')) { require __DIR__ . '/../views/live_join.php'; exit; diff --git a/examples/crash_reporter/backend/src/Auth.php b/examples/crash_reporter/backend/src/Auth.php index 9981227..f7db881 100644 --- a/examples/crash_reporter/backend/src/Auth.php +++ b/examples/crash_reporter/backend/src/Auth.php @@ -138,8 +138,16 @@ final class Auth { } public static function basePath(): string { - $bp = cfg('base_path', ''); - return rtrim($bp, '/'); + $bp = rtrim((string) cfg('base_path', ''), '/'); + if ($bp === '') { + return $bp; + } + // If nginx serves /issues/ but config.php still has legacy /crashes, asset URLs must match nginx. + $uri = (string) ($_SERVER['REQUEST_URI'] ?? ''); + if (str_contains($uri, '/issues') && str_ends_with($bp, '/crashes')) { + return substr($bp, 0, -strlen('/crashes')) . '/issues'; + } + return $bp; } /** Shared project prefix, e.g. /app/androidcast_project */ diff --git a/examples/crash_reporter/backend/src/LiveCastRepository.php b/examples/crash_reporter/backend/src/LiveCastRepository.php index f8b7953..0b5398a 100644 --- a/examples/crash_reporter/backend/src/LiveCastRepository.php +++ b/examples/crash_reporter/backend/src/LiveCastRepository.php @@ -236,6 +236,23 @@ final class LiveCastRepository { if ($stmt->rowCount() < 1) { return null; } + if (isset($input['stats']) && is_array($input['stats'])) { + $row = self::sessionById($sessionId); + $meta = []; + if ($row !== null && !empty($row['metadata_json'])) { + $decoded = json_decode((string) $row['metadata_json'], true); + if (is_array($decoded)) { + $meta = $decoded; + } + } + $meta['last_stats'] = $input['stats']; + $meta['last_stats_ms'] = $now; + $upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?'); + $upd->execute([ + safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $sessionId, + ]); + } return self::sessionById($sessionId); } @@ -409,6 +426,7 @@ final class LiveCastRepository { 'codec_video' => (string) ($row['codec_video'] ?? ''), 'codec_audio' => (string) ($row['codec_audio'] ?? ''), 'bw_mode' => (string) ($row['bw_mode'] ?? ''), + 'metadata_json' => (string) ($row['metadata_json'] ?? ''), ]; } @@ -418,7 +436,10 @@ final class LiveCastRepository { } private static function joinLongUrl(string $sessionId, bool $direct): string { - $base = rtrim((string) cfg('base_path', '/app/androidcast_project/crashes'), '/'); + $base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/'); + if (str_ends_with($base, '/crashes')) { + $base = substr($base, 0, -strlen('/crashes')) . '/issues'; + } $qs = 'session_id=' . rawurlencode($sessionId); if ($direct) { $qs .= '&direct=1'; @@ -549,6 +570,9 @@ final class LiveCastRepository { 'codec_video' => $row['codec_video'], 'codec_audio' => $row['codec_audio'], 'bw_mode' => $row['bw_mode'], + 'join_short_url' => $row['join_short_url'] ?? '', + 'join_qr_url' => $row['join_qr_url'] ?? '', + 'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php', ]; } diff --git a/examples/crash_reporter/backend/src/LiveCastSignalingRepository.php b/examples/crash_reporter/backend/src/LiveCastSignalingRepository.php new file mode 100644 index 0000000..219eddb --- /dev/null +++ b/examples/crash_reporter/backend/src/LiveCastSignalingRepository.php @@ -0,0 +1,134 @@ +exec( + "CREATE TABLE live_cast_signal_messages ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + peer_role VARCHAR(16) NOT NULL DEFAULT 'caster', + msg_type VARCHAR(24) NOT NULL, + payload_json LONGTEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + KEY idx_live_signal_session_id (session_id, id), + KEY idx_live_signal_session_type (session_id, msg_type, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_signal_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + peer_role TEXT NOT NULL DEFAULT 'caster', + msg_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + )" + ); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_signal_session_id ON live_cast_signal_messages (session_id, id)'); + } + } + }); + self::$schemaEnsured = true; + } + + /** @param arrayReady when you are.
Time left: 5:00
+ +- Media is previewed locally only until the WebRTC viewer ships. Session metadata is recorded for analytics. + WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN.
diff --git a/examples/crash_reporter/backend/views/live_join.php b/examples/crash_reporter/backend/views/live_join.php index e6637ab..954dc0b 100644 --- a/examples/crash_reporter/backend/views/live_join.php +++ b/examples/crash_reporter/backend/views/live_join.php @@ -20,6 +20,7 @@ $role = trim((string) ($_GET['role'] ?? '')); + Loading session… + diff --git a/examples/platform/footer.config.example.php b/examples/platform/footer.config.example.php index 67fa1d5..0d3209f 100644 --- a/examples/platform/footer.config.example.php +++ b/examples/platform/footer.config.example.php @@ -20,7 +20,10 @@ return [ 'bottom' => null, // --- Default left column when `left` is null --- - 'holder' => 'Anton Afanaasyeu, 2026 - current', + // Use holder_name + holder_url for a safe link (holder HTML is escaped if used alone). + 'holder_name' => 'Anton Afanaasyeu', + 'holder_url' => 'https://f0xx.org', + 'holder' => null, 'copyright_symbol' => "\u{00A9}", 'show_year' => false, // null = current calendar year at render time (end of range). diff --git a/examples/platform/footer.php b/examples/platform/footer.php index ec5c045..d7dee69 100644 --- a/examples/platform/footer.php +++ b/examples/platform/footer.php @@ -74,13 +74,17 @@ function platform_footer_year_label(array $cfg): string function platform_footer_default_left(array $cfg): string { $symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}")); + $holderName = trim((string) ($cfg['holder_name'] ?? '')); + $holderUrl = trim((string) ($cfg['holder_url'] ?? '')); $holder = trim((string) ($cfg['holder'] ?? '')); $showYear = (bool) ($cfg['show_year'] ?? true); $parts = []; if ($symbol !== '') { $parts[] = $symbol; } - if ($holder !== '') { + if ($holderName !== '') { + $parts[] = $holderName; + } elseif ($holder !== '') { $parts[] = $holder; } $line = implode(' ', $parts); @@ -95,6 +99,41 @@ function platform_footer_default_left(array $cfg): string return $line . ', ' . $yearLabel; } +/** Render left footer HTML (holder link when configured). */ +function platform_footer_render_left_html(array $cfg, array $opts): ?string +{ + $raw = platform_footer_resolve_left($cfg, $opts); + if ($raw === null) { + return null; + } + $holderName = trim((string) ($cfg['holder_name'] ?? '')); + $holderUrl = trim((string) ($cfg['holder_url'] ?? '')); + if (platform_footer_field($cfg, $opts, 'left') === null + && $holderName !== '' && $holderUrl !== '') { + $text = platform_footer_h($raw); + $name = platform_footer_h($holderName); + $link = '' . $name . ''; + if (str_contains($text, $name)) { + return substr_replace($text, $link, strpos($text, $name), strlen($name)); + } + return $link . ($text !== '' ? ' ' . $text : ''); + } + + return platform_footer_h($raw); +} + +/** True when default left uses holder_name + holder_url (skip i18n text replacement). */ +function platform_footer_left_has_link(array $cfg, array $opts): bool +{ + if (platform_footer_field($cfg, $opts, 'left') !== null) { + return false; + } + $holderName = trim((string) ($cfg['holder_name'] ?? '')); + $holderUrl = trim((string) ($cfg['holder_url'] ?? '')); + + return $holderName !== '' && $holderUrl !== ''; +} + /** * Resolve optional row text (null/empty = omit row). Supports per-render overrides in $opts. * @@ -167,7 +206,9 @@ function platform_render_footer(array $opts = []): void $useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true); $i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright'); $leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null; + $leftHasLink = platform_footer_left_has_link($cfg, $opts); $yearEnd = platform_footer_year_end($cfg); + $leftHtml = platform_footer_render_left_html($cfg, $opts); echo '