@orchyn/mcp 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,9 +43,12 @@ export function jwtExpiry(token) {
43
43
  export class OrchynClient {
44
44
  baseUrl;
45
45
  tokenProvider;
46
- constructor(baseUrl, tokenProvider) {
46
+ /** Stable per-call key so a retried tool call is billed once. */
47
+ idempotencyKey;
48
+ constructor(baseUrl, tokenProvider, idempotencyKey) {
47
49
  this.baseUrl = baseUrl.replace(/\/+$/, "");
48
50
  this.tokenProvider = tokenProvider;
51
+ this.idempotencyKey = idempotencyKey;
49
52
  }
50
53
  async request(method, path, opts = {}) {
51
54
  const doRequest = async (accessToken) => {
@@ -53,6 +56,12 @@ export class OrchynClient {
53
56
  if (opts.body !== undefined) {
54
57
  headers["content-type"] = "application/json";
55
58
  }
59
+ // Billed calls carry the key so the backend charges a retry once. A
60
+ // redeploy or dropped stream mid-call leaves the client with no result,
61
+ // and its retry must not cost the user a second time.
62
+ if (opts.idempotent && this.idempotencyKey) {
63
+ headers["idempotency-key"] = this.idempotencyKey;
64
+ }
56
65
  if (accessToken) {
57
66
  headers.authorization = `Bearer ${accessToken}`;
58
67
  }
@@ -129,6 +138,7 @@ export class OrchynClient {
129
138
  async callTool(name, args) {
130
139
  const rpc = await this.request("POST", "/mcp", {
131
140
  auth: true,
141
+ idempotent: true,
132
142
  body: {
133
143
  jsonrpc: "2.0",
134
144
  id: Date.now(),
@@ -34,7 +34,11 @@ export const ORCHYN_UI_TEMPLATE = `<!DOCTYPE html>
34
34
  }}
35
35
  html{overflow-x:hidden;overflow-y:auto}
36
36
  *{margin:0;padding:0;box-sizing:border-box;}
37
- body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--fg);padding:16px;line-height:1.5;overflow-x:hidden;}
37
+ /* An author display rule beats the UA's [hidden] rule, so any hidden
38
+ overlay (buffering, error) would stay painted and keep swallowing clicks.
39
+ Make the attribute win outright. */
40
+ [hidden]{display:none !important;}
41
+ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--fg);padding:10px;line-height:1.5;overflow-x:hidden;max-width:100vw;}
38
42
 
39
43
  /* ─── Content entrance ─── */
40
44
  .fade-in{animation:fadeIn .4s ease-out;}
@@ -50,8 +54,8 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
50
54
  .stagger>*:nth-child(8){animation-delay:.4s}
51
55
 
52
56
  /* ─── Cards ─── */
53
- .gallery-wrap{position:relative;margin:0 -4px;overflow:visible}
54
- .gallery{display:flex;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;padding:4px 4px 8px;scrollbar-width:none;width:100%;}
57
+ .gallery-wrap{position:relative;overflow:hidden;max-width:100%}
58
+ .gallery{display:flex;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;padding:2px 0 8px;scrollbar-width:none;width:100%;max-width:100%;}
55
59
  .gallery::-webkit-scrollbar{display:none}
56
60
  .gallery-dots{display:flex;justify-content:center;gap:6px;margin-top:10px;flex-wrap:wrap}
57
61
  .gallery-dots .dot{width:8px;height:8px;border-radius:50%;background:var(--border);border:none;cursor:pointer;transition:var(--transition);padding:0}
@@ -61,18 +65,30 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
61
65
  .gallery-nav:hover{background:var(--accent);color:#fff}
62
66
  .gallery-nav.prev{left:2px}
63
67
  .gallery-nav.next{right:2px}
64
- .card{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:380px;min-width:380px;background:var(--card);display:block;box-shadow:var(--shadow-sm);transition:var(--transition);cursor:default;scroll-snap-align:start;flex-shrink:0}
68
+ .card{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:min(380px,100%);min-width:min(380px,100%);max-width:100%;background:var(--card);display:block;box-shadow:var(--shadow-sm);transition:var(--transition);cursor:default;scroll-snap-align:center;flex-shrink:0}
69
+ /* A media card is the player plus a footer — no metadata column, the chrome
70
+ lives on the media itself. */
71
+ /* Two classes so this outranks .card-wide's width:100%. The card derives its
72
+ width from the player's aspect ratio, so the footer sits under the media
73
+ instead of stretching away from it on a wide screen. */
74
+ .card.card-media{--ar-w:9;--ar-h:16;
75
+ width:min(100%,calc(var(--stage-max,520px) * var(--ar-w) / var(--ar-h)));
76
+ min-width:0;margin:0 auto;background:transparent;border-color:transparent;box-shadow:none;}
77
+ .card.card-media.h{--ar-w:16;--ar-h:9}
78
+ .card.card-media.sq{--ar-w:1;--ar-h:1}
79
+ .card-media .card-foot{padding:8px 4px 0;background:transparent;display:flex;justify-content:flex-end;}
80
+ .card-media .btn-sm{margin-top:0;}
65
81
  .card:hover{box-shadow:var(--shadow-md);transform:translateY(-2px);border-color:var(--muted);}
66
82
  .card-wide{width:100%;}
67
- .card img{width:100%;height:260px;object-fit:cover;display:block;transition:transform .3s ease;}
68
- .card:hover img{transform:scale(1.02);}
69
- .card-wide img{max-height:340px;}
83
+ /* Thumbnail styling for plain (non-player) cards only. The player owns the
84
+ sizing of its own media — letting this rule reach .mp-slide cropped every
85
+ slide to a 260px letterbox and scaled it on hover. */
86
+ .card img:not(.mp-slide){width:100%;height:260px;object-fit:cover;display:block;transition:transform .3s ease;}
87
+ .card:hover img:not(.mp-slide){transform:scale(1.02);}
88
+ .card-wide img:not(.mp-slide){max-height:340px;}
70
89
  .card video{width:100%;min-height:220px;max-height:520px;display:block;background:#000;border:none;outline:none;object-fit:contain;}
71
90
  .card-wide video{max-height:520px;}
72
- .video-wrap{width:100%;background:#000;position:relative;overflow:hidden;}
73
- .video-wrap.v{aspect-ratio:9/16;max-height:520px;}
74
- .video-wrap.h{aspect-ratio:16/9;max-height:360px;}
75
- .video-wrap.sq{aspect-ratio:1/1;max-height:440px;}
91
+
76
92
  .card-body{padding:14px 16px;}
77
93
 
78
94
  /* ─── Badges & Tags ─── */
@@ -91,7 +107,7 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
91
107
 
92
108
  /* ─── Stats ─── */
93
109
  .stats{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px;}
94
- .stat-pill{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--fg);background:var(--tag);padding:5px 12px;border-radius:999px;transition:var(--transition);}
110
+ .stat-pill{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--fg);background:var(--tag);padding:5px 12px;border-radius:999px;transition:var(--transition);display:inline-flex;align-items:center;gap:4px;}
95
111
  .stat-pill:hover{background:var(--border);}
96
112
 
97
113
  /* ─── Buttons ─── */
@@ -129,17 +145,126 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
129
145
  .comment-row:hover{background:var(--tag);margin:0 -6px;padding:10px 6px;border-radius:var(--radius-sm);}
130
146
 
131
147
  /* ─── Creator cards ─── */
132
- .creator-card{border:1px solid var(--border);border-radius:var(--radius);padding:16px;width:230px;background:var(--card);display:inline-block;vertical-align:top;margin:4px;box-shadow:var(--shadow-sm);transition:var(--transition);}
148
+ .creator-card{border:1px solid var(--border);border-radius:var(--radius);padding:16px;width:min(240px,80vw);min-width:min(240px,80vw);background:var(--card);display:block;box-shadow:var(--shadow-sm);transition:var(--transition);scroll-snap-align:start;flex-shrink:0}
133
149
  .creator-card:hover{box-shadow:var(--shadow-md);transform:translateY(-2px);}
134
150
  .avatar{width:52px;height:52px;border-radius:50%;object-fit:cover;border:2px solid var(--border);}
135
151
  .avatar-placeholder{width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:22px;}
136
152
  .verified{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;color:#fff;font-size:10px;margin-left:4px;}
137
153
 
138
154
  /* ─── Sound cards ─── */
139
- .sound-card{border:1px solid var(--border);border-radius:var(--radius);padding:14px;width:270px;background:var(--card);display:inline-block;vertical-align:top;margin:4px;box-shadow:var(--shadow-sm);transition:var(--transition);}
155
+ .sound-card{border:1px solid var(--border);border-radius:var(--radius);padding:14px;width:min(300px,88vw);min-width:min(300px,88vw);background:var(--card);display:block;box-shadow:var(--shadow-sm);transition:var(--transition);scroll-snap-align:start;flex-shrink:0}
140
156
  .sound-card:hover{box-shadow:var(--shadow-md);transform:translateY(-2px);}
141
157
  .sound-cover{width:68px;height:68px;border-radius:var(--radius-sm);object-fit:cover;}
142
158
 
159
+ /* ─── Media player ─── */
160
+ /* ONE player shell for every kind of media. A video and a slideshow differ
161
+ only in what sits on the stage — the overlay chrome and the control bar
162
+ below are the same elements, in the same order, every time. */
163
+ /* The player is one aspect-correct block: the stage, the overlay furniture
164
+ and the control bar share its box, so the controls always span exactly the
165
+ media and never float over dead space.
166
+ Width is derived from the height cap, so the media is fully visible without
167
+ scrolling (never taller than --stage-max) and never wider than the frame. */
168
+ .mp{--ar-w:9;--ar-h:16;position:relative;background:#000;overflow:hidden;margin:0 auto;
169
+ width:min(100%,calc(var(--stage-max,520px) * var(--ar-w) / var(--ar-h)));}
170
+ /* Inside a media card the card already owns the width; just fill it. */
171
+ .card-media>.mp{width:100%;}
172
+ .mp.h{--ar-w:16;--ar-h:9}
173
+ .mp.sq{--ar-w:1;--ar-h:1}
174
+ .mp-stage{position:relative;width:100%;aspect-ratio:var(--ar-w) / var(--ar-h);max-height:var(--stage-max,520px);background:#000;overflow:hidden;}
175
+ .mp .mp-stage>video{width:100%;height:100%;max-height:none;object-fit:contain;display:block;background:#000;}
176
+ .mp .mp-slide{position:absolute;inset:0;width:100%;height:100%;max-height:none;object-fit:contain;opacity:0;transition:opacity .45s ease;background:#000;transform:none;}
177
+ .mp-slide.active{opacity:1;}
178
+
179
+ /* Overlay chrome — platform-native furniture sitting on the media itself. */
180
+ .mp-ov{position:absolute;inset:0;pointer-events:none;z-index:2;}
181
+ /* Overlay text sits on whatever the media happens to be — a bright frame
182
+ would swallow it, so the lower third gets a scrim to read against. */
183
+ .mp-ov::after{content:"";position:absolute;left:0;right:0;bottom:0;height:46%;
184
+ background:linear-gradient(transparent,rgba(0,0,0,.28) 38%,rgba(0,0,0,.72));pointer-events:none;}
185
+ .mp-ov::before{content:"";position:absolute;left:0;right:0;top:0;height:16%;
186
+ background:linear-gradient(rgba(0,0,0,.45),transparent);pointer-events:none;}
187
+ .mp-badge,.mp-chip,.mp-rail,.mp-meta,.mp-tap,.mp-nav{z-index:1;}
188
+ .mp-ov>*{pointer-events:auto;}
189
+ .mp-badge{position:absolute;top:10px;left:10px;display:inline-flex;align-items:center;gap:5px;padding:4px 9px;border-radius:999px;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);color:#fff;font-size:11px;font-weight:700;text-transform:capitalize;}
190
+ .mp-chip{position:absolute;top:10px;right:10px;padding:3px 9px;border-radius:999px;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);color:#fff;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}
191
+
192
+ /* Right-hand action rail — the TikTok/Reels engagement column. */
193
+ .mp-rail{position:absolute;right:8px;bottom:76px;display:flex;flex-direction:column;align-items:center;gap:14px;}
194
+ .mp-act{display:flex;flex-direction:column;align-items:center;gap:3px;background:none;border:none;padding:0;cursor:default;}
195
+ .mp-act .ic{width:38px;height:38px;border-radius:50%;background:rgba(0,0,0,.4);backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;color:#fff;transition:var(--transition);}
196
+ .mp-act:hover .ic{background:rgba(0,0,0,.62);transform:scale(1.06)}
197
+ .mp-act .n{color:#fff;font-size:11px;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,.8);font-variant-numeric:tabular-nums;}
198
+
199
+ /* Bottom-left caption block — handle, text, track. */
200
+ .mp-meta{position:absolute;left:12px;right:64px;bottom:60px;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.85);}
201
+ .mp-handle{font-size:13px;font-weight:800;margin-bottom:3px;}
202
+ .mp-cap{font-size:12px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;opacity:.95;}
203
+ .mp-song{display:flex;align-items:center;gap:5px;margin-top:6px;font-size:11px;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
204
+ .mp-song .note{display:inline-flex;flex-shrink:0}
205
+ /* Spins only while the track is actually playing — a permanently rotating
206
+ note just reads as a rendering glitch. */
207
+ .mp.playing .mp-song .note{animation:spin 3s linear infinite;}
208
+ .mp-song-t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
209
+ @keyframes spin{to{transform:rotate(360deg)}}
210
+
211
+ /* Centre tap target — the big play triangle while paused. */
212
+ .mp-tap{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:none;background:transparent;cursor:pointer;}
213
+ .mp-tap span{width:64px;height:64px;border-radius:50%;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-size:26px;color:#fff;transition:var(--transition);opacity:1;}
214
+ .mp.playing .mp-tap span{opacity:0;}
215
+ .mp.playing:hover .mp-tap span{opacity:.65;}
216
+
217
+ /* Step arrows — slideshows only, but the same shape as everything else. */
218
+ .mp-nav{position:absolute;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:50%;background:rgba(0,0,0,.45);color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;opacity:0;transition:var(--transition);}
219
+ .mp:hover .mp-nav,.mp:focus-within .mp-nav{opacity:1;}
220
+ .mp-nav.prev{left:8px}.mp-nav.next{right:8px}
221
+ .mp-nav:hover{background:rgba(0,0,0,.8)}
222
+
223
+ /* Control bar — IDENTICAL markup for video and slideshow. */
224
+ .mp-ctl{position:absolute;left:0;right:0;bottom:0;z-index:3;display:flex;align-items:center;gap:9px;padding:9px 11px;background:linear-gradient(transparent,rgba(0,0,0,.85));}
225
+ .mp-btn{width:30px;height:30px;flex-shrink:0;border-radius:50%;background:rgba(255,255,255,.95);color:#111;border:none;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;transition:var(--transition);}
226
+ .mp-btn:hover{transform:scale(1.09)}
227
+ .mp-time{color:#fff;font-size:10px;font-weight:700;font-variant-numeric:tabular-nums;flex-shrink:0;text-shadow:0 1px 2px rgba(0,0,0,.8);}
228
+ .mp-seek{flex:1;min-width:0;height:14px;display:flex;align-items:center;cursor:pointer;}
229
+ /* One seek widget: a plain bar for video, segmented for a slideshow. */
230
+ .mp-segs{width:100%;height:3px;display:flex;gap:2px;border-radius:999px;overflow:hidden;}
231
+ .mp-seg{flex:1;height:100%;background:rgba(255,255,255,.32);border-radius:999px;overflow:hidden;}
232
+ .mp-seg>i{display:block;height:100%;width:0;background:#fff;border-radius:999px;}
233
+ .mp-seg.done>i{width:100%;}
234
+ .mp-icon{background:none;border:none;color:#fff;cursor:pointer;font-size:14px;flex-shrink:0;opacity:.9;padding:0;line-height:1;}
235
+ .mp-icon:hover{opacity:1}
236
+
237
+ /* Buffering + failure — visible states, not a frozen black frame. */
238
+ .mp-spin{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:2;pointer-events:none;}
239
+ .mp-spin>i{width:34px;height:34px;border-radius:50%;border:3px solid rgba(255,255,255,.25);border-top-color:#fff;animation:spin .8s linear infinite;}
240
+ .mp-err{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;z-index:4;background:rgba(0,0,0,.82);color:#fff;font-size:12px;font-weight:600;text-align:center;padding:16px;}
241
+
242
+ /* Keyboard focus has to be visible — the player is reachable by Tab. */
243
+ .mp:focus-visible{outline:2px solid #fff;outline-offset:-2px;}
244
+ .mp-btn:focus-visible,.mp-icon:focus-visible,.mp-nav:focus-visible,.mp-tap:focus-visible,
245
+ .au-play:focus-visible,.gallery-nav:focus-visible,.dot:focus-visible{outline:2px solid #fff;outline-offset:2px;}
246
+ .mp-seek{position:relative;}
247
+ .mp-seek:hover .mp-segs{height:5px;}
248
+ .mp-segs{transition:height .15s ease;}
249
+
250
+ /* Respect a reduced-motion preference: no spinning note, no crossfades. */
251
+ @media(prefers-reduced-motion:reduce){
252
+ .mp-song .note{animation:none}
253
+ .mp-slide{transition:none}
254
+ .mp-spin>i{animation-duration:2s}
255
+ *{scroll-behavior:auto !important}
256
+ }
257
+
258
+ /* ─── Inline audio player (sound cards) ─── */
259
+ .au{display:flex;align-items:center;gap:10px;margin-top:10px;}
260
+ .au-play{width:34px;height:34px;flex-shrink:0;border-radius:50%;color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:var(--transition);box-shadow:0 0 0 1px color-mix(in srgb,var(--fg) 22%,transparent);}
261
+ .au-play:hover{transform:scale(1.08);filter:brightness(1.1)}
262
+ .au-body{flex:1;min-width:0}
263
+ .au-seek{width:100%;height:5px;border-radius:999px;background:color-mix(in srgb,var(--fg) 22%,transparent);cursor:pointer;overflow:hidden;position:relative}
264
+ .au-seek>i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);}
265
+ .au-time{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin-top:3px;font-variant-numeric:tabular-nums}
266
+ .au-missing{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px;}
267
+
143
268
  /* ─── Credits ─── */
144
269
  .credits-card{border:1px solid var(--border);border-radius:16px;max-width:380px;background:var(--card);margin:8px 0;padding:24px;box-shadow:var(--shadow-md);}
145
270
  .tier-badge{display:inline-flex;align-items:center;padding:4px 12px;background:linear-gradient(135deg,#eff6ff,#dbeafe);border-radius:999px;font-size:12px;color:#1e40af;font-weight:700;}
@@ -298,10 +423,450 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
298
423
 
299
424
  // ─── Helpers ───
300
425
  function esc(s){return String(s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}
426
+ function fmtTime(sec){sec=Math.max(0,Math.floor(Number(sec)||0));return Math.floor(sec/60)+":"+("0"+(sec%60)).slice(-2);}
427
+
428
+ // ─── Audio engine ───
429
+ // Every <audio> in the view registers here so only one track is ever
430
+ // audible: starting a sound preview pauses a playing slideshow and vice
431
+ // versa. Elements are re-collected after each render.
432
+ var audioReg=[];
433
+ function registerAudio(el){
434
+ if(!el||audioReg.indexOf(el)>-1)return;
435
+ audioReg.push(el);
436
+ el.addEventListener("play",function(){
437
+ for(var i=0;i<audioReg.length;i++){
438
+ var a=audioReg[i];
439
+ if(a!==el&&!a.paused){try{a.pause();}catch(e){}}
440
+ }
441
+ });
442
+ }
443
+ function resetAudio(){
444
+ for(var i=0;i<audioReg.length;i++){try{audioReg[i].pause();}catch(e){}}
445
+ audioReg=[];
446
+ }
447
+
448
+ // ─── Media player controller ───
449
+ // A transport hides the difference between the two media kinds behind one
450
+ // interface (duration/time/seek/play/pause). The control bar is wired to
451
+ // the transport, never to a <video> or an <audio> directly, so the same
452
+ // handlers drive every player and the controls behave identically.
453
+ var SLIDE_MS=3000;
454
+
455
+ function videoTransport(box){
456
+ var v=box.querySelector("video");
457
+ return {
458
+ kind:"video",
459
+ el:v,
460
+ duration:function(){return isFinite(v.duration)?v.duration:0;},
461
+ time:function(){return v.currentTime||0;},
462
+ paused:function(){return v.paused;},
463
+ play:function(){var r=v.play();if(r&&r.catch)r.catch(function(){});},
464
+ pause:function(){try{v.pause();}catch(e){}},
465
+ seek:function(t){try{v.currentTime=t;}catch(e){}},
466
+ muted:function(m){if(m===undefined)return v.muted;v.muted=m;return m;},
467
+ step:function(dir){this.seek(Math.max(0,this.time()+dir*5));},
468
+ // Video paints one continuous segment.
469
+ segment:function(){return 0;},
470
+ audio:null,
471
+ on:function(ev,cb){v.addEventListener(ev,cb);}
472
+ };
473
+ }
474
+
475
+ function slidesTransport(box){
476
+ var slides=box.querySelectorAll(".mp-slide");
477
+ var audio=box.querySelector("audio");
478
+ var total=slides.length||1;
479
+ var idx=0,base=0,startedAt=0,running=false,seekBroken=false;
480
+ // With a track, the track is the clock and each slide owns an equal share
481
+ // of it. Without one, slides run on a fixed beat.
482
+ // A track we cannot seek (a host that serves the audio without range
483
+ // support) would strand the clock at zero while the slides moved on, so
484
+ // we fall back to the wall clock the first time a seek refuses to take.
485
+ function driven(){
486
+ return !seekBroken&&!!audio&&isFinite(audio.duration)&&audio.duration>0;
487
+ }
488
+ function per(){return driven()?audio.duration/total:SLIDE_MS/1000;}
489
+ function paint(){
490
+ for(var k=0;k<total;k++)slides[k].classList.toggle("active",k===idx);
491
+ var chip=box.querySelector(".mp-idx");
492
+ if(chip)chip.textContent=idx+1;
493
+ }
494
+ var t={
495
+ kind:"slides",
496
+ el:box,
497
+ total:total,
498
+ audio:audio,
499
+ duration:function(){return driven()?audio.duration:total*(SLIDE_MS/1000);},
500
+ time:function(){
501
+ if(driven())return audio.currentTime||0;
502
+ return base+(running?(Date.now()-startedAt)/1000:0);
503
+ },
504
+ paused:function(){return !running;},
505
+ play:function(){
506
+ running=true;startedAt=Date.now();
507
+ if(audio){var r=audio.play();if(r&&r.catch)r.catch(function(){});}
508
+ },
509
+ pause:function(){
510
+ if(running)base=t.time();
511
+ running=false;
512
+ if(audio){try{audio.pause();}catch(e){}}
513
+ },
514
+ seek:function(sec){
515
+ sec=Math.max(0,Math.min(sec,t.duration()));
516
+ base=sec;startedAt=Date.now();
517
+ if(audio){
518
+ try{
519
+ audio.currentTime=sec;
520
+ if(sec>0.5&&Math.abs(audio.currentTime-sec)>1)seekBroken=true;
521
+ }catch(e){seekBroken=true;}
522
+ }
523
+ idx=Math.max(0,Math.min(total-1,Math.floor(sec/per())));
524
+ paint();
525
+ },
526
+ muted:function(m){
527
+ if(!audio)return true;
528
+ if(m===undefined)return audio.muted;
529
+ audio.muted=m;return m;
530
+ },
531
+ // Slideshows step a whole slide at a time, not five seconds.
532
+ step:function(dir){t.seek((idx+dir)*per());},
533
+ segment:function(){return idx;},
534
+ sync:function(){
535
+ var want=Math.max(0,Math.min(total-1,Math.floor(t.time()/per())));
536
+ if(want!==idx){idx=want;paint();}
537
+ return t.time()>=t.duration();
538
+ },
539
+ on:function(ev,cb){if(audio)audio.addEventListener(ev,cb);}
540
+ };
541
+ paint();
542
+ return t;
543
+ }
544
+
545
+ // ─── Player registry ───
546
+ // Exclusivity cannot ride on the audio elements alone: a silent slideshow
547
+ // owns no <audio>, so it would happily keep advancing underneath a video
548
+ // that just started. Every player registers its own stop() instead.
549
+ var players=[];
550
+ function registerPlayer(rec){players.push(rec);}
551
+ function stopOtherPlayers(except){
552
+ for(var i=0;i<players.length;i++){
553
+ if(players[i]!==except&&players[i].isPlaying())players[i].stop(false);
554
+ }
555
+ }
556
+ function stopAllPlayers(){stopOtherPlayers(null);}
557
+ function resetPlayers(){stopAllPlayers();players=[];}
558
+
559
+ // Mute is a session preference, not a per-card one — having to silence
560
+ // every card in a feed one at a time is the kind of thing that makes people
561
+ // close the tab.
562
+ var mutePref=false;
563
+
564
+ function initMediaPlayers(root){
565
+ var nodes=(root||document).querySelectorAll(".mp[data-mp]");
566
+ for(var n=0;n<nodes.length;n++)initMediaPlayer(nodes[n]);
567
+ observeVisibility();
568
+ }
569
+
570
+ // ─── Pause on scroll-away ───
571
+ // Paging to the next card in a gallery must stop the one you left, or two
572
+ // soundtracks end up playing over each other.
573
+ var mpObserver=null;
574
+ function observeVisibility(){
575
+ if(!("IntersectionObserver" in window))return;
576
+ if(mpObserver)mpObserver.disconnect();
577
+ mpObserver=new IntersectionObserver(function(entries){
578
+ for(var i=0;i<entries.length;i++){
579
+ var e=entries[i],rec=e.target.__mpRec;
580
+ if(!rec)continue;
581
+ if(e.intersectionRatio>=0.6){rec.wasVisible=true;continue;}
582
+ // Only a card that was genuinely on screen and has scrolled away
583
+ // stops. Without the transition check, a smooth-scroll that is still
584
+ // settling reads as "off screen" and kills playback the instant the
585
+ // user presses play on a partly-visible card.
586
+ if(e.intersectionRatio<0.25&&rec.wasVisible){
587
+ rec.wasVisible=false;
588
+ if(rec.isPlaying())rec.stop(false);
589
+ }
590
+ }
591
+ },{threshold:[0,0.25,0.6,1]});
592
+ var nodes=document.querySelectorAll(".mp[data-mp]");
593
+ for(var n=0;n<nodes.length;n++)mpObserver.observe(nodes[n]);
594
+ }
595
+
596
+ // Leaving the tab (or the host collapsing the view) must not leave audio
597
+ // playing out of sight.
598
+ document.addEventListener("visibilitychange",function(){
599
+ if(document.hidden)stopAllPlayers();
600
+ });
601
+ window.addEventListener("pagehide",stopAllPlayers);
602
+
603
+ function initMediaPlayer(box){
604
+ if(box.__mpInit)return;
605
+ box.__mpInit=true;
606
+ var t=box.getAttribute("data-mp")==="video"?videoTransport(box):slidesTransport(box);
607
+ if(!t.el)return;
608
+ if(t.audio)registerAudio(t.audio);
609
+ if(t.kind==="video")registerAudio(t.el);
610
+
611
+ var playBtn=box.querySelector(".mp-play");
612
+ var tapBtn=box.querySelector(".mp-tap");
613
+ var muteBtn=box.querySelector(".mp-mute");
614
+ var seekBar=box.querySelector(".mp-seek");
615
+ var segs=box.querySelectorAll(".mp-seg");
616
+ var curEl=box.querySelector(".mp-cur");
617
+ var durEl=box.querySelector(".mp-dur");
618
+ // Driven by an interval, not requestAnimationFrame: hosts render these
619
+ // cards in iframes that often are not compositing, where rAF never fires.
620
+ var ticker=null;
621
+
622
+ function paintSeek(){
623
+ var d=t.duration(),now=t.time();
624
+ if(segs.length<=1){
625
+ if(segs[0])segs[0].firstChild.style.width=(d>0?Math.min(100,(now/d)*100):0)+"%";
626
+ }else{
627
+ var per=d/segs.length,active=t.segment();
628
+ for(var k=0;k<segs.length;k++){
629
+ segs[k].classList.toggle("done",k<active);
630
+ segs[k].firstChild.style.width=
631
+ k<active?"100%":k>active?"0":Math.max(0,Math.min(100,((now-k*per)/per)*100))+"%";
632
+ }
633
+ }
634
+ if(curEl)curEl.textContent=fmtTime(now);
635
+ if(durEl)durEl.textContent=fmtTime(d);
636
+ }
637
+ function setPlaying(on){
638
+ box.classList.toggle("playing",on);
639
+ if(playBtn){
640
+ playBtn.innerHTML=mpIcon(on?"pause":"play",16);
641
+ playBtn.setAttribute("aria-label",on?"Pause":"Play");
642
+ }
643
+ if(tapBtn){
644
+ tapBtn.innerHTML="<span>"+mpIcon(on?"pause":"play",28)+"</span>";
645
+ tapBtn.setAttribute("aria-label",on?"Pause":"Play");
646
+ }
647
+ }
648
+ function applyMute(){
649
+ t.muted(mutePref);
650
+ if(muteBtn){
651
+ muteBtn.innerHTML=mpIcon(mutePref?"muted":"volume",17);
652
+ muteBtn.setAttribute("aria-label",mutePref?"Unmute":"Mute");
653
+ muteBtn.setAttribute("aria-pressed",mutePref?"true":"false");
654
+ }
655
+ }
656
+ function tick(){
657
+ paintSeek();
658
+ if(t.kind==="slides"&&t.sync&&!t.paused()&&t.sync())stop(true);
659
+ }
660
+ function start(){
661
+ // Only one thing plays at a time, across every card in the view.
662
+ stopOtherPlayers(rec);
663
+ rec.wasVisible=true;
664
+ t.play();setPlaying(true);
665
+ clearInterval(ticker);ticker=setInterval(tick,100);
666
+ tick();
667
+ }
668
+ function stop(reset){
669
+ t.pause();setPlaying(false);
670
+ clearInterval(ticker);ticker=null;
671
+ if(reset)t.seek(0);
672
+ paintSeek();
673
+ }
674
+ function toggle(){t.paused()?start():stop(false);}
675
+
676
+ // Registered before any wiring so start() can reach it.
677
+ var rec={wasVisible:false,
678
+ isPlaying:function(){return box.classList.contains("playing");},
679
+ stop:function(reset){stop(reset);}};
680
+ box.__mpRec=rec;
681
+ registerPlayer(rec);
682
+
683
+ if(playBtn)playBtn.addEventListener("click",toggle);
684
+ if(tapBtn)tapBtn.addEventListener("click",toggle);
685
+ if(muteBtn)muteBtn.addEventListener("click",function(){
686
+ mutePref=!mutePref;
687
+ // Apply to every player, so the choice sticks across the whole feed.
688
+ var all=document.querySelectorAll(".mp[data-mp]");
689
+ for(var i=0;i<all.length;i++)if(all[i].__mpApplyMute)all[i].__mpApplyMute();
690
+ });
691
+ box.__mpApplyMute=applyMute;
692
+
693
+ // Keyboard: the same keys people already expect from a video player.
694
+ box.addEventListener("keydown",function(e){
695
+ var k=e.key;
696
+ if(k===" "||k==="Spacebar"||k==="k"){e.preventDefault();toggle();}
697
+ else if(k==="ArrowRight"){e.preventDefault();t.step(1);paintSeek();}
698
+ else if(k==="ArrowLeft"){e.preventDefault();t.step(-1);paintSeek();}
699
+ else if(k==="m"||k==="M"){e.preventDefault();if(muteBtn)muteBtn.click();}
700
+ else if(k==="Home"){e.preventDefault();t.seek(0);paintSeek();}
701
+ });
702
+ if(seekBar)seekBar.addEventListener("click",function(e){
703
+ var d=t.duration();
704
+ if(d<=0)return;
705
+ var r=seekBar.getBoundingClientRect();
706
+ t.seek(((e.clientX-r.left)/r.width)*d);
707
+ paintSeek();
708
+ });
709
+ box.addEventListener("click",function(e){
710
+ var nav=e.target.closest?e.target.closest(".mp-nav"):null;
711
+ if(!nav)return;
712
+ t.step(Number(nav.getAttribute("data-dir"))||1);
713
+ paintSeek();
714
+ });
715
+ // Media the user paused from elsewhere (another player claimed the
716
+ // audio, or the native video UI) must not leave the button lying.
717
+ t.on("ended",function(){stop(true);});
718
+ t.on("pause",function(){if(!box.classList.contains("playing"))return;setPlaying(false);clearInterval(ticker);});
719
+ t.on("play",function(){if(!box.classList.contains("playing"))start();});
720
+ t.on("loadedmetadata",paintSeek);
721
+ if(t.kind==="slides")t.on("timeupdate",tick);
722
+
723
+ // Buffering and failure are states the user can see, not a frozen frame.
724
+ var spin=box.querySelector(".mp-spin"),errBox=box.querySelector(".mp-err");
725
+ function busy(on){if(spin)spin.hidden=!on;}
726
+ if(t.kind==="video"){
727
+ t.on("waiting",function(){busy(true);});
728
+ t.on("stalled",function(){busy(true);});
729
+ t.on("playing",function(){busy(false);});
730
+ t.on("canplay",function(){busy(false);});
731
+ }
732
+ var media=t.kind==="video"?t.el:t.audio;
733
+ if(media)media.addEventListener("error",function(){
734
+ busy(false);
735
+ if(errBox)errBox.hidden=false;
736
+ stop(false);
737
+ });
738
+
739
+ setPlaying(false);
740
+ applyMute();
741
+ paintSeek();
742
+ }
743
+
744
+ // ─── Sound card players ───
745
+ function initSoundPlayers(root){
746
+ var nodes=(root||document).querySelectorAll(".au[data-au]");
747
+ for(var n=0;n<nodes.length;n++){
748
+ (function(wrap){
749
+ if(wrap.__auInit)return;
750
+ wrap.__auInit=true;
751
+ var audio=wrap.querySelector("audio");
752
+ var btn=wrap.querySelector(".au-play");
753
+ var seek=wrap.querySelector(".au-seek");
754
+ var fill=seek?seek.firstChild:null;
755
+ var cur=wrap.querySelector(".au-cur");
756
+ var dur=wrap.querySelector(".au-dur");
757
+ if(!audio||!btn)return;
758
+ registerAudio(audio);
759
+ btn.addEventListener("click",function(){
760
+ if(audio.paused){var pr=audio.play();if(pr&&pr.catch)pr.catch(function(){
761
+ wrap.setAttribute("data-error","1");
762
+ if(cur)cur.textContent="unavailable";
763
+ });}
764
+ else audio.pause();
765
+ });
766
+ function mark(on){
767
+ btn.innerHTML=mpIcon(on?"pause":"play",14);
768
+ btn.setAttribute("aria-label",on?"Pause sound":"Play sound");
769
+ }
770
+ // A sound preview must also silence any playing card, not just other
771
+ // previews — they are all one audio surface to the user.
772
+ audio.addEventListener("play",function(){mark(true);stopAllPlayers();});
773
+ audio.addEventListener("pause",function(){mark(false);});
774
+ audio.addEventListener("ended",function(){mark(false);if(fill)fill.style.width="0";});
775
+ audio.addEventListener("loadedmetadata",function(){
776
+ if(dur&&isFinite(audio.duration))dur.textContent=fmtTime(audio.duration);
777
+ });
778
+ audio.addEventListener("timeupdate",function(){
779
+ if(cur)cur.textContent=fmtTime(audio.currentTime);
780
+ if(fill&&isFinite(audio.duration)&&audio.duration>0)
781
+ fill.style.width=((audio.currentTime/audio.duration)*100)+"%";
782
+ });
783
+ if(seek)seek.addEventListener("click",function(e){
784
+ if(!isFinite(audio.duration)||audio.duration<=0)return;
785
+ var r=seek.getBoundingClientRect();
786
+ try{audio.currentTime=((e.clientX-r.left)/r.width)*audio.duration;}catch(err){}
787
+ });
788
+ })(nodes[n]);
789
+ }
790
+ }
791
+
792
+ // Wire every player in the freshly rendered view.
793
+ function initPlayers(){
794
+ resetPlayers();
795
+ resetAudio();
796
+ fitViewport();
797
+ initMediaPlayers(document);
798
+ initSoundPlayers(document);
799
+ refitToContent();
800
+ }
801
+
802
+ // ─── Viewport fit ───
803
+ // The stage must never need scrolling to be seen whole, and never grow
804
+ // wider than the frame. We cap its height to what the host actually gives
805
+ // us (visualViewport tracks the on-screen keyboard, so the media reclaims
806
+ // the space the moment the keyboard is dismissed) and let the aspect ratio
807
+ // derive the width from there. Only ever clamps downward from HARD_MAX, so
808
+ // the height we report back to the host can't feed itself.
809
+ var HARD_MAX=560,RESERVE=96;
810
+ function availableHeight(){
811
+ var vv=window.visualViewport;
812
+ return (vv&&vv.height)||window.innerHeight||HARD_MAX;
813
+ }
814
+ function setStageMax(px){
815
+ document.documentElement.style.setProperty(
816
+ "--stage-max",Math.max(200,Math.min(HARD_MAX,px))+"px");
817
+ }
818
+ function fitViewport(){
819
+ // First pass, before the view exists: reserve a conservative slice for
820
+ // the card footer and body padding.
821
+ setStageMax(availableHeight()-RESERVE);
822
+ }
823
+ // Second pass, once the view is laid out: measure what the chrome around
824
+ // the stage actually costs instead of guessing, so the player lands exactly
825
+ // inside the viewport with no scrollbar. Runs once per render — it reads a
826
+ // measurement and writes a cap, so it settles rather than looping.
827
+ function refitToContent(){
828
+ var stage=document.querySelector(".mp-stage");
829
+ if(!stage)return;
830
+ var stageH=stage.getBoundingClientRect().height;
831
+ if(!stageH)return;
832
+ var extra=Math.max(0,(document.body.scrollHeight||0)-stageH);
833
+ setStageMax(availableHeight()-extra);
834
+ }
835
+ // The on-screen keyboard shrinks visualViewport; dismissing it gives the
836
+ // height straight back, so the media returns to full size on its own.
837
+ function onViewportChange(){fitViewport();refitToContent();reportSize();}
838
+ if(window.visualViewport)window.visualViewport.addEventListener("resize",onViewportChange);
839
+ window.addEventListener("orientationchange",function(){setTimeout(onViewportChange,120);});
840
+
301
841
  function fmtNum(n){n=Number(n)||0;return n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e3?(n/1e3).toFixed(1)+"K":String(n);}
302
842
  function pColor(p){return{tiktok:"#000",douyin:"#000",instagram:"#E4405F",youtube:"#FF0000",xiaohongshu:"#FF2442",x:"#000",twitter:"#1DA1F2",bilibili:"#00A1D6",linkedin:"#0A66C2"}[p]||"#6B7280";}
303
843
  // Official brand mark (simple-icons path) as an inline SVG that inherits
304
844
  // the current text color via fill="currentColor" — matches badge accents.
845
+ // ─── UI icon set ───
846
+ // Real vector icons rather than emoji: emoji render differently on every
847
+ // OS (and some platforms colour them), so the control bar and engagement
848
+ // rail would not look the same from one machine to the next. These inherit
849
+ // currentColor and size, so one icon serves every context.
850
+ var MP_ICONS={
851
+ heart:"M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z",
852
+ comment:"M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z",
853
+ share:"M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z",
854
+ eye:"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z",
855
+ play:"M8 5v14l11-7z",
856
+ pause:"M6 19h4V5H6v14zm8-14v14h4V5h-4z",
857
+ volume:"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z",
858
+ muted:"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z",
859
+ note:"M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z",
860
+ prev:"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z",
861
+ next:"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z",
862
+ open:"M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
863
+ warn:"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"
864
+ };
865
+ function mpIcon(name,size){
866
+ var s=size||18,d=MP_ICONS[name]||"";
867
+ return '<svg viewBox="0 0 24 24" width="'+s+'" height="'+s+'" fill="currentColor" style="display:block;flex-shrink:0" aria-hidden="true" focusable="false"><path d="'+d+'"/></svg>';
868
+ }
869
+
305
870
  function pSvg(p,size){
306
871
  var s=size||14,paths={
307
872
  tiktok:"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
@@ -324,15 +889,107 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
324
889
  }
325
890
 
326
891
  // ─── Post Card ───
892
+ // ─── Media player markup ───
893
+ // Video and slideshow render the SAME shell: same overlay furniture, same
894
+ // control bar, same order. Only the stage contents differ — a <video>, or a
895
+ // stack of slides with the post's track behind them. Everything below the
896
+ // stage is byte-for-byte identical between the two, which is the point:
897
+ // controls must not move or change shape from one post to the next.
898
+ var mpId=0;
899
+ // Aspect class of the most recent player, so the enclosing card can adopt it.
900
+ var lastPlayerAr="v";
901
+ function mediaPlayerHtml(p,images,video,platform){
902
+ var id="mp"+(mpId++);
903
+ var isSlideshow=!video&&images.length>1;
904
+ var music=p.musicProxyUrl||p.musicUrl||"";
905
+ var thumb=p.thumbnailUrl||"";
906
+ var songLabel=[p.musicTitle||"",p.musicAuthor||""].filter(Boolean).join(" \u00b7 ");
907
+
908
+ // Shape: vertical is the short-form default; long-form and the desktop
909
+ // networks are wide; carousels on those networks are square.
910
+ var fmt=p.detectedFormat||"",dur=Number(p.duration)||0,ar="v";
911
+ var wide=(platform==="youtube"&&!(dur>0&&dur<=60))||platform==="linkedin"||platform==="twitter"||platform==="x";
912
+ if(fmt==="Long-form")wide=true;
913
+ if(fmt==="Short-form")wide=false;
914
+ if(wide)ar=isSlideshow?"sq":"h";
915
+
916
+ // Stage
917
+ var stage="";
918
+ if(video){
919
+ stage='<video src="'+esc(video)+'" preload="metadata" playsinline'
920
+ +(thumb?' poster="'+esc(thumb)+'"':"")+"></video>";
921
+ }else{
922
+ for(var i=0;i<images.length;i++)
923
+ stage+='<img class="mp-slide'+(i===0?" active":"")+'" src="'+esc(images[i])
924
+ +'" alt="slide '+(i+1)+'" loading="'+(i===0?"eager":"lazy")+'"/>';
925
+ }
926
+
927
+ // Overlay: brand badge, slide counter, engagement rail, caption block.
928
+ var acts=[["heart",p.likes,"likes"],["comment",p.comments,"comments"],
929
+ ["share",p.shares,"shares"],["eye",p.views,"views"]]
930
+ .filter(function(a){return Number(a[1])>0;})
931
+ .map(function(a){
932
+ return '<div class="mp-act" title="'+fmtNum(a[1])+" "+a[2]+'">'
933
+ +'<div class="ic">'+mpIcon(a[0],19)+"</div>"
934
+ +'<div class="n">'+fmtNum(a[1])+"</div></div>";
935
+ }).join("");
936
+ var handle=p.creatorHandle?'<div class="mp-handle">@'+esc(p.creatorHandle)+"</div>":"";
937
+ var capText=p.caption||p.title||"";
938
+ var cap=capText?'<div class="mp-cap">'+esc(capText)+"</div>":"";
939
+ var song=songLabel||music
940
+ ? '<div class="mp-song"><span class="note">'+mpIcon("note",12)+"</span>"
941
+ +'<span class="mp-song-t">'+esc(songLabel||"original sound")+"</span></div>"
942
+ : "";
943
+ var counter=isSlideshow?'<div class="mp-chip"><span class="mp-idx">1</span>/'+images.length+"</div>":"";
944
+ var navs=isSlideshow
945
+ ? '<button class="mp-nav prev" type="button" data-dir="-1" aria-label="Previous slide">'+mpIcon("prev",20)+"</button>"
946
+ +'<button class="mp-nav next" type="button" data-dir="1" aria-label="Next slide">'+mpIcon("next",20)+"</button>"
947
+ : "";
948
+
949
+ // Seek widget: one segment for video, one per slide for a slideshow.
950
+ var segCount=isSlideshow?images.length:1,segs="";
951
+ for(var k=0;k<segCount;k++)segs+='<div class="mp-seg"><i></i></div>';
952
+
953
+ var label=(p.creatorHandle?"@"+p.creatorHandle+" — ":"")+(isSlideshow?"slideshow":"video");
954
+ lastPlayerAr=ar;
955
+ return '<div class="mp '+ar+'" id="'+id+'" data-mp="'+(isSlideshow?"slides":"video")+'"'
956
+ +(isSlideshow?' data-slides="'+images.length+'"':"")
957
+ +' tabindex="0" role="group" aria-label="'+esc(label)+'">'
958
+ +'<div class="mp-stage">'+stage
959
+ +'<div class="mp-spin" hidden><i></i></div>'
960
+ +'<div class="mp-err" hidden>'+mpIcon("warn",22)
961
+ +"<span>This media could not be loaded</span></div>"
962
+ +"</div>"
963
+ +'<div class="mp-ov">'
964
+ +'<div class="mp-badge">'+pSvg(platform,12)+" "+esc(platform)+"</div>"
965
+ +counter+navs
966
+ +'<div class="mp-rail">'+acts+"</div>"
967
+ +'<div class="mp-meta">'+handle+cap+song+"</div>"
968
+ +'<button class="mp-tap" type="button" aria-label="Play"><span>'+mpIcon("play",28)+"</span></button>"
969
+ +"</div>"
970
+ +'<div class="mp-ctl">'
971
+ +'<button class="mp-btn mp-play" type="button" aria-label="Play">'+mpIcon("play",16)+"</button>"
972
+ +'<span class="mp-time mp-cur">0:00</span>'
973
+ +'<div class="mp-seek" role="slider" tabindex="-1" aria-label="Seek"'
974
+ +' aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">'
975
+ +'<div class="mp-segs">'+segs+"</div></div>"
976
+ +'<span class="mp-time mp-dur">0:00</span>'
977
+ +'<button class="mp-icon mp-mute" type="button" aria-label="Mute" aria-pressed="false">'
978
+ +mpIcon("volume",17)+"</button>"
979
+ +"</div>"
980
+ +(music?'<audio preload="metadata" src="'+esc(music)+'"></audio>':"")
981
+ +"</div>";
982
+ }
983
+
327
984
  function postCard(p,wide){
328
985
  var platform=p.platform||"unknown",color=pColor(platform),brandSvg=pSvg(platform);
329
986
  var title=p.title||p.caption||"",handle=p.creatorHandle||"",url=p.externalUrl||"";
330
987
  var thumb=p.thumbnailUrl||"",ct=p.contentType||"post";
331
988
  var views=p.views||0,likes=p.likes||0,comments=p.comments||0,shares=p.shares||0;
332
989
  var cls=wide?"card card-wide":"card";
333
- var statsHtml=[[views,"👁️"],[likes,"❤️"],[comments,"💬"],[shares,"🔄"]]
990
+ var statsHtml=[[views,"eye"],[likes,"heart"],[comments,"comment"],[shares,"share"]]
334
991
  .filter(function(s){return s[0]>0})
335
- .map(function(s){return'<span class="stat-pill">'+s[1]+" "+fmtNum(s[0])+"</span>";}).join("");
992
+ .map(function(s){return'<span class="stat-pill">'+mpIcon(s[1],13)+" "+fmtNum(s[0])+"</span>";}).join("");
336
993
  var handleHtml=handle?'<span class="handle">@'+esc(handle)+"</span>":"";
337
994
  var linkHtml=url?'<a href="'+esc(url)+'" target="_blank" class="btn" style="background:'+color+'">View on '+esc(platform)+' →</a>':"";
338
995
  // Prefer video playback over a static thumbnail when the post has one
@@ -343,45 +1000,33 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
343
1000
  if(p.mediaItems&&p.mediaItems.length){
344
1001
  for(var i=0;i<p.mediaItems.length;i++){
345
1002
  var m=p.mediaItems[i];
346
- if(m.kind==="video"&&m.preview_url){if(!video)video=m.preview_url;}
347
- else if(m.kind==="image"&&m.preview_url&&images.length<8)images.push(m.preview_url);
1003
+ // "proxy_url" is the same asset served through Orchyn — the raw
1004
+ // preview_url is a signed CDN link that this frame can't load.
1005
+ var mu=m.proxy_url||m.preview_url;
1006
+ if(m.kind==="video"&&mu){if(!video)video=mu;}
1007
+ else if(m.kind==="image"&&mu&&images.length<12)images.push(mu);
348
1008
  }
349
1009
  }
350
1010
  var mediaHtml="";
351
1011
  var vh=wide?"340px":"200px";
352
1012
  var bodyText=p.caption||p.text||"";
353
- if(video){
354
- // Detect aspect ratio from format/platform data.
355
- // Short-form vertical: TikTok, Reels, Shorts (9:16) -> taller card
356
- // Long-form horizontal: YouTube, LinkedIn, X (16:9) -> standard card
357
- var fmt=p.detectedFormat||"";
358
- var dur=p.duration||0;
359
- var isVertical=false;
360
- if(fmt==="Short-form")isVertical=true;
361
- else if(fmt==="Long-form")isVertical=false;
362
- else if(platform==="tiktok"||platform==="douyin")isVertical=true;
363
- else if(platform==="youtube"&&dur>0&&dur<=60)isVertical=true;
364
- else if(platform==="instagram")isVertical=true;
365
- var arCls=isVertical?"v":"h";
366
- var _vc="video-wrap "+arCls;
367
- mediaHtml='<div class=""+_vc+"">'
368
- +'<video src="'+esc(video)+'" controls preload="metadata" playsinline '
369
- +'poster="'+esc(thumb)+'" '
370
- +'style="width:100%;height:100%;object-fit:contain;display:block;background:#000">'
371
- +'Your browser does not support video playback.</video></div>';
372
- }else if(images.length>1){
373
- // Carousel / multi-image gallery — horizontally scrollable strip.
374
- var strip=images.map(function(u,i){
375
- return '<img src="'+esc(u)+'" alt="slide '+(i+1)+'" loading="lazy" style="height:'+vh+';max-width:85%;object-fit:cover;border-radius:6px;flex-shrink:0;display:block"/>';
376
- }).join("");
377
- mediaHtml='<div style="display:flex;gap:6px;overflow-x:auto;padding:8px;background:var(--border);border-bottom:1px solid var(--border)">'+strip+'</div>'
378
- +'<div style="display:flex;align-items:center;gap:6px;padding:6px 10px;font-size:11px;color:var(--muted);font-weight:600;border-bottom:1px solid var(--border)">🖼 '+images.length+' images</div>';
379
- }else if(thumb){
1013
+ var isSlideshow=(ct==="slideshow"||ct==="carousel")&&images.length>1;
1014
+ // Anything playable a video or a slideshow — goes through the one
1015
+ // player, so the controls are the same on every card.
1016
+ if(video||isSlideshow||images.length>1){
1017
+ mediaHtml=mediaPlayerHtml(p,images,video,platform);
1018
+ // The player already carries the handle, caption and stats as overlay
1019
+ // chrome, so the card body underneath is just the outbound link.
1020
+ return '<div class="'+cls+' card-media '+lastPlayerAr+'">'+mediaHtml
1021
+ +(url?'<div class="card-foot"><a href="'+esc(url)+'" target="_blank" rel="noopener" class="btn btn-sm" style="background:'+color+'">'+mpIcon("open",13)+"<span>Open on "+esc(platform)+"</span></a></div>":"")
1022
+ +"</div>";
1023
+ }
1024
+ if(thumb){
380
1025
  mediaHtml='<img src="'+esc(thumb)+'" alt="thumbnail" loading="lazy"/>';
381
1026
  }else if(bodyText){
382
1027
  // Text-only post (LinkedIn / X) — styled quote block.
383
1028
  mediaHtml='<div style="padding:16px 18px;border-bottom:1px solid var(--border);background:var(--card)">'
384
- +'<div style="font-size:13px;line-height:1.55;color:var(--fg);white-space:pre-line">'+esc(bodyText.length>400?bodyText.slice(0,400)+"":bodyText)+'</div></div>';
1029
+ +'<div style="font-size:13px;line-height:1.55;color:var(--fg);white-space:pre-line">'+esc(bodyText.length>400?bodyText.slice(0,400)+"\u2026":bodyText)+'</div></div>';
385
1030
  }
386
1031
  var ctTag=wide?'<span style="font-size:12px;color:var(--muted);text-transform:capitalize">'+esc(ct)+"</span>":"";
387
1032
  return '<div class="'+cls+'">'+mediaHtml+'<div class="card-body">'
@@ -550,7 +1195,14 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
550
1195
  }
551
1196
  initGalleryNav();
552
1197
 
1198
+ // Every render replaces the DOM wholesale, so player wiring has to run
1199
+ // after it — one wrapper instead of a call at each of the many early
1200
+ // returns below.
553
1201
  function render(result){
1202
+ try{renderView(result);}finally{initPlayers();setTimeout(reportSize,80);}
1203
+ }
1204
+
1205
+ function renderView(result){
554
1206
  var app=document.getElementById("app");if(!app)return;
555
1207
  var d=extractResult(result);
556
1208
  if(!d||typeof d!=="object"){app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";return;}
@@ -573,23 +1225,37 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
573
1225
  var user=c.username||c.user||"",text=(c.text||c.comment||"").slice(0,140),likes=c.likes||c.likeCount||0;
574
1226
  return '<div class="comment-row"><div style="flex:1"><span style="font-size:12px;font-weight:600">@'+esc(user)+"</span>"
575
1227
  +'<div style="font-size:13px;color:var(--fg);line-height:1.45;margin-top:3px">'+esc(text)+"</div></div>"
576
- +(likes>0?'<span style="font-size:11px;color:var(--muted);white-space:nowrap">❤️ '+fmtNum(likes)+"</span>":"")+"</div>";
1228
+ +(likes>0?'<span class="stat-pill" style="font-size:11px;white-space:nowrap">'+mpIcon("heart",12)+" "+fmtNum(likes)+"</span>":"")+"</div>";
577
1229
  }).join("");
578
1230
  app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
579
- +'<div style="font-size:16px;font-weight:700;margin-bottom:12px">💬 Comments ('+d.comments.length+')</div>'+ch+"</div></div>";return;}
1231
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:12px">'+mpIcon("comment",17)+"<span>Comments ("+d.comments.length+")</span></div>"+ch+"</div></div>";return;}
580
1232
  // Sounds
581
1233
  if(d.sounds&&Array.isArray(d.sounds)){
582
- app.innerHTML=galleryWrap(d.sounds.map(function(s){
1234
+ app.innerHTML=galleryWrap(d.sounds.map(function(s,i){
583
1235
  var color=pColor(s.platform||"tiktok");
584
- var cover=s.coverUrl?'<img class="sound-cover" src="'+esc(s.coverUrl)+'" alt="cover" loading="lazy"/>'
1236
+ var coverSrc=s.coverProxyUrl||s.coverUrl||"";
1237
+ var cover=coverSrc?'<img class="sound-cover" src="'+esc(coverSrc)+'" alt="cover" loading="lazy"/>'
585
1238
  :'<div class="sound-cover" style="background:'+color+'15;display:flex;align-items:center;justify-content:center;font-size:26px">🎵</div>';
586
- var play=s.playUrl||s.url||"";
587
- var playBtn=play?'<a href="'+esc(play)+'" target="_blank" class="btn btn-sm" style="background:'+color+'">▶ Play</a>':"";
1239
+ // The raw CDN URL is signed and cross-origin — only the proxied one
1240
+ // actually plays in this sandboxed frame. A bare link never played
1241
+ // anything in-place, which is why sound cards were silent.
1242
+ var play=s.playProxyUrl||s.playUrl||s.url||"";
1243
+ var secs=Number(s.duration)||0;
1244
+ var meta=[s.artist||s.author||"",secs>0?fmtTime(secs):""].filter(Boolean).join(" • ");
1245
+ var player=play
1246
+ ? '<div class="au" data-au="au'+i+'">'
1247
+ +'<button class="au-play" style="background:'+color+'" aria-label="Play sound">'+mpIcon("play",14)+"</button>"
1248
+ +'<div class="au-body"><div class="au-seek"><i></i></div>'
1249
+ +'<div class="au-time"><span class="au-cur">0:00</span><span class="au-dur">'
1250
+ +(secs>0?fmtTime(secs):"--:--")+"</span></div></div>"
1251
+ +'<audio preload="metadata" src="'+esc(play)+'"></audio></div>'
1252
+ : '<div class="au-missing">'+mpIcon("warn",13)+"<span>No preview for this track</span></div>";
588
1253
  return '<div class="sound-card"><div style="display:flex;gap:12px;align-items:center">'
589
1254
  +cover+'<div style="flex:1;min-width:0">'
590
1255
  +'<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.title||"")+"</div>"
591
- +'<div style="font-size:12px;color:var(--muted)">'+esc(s.artist||s.author||"")+(s.duration?" • "+esc(s.duration):"")+"</div>"
592
- +playBtn+"</div></div></div>";
1256
+ +'<div style="font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(meta)+"</div>"
1257
+ +(s.videoCount?'<div style="font-size:11px;color:var(--muted);margin-top:2px">'+fmtNum(s.videoCount)+" videos</div>":"")
1258
+ +"</div></div>"+player+"</div>";
593
1259
  }).join(""),d.sounds.length);return;}
594
1260
  // Credits
595
1261
  if(d.balance!=null||d.tier){
@@ -640,4 +1306,5 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
640
1306
  })();
641
1307
  </script>
642
1308
  </body>
643
- </html>`;
1309
+ </html>
1310
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchyn/mcp",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "description": "MCP server for orchyn - fetch, discover and understand TikTok/Instagram/YouTube/X/LinkedIn posts with AI (media metadata, niche discovery, hook & viral analysis)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,8 @@
21
21
  "start": "node dist/index.js",
22
22
  "login": "node dist/index.js login",
23
23
  "test": "vitest run",
24
- "prepare": "npm run build"
24
+ "prepare": "npm run build",
25
+ "test:e2e": "playwright test"
25
26
  },
26
27
  "keywords": [
27
28
  "mcp",
@@ -39,6 +40,7 @@
39
40
  "zod": "^3.24.0"
40
41
  },
41
42
  "devDependencies": {
43
+ "@playwright/test": "^1.62.1",
42
44
  "@types/node": "^22.0.0",
43
45
  "typescript": "^5.5.0",
44
46
  "vitest": "^3.1.0"