@kangsicj/nexussync 1.1.0 → 1.2.1

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.1-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)
@@ -74,6 +74,7 @@ No subtitles on that video? Turn on live speech recognition with translation int
74
74
  - **Make it yours** — pick your name and colour; your bubbles and doodles wear it on every screen.
75
75
  - **Calm, never jumpy** — phones going to sleep, slow Wi-Fi or a player restarting on its own never pause the whole room.
76
76
  - **No accidental pauses** — tapping the chat, a doodle or a reaction never nudges the video for anyone.
77
+ - **No ad interruptions** — ad boxes and links laid over the video are hidden while you watch, and ad pop-ups opened by a tap on the player are stopped.
77
78
  - **Invisible until needed** — the ⚙️ button appears only next to real videos, never on ordinary pages.
78
79
  - **Private rooms** — everything you share in a room is end-to-end encrypted; only people with the PIN can join.
79
80
 
@@ -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.1
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 *://*/*
@@ -25,6 +24,7 @@
25
24
  // @grant GM.getValue
26
25
  // @grant GM.listValues
27
26
  // @grant GM.xmlHttpRequest
27
+ // @grant unsafeWindow
28
28
  // @connect translate.googleapis.com
29
29
  // @connect nexussync-dd5b4-default-rtdb.asia-southeast1.firebasedatabase.app
30
30
  // @connect api.openai.com
@@ -32,5 +32,4 @@
32
32
  // @connect api.anthropic.com
33
33
  // @connect api-free.deepl.com
34
34
  // @connect api.deepl.com
35
- // @require https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.5.0/lz-string.min.js#sha256=95f4d1cbf099f57161b664bc048426ec3df92637801a4c79116e83315aa787e7
36
35
  // ==/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.1
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 *://*/*
@@ -25,6 +24,7 @@
25
24
  // @grant GM.getValue
26
25
  // @grant GM.listValues
27
26
  // @grant GM.xmlHttpRequest
27
+ // @grant unsafeWindow
28
28
  // @connect translate.googleapis.com
29
29
  // @connect nexussync-dd5b4-default-rtdb.asia-southeast1.firebasedatabase.app
30
30
  // @connect api.openai.com
@@ -32,20 +32,23 @@
32
32
  // @connect api.anthropic.com
33
33
  // @connect api-free.deepl.com
34
34
  // @connect api.deepl.com
35
- // @require https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.5.0/lz-string.min.js#sha256=95f4d1cbf099f57161b664bc048426ec3df92637801a4c79116e83315aa787e7
36
35
  // ==/UserScript==
37
36
 
38
37
  (async function () {
39
38
  'use strict';
40
39
 
40
+ // lz-string 1.5.0 — MIT License, © pieroxy (https://github.com/pieroxy/lz-string). Bundled so
41
+ // no extra file has to be fetched (and verified) by the userscript manager.
42
+ 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; })();
43
+
41
44
  // The version of the code that is actually running (the manager's metadata can lag
42
45
  // behind when an old copy is still installed).
43
- const NXU_VERSION = '1.1.0';
46
+ const NXU_VERSION = '1.2.1';
44
47
  // Rank used by the one-instance guard below and to tell newer builds apart in a room.
45
48
  // It is independent of the public version (1.0.0 is the first public release, after
46
49
  // private builds that reached ranks up to 10.2.0), so every older copy still installed
47
50
  // steps aside instead of this release.
48
- const NXU_RANK = '11.1.0';
51
+ const NXU_RANK = '11.2.1';
49
52
  const NXU_SESSION = Math.random().toString(36).slice(2, 10); // this page load // raise with every release
50
53
  // App icon (same artwork as assets/icon.svg / @icon), inlined so it shows offline too.
51
54
  const NXU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiByb2xlPSJpbWciIGFyaWEtbGFiZWw9Ik5leHVzU3luYyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJueGJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNWIyZWZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZmY0ZmEzIi8+PC9saW5lYXJHcmFkaWVudD48cmFkaWFsR3JhZGllbnQgaWQ9Im54Z2xvdyIgY3g9IjAuODIiIGN5PSIwLjE0IiByPSIwLjciPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzdjZjlmZiIgc3RvcC1vcGFjaXR5PSIwLjU1Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjN2NmOWZmIiBzdG9wLW9wYWNpdHk9IjAiLz48L3JhZGlhbEdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0ibnhwbGF5IiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZmZmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZmZlM2YxIi8+PC9saW5lYXJHcmFkaWVudD48Y2xpcFBhdGggaWQ9Im54ciI+PHJlY3Qgd2lkdGg9IjI1NiIgaGVpZ2h0PSIyNTYiIHJ4PSI1OCIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNueHIpIj48cmVjdCB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgZmlsbD0idXJsKCNueGJnKSIvPjxyZWN0IHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2IiBmaWxsPSJ1cmwoI254Z2xvdykiLz48ZyBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTYwIDExNiBBNzAgNzAgMCAwIDEgMTc4IDcyIi8+PHBhdGggZD0iTTE5NiAxNDAgQTcwIDcwIDAgMCAxIDc4IDE4NCIvPjwvZz48cGF0aCBkPSJNMTg2IDUwIEwyMDAgOTIgTDE1NyA4NiBaIiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTcwIDIwNiBMNTYgMTY0IEw5OSAxNzAgWiIgZmlsbD0iI2ZmZmZmZiIvPjxwYXRoIGQ9Ik0xMTAgOTIgUTEwNCA4OCAxMDQgOTYgTDEwNCAxNjAgUTEwNCAxNjggMTEwIDE2NCBMMTYyIDEzMiBRMTY4IDEyOCAxNjIgMTI0IFoiIGZpbGw9InVybCgjbnhwbGF5KSIvPjwvZz48L3N2Zz4=';
