Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 88d7c8fc087ab49a2fd3ee63bf184d15d1f0fa31)
+++ src/routes/activitypub.js	(revision e27b8db4e316874f7498fbe36e20270bc39b1d7f)
@@ -154,4 +154,11 @@
   const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
   const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
+  // The heavier sibling (5.6): may a third party's PLAYER run inside the app,
+  // and may a link hand the child over to a browser? Both are the guardians'
+  // call, both default to off for a ward, and both need the preview gate open
+  // first: you cannot play, or follow, what you may not see. Served here so
+  // the app knows what it may offer instead of guessing.
+  const playbackAllowed = embedsAllowed
+    && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
   const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
     id: `${t.id}#create`,
@@ -206,5 +213,6 @@
       // An external (non-fediverse) embed, thumbnail-only and never an iframe.
       // Omitted entirely when the gate is closed (see above).
-      'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json) : undefined,
+      // Carries shaer:playerUrl only when the playback gate is open too.
+      'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json, { playback: playbackAllowed }) : undefined,
     },
   }));
@@ -213,4 +221,15 @@
     id: `${base}/ap/users/${auth.site.slug}/inbox`,
     type: 'OrderedCollection',
+    // What this account may do with what is in here (FEP-633c 5.6). Owner-only
+    // by construction, and never on the public actor document: it says
+    // something about a child, and only the child and its guardians need it.
+    'shaer:capabilities': {
+      'shaer:externalEmbeds': embedsAllowed,
+      'shaer:externalPlayback': playbackAllowed,
+      // Leaving the app is the same decision as playing inside it: with the
+      // gate shut a link is shown but not followed, so the door is closed too
+      // and not just the picture over it.
+      'shaer:externalLinks': playbackAllowed,
+    },
     totalItems: items.length,
     orderedItems: items,
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 88d7c8fc087ab49a2fd3ee63bf184d15d1f0fa31)
+++ src/routes/guardian.js	(revision e27b8db4e316874f7498fbe36e20270bc39b1d7f)
@@ -54,5 +54,6 @@
     'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
     // A gated-setting proposal from a fellow guardian (5.6).
-    'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree'];
+    'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree',
+    'play_propose', 'play_on', 'play_off'];
   const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
   s.wave = i18nT(L, 'guardian.wave');
@@ -90,4 +91,5 @@
       ...w,
       embeds: wardEmbedSetting(w.other_uri),
+      playback: wardPlaybackSetting(w.other_uri),
       guardians: wardGuardianStatuses(w.other_uri),
     })),
@@ -466,11 +468,14 @@
  * the ward lives elsewhere and the setting is not ours to show.
  */
-function wardEmbedSetting(uri) {
+function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
+/** The playback gate of a ward we host (5.6): the heavier sibling. */
+function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
+function wardGateSetting(uri, column) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
   if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
   const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
-  const row = slug ? db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(slug) : null;
+  const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
   if (!row) return null;
-  return row.external_embeds === null || row.external_embeds === undefined ? false : row.external_embeds === 1;
+  return row.v === null || row.v === undefined ? false : row.v === 1;
 }
 
@@ -480,4 +485,8 @@
 // can move, and only a committed guardian of THAT ward may move it.
 router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
+  req.body = { ...req.body, feature: req.body?.feature === 'shaer:externalPlayback' ? 'shaer:externalPlayback' : 'shaer:externalEmbeds' };
+  return proposeGated(req, res);
+});
+function proposeGated(req, res) {
   const site = siteForUser(req);
   if (!site) return res.status(404).json({ error: 'no_site' });
@@ -496,5 +505,5 @@
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
   const me = AP.actorId(base, site.slug);
-  const feature = 'shaer:externalEmbeds';
+  const feature = req.body.feature;   // normalised by the route above
   const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
   const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
@@ -504,4 +513,12 @@
     Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow);
     const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow);
+    // Same forward as the S2S path: without it the other guardians never learn
+    // the proposal exists and a threshold of two can never be met.
+    if (r.state === 'open') {
+      for (const g of Guardianship.listGuardians(localWard.slug).map((x) => x.other_uri)) {
+        if (g === me) continue;
+        AP.deliverToActor(site, g, { ...offer, to: [g] }).catch(() => { /* queued */ });
+      }
+    }
     return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of });
   }
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 88d7c8fc087ab49a2fd3ee63bf184d15d1f0fa31)
+++ src/routes/posts.js	(revision e27b8db4e316874f7498fbe36e20270bc39b1d7f)
@@ -1084,8 +1084,27 @@
 function gateEmbeds(site, rows) {
   if (!site || !rows.length) return rows;
-  let isWard = false;
-  try { isWard = Guardianship.listGuardians(site.slug).length > 0; } catch { /* no relations yet */ }
-  if (Guardianship.externalEmbedsAllowed(site.external_embeds, isWard)) return rows;
+  if (embedsAllowedFor(site)) return rows;
   return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
+}
+
+function isWardSite(site) {
+  try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
+}
+function embedsAllowedFor(site) {
+  return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
+}
+/**
+ * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
+ * sibling of the preview gate.) This was the hole: the player iframe is built
+ * from the note's content by timelineEmbedHtml, on a path that never touched
+ * gateEmbeds. A ward whose guardians had allowed nothing still got the full
+ * YouTube player on the web, while the app showed nothing at all: the heavy
+ * thing open, the light thing shut. Playback also requires the preview gate,
+ * because you cannot play what you may not see.
+ */
+function playbackAllowedFor(site) {
+  if (!site) return true;
+  if (!embedsAllowedFor(site)) return false;
+  return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
 }
 
@@ -1098,6 +1117,9 @@
   const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
   const hasMore = rows.length > FEED_PAGE;
+  // Players (a third party's engine inside our page) ride the playback gate;
+  // a Klonkt site's own audio embed is ours and stays.
+  const mayPlay = playbackAllowedFor(site);
   const timeline = rows.slice(0, FEED_PAGE).map((p) => {
-    let embedHtml = timelineEmbedHtml(p.content);
+    let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
     let content = p.content;
     let embedUrl = null;
