@kangsicj/nexussync 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br>
6
6
 
7
- [![Version](https://img.shields.io/badge/version-1.1.0-7C4DFF?style=for-the-badge)](./CHANGELOG.md)
7
+ [![Version](https://img.shields.io/badge/version-1.2.0-7C4DFF?style=for-the-badge)](./CHANGELOG.md)
8
8
  [![Tampermonkey](https://img.shields.io/badge/Tampermonkey-PC%20%C2%B7%20Android-00485B?style=for-the-badge&logo=tampermonkey&logoColor=white)](https://www.tampermonkey.net/)
9
9
  [![iOS Safari](https://img.shields.io/badge/Userscripts-iPhone%20%C2%B7%20iPad-0A84FF?style=for-the-badge&logo=safari&logoColor=white)](https://apps.apple.com/app/userscripts/id1463298887)
10
10
  [![Sponsor](https://img.shields.io/badge/Fairy-Sponsor-FF6FB5?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://fairy.hada.io/@jinny)
@@ -1,7 +1,6 @@
1
1
  // ==UserScript==
2
- // @name NexusSync Ultimate: CRDT Canvas & Media Sync
3
- // @name:ko 넥서스싱크 얼티밋: 캔버스 및 미디어 동기화
4
- // @name:nl-BE NexusSync Ultimate: Canvas & Media Synchronisatie
2
+ // @name NexusSync
3
+ // @name:ko NexusSync (넥서스싱크)
5
4
  // @namespace https://github.com/kangsicj/NexusSync
6
5
  // @author kangsicj
7
6
  // @homepageURL https://github.com/kangsicj/NexusSync
@@ -9,7 +8,7 @@
9
8
  // @icon https://cdn.jsdelivr.net/npm/@kangsicj/nexussync@latest/assets/icon.svg
10
9
  // @updateURL https://cdn.jsdelivr.net/npm/@kangsicj/nexussync@latest/dist/NexusSync.meta.js
11
10
  // @downloadURL https://cdn.jsdelivr.net/npm/@kangsicj/nexussync@latest/dist/NexusSync.user.js
12
- // @version 1.1.0
11
+ // @version 1.2.0
13
12
  // @description Watch any web video together in perfect sync: cinema mode, live AI subtitles & translation, chat bubbles with auto-translation, quick ink, cover, go-together to the next episode.
14
13
  // @description:ko 어떤 웹 영상이든 완벽한 싱크로 함께 보기: 시네마 모드, 실시간 AI 자막·번역, 자동 번역 말풍선 채팅, 잠깐 그리기, 가리기, 다음 화도 함께.
15
14
  // @match *://*/*
@@ -32,5 +31,4 @@
32
31
  // @connect api.anthropic.com
33
32
  // @connect api-free.deepl.com
34
33
  // @connect api.deepl.com
35
- // @require https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.5.0/lz-string.min.js#sha256=95f4d1cbf099f57161b664bc048426ec3df92637801a4c79116e83315aa787e7
36
34
  // ==/UserScript==
@@ -1,7 +1,6 @@
1
1
  // ==UserScript==
2
- // @name NexusSync Ultimate: CRDT Canvas & Media Sync
3
- // @name:ko 넥서스싱크 얼티밋: 캔버스 및 미디어 동기화
4
- // @name:nl-BE NexusSync Ultimate: Canvas & Media Synchronisatie
2
+ // @name NexusSync
3
+ // @name:ko NexusSync (넥서스싱크)
5
4
  // @namespace https://github.com/kangsicj/NexusSync
6
5
  // @author kangsicj
7
6
  // @homepageURL https://github.com/kangsicj/NexusSync
@@ -9,7 +8,7 @@
9
8
  // @icon https://cdn.jsdelivr.net/npm/@kangsicj/nexussync@latest/assets/icon.svg
10
9
  // @updateURL https://cdn.jsdelivr.net/npm/@kangsicj/nexussync@latest/dist/NexusSync.meta.js
11
10
  // @downloadURL https://cdn.jsdelivr.net/npm/@kangsicj/nexussync@latest/dist/NexusSync.user.js
12
- // @version 1.1.0
11
+ // @version 1.2.0
13
12
  // @description Watch any web video together in perfect sync: cinema mode, live AI subtitles & translation, chat bubbles with auto-translation, quick ink, cover, go-together to the next episode.
14
13
  // @description:ko 어떤 웹 영상이든 완벽한 싱크로 함께 보기: 시네마 모드, 실시간 AI 자막·번역, 자동 번역 말풍선 채팅, 잠깐 그리기, 가리기, 다음 화도 함께.
15
14
  // @match *://*/*
@@ -32,20 +31,23 @@
32
31
  // @connect api.anthropic.com
33
32
  // @connect api-free.deepl.com
34
33
  // @connect api.deepl.com
35
- // @require https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.5.0/lz-string.min.js#sha256=95f4d1cbf099f57161b664bc048426ec3df92637801a4c79116e83315aa787e7
36
34
  // ==/UserScript==
37
35
 
38
36
  (async function () {
39
37
  'use strict';
40
38
 
39
+ // lz-string 1.5.0 — MIT License, © pieroxy (https://github.com/pieroxy/lz-string). Bundled so
40
+ // no extra file has to be fetched (and verified) by the userscript manager.
41
+ const LZString = (() => { var LZString=function(){var r=String.fromCharCode,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",e={};function t(r,o){if(!e[r]){e[r]={};for(var n=0;n<r.length;n++)e[r][r.charAt(n)]=n}return e[r][o]}var i={compressToBase64:function(r){if(null==r)return"";var n=i._compress(r,6,function(r){return o.charAt(r)});switch(n.length%4){default:case 0:return n;case 1:return n+"===";case 2:return n+"==";case 3:return n+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(n){return t(o,r.charAt(n))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(r){return null==r?"":""==r?null:i._decompress(r.length,16384,function(o){return r.charCodeAt(o)-32})},compressToUint8Array:function(r){for(var o=i.compress(r),n=new Uint8Array(2*o.length),e=0,t=o.length;e<t;e++){var s=o.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null==o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;e<t;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(r){return null==r?"":i._compress(r,6,function(r){return n.charAt(r)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(o){return t(n,r.charAt(o))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(r,o,n){if(null==r)return"";var e,t,i,s={},u={},a="",p="",c="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<r.length;i+=1)if(a=r.charAt(i),Object.prototype.hasOwnProperty.call(s,a)||(s[a]=f++,u[a]=!0),p=c+a,Object.prototype.hasOwnProperty.call(s,p))c=p;else{if(Object.prototype.hasOwnProperty.call(u,c)){if(c.charCodeAt(0)<256){for(e=0;e<h;e++)m<<=1,v==o-1?(v=0,d.push(n(m)),m=0):v++;for(t=c.charCodeAt(0),e=0;e<8;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;e<h;e++)m=m<<1|t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=c.charCodeAt(0),e=0;e<16;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}0==--l&&(l=Math.pow(2,h),h++),delete u[c]}else for(t=s[c],e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;0==--l&&(l=Math.pow(2,h),h++),s[p]=f++,c=String(a)}if(""!==c){if(Object.prototype.hasOwnProperty.call(u,c)){if(c.charCodeAt(0)<256){for(e=0;e<h;e++)m<<=1,v==o-1?(v=0,d.push(n(m)),m=0):v++;for(t=c.charCodeAt(0),e=0;e<8;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;e<h;e++)m=m<<1|t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=c.charCodeAt(0),e=0;e<16;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}0==--l&&(l=Math.pow(2,h),h++),delete u[c]}else for(t=s[c],e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;0==--l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==o-1){d.push(n(m));break}v++}return d.join("")},decompress:function(r){return null==r?"":""==r?null:i._decompress(r.length,32768,function(o){return r.charCodeAt(o)})},_decompress:function(o,n,e){var t,i,s,u,a,p,c,l=[],f=4,h=4,d=3,m="",v=[],g={val:e(0),position:n,index:1};for(t=0;t<3;t+=1)l[t]=t;for(s=0,a=Math.pow(2,2),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;switch(s){case 0:for(s=0,a=Math.pow(2,8),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;c=r(s);break;case 1:for(s=0,a=Math.pow(2,16),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;c=r(s);break;case 2:return""}for(l[3]=c,i=c,v.push(c);;){if(g.index>o)return"";for(s=0,a=Math.pow(2,d),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;switch(c=s){case 0:for(s=0,a=Math.pow(2,8),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;l[h++]=r(s),c=h-1,f--;break;case 1:for(s=0,a=Math.pow(2,16),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;l[h++]=r(s),c=h-1,f--;break;case 2:return v.join("")}if(0==f&&(f=Math.pow(2,d),d++),l[c])m=l[c];else{if(c!==h)return null;m=i+i.charAt(0)}v.push(m),l[h++]=i+m.charAt(0),i=m,0==--f&&(f=Math.pow(2,d),d++)}}};return i}(); return LZString; })();
42
+
41
43
  // The version of the code that is actually running (the manager's metadata can lag
42
44
  // behind when an old copy is still installed).
43
- const NXU_VERSION = '1.1.0';
45
+ const NXU_VERSION = '1.2.0';
44
46
  // Rank used by the one-instance guard below and to tell newer builds apart in a room.
45
47
  // It is independent of the public version (1.0.0 is the first public release, after
46
48
  // private builds that reached ranks up to 10.2.0), so every older copy still installed
47
49
  // steps aside instead of this release.
48
- const NXU_RANK = '11.1.0';
50
+ const NXU_RANK = '11.2.0';
49
51
  const NXU_SESSION = Math.random().toString(36).slice(2, 10); // this page load // raise with every release
50
52
  // App icon (same artwork as assets/icon.svg / @icon), inlined so it shows offline too.
51
53
  const NXU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiByb2xlPSJpbWciIGFyaWEtbGFiZWw9Ik5leHVzU3luYyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJueGJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNWIyZWZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZmY0ZmEzIi8+PC9saW5lYXJHcmFkaWVudD48cmFkaWFsR3JhZGllbnQgaWQ9Im54Z2xvdyIgY3g9IjAuODIiIGN5PSIwLjE0IiByPSIwLjciPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzdjZjlmZiIgc3RvcC1vcGFjaXR5PSIwLjU1Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjN2NmOWZmIiBzdG9wLW9wYWNpdHk9IjAiLz48L3JhZGlhbEdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0ibnhwbGF5IiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZmZmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZmZlM2YxIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9Im54ciI+PHJlY3Qgd2lkdGg9IjI1NiIgaGVpZ2h0PSIyNTYiIHJ4PSI1OCIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNueHIpIj48cmVjdCB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgZmlsbD0idXJsKCNueGJnKSIvPjxyZWN0IHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2IiBmaWxsPSJ1cmwoI254Z2xvdykiLz48ZyBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTYwIDExNiBBNzAgNzAgMCAwIDEgMTc4IDcyIi8+PHBhdGggZD0iTTE5NiAxNDAgQTcwIDcwIDAgMCAxIDc4IDE4NCIvPjwvZz48cGF0aCBkPSJNMTg2IDUwIEwyMDAgOTIgTDE1NyA4NiBaIiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTcwIDIwNiBMNTYgMTY0IEw5OSAxNzAgWiIgZmlsbD0iI2ZmZmZmZiIvPjxwYXRoIGQ9Ik0xMTAgOTIgUTEwNCA4OCAxMDQgOTYgTDEwNCAxNjAgUTEwNCAxNjggMTEwIDE2NCBMMTYyIDEzMiBRMTY4IDEyOCAxNjIgMTI0IFoiIGZpbGw9InVybCgjbnhwbGF5KSIvPjwvZz48L3N2Zz4=';
@@ -3839,6 +3841,11 @@
3839
3841
 
3840
3842
  function _writeVideoStateFields(state) {
3841
3843
  yVideo.set('state', _stateForDoc(state));
3844
+ // Each device also keeps its latest decision under its own key. Two devices writing the
3845
+ // shared 'state' at the same moment are settled by Yjs in a fixed order — on a slow link
3846
+ // the same device always won, so the other one's pause could never get through. Keys
3847
+ // that only one device writes are never lost; readers pick the newest decision.
3848
+ yVideo.set(DEVICE_KEY_PREFIX + STATE.userId, _stateForDoc(state));
3842
3849
  // Legacy mirror for clients that have not upgraded yet.
3843
3850
  yVideo.set('eventId', state.eventId);
3844
3851
  yVideo.set('sourceDeviceId', state.sourceDeviceId);
@@ -3885,8 +3892,12 @@
3885
3892
  function _repairAcceptedVideoState() {
3886
3893
  const accepted = STATE._acceptedVideoState;
3887
3894
  if (!accepted || !ydoc || !yVideo) return;
3895
+ // Our own key carries what this device holds as the decision; once it shows the
3896
+ // accepted one, every reader can see it and there is nothing to rewrite.
3897
+ const own = _normalizeVideoState(yVideo.get(DEVICE_KEY_PREFIX + STATE.userId));
3898
+ if (own && own.eventId === accepted.eventId) return;
3888
3899
  const current = _normalizeVideoState(yVideo.get('state'));
3889
- if (current && current.eventId === accepted.eventId) return;
3900
+ if (current && current.eventId === accepted.eventId && own) return;
3890
3901
  // Writing back is only for a decision a stale writer covered up, and at most every
3891
3902
  // 3 s: two devices that disagree must not keep rewriting the doc at each other.
3892
3903
  if (accepted.action === 'heartbeat') return;
@@ -3897,13 +3908,29 @@
3897
3908
  logDebug(`[Authority] Restored accepted media state ${accepted.eventId}`, 'warn');
3898
3909
  }
3899
3910
 
3911
+ const DEVICE_KEY_PREFIX = 's:';
3912
+ // The newest decision among the shared key and every device's own key.
3913
+ function _newestVideoState(keys) {
3914
+ let best = null;
3915
+ for (const key of keys) {
3916
+ if (key !== 'state' && !(key.startsWith(DEVICE_KEY_PREFIX) && key !== DEVICE_KEY_PREFIX + STATE.userId)) continue;
3917
+ const st = _normalizeVideoState(yVideo.get(key));
3918
+ if (st && (!best || _isVideoStateNewer(st, best))) best = st;
3919
+ }
3920
+ return best;
3921
+ }
3900
3922
  function _readObservedVideoState(event) {
3901
3923
  if (!yVideo) return null;
3902
- if (event && event.keysChanged && event.keysChanged.has('state')) {
3903
- return _normalizeVideoState(yVideo.get('state'));
3924
+ if (event && event.keysChanged) {
3925
+ const keys = [...event.keysChanged].filter(k => k === 'state' || k.startsWith(DEVICE_KEY_PREFIX));
3926
+ if (keys.length) return _newestVideoState(keys);
3904
3927
  }
3905
3928
  return _readLegacyVideoState();
3906
3929
  }
3930
+ function _currentDocVideoState() {
3931
+ if (!yVideo) return null;
3932
+ return _newestVideoState([...yVideo.keys()]) || _readLegacyVideoState();
3933
+ }
3907
3934
 
3908
3935
  function _acceptRemoteVideoState(rawState) {
3909
3936
  const state = _normalizeVideoState(rawState);
@@ -3927,7 +3954,7 @@
3927
3954
 
3928
3955
  function _getVideoState() {
3929
3956
  if (!yVideo) return STATE._acceptedVideoState;
3930
- const current = _normalizeVideoState(yVideo.get('state')) || _readLegacyVideoState();
3957
+ const current = _currentDocVideoState();
3931
3958
  if (!STATE._acceptedVideoState) return current;
3932
3959
  return _isVideoStateNewer(current, STATE._acceptedVideoState) ? current : STATE._acceptedVideoState;
3933
3960
  }
@@ -4920,7 +4947,7 @@
4920
4947
  clearTimeout(STATE._deferredObserve);
4921
4948
  STATE._deferredObserve = setTimeout(() => {
4922
4949
  if (!yVideo) return;
4923
- _onObservedVideoState(_normalizeVideoState(yVideo.get('state')) || _readLegacyVideoState(), true);
4950
+ _onObservedVideoState(_currentDocVideoState(), true);
4924
4951
  }, Math.max(50, STATE.localIntentUntil - Date.now() + 60));
4925
4952
  return;
4926
4953
  }
@@ -6579,6 +6606,8 @@
6579
6606
  .sync-bubble { position:absolute; top:20px; left:50%; transform:translate(-50%,-20px); background:var(--glass-strong); color:#fff; padding:10px 22px; border-radius:24px; border-left:4px solid var(--accent); font-weight:600; opacity:0; transition:opacity 0.4s, transform 0.4s; z-index:1900; white-space:nowrap; }
6580
6607
  .sync-bubble.visible { opacity:1; transform:translate(-50%,0); }
6581
6608
  /* bubbles */
6609
+ .nexus-container.nx-composing .nx-bubbles { top:calc(70px + var(--nx-compose-h, 120px) + env(safe-area-inset-top, 0px)); }
6610
+ .nx-bubbles { transition:top 0.25s ease; }
6582
6611
  .nx-bubbles { position:absolute; top:calc(64px + env(safe-area-inset-top, 0px)); left:calc(12px + env(safe-area-inset-left, 0px)); z-index:1950; display:flex; flex-direction:column; gap:8px; width:min(300px, calc(100% - 24px)); pointer-events:none; }
6583
6612
  .nx-bubble { pointer-events:auto; display:block; width:100%; text-align:left; border:none; cursor:pointer; color:#fff; font:inherit; background:rgba(18,20,26,0.52); -webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px); text-shadow:0 1px 2px rgba(0,0,0,0.8);
6584
6613
  border-radius:16px 16px 16px 6px; padding:9px 12px 10px; box-shadow:0 8px 24px rgba(0,0,0,0.45); border-left:4px solid var(--c, #69f0ae); opacity:0; transform:translateX(-24px) scale(0.96); transition:opacity 0.25s ease, transform 0.25s ease; }
@@ -6627,13 +6656,16 @@
6627
6656
  .nx-next-k { font-size:11px; font-weight:800; opacity:0.75; text-transform:uppercase; letter-spacing:0.04em; }
6628
6657
  .nx-next-l { font-size:14px; font-weight:800; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
6629
6658
  .nx-next-x { flex:0 0 auto; width:28px; height:28px; border:none; border-radius:50%; background:rgba(255,255,255,0.12); color:#fff; cursor:pointer; font-size:12px; }
6630
- .nx-compose { position:absolute; left:50%; bottom:calc(112px + env(safe-area-inset-bottom, 0px)); z-index:2060; width:min(520px, calc(100% - 24px)); transform:translate(-50%, 16px) scale(0.96); opacity:0; pointer-events:none;
6659
+ /* At the top: subtitles live at the bottom of the picture, and on phones the keyboard covers the bottom too. */
6660
+ .nx-compose { position:absolute; left:50%; top:calc(60px + env(safe-area-inset-top, 0px)); z-index:2060; width:min(520px, calc(100% - 24px)); transform:translate(-50%, -14px) scale(0.96); opacity:0; pointer-events:none;
6631
6661
  background:rgba(16,18,24,0.58); -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px); border-radius:18px; padding:8px 8px 9px; text-shadow:0 1px 2px rgba(0,0,0,0.75); box-shadow:0 14px 36px rgba(0,0,0,0.55), 0 0 0 1px color-mix(in srgb, var(--c, #69f0ae) 50%, transparent);
6632
6662
  transition:opacity 0.22s ease, transform 0.25s cubic-bezier(.2,1.3,.4,1); display:flex; flex-direction:column; gap:6px; }
6633
6663
  .nx-compose.open { opacity:1; transform:translate(-50%, 0) scale(1); pointer-events:auto; }
6634
6664
  .nx-compose-head { display:flex; align-items:center; gap:6px; padding:0 2px 0 6px; }
6635
6665
  .nx-compose-to { flex:1; min-width:0; font-size:12px; font-weight:800; color:var(--c, #69f0ae); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
6636
6666
  .nx-compose-x { flex:0 0 auto; width:28px; height:28px; border:none; border-radius:50%; background:rgba(255,255,255,0.08); color:#fff; cursor:pointer; font-size:13px; font-family:inherit; }
6667
+ .nx-compose-sub { font-size:13px; line-height:1.35; padding:5px 10px; border-radius:10px; background:rgba(0,0,0,0.35); color:#fff; font-weight:600; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
6668
+ .nx-compose-sub:empty { display:none; }
6637
6669
  .nx-compose-chips { display:flex; gap:6px; overflow-x:auto; padding:0 2px; }
6638
6670
  .nx-compose-row { display:flex; gap:6px; }
6639
6671
  .nx-compose-input { flex:1; min-width:0; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.14); color:#fff; border-radius:999px; padding:9px 14px; font-size:16px; font-family:inherit; outline:none; }
@@ -7812,6 +7844,8 @@
7812
7844
  };
7813
7845
  head.append(me, to, full, x);
7814
7846
  const chips = createEl('div', 'nx-compose-chips');
7847
+ // The line being spoken right now, so writing a message never means missing it.
7848
+ const subEcho = createEl('div', 'nx-compose-sub');
7815
7849
  const row = createEl('div', 'nx-compose-row');
7816
7850
  const input = createEl('input', 'nx-compose-input', '', { type: 'text', maxlength: '500', placeholder: t('talkPlaceholder'), enterkeyhint: 'send', autocomplete: 'off' });
7817
7851
  const send = createEl('button', 'nx-compose-send', '➤', { type: 'button', 'aria-label': t('talkSend') });
@@ -7823,7 +7857,7 @@
7823
7857
  });
7824
7858
  input.addEventListener('input', () => { SOCIAL.typing(); this._armClose(); });
7825
7859
  row.append(input, send);
7826
- wrap.append(head, chips, row);
7860
+ wrap.append(head, subEcho, chips, row);
7827
7861
  ['pointerdown', 'click', 'touchstart'].forEach(ev => wrap.addEventListener(ev, (e) => e.stopPropagation()));
7828
7862
  container.appendChild(wrap);
7829
7863
  this._composer = wrap;
@@ -7838,6 +7872,12 @@
7838
7872
  const cnt = this._composer.querySelector('.nx-hist-count');
7839
7873
  if (cnt) cnt.textContent = n ? (n > 99 ? '99+' : String(n)) : '';
7840
7874
  this._composer.classList.add('open');
7875
+ clearInterval(this._subTimer);
7876
+ this._subTimer = setInterval(() => this._echoSubtitle(), 300);
7877
+ this._echoSubtitle();
7878
+ // The bubbles step down below the open box (both live at the top now).
7879
+ const host = getContainer();
7880
+ if (host) { host.classList.add('nx-composing'); requestAnimationFrame(() => host.style.setProperty('--nx-compose-h', (this._composer.offsetHeight || 120) + 'px')); }
7841
7881
  this._fitKeyboard();
7842
7882
  if (!this._vvBound && window.visualViewport) {
7843
7883
  this._vvBound = true;
@@ -7869,9 +7909,23 @@
7869
7909
  if (!this._composer) return;
7870
7910
  if (this._composer.classList.contains('open')) UI_GUARD.shield();
7871
7911
  this._composer.classList.remove('open');
7912
+ clearInterval(this._subTimer);
7913
+ getContainer()?.classList.remove('nx-composing');
7872
7914
  try { this._input?.blur(); } catch (_) { }
7873
7915
  },
7874
7916
  isOpen() { return !!this._composer?.classList.contains('open'); },
7917
+ _echoSubtitle() {
7918
+ const box = this._composer?.querySelector('.nx-compose-sub');
7919
+ if (!box) return;
7920
+ if (!this.isOpen()) { clearInterval(this._subTimer); return; }
7921
+ let text = '';
7922
+ const finalSlot = shadow?.getElementById('nxu-final-slot');
7923
+ const last = finalSlot?.lastElementChild;
7924
+ if (last && TRANSLATOR.active) text = (last.querySelector('.sub-final-text')?.textContent || '' || '').trim();
7925
+ if (!text) { try { text = COVER._cueText(); } catch (_) { } }
7926
+ text = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 160);
7927
+ if (box.textContent !== (text ? '💬 ' + text : '')) box.textContent = text ? '💬 ' + text : '';
7928
+ },
7875
7929
  // Quick replies in the reader's language; ✏️ edits them (saved on this device).
7876
7930
  _renderChips() {
7877
7931
  const chips = this._composer?.querySelector('.nx-compose-chips');
@@ -8285,9 +8339,13 @@
8285
8339
  _cancelledFor: '',
8286
8340
  _count: null,
8287
8341
  enabled() { return STORE.get('nxu_auto_next', true) !== false; },
8288
- RX_TEXT: /(다음\s*(화|회|편|회차|에피소드|episode|ep\.?)|다음\s*$|^\s*다음|next\s*(episode|ep\b|ep\.|part|chapter|video)|^\s*next\s*$|次の(話|エピソード)|次へ|下一[集话話]|siguiente\s*episodio|épisode\s*suivant|nächste\s*folge|próximo\s*episódio|⏭|»\s*$)/i,
8289
- RX_ATTR: /(next[-_ ]?(ep|episode|video|btn|button|link)|btn[-_]?next|episode[-_]?next|nav[-_]?next|\bnext\b)/i,
8290
- RX_BAD: /(prev|이전|previous|前|上一|comment|댓글|page|페이지|article|post|기사|뉴스)/i,
8342
+ // Words that clearly mean "next episode" …
8343
+ RX_TEXT: /(다음\s*(화|회|편|회차|에피소드|episode|ep\.?)|next\s*(episode|ep\b|ep\.|part|chapter)|次の(話|エピソード)|下一[集话話]|siguiente\s*episodio|épisode\s*suivant|nächste\s*folge|próximo\s*episódio)/i,
8344
+ // … and ones that only might ("다음", "Next", ⏭): they need a matching class/id too.
8345
+ RX_WEAK: /(^\s*다음\s*[▶>»]?\s*$|^\s*next\s*[▶>»]?\s*$|^\s*⏭\s*$|次へ)/i,
8346
+ RX_ATTR: /(next[-_ ]?(ep|episode|eps|video|chapter)|episode[-_]?next|ep[-_]?next|btn[-_]?next[-_]?ep)/i,
8347
+ RX_ATTR_WEAK: /(btn[-_]?next|nav[-_]?next|\bnext\b)/i,
8348
+ RX_BAD: /(prev|이전|previous|前|上一|comment|댓글|page|페이지|article|post|글|게시물|기사|뉴스|story|reply)/i,
8291
8349
  _visible(el) {
8292
8350
  if (!el.isConnected || el.closest('#nxu-host')) return false;
8293
8351
  const r = el.getBoundingClientRect();
@@ -8303,7 +8361,7 @@
8303
8361
  const here = location.origin + location.pathname + location.search;
8304
8362
  const out = [];
8305
8363
  const rel = document.querySelector('a[rel~="next"][href], link[rel~="next"][href]');
8306
- if (rel) out.push({ el: rel.tagName === 'A' ? rel : null, href: rel.href, label: rel.tagName === 'A' ? this._label(rel) : '', score: 40 });
8364
+ if (rel && !this.RX_BAD.test(rel.href)) out.push({ el: rel.tagName === 'A' ? rel : null, href: rel.href, label: rel.tagName === 'A' ? this._label(rel) : '', score: 30 });
8307
8365
  const nodes = document.querySelectorAll('a[href], button, [role="button"], [onclick]');
8308
8366
  let n = 0;
8309
8367
  for (const el of nodes) {
@@ -8312,9 +8370,11 @@
8312
8370
  const attrs = `${el.id} ${typeof el.className === 'string' ? el.className : ''} ${el.getAttribute('data-action') || ''}`;
8313
8371
  let score = 0;
8314
8372
  if (this.RX_TEXT.test(label)) score += 60;
8315
- if (this.RX_ATTR.test(attrs)) score += 25;
8373
+ else if (this.RX_WEAK.test(label)) score += 20;
8374
+ if (this.RX_ATTR.test(attrs)) score += 40;
8375
+ else if (this.RX_ATTR_WEAK.test(attrs)) score += 15;
8316
8376
  if (!score) continue;
8317
- if (this.RX_BAD.test(label) || /prev/i.test(attrs)) score -= 70;
8377
+ if (this.RX_BAD.test(label) || /prev|page|post|article|comment/i.test(attrs)) score -= 70;
8318
8378
  if (score <= 0 || !this._visible(el)) continue;
8319
8379
  const href = el.tagName === 'A' ? el.href : '';
8320
8380
  if (href) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangsicj/nexussync",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Watch-together userscript: media sync, cinema mode, live AI subtitles, chat bubbles and quick ink for Tampermonkey and iOS Userscripts.",
5
5
  "keywords": [
6
6
  "userscript",