@@ -171,7 +174,7 @@
171
174
  'nxu_idle_timeout', 'nxu_sub_size', 'nxu_sub_opacity', 'nxu_sub_position', 'nxu_speaker_language',
172
175
  'nxu_subtitle_language', 'nxu_audio_mode', 'nxu_translation_provider', 'nxu_stt_local_first',
173
176
  'nxu_smart_speech_locale', 'nxu_fab_pos', 'nxu_asr_provider', 'nxu_key_openai', 'nxu_key_anthropic', 'nxu_key_groq', 'nxu_key_deepl',
174
- 'nxu_model_openai', 'nxu_model_anthropic', 'nxu_cinema_ambient', 'nxu_cinema_fit', 'nxu_cc_remaining', 'nxu_cinema_controls', 'nxu_nickname', 'nxu_color', 'nxu_quick_replies', 'nxu_chat_translate', 'nxu_sync_offset', 'nxu_cover_band', 'nxu_cover_marks', 'nxu_travel', 'nxu_auto_next', 'nxu_auto_cinema_sites', 'nxu_glossary', 'nxu_panel_size_chat-panel', 'nxu_panel_size_sub-log-panel', 'nxu_panel_size_debug-panel', 'nxu_panel_size_trans-modal', 'nxu_cinema_rotate', 'nxu_rotate_denied_' + HOST
177
+ 'nxu_model_openai', 'nxu_model_anthropic', 'nxu_cinema_ambient', 'nxu_cinema_fit', 'nxu_cc_remaining', 'nxu_cinema_controls', 'nxu_nickname', 'nxu_color', 'nxu_quick_replies', 'nxu_chat_translate', 'nxu_sync_offset', 'nxu_cover_band', 'nxu_cover_marks', 'nxu_travel', 'nxu_auto_next', 'nxu_auto_cinema_sites', 'nxu_ad_guard', 'nxu_glossary', 'nxu_panel_size_chat-panel', 'nxu_panel_size_sub-log-panel', 'nxu_panel_size_debug-panel', 'nxu_panel_size_trans-modal', 'nxu_cinema_rotate', 'nxu_rotate_denied_' + HOST
175
178
  ]);
176
179
 
177
180
  // ───────────────────────── Cross-origin HTTP (GM xhr first, fetch fallback)
@@ -483,9 +486,31 @@
483
486
  return w * h;
484
487
  }
485
488
 
489
+ // Vibration. Android: the Vibration API. iPhone/iPad have no Vibration API; Safari 18+
490
+ // gives a light tap when a switch control is toggled, which works inside a tap only.
491
+ let _hapticSwitch = null;
486
492
  function triggerHaptic(type = 'light') {
487
- if (!navigator.vibrate) return;
488
- try { navigator.vibrate(type === 'heavy' ? [100, 50, 100] : 15); } catch (_) { }
493
+ if (navigator.vibrate) {
494
+ try { navigator.vibrate(type === 'heavy' ? [100, 50, 100] : 15); } catch (_) { }
495
+ return;
496
+ }
497
+ if (!IS_IOS) return;
498
+ try {
499
+ if (!_hapticSwitch || !_hapticSwitch.isConnected) {
500
+ const label = document.createElement('label');
501
+ label.setAttribute('aria-hidden', 'true');
502
+ label.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
503
+ const input = document.createElement('input');
504
+ input.type = 'checkbox';
505
+ input.setAttribute('switch', '');
506
+ input.tabIndex = -1;
507
+ label.appendChild(input);
508
+ (document.body || document.documentElement).appendChild(label);
509
+ _hapticSwitch = label;
510
+ }
511
+ const pulses = type === 'heavy' ? 3 : 1;
512
+ for (let i = 0; i < pulses; i++) setTimeout(() => { try { _hapticSwitch.click(); } catch (_) { } }, i * 110);
513
+ } catch (_) { }
489
514
  }
490
515
 
491
516
  function generateUserColor(id) {
@@ -622,7 +647,7 @@
622
647
  travelBack: 'Back in the room', travelMoved: 'Moved to “{title}” · tap to go together', travelGoing: 'Going together…',
623
648
  chatTrOn: 'Auto-translate: on', chatTrOff: 'Auto-translate: off', chatTrShowOrig: 'Tap to see the original', chatTrShowTr: 'Tap to see the translation', chatTrFrom: 'Translated from {lang}',
624
649
  inkClosing: 'Closing soon — move or tap to keep drawing',
625
- modeSitePlayer: 'Site player in use', modeNexusPlayer: 'NexusSync player in use', modeSiteHint: 'Change subtitles or the server in the site’s player, then switch.', modeSiteHintAuto: 'Cinema started by itself. Pick subtitles or a server in the site’s player first if needed, then switch.', modeNexusHint: 'Everything is controlled by NexusSync. The site’s own settings are one tap away.', modeUseNexus: 'Use NexusSync player', modeUseSite: 'Use site player', autoCinemaStopSite: 'No auto-cinema on this site', autoCinemaOnSite: 'Auto-cinema on for {site}', autoCinemaOffSite: 'Auto-cinema off for {site}', autoCinemaBtnOn: 'Auto-cinema: on (this site)', autoCinemaBtnOff: 'Auto-cinema: off (this site)', ccPip: 'Picture-in-Picture (keep watching in a small window)', nextEpTitle: 'Up next', nextEpGo: 'Next episode', nextEpCancel: 'Stay on this episode', nextEpGoing: 'Next episode…', talkMenu: 'Chat', inkMenu: 'Quick sketch', quickEdit: 'Edit quick replies', quickEditHint: 'Separate with |', quickReset: 'Back to the default replies', save: 'Save',
650
+ modeSitePlayer: 'Site player in use', modeNexusPlayer: 'NexusSync player in use', modeSiteHint: 'Change subtitles or the server in the site’s player, then switch.', modeSiteHintAuto: 'Cinema started by itself. Pick subtitles or a server in the site’s player first if needed, then switch.', modeNexusHint: 'Everything is controlled by NexusSync. The site’s own settings are one tap away.', modeUseNexus: 'Use NexusSync player', modeUseSite: 'Use site player', autoCinemaStopSite: 'No auto-cinema on this site', autoCinemaOnSite: 'Auto-cinema on for {site}', autoCinemaOffSite: 'Auto-cinema off for {site}', autoCinemaBtnOn: 'Auto-cinema: on (this site)', autoCinemaBtnOff: 'Auto-cinema: off (this site)', adGuardOn: 'Ad guard: on', adGuardOff: 'Ad guard: off', adGuardHid: 'Hid an ad / link laid over the video', adGuardPopup: 'Stopped an ad pop-up', ccPip: 'Picture-in-Picture (keep watching in a small window)', nextEpTitle: 'Up next', nextEpGo: 'Next episode', nextEpCancel: 'Stay on this episode', nextEpGoing: 'Next episode…', talkMenu: 'Chat', inkMenu: 'Quick sketch', quickEdit: 'Edit quick replies', quickEditHint: 'Separate with |', quickReset: 'Back to the default replies', save: 'Save',
626
651
  talkTitle: 'Chat bubble', talkPlaceholder: 'Say something…', talkReplyTo: '↩ Reply to {name}', talkClose: 'Close', talkSend: 'Send', talkFull: 'Open the chat history (every message so far)', talkHistory: 'History',
627
652
  inkTitle: 'Quick sketch (fades away)', inkHint: 'Draw on the screen — it fades in a few seconds', inkDone: 'Done', inkBubble: 'is sketching on the screen — tap to draw back',
628
653
  you: 'You', roomBuffering: '⏳ {name} is loading… everyone waits', roomResume: '▶️ Everyone is ready — playing together', roomRate: '⏩ Speed {rate}x synced',
@@ -705,7 +730,7 @@
705
730
  travelBack: '방에 다시 연결했어요', travelMoved: '「{title}」(으)로 이동했어요 · 눌러서 같이 가기', travelGoing: '같이 이동하는 중…',
706
731
  chatTrOn: '자동 번역 켜짐', chatTrOff: '자동 번역 꺼짐', chatTrShowOrig: '눌러서 원문 보기', chatTrShowTr: '눌러서 번역 보기', chatTrFrom: '{lang}에서 번역됨',
707
732
  inkClosing: '곧 닫혀요 — 계속 그리려면 움직이거나 눌러 주세요',
708
- modeSitePlayer: '사이트 플레이어 사용 중', modeNexusPlayer: 'NexusSync 플레이어 사용 중', modeSiteHint: '자막이나 서버는 사이트 플레이어에서 바꾼 뒤 전환하세요.', modeSiteHintAuto: '시네마 모드가 자동으로 켜졌어요. 필요하면 사이트 플레이어에서 자막·서버를 먼저 고른 뒤 전환하세요.', modeNexusHint: 'NexusSync가 모든 조작을 맡아요. 사이트 설정은 한 번 탭으로 돌아갈 수 있어요.', modeUseNexus: 'NexusSync 플레이어로', modeUseSite: '사이트 플레이어로', autoCinemaStopSite: '이 사이트는 자동 시네마 끄기', autoCinemaOnSite: '{site}에서 자동 시네마 켜짐', autoCinemaOffSite: '{site}에서 자동 시네마 꺼짐', autoCinemaBtnOn: '자동 시네마: 켬 (이 사이트)', autoCinemaBtnOff: '자동 시네마: 끔 (이 사이트)', ccPip: 'PiP (작은 창으로 계속 보기)', nextEpTitle: '다음 화', nextEpGo: '다음 화 보기', nextEpCancel: '이번 화에 머물기', nextEpGoing: '다음 화로 이동하는 중…', talkMenu: '말풍선 채팅', inkMenu: '잠깐 그리기', quickEdit: '빠른 답장 편집', quickEditHint: '| 로 구분', quickReset: '기본 답장으로', save: '저장',
733
+ modeSitePlayer: '사이트 플레이어 사용 중', modeNexusPlayer: 'NexusSync 플레이어 사용 중', modeSiteHint: '자막이나 서버는 사이트 플레이어에서 바꾼 뒤 전환하세요.', modeSiteHintAuto: '시네마 모드가 자동으로 켜졌어요. 필요하면 사이트 플레이어에서 자막·서버를 먼저 고른 뒤 전환하세요.', modeNexusHint: 'NexusSync가 모든 조작을 맡아요. 사이트 설정은 한 번 탭으로 돌아갈 수 있어요.', modeUseNexus: 'NexusSync 플레이어로', modeUseSite: '사이트 플레이어로', autoCinemaStopSite: '이 사이트는 자동 시네마 끄기', autoCinemaOnSite: '{site}에서 자동 시네마 켜짐', autoCinemaOffSite: '{site}에서 자동 시네마 꺼짐', autoCinemaBtnOn: '자동 시네마: 켬 (이 사이트)', autoCinemaBtnOff: '자동 시네마: 끔 (이 사이트)', adGuardOn: '광고 가리기: 켬', adGuardOff: '광고 가리기: 끔', adGuardHid: '영상 위에 덮인 광고·링크를 숨겼어요', adGuardPopup: '광고 팝업을 막았어요', ccPip: 'PiP (작은 창으로 계속 보기)', nextEpTitle: '다음 화', nextEpGo: '다음 화 보기', nextEpCancel: '이번 화에 머물기', nextEpGoing: '다음 화로 이동하는 중…', talkMenu: '말풍선 채팅', inkMenu: '잠깐 그리기', quickEdit: '빠른 답장 편집', quickEditHint: '| 로 구분', quickReset: '기본 답장으로', save: '저장',
709
734
  talkTitle: '말풍선 채팅', talkPlaceholder: '메시지 보내기…', talkReplyTo: '↩ {name} 님에게 답장', talkClose: '닫기', talkSend: '보내기', talkFull: '지금까지의 대화 기록 전체 보기', talkHistory: '대화 기록',
710
735
  inkTitle: '잠깐 그리기 (몇 초 뒤 사라짐)', inkHint: '화면에 그려 보세요 — 몇 초 뒤 사라져요', inkDone: '완료', inkBubble: '님이 화면에 그리고 있어요 — 탭해서 같이 그리기',
711
736
  you: '나', roomBuffering: '⏳ {name} 기기 로딩 중… 모두 잠시 대기', roomResume: '▶️ 모두 준비됨 — 함께 재생합니다', roomRate: '⏩ {rate}배속 동기화',
@@ -3839,6 +3864,11 @@
3839
3864
 
3840
3865
  function _writeVideoStateFields(state) {
3841
3866
  yVideo.set('state', _stateForDoc(state));
3867
+ // Each device also keeps its latest decision under its own key. Two devices writing the
3868
+ // shared 'state' at the same moment are settled by Yjs in a fixed order — on a slow link
3869
+ // the same device always won, so the other one's pause could never get through. Keys
3870
+ // that only one device writes are never lost; readers pick the newest decision.
3871
+ yVideo.set(DEVICE_KEY_PREFIX + STATE.userId, _stateForDoc(state));
3842
3872
  // Legacy mirror for clients that have not upgraded yet.
3843
3873
  yVideo.set('eventId', state.eventId);
3844
3874
  yVideo.set('sourceDeviceId', state.sourceDeviceId);
@@ -3885,8 +3915,12 @@
3885
3915
  function _repairAcceptedVideoState() {
3886
3916
  const accepted = STATE._acceptedVideoState;
3887
3917
  if (!accepted || !ydoc || !yVideo) return;
3918
+ // Our own key carries what this device holds as the decision; once it shows the
3919
+ // accepted one, every reader can see it and there is nothing to rewrite.
3920
+ const own = _normalizeVideoState(yVideo.get(DEVICE_KEY_PREFIX + STATE.userId));
3921
+ if (own && own.eventId === accepted.eventId) return;
3888
3922
  const current = _normalizeVideoState(yVideo.get('state'));
3889
- if (current && current.eventId === accepted.eventId) return;
3923
+ if (current && current.eventId === accepted.eventId && own) return;
3890
3924
  // Writing back is only for a decision a stale writer covered up, and at most every
3891
3925
  // 3 s: two devices that disagree must not keep rewriting the doc at each other.
3892
3926
  if (accepted.action === 'heartbeat') return;
@@ -3897,13 +3931,29 @@
3897
3931
  logDebug(`[Authority] Restored accepted media state ${accepted.eventId}`, 'warn');
3898
3932
  }
3899
3933
 
3934
+ const DEVICE_KEY_PREFIX = 's:';
3935
+ // The newest decision among the shared key and every device's own key.
3936
+ function _newestVideoState(keys) {
3937
+ let best = null;
3938
+ for (const key of keys) {
3939
+ if (key !== 'state' && !(key.startsWith(DEVICE_KEY_PREFIX) && key !== DEVICE_KEY_PREFIX + STATE.userId)) continue;
3940
+ const st = _normalizeVideoState(yVideo.get(key));
3941
+ if (st && (!best || _isVideoStateNewer(st, best))) best = st;
3942
+ }
3943
+ return best;
3944
+ }
3900
3945
  function _readObservedVideoState(event) {
3901
3946
  if (!yVideo) return null;
3902
- if (event && event.keysChanged && event.keysChanged.has('state')) {
3903
- return _normalizeVideoState(yVideo.get('state'));
3947
+ if (event && event.keysChanged) {
3948
+ const keys = [...event.keysChanged].filter(k => k === 'state' || k.startsWith(DEVICE_KEY_PREFIX));
3949
+ if (keys.length) return _newestVideoState(keys);
3904
3950
  }
3905
3951
  return _readLegacyVideoState();
3906
3952
  }
3953
+ function _currentDocVideoState() {
3954
+ if (!yVideo) return null;
3955
+ return _newestVideoState([...yVideo.keys()]) || _readLegacyVideoState();
3956
+ }
3907
3957
 
3908
3958
  function _acceptRemoteVideoState(rawState) {
3909
3959
  const state = _normalizeVideoState(rawState);
@@ -3927,7 +3977,7 @@
3927
3977
 
3928
3978
  function _getVideoState() {
3929
3979
  if (!yVideo) return STATE._acceptedVideoState;
3930
- const current = _normalizeVideoState(yVideo.get('state')) || _readLegacyVideoState();
3980
+ const current = _currentDocVideoState();
3931
3981
  if (!STATE._acceptedVideoState) return current;
3932
3982
  return _isVideoStateNewer(current, STATE._acceptedVideoState) ? current : STATE._acceptedVideoState;
3933
3983
  }
@@ -4920,7 +4970,7 @@
4920
4970
  clearTimeout(STATE._deferredObserve);
4921
4971
  STATE._deferredObserve = setTimeout(() => {
4922
4972
  if (!yVideo) return;
4923
- _onObservedVideoState(_normalizeVideoState(yVideo.get('state')) || _readLegacyVideoState(), true);
4973
+ _onObservedVideoState(_currentDocVideoState(), true);
4924
4974
  }, Math.max(50, STATE.localIntentUntil - Date.now() + 60));
4925
4975
  return;
4926
4976
  }
@@ -6579,6 +6629,8 @@
6579
6629
  .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
6630
  .sync-bubble.visible { opacity:1; transform:translate(-50%,0); }
6581
6631
  /* bubbles */
6632
+ .nexus-container.nx-composing .nx-bubbles { top:calc(70px + var(--nx-compose-h, 120px) + env(safe-area-inset-top, 0px)); }
6633
+ .nx-bubbles { transition:top 0.25s ease; }
6582
6634
  .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
6635
  .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
6636
  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 +6679,16 @@
6627
6679
  .nx-next-k { font-size:11px; font-weight:800; opacity:0.75; text-transform:uppercase; letter-spacing:0.04em; }
6628
6680
  .nx-next-l { font-size:14px; font-weight:800; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
6629
6681
  .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;
6682
+ /* At the top: subtitles live at the bottom of the picture, and on phones the keyboard covers the bottom too. */
6683
+ .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
6684
  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
6685
  transition:opacity 0.22s ease, transform 0.25s cubic-bezier(.2,1.3,.4,1); display:flex; flex-direction:column; gap:6px; }
6633
6686
  .nx-compose.open { opacity:1; transform:translate(-50%, 0) scale(1); pointer-events:auto; }
6634
6687
  .nx-compose-head { display:flex; align-items:center; gap:6px; padding:0 2px 0 6px; }
6635
6688
  .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
6689
  .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; }
6690
+ .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; }
6691
+ .nx-compose-sub:empty { display:none; }
6637
6692
  .nx-compose-chips { display:flex; gap:6px; overflow-x:auto; padding:0 2px; }
6638
6693
  .nx-compose-row { display:flex; gap:6px; }
6639
6694
  .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; }
@@ -6701,7 +6756,9 @@
6701
6756
  /* together */
6702
6757
  .react-sep { width:1px; align-self:stretch; background:rgba(255,255,255,0.18); margin:2px 2px; }
6703
6758
  .nexus-container.nx-shake { animation:nxShake 0.5s ease; }
6704
- @keyframes nxShake { 15% { transform:translateX(-5px); } 30% { transform:translateX(5px); } 45% { transform:translateX(-4px); } 60% { transform:translateX(3px); } 80% { transform:translateX(-1px); } }
6759
+ /* "translate", not "transform": the container is turned 90° with transform in iPhone
6760
+ landscape view, and animating transform briefly put it back upright. */
6761
+ @keyframes nxShake { 15% { translate:-6px 0; } 30% { translate:6px 0; } 45% { translate:-5px 0; } 60% { translate:4px 0; } 80% { translate:-2px 0; } 100% { translate:0 0; } }
6705
6762
  .nx-ping-catcher { position:absolute; inset:0; z-index:2100; pointer-events:auto; cursor:crosshair; background:rgba(0,0,0,0.12); }
6706
6763
  .nx-ping-hint { position:absolute; top:calc(16px + env(safe-area-inset-top, 0px)); left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.75); color:#fff; padding:8px 16px; border-radius:999px; font-size:13px; font-weight:700; white-space:nowrap; }
6707
6764
  .nx-ping { position:absolute; z-index:2050; width:0; height:0; pointer-events:none; }
@@ -6924,6 +6981,9 @@
6924
6981
  }
6925
6982
  shadow = host.attachShadow({ mode: 'open' });
6926
6983
  UI_GUARD.install(host, shadow);
6984
+ // Taps on NexusSync's own controls stop here: many video sites open an ad tab
6985
+ // ("speed test", pop-unders) from a click listener on the whole page.
6986
+ ['click', 'dblclick', 'auxclick', 'contextmenu', 'mousedown', 'pointerdown', 'touchstart'].forEach(type => host.addEventListener(type, (e) => e.stopPropagation()));
6927
6987
  if (!SUPPORTS_TOP_LAYER) {
6928
6988
  // Without the top layer, paint order decides between equal z-indexes: stay last.
6929
6989
  new MutationObserver(() => {
@@ -6973,6 +7033,11 @@
6973
7033
  const btnCinema = mk('nxu-cinema', '🍿 ' + t('cinema'), t('cinemaTitle'));
6974
7034
  const btnAutoCinema = mk('nxu-auto-cinema', '');
6975
7035
  btnAutoCinema.classList.add('subtle');
7036
+ const btnAdGuard = mk('nxu-ad-guard', '');
7037
+ btnAdGuard.classList.add('subtle');
7038
+ const paintAdGuard = () => { const on = AD_GUARD.enabled(); btnAdGuard.textContent = '🛡️ ' + t(on ? 'adGuardOn' : 'adGuardOff'); btnAdGuard.classList.toggle('active', on); };
7039
+ paintAdGuard();
7040
+ btnAdGuard.onclick = () => { STORE.set('nxu_ad_guard', !AD_GUARD.enabled()); paintAdGuard(); showToast('🛡️ ' + t(AD_GUARD.enabled() ? 'adGuardOn' : 'adGuardOff')); };
6976
7041
  const btnPiP = mk('nxu-pip', '📺 PiP');
6977
7042
  const btnRate = mk('nxu-rate', '⏩ 1x', t('rateTitle'));
6978
7043
  // Talking happens in speech bubbles and quick ink now: the menu opens those directly
@@ -7086,7 +7151,7 @@
7086
7151
  toast.setAttribute('role', 'status');
7087
7152
 
7088
7153
  // Sketching and chat live in the reaction bar (🖍️ 💬); the menu does not repeat them.
7089
- dock.append(btnFindMedia, btnLink, btnInvite, btnSyncStatus, btnCinema, btnAutoCinema, btnPiP, btnRate, btnBookmark, btnTranslate, btnSubLog, btnDebug, btnHideSite);
7154
+ dock.append(btnFindMedia, btnLink, btnInvite, btnSyncStatus, btnCinema, btnAutoCinema, btnAdGuard, btnPiP, btnRate, btnBookmark, btnTranslate, btnSubLog, btnDebug, btnHideSite);
7090
7155
  uiWrapper.append(dock, reactPill, fab);
7091
7156
  nexusContainer.append(drawCanvas, uiWrapper, chatPanel, subLogPanel, manualModal, transModal, toast, debugPanel);
7092
7157
  shadow.appendChild(nexusContainer);
@@ -7812,6 +7877,8 @@
7812
7877
  };
7813
7878
  head.append(me, to, full, x);
7814
7879
  const chips = createEl('div', 'nx-compose-chips');
7880
+ // The line being spoken right now, so writing a message never means missing it.
7881
+ const subEcho = createEl('div', 'nx-compose-sub');
7815
7882
  const row = createEl('div', 'nx-compose-row');
7816
7883
  const input = createEl('input', 'nx-compose-input', '', { type: 'text', maxlength: '500', placeholder: t('talkPlaceholder'), enterkeyhint: 'send', autocomplete: 'off' });
7817
7884
  const send = createEl('button', 'nx-compose-send', '➤', { type: 'button', 'aria-label': t('talkSend') });
@@ -7823,7 +7890,7 @@
7823
7890
  });
7824
7891
  input.addEventListener('input', () => { SOCIAL.typing(); this._armClose(); });
7825
7892
  row.append(input, send);
7826
- wrap.append(head, chips, row);
7893
+ wrap.append(head, subEcho, chips, row);
7827
7894
  ['pointerdown', 'click', 'touchstart'].forEach(ev => wrap.addEventListener(ev, (e) => e.stopPropagation()));
7828
7895
  container.appendChild(wrap);
7829
7896
  this._composer = wrap;
@@ -7838,6 +7905,12 @@
7838
7905
  const cnt = this._composer.querySelector('.nx-hist-count');
7839
7906
  if (cnt) cnt.textContent = n ? (n > 99 ? '99+' : String(n)) : '';
7840
7907
  this._composer.classList.add('open');
7908
+ clearInterval(this._subTimer);
7909
+ this._subTimer = setInterval(() => this._echoSubtitle(), 300);
7910
+ this._echoSubtitle();
7911
+ // The bubbles step down below the open box (both live at the top now).
7912
+ const host = getContainer();
7913
+ if (host) { host.classList.add('nx-composing'); requestAnimationFrame(() => host.style.setProperty('--nx-compose-h', (this._composer.offsetHeight || 120) + 'px')); }
7841
7914
  this._fitKeyboard();
7842
7915
  if (!this._vvBound && window.visualViewport) {
7843
7916
  this._vvBound = true;
@@ -7869,9 +7942,23 @@
7869
7942
  if (!this._composer) return;
7870
7943
  if (this._composer.classList.contains('open')) UI_GUARD.shield();
7871
7944
  this._composer.classList.remove('open');
7945
+ clearInterval(this._subTimer);
7946
+ getContainer()?.classList.remove('nx-composing');
7872
7947
  try { this._input?.blur(); } catch (_) { }
7873
7948
  },
7874
7949
  isOpen() { return !!this._composer?.classList.contains('open'); },
7950
+ _echoSubtitle() {
7951
+ const box = this._composer?.querySelector('.nx-compose-sub');
7952
+ if (!box) return;
7953
+ if (!this.isOpen()) { clearInterval(this._subTimer); return; }
7954
+ let text = '';
7955
+ const finalSlot = shadow?.getElementById('nxu-final-slot');
7956
+ const last = finalSlot?.lastElementChild;
7957
+ if (last && TRANSLATOR.active) text = (last.querySelector('.sub-final-text')?.textContent || '' || '').trim();
7958
+ if (!text) { try { text = COVER._cueText(); } catch (_) { } }
7959
+ text = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 160);
7960
+ if (box.textContent !== (text ? '💬 ' + text : '')) box.textContent = text ? '💬 ' + text : '';
7961
+ },
7875
7962
  // Quick replies in the reader's language; ✏️ edits them (saved on this device).
7876
7963
  _renderChips() {
7877
7964
  const chips = this._composer?.querySelector('.nx-compose-chips');
@@ -8151,6 +8238,97 @@
8151
8238
  }
8152
8239
  };
8153
8240
 
8241
+ // ───────────────────────── Ad guard
8242
+ // While a video is being watched, links and ad boxes that sites lay over the picture
8243
+ // ("speed test", "download", sponsor banners) are hidden, and new tabs a site tries to
8244
+ // open to another website right after a tap on the player are stopped.
8245
+ const AD_GUARD = {
8246
+ RX_AD: /(speed\s*test|speedtest|속도\s*(측정|테스트)|광고|advert|sponsor|click\s*here|download\s*now|install\s*now|casino|betting|vpn|지금\s*(설치|다운|확인)|무료\s*(다운|설치)|바로가기)/i,
8247
+ RX_CONTROL: /(control|vjs-|jw-|plyr|shaka|ytp-|caption|subtitle|cue|track|progress|seek|volume|menu|setting|spinner|loading|poster|time|title|nxu)/i,
8248
+ hidden: new WeakSet(),
8249
+ _count: 0,
8250
+ _toasted: false,
8251
+ _blockedPopups: 0,
8252
+ enabled() { return STORE.get('nxu_ad_guard', true) !== false; },
8253
+ _watching() { const v = STATE.video; return !!v && v.isConnected && !v.paused && !v.ended && v.readyState >= 2; },
8254
+ _otherOrigin(href) { try { const u = new URL(href, location.href); return /^https?:$/.test(u.protocol) && u.origin !== location.origin; } catch (_) { return false; } },
8255
+ _adLike(el) {
8256
+ if (el.tagName === 'IFRAME') {
8257
+ if (VIDEO_DETECTOR.frameReports.has(el)) return false; // the player itself
8258
+ return this._otherOrigin(el.src || '');
8259
+ }
8260
+ const link = el.matches('a[href]') ? el : el.querySelector?.('a[href]');
8261
+ const outLink = !!link && this._otherOrigin(link.href) && !/^(#|javascript:)/i.test(link.getAttribute('href') || '');
8262
+ const text = (el.textContent || '').trim().slice(0, 200);
8263
+ const clicky = outLink || el.matches('a, [onclick]') || getComputedStyle(el).cursor === 'pointer';
8264
+ if (outLink && (link.target === '_blank' || this.RX_AD.test(text) || el.querySelector('img, iframe'))) return true;
8265
+ return clicky && this.RX_AD.test(text);
8266
+ },
8267
+ scan() {
8268
+ if (!this.enabled() || !this._watching()) return;
8269
+ const v = STATE.video;
8270
+ const r = v.getBoundingClientRect();
8271
+ if (r.width < 240 || r.height < 135) return;
8272
+ const seen = new Set();
8273
+ for (const fx of [0.15, 0.5, 0.85]) for (const fy of [0.15, 0.5, 0.85]) {
8274
+ const x = r.left + r.width * fx, y = r.top + r.height * fy;
8275
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) continue;
8276
+ let stack;
8277
+ try { stack = document.elementsFromPoint(x, y); } catch (_) { continue; }
8278
+ for (const el of stack) {
8279
+ if (el === v || el.contains(v)) break; // reached the picture
8280
+ if (seen.has(el) || this.hidden.has(el) || el.closest('#nxu-host') || el.id === 'nxu-host') continue;
8281
+ seen.add(el);
8282
+ // Climb to the whole overlay box (but never to something holding the video).
8283
+ let box = el;
8284
+ while (box.parentElement && !box.parentElement.contains(v) && box.parentElement !== document.body) box = box.parentElement;
8285
+ if (this.hidden.has(box)) continue;
8286
+ const sig = `${box.id} ${typeof box.className === 'string' ? box.className : ''}`;
8287
+ if (this.RX_CONTROL.test(sig)) continue;
8288
+ const br = box.getBoundingClientRect();
8289
+ if (br.width * br.height > r.width * r.height * 0.92 && !box.querySelector?.('a[href], iframe')) continue; // likely the player's own click layer
8290
+ if (!this._adLike(box) && !this._adLike(el)) continue;
8291
+ this.hide(box);
8292
+ }
8293
+ }
8294
+ },
8295
+ hide(el) {
8296
+ this.hidden.add(el);
8297
+ el.setAttribute('data-nxu-adguard', '');
8298
+ el.style.setProperty('display', 'none', 'important');
8299
+ this._count++;
8300
+ logDebug('Ad guard: hid an overlay on the video (' + (el.tagName || '') + ')', 'info');
8301
+ if (!this._toasted && IS_TOP) { this._toasted = true; showToast('🛡️ ' + t('adGuardHid')); }
8302
+ if (!IS_TOP) FRAME.postToParent({ type: 'NXU_ACTIVITY' });
8303
+ },
8304
+ // window.open from the page while watching: a tab to another website opened right after
8305
+ // a tap on the player is almost always an ad (pop-under). Same-site windows still open.
8306
+ installPopupGuard() {
8307
+ const W = typeof unsafeWindow !== 'undefined' && unsafeWindow ? unsafeWindow : null;
8308
+ if (!W || typeof W.open !== 'function' || W.__nxuPopupGuard) return;
8309
+ const orig = W.open;
8310
+ const self = this;
8311
+ try {
8312
+ W.__nxuPopupGuard = true;
8313
+ W.open = function (url, ...rest) {
8314
+ try {
8315
+ const busy = self.enabled() && (self._watching() || STATE.isCinema || STATE.handshakeVerified);
8316
+ const recentTap = Date.now() - ((STATE._lastPlayerGesture && STATE._lastPlayerGesture.at) || 0) < 2000;
8317
+ if (busy && recentTap && url && self._otherOrigin(String(url))) {
8318
+ self._blockedPopups++;
8319
+ logDebug('Ad guard: stopped a pop-up to ' + String(url).slice(0, 80), 'warn');
8320
+ if (IS_TOP) showToast('🛡️ ' + t('adGuardPopup'));
8321
+ return null;
8322
+ }
8323
+ } catch (_) { }
8324
+ return orig.apply(this, [url, ...rest]);
8325
+ };
8326
+ } catch (_) { }
8327
+ }
8328
+ };
8329
+ AD_GUARD.installPopupGuard();
8330
+ setInterval(() => { try { AD_GUARD.scan(); } catch (_) { } }, 1500);
8331
+
8154
8332
  // ───────────────────────── Automatic cinema
8155
8333
  // Opening a page that is all about one video (an episode, a movie — not a feed with many
8156
8334
  // posts) switches to cinema mode as soon as the video is ready. It keeps the site's own
@@ -8285,9 +8463,13 @@
8285
8463
  _cancelledFor: '',
8286
8464
  _count: null,
8287
8465
  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,
8466
+ // Words that clearly mean "next episode" …
8467
+ 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,
8468
+ // … and ones that only might ("다음", "Next", ⏭): they need a matching class/id too.
8469
+ RX_WEAK: /(^\s*다음\s*[▶>»]?\s*$|^\s*next\s*[▶>»]?\s*$|^\s*⏭\s*$|次へ)/i,
8470
+ RX_ATTR: /(next[-_ ]?(ep|episode|eps|video|chapter)|episode[-_]?next|ep[-_]?next|btn[-_]?next[-_]?ep)/i,
8471
+ RX_ATTR_WEAK: /(btn[-_]?next|nav[-_]?next|\bnext\b)/i,
8472
+ RX_BAD: /(prev|이전|previous|前|上一|comment|댓글|page|페이지|article|post|글|게시물|기사|뉴스|story|reply)/i,
8291
8473
  _visible(el) {
8292
8474
  if (!el.isConnected || el.closest('#nxu-host')) return false;
8293
8475
  const r = el.getBoundingClientRect();
@@ -8303,7 +8485,7 @@
8303
8485
  const here = location.origin + location.pathname + location.search;
8304
8486
  const out = [];
8305
8487
  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 });
8488
+ 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
8489
  const nodes = document.querySelectorAll('a[href], button, [role="button"], [onclick]');
8308
8490
  let n = 0;
8309
8491
  for (const el of nodes) {
@@ -8312,9 +8494,11 @@
8312
8494
  const attrs = `${el.id} ${typeof el.className === 'string' ? el.className : ''} ${el.getAttribute('data-action') || ''}`;
8313
8495
  let score = 0;
8314
8496
  if (this.RX_TEXT.test(label)) score += 60;
8315
- if (this.RX_ATTR.test(attrs)) score += 25;
8497
+ else if (this.RX_WEAK.test(label)) score += 20;
8498
+ if (this.RX_ATTR.test(attrs)) score += 40;
8499
+ else if (this.RX_ATTR_WEAK.test(attrs)) score += 15;
8316
8500
  if (!score) continue;
8317
- if (this.RX_BAD.test(label) || /prev/i.test(attrs)) score -= 70;
8501
+ if (this.RX_BAD.test(label) || /prev|page|post|article|comment/i.test(attrs)) score -= 70;
8318
8502
  if (score <= 0 || !this._visible(el)) continue;
8319
8503
  const href = el.tagName === 'A' ? el.href : '';
8320
8504
  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.1",
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",