index.html 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
  6. <meta name="theme-color" content="#000000">
  7. <title>Arozcast</title>
  8. <link rel="manifest" href="manifest.json" crossorigin="use-credentials">
  9. <script src="../script/alpine.min.js" defer></script>
  10. <script src="../script/jquery.min.js"></script>
  11. <script src="../script/ao_module.js"></script>
  12. <style>
  13. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  14. :root {
  15. --bg: #000;
  16. --accent: #b0b0b0;
  17. --text: #f0f0f5;
  18. --text2: #9090a8;
  19. --bar-bg: rgba(10, 10, 15, 0.82);
  20. }
  21. html, body {
  22. height: 100%; overflow: hidden;
  23. background: var(--bg); color: var(--text);
  24. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  25. user-select: none;
  26. }
  27. #app {
  28. position: relative; width: 100vw; height: 100vh;
  29. display: flex; align-items: center; justify-content: center;
  30. overflow: hidden;
  31. }
  32. /* ── Background layer ────────────────────────────────────────── */
  33. .bg-layer {
  34. position: absolute; inset: 0; z-index: 0;
  35. background: #000;
  36. transition: opacity .6s ease;
  37. }
  38. .bg-layer img {
  39. width: 100%; height: 100%; object-fit: cover;
  40. filter: blur(32px) brightness(0.35) saturate(1.4);
  41. transform: scale(1.08);
  42. }
  43. /* ── Waiting screen ─────────────────────────────────────────── */
  44. .waiting-screen {
  45. position: relative; z-index: 10;
  46. display: flex; flex-direction: column;
  47. align-items: center; justify-content: center;
  48. gap: 0;
  49. text-align: center;
  50. padding: 32px;
  51. }
  52. .brand-logo {
  53. display: flex; align-items: center; gap: 12px;
  54. margin-bottom: 40px;
  55. }
  56. .brand-icon {
  57. width: 48px; height: 48px;
  58. }
  59. .brand-name {
  60. font-size: 28px; font-weight: 800;
  61. letter-spacing: -0.5px; color: var(--text);
  62. }
  63. .code-label {
  64. font-size: 13px; text-transform: uppercase; letter-spacing: .15em;
  65. color: var(--text2); margin-bottom: 16px;
  66. }
  67. .code-display {
  68. display: flex; gap: 12px; margin-bottom: 32px;
  69. }
  70. .code-digit {
  71. width: 72px; height: 88px;
  72. background: rgba(255,255,255,.07);
  73. border: 1.5px solid rgba(255,255,255,.12);
  74. border-radius: 14px;
  75. display: flex; align-items: center; justify-content: center;
  76. font-size: 52px; font-weight: 700; font-variant-numeric: tabular-nums;
  77. color: var(--text);
  78. backdrop-filter: blur(8px);
  79. }
  80. .waiting-hint {
  81. font-size: 14px; color: var(--text2); max-width: 320px; line-height: 1.6;
  82. }
  83. .peer-count {
  84. margin-top: 24px;
  85. font-size: 12px; color: var(--text2);
  86. display: flex; align-items: center; gap: 6px;
  87. }
  88. .peer-dot {
  89. width: 7px; height: 7px; border-radius: 50%;
  90. background: #22c55e;
  91. box-shadow: 0 0 6px #22c55e;
  92. }
  93. .peer-dot.idle { background: var(--text2); box-shadow: none; }
  94. /* ── Audio/cover mode ───────────────────────────────────────── */
  95. .cover-display {
  96. position: relative; z-index: 10;
  97. display: flex; flex-direction: column;
  98. align-items: center; justify-content: center;
  99. gap: 20px;
  100. padding-bottom: 80px;
  101. }
  102. .cover-art {
  103. width: min(55vw, 55vh);
  104. height: min(55vw, 55vh);
  105. border-radius: 18px;
  106. object-fit: cover;
  107. box-shadow: 0 24px 64px rgba(0,0,0,.7);
  108. }
  109. .track-meta {
  110. text-align: center;
  111. }
  112. .track-name {
  113. font-size: 22px; font-weight: 700; color: var(--text);
  114. max-width: 60vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  115. }
  116. .track-artist {
  117. font-size: 15px; color: var(--text2); margin-top: 4px;
  118. }
  119. /* ── Video mode ─────────────────────────────────────────────── */
  120. #acVideo {
  121. position: absolute; inset: 0; z-index: 8;
  122. width: 100%; height: 100%; object-fit: contain;
  123. background: #000;
  124. }
  125. /* ── Photo mode ─────────────────────────────────────────────── */
  126. .photo-display {
  127. position: absolute; inset: 0; z-index: 8;
  128. display: flex; align-items: center; justify-content: center;
  129. background: #000;
  130. }
  131. .photo-display img {
  132. max-width: 100%; max-height: 100%;
  133. object-fit: contain;
  134. }
  135. /* ── Screen share mode (WebRTC) ─────────────────────────────── */
  136. #acScreen {
  137. position: absolute; inset: 0; z-index: 9;
  138. width: 100%; height: 100%; object-fit: contain;
  139. background: #000;
  140. }
  141. /* ── Hidden audio ───────────────────────────────────────────── */
  142. #acAudio { display: none; }
  143. /* ── Bottom toolbar ─────────────────────────────────────────── */
  144. .toolbar {
  145. position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
  146. z-index: 200;
  147. display: flex; align-items: center; gap: 8px;
  148. background: var(--bar-bg);
  149. backdrop-filter: blur(20px) saturate(1.5);
  150. border: 1px solid rgba(255,255,255,.1);
  151. border-radius: 40px;
  152. padding: 8px 16px;
  153. min-width: 200px; max-width: 480px;
  154. box-shadow: 0 8px 32px rgba(0,0,0,.5);
  155. transition: opacity .3s ease;
  156. }
  157. .toolbar.hidden { opacity: 0; pointer-events: none; }
  158. .tb-btn {
  159. flex-shrink: 0;
  160. width: 36px; height: 36px;
  161. background: rgba(255,255,255,.08);
  162. border: none; border-radius: 50%; cursor: pointer;
  163. display: flex; align-items: center; justify-content: center;
  164. color: var(--text); transition: background .15s;
  165. padding: 0;
  166. }
  167. .tb-btn:hover { background: rgba(255,255,255,.18); }
  168. .tb-btn svg { width: 18px; height: 18px; fill: currentColor; }
  169. .tb-btn.active { background: rgba(176,176,176,.28); }
  170. .tb-btn.active img { filter: brightness(1.4) saturate(1.6); }
  171. .tb-filename {
  172. flex: 1; text-align: center;
  173. font-size: 13px; font-weight: 500;
  174. color: var(--text);
  175. overflow: hidden;
  176. padding: 0 4px;
  177. display: flex; flex-direction: column; align-items: center; gap: 1px;
  178. }
  179. .tb-filename-main {
  180. overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  181. max-width: 100%;
  182. }
  183. .tb-roomcode {
  184. font-size: 10px; opacity: 0.5; letter-spacing: 0.07em;
  185. white-space: nowrap;
  186. }
  187. /* ── Disconnected banner (sender dropped; media still playing) ── */
  188. .disconnected-banner {
  189. position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
  190. z-index: 300;
  191. background: rgba(20,20,30,0.82);
  192. backdrop-filter: blur(10px);
  193. border: 1px solid rgba(255,255,255,.1);
  194. border-radius: 20px;
  195. padding: 6px 18px;
  196. font-size: 11px; color: rgba(255,255,255,0.75);
  197. white-space: nowrap;
  198. pointer-events: none;
  199. }
  200. /* ── Disconnected notice ────────────────────────────────────── */
  201. .disconnected-notice {
  202. display: flex; flex-direction: column; align-items: center;
  203. gap: 2px; margin-bottom: 8px; padding: 16px 24px;
  204. background: rgba(239,68,68,.08);
  205. border: 1px solid rgba(239,68,68,.2);
  206. border-radius: 12px; color: #fca5a5;
  207. max-width: 320px; text-align: center;
  208. }
  209. /* ── Loading indicator ──────────────────────────────────────── */
  210. .loading-ring {
  211. width: 40px; height: 40px;
  212. border: 3px solid rgba(255,255,255,.1);
  213. border-top-color: var(--accent);
  214. border-radius: 50%;
  215. animation: spin .8s linear infinite;
  216. margin: 0 auto 16px;
  217. }
  218. @keyframes spin { to { transform: rotate(360deg); } }
  219. /* ── Toast ───────────────────────────────────────────────────── */
  220. .toast {
  221. position: fixed; top: 24px; left: 50%; transform: translateX(-50%);
  222. z-index: 300;
  223. background: rgba(30,30,40,.95);
  224. border: 1px solid rgba(255,255,255,.1);
  225. border-radius: 8px;
  226. padding: 10px 20px;
  227. font-size: 13px; color: var(--text);
  228. backdrop-filter: blur(16px);
  229. box-shadow: 0 4px 24px rgba(0,0,0,.4);
  230. transition: opacity .3s ease;
  231. pointer-events: none;
  232. }
  233. .toast.hidden { opacity: 0; }
  234. /* ── Progress bar (audio/video) ─────────────────────────────── */
  235. .media-progress {
  236. position: fixed; bottom: 0; left: 0; right: 0;
  237. height: 3px; z-index: 201;
  238. background: rgba(255,255,255,.1);
  239. }
  240. .media-progress-fill {
  241. height: 100%; background: var(--accent);
  242. transition: width .5s linear;
  243. }
  244. </style>
  245. </head>
  246. <body>
  247. <div id="app" x-data="arozcastApp()" x-init="init()">
  248. <!-- Background art (blurred) for audio mode -->
  249. <div class="bg-layer" x-show="mediaType === 'audio' && currentTrack">
  250. <img :src="coverSrc" alt="" @error="$event.target.style.display='none'">
  251. </div>
  252. <!-- ── Waiting / idle screen ─────────────────────────────────── -->
  253. <div class="waiting-screen" x-show="!currentTrack && !loading">
  254. <div class="brand-logo">
  255. <svg class="brand-icon" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
  256. <rect width="48" height="48" rx="12" fill="#b0b0b0"/>
  257. <path d="M34 14H14a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4v-2h-4V16h20v4h2v-4a2 2 0 0 0-2-2Z" fill="white"/>
  258. <path d="M10 36v-4a6 6 0 0 1 6 6h-4a2 2 0 0 0-2-2Zm0 4h4a10 10 0 0 0-4-8v4a6 6 0 0 1 4 4Zm0 0h-2v-2h2v2Z" fill="white" fill-opacity="0.8"/>
  259. <path d="M22 38h-4a14 14 0 0 0-14-14v4a10 10 0 0 1 10 10h4a14 14 0 0 0-14-14v-4a18 18 0 0 1 18 18Z" fill="white" fill-opacity="0.5"/>
  260. </svg>
  261. <span class="brand-name">Arozcast</span>
  262. </div>
  263. <div class="code-label">Connection code</div>
  264. <div class="code-display">
  265. <template x-for="d in codeDigits" :key="d.i">
  266. <div class="code-digit" x-text="d.v"></div>
  267. </template>
  268. </div>
  269. <div class="waiting-hint" x-show="senderState !== 'disconnected'">
  270. Enter this code in Musicify on your phone or another device to start casting,
  271. or use <a href="screenshare.html" target="_blank" style="color:var(--accent);text-decoration:underline;text-underline-offset:3px;">Screen Share</a>
  272. to stream your desktop to this display.
  273. </div>
  274. <div class="disconnected-notice" x-show="senderState === 'disconnected'">
  275. <img src="./img/disconnected.svg" style="width:32px;height:32px;margin-bottom:12px;" alt="Disconnected">
  276. <div style="font-size:15px;font-weight:600;color:var(--text);">Sender disconnected</div>
  277. <div style="font-size:13px;color:var(--text2);margin-top:4px;">Enter the code above in Musicify to reconnect.</div>
  278. </div>
  279. <div class="peer-count">
  280. <div class="peer-dot" :class="{idle: senderState !== 'connected'}"></div>
  281. <span x-text="senderState === 'connected' ? '1 sender connected' : senderState === 'disconnected' ? 'Waiting for reconnect...' : 'Waiting for sender...'"></span>
  282. </div>
  283. </div>
  284. <!-- ── Loading spinner ───────────────────────────────────────── -->
  285. <div class="waiting-screen" x-show="loading">
  286. <div class="loading-ring"></div>
  287. <div class="waiting-hint" x-text="loadingMsg"></div>
  288. </div>
  289. <!-- ── Audio / cover mode ────────────────────────────────────── -->
  290. <div class="cover-display" x-show="currentTrack && mediaType === 'audio'">
  291. <img class="cover-art" :src="coverSrc" alt="Cover"
  292. @error="coverSrc = 'img/music_placeholder.png'">
  293. <div class="track-meta">
  294. <div class="track-name" x-text="currentTrack ? currentTrack.name : ''"></div>
  295. <div class="track-artist" x-text="currentTrack ? (currentTrack.artist || '') : ''"></div>
  296. </div>
  297. </div>
  298. <!-- ── Video mode ────────────────────────────────────────────── -->
  299. <video id="acVideo" x-show="currentTrack && mediaType === 'video'"
  300. playsinline
  301. @timeupdate="onVideoTimeUpdate()"
  302. @loadedmetadata="onVideoMeta()"
  303. @ended="onMediaEnded()">
  304. </video>
  305. <!-- ── Photo mode ────────────────────────────────────────────── -->
  306. <div class="photo-display" x-show="currentTrack && mediaType === 'photo'">
  307. <img :src="photoSrc" alt="Photo">
  308. </div>
  309. <!-- ── Screen share mode (WebRTC) ───────────────────────────── -->
  310. <video id="acScreen" x-show="currentTrack && mediaType === 'screen'"
  311. playsinline autoplay>
  312. </video>
  313. <!-- Hidden audio element for audio mode -->
  314. <audio id="acAudio"
  315. @timeupdate="onAudioTimeUpdate()"
  316. @loadedmetadata="onAudioMeta()"
  317. @ended="onMediaEnded()">
  318. </audio>
  319. <!-- ── Disconnected banner (sender dropped but media still playing) ── -->
  320. <div class="disconnected-banner"
  321. x-show="currentTrack && senderState === 'disconnected'"
  322. x-text="'Sender disconnected · room ' + roomCode + ' · playback continues'">
  323. </div>
  324. <!-- ── Bottom toolbar ────────────────────────────────────────── -->
  325. <div class="toolbar" :class="{hidden: toolbarHidden && currentTrack && mediaType !== 'photo'}"
  326. @mousemove="showToolbar()" @mouseenter="showToolbar()">
  327. <!-- Fullscreen toggle -->
  328. <button class="tb-btn" @click="toggleFullscreen()" title="Toggle fullscreen">
  329. <img :src="isFullscreen ? 'img/fullscreen_exit.svg' : 'img/fullscreen.svg'" style="width:20px;height:20px;">
  330. </button>
  331. <!-- Filename + room code -->
  332. <span class="tb-filename">
  333. <span class="tb-filename-main" x-text="currentTrack ? currentTrack.name : 'Arozcast'"></span>
  334. <span class="tb-roomcode" x-show="roomCode" x-text="'Room ' + roomCode"></span>
  335. </span>
  336. <!-- Mute toggle -->
  337. <button class="tb-btn" @click="toggleMute()" :title="isMuted ? 'Unmute' : 'Mute'">
  338. <img :src="isMuted ? 'img/mute.svg' : 'img/unmute.svg'" style="width:20px;height:20px;">
  339. </button>
  340. <!-- Repeat indicator (controlled by sender) -->
  341. <button class="tb-btn" :class="{active: repeatMode !== 'none'}"
  342. :title="repeatMode === 'none' ? 'Repeat: off' : repeatMode === 'one' ? 'Repeat: one' : 'Repeat: all'"
  343. x-show="currentTrack" style="pointer-events:none;">
  344. <img src="img/repeat.svg" style="width:20px;height:20px;">
  345. </button>
  346. </div>
  347. <!-- ── Progress bar ───────────────────────────────────────────── -->
  348. <div class="media-progress" x-show="currentTrack && (mediaType === 'audio' || mediaType === 'video')">
  349. <div class="media-progress-fill" :style="'width:' + progressPct + '%'"></div>
  350. </div>
  351. <!-- ── Toast ─────────────────────────────────────────────────── -->
  352. <div class="toast" :class="{hidden: !toastVisible}" x-text="toastMsg"></div>
  353. </div>
  354. <script>
  355. function arozcastApp() {
  356. return {
  357. // State
  358. roomCode: '',
  359. codeDigits: [],
  360. loading: true,
  361. loadingMsg: 'Creating room...',
  362. // Connection
  363. ws: null,
  364. peerCount: 0,
  365. senderState: 'none', // 'none' | 'connected' | 'disconnected'
  366. _statusInterval: null,
  367. _peerTimer: null,
  368. _closing: false,
  369. // Media
  370. currentTrack: null,
  371. mediaType: 'audio', // 'audio' | 'video' | 'photo'
  372. coverSrc: '',
  373. photoSrc: '',
  374. defaultCover: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%231e1e26'/%3E%3C/svg%3E",
  375. isPlaying: false,
  376. currentTime: 0,
  377. duration: 0,
  378. volume: 100,
  379. isMuted: false,
  380. repeatMode: 'none', // 'none' | 'one' | 'all' — set by sender via media.repeat
  381. // Toolbar
  382. isFullscreen: false,
  383. toolbarHidden: false,
  384. _toolbarTimer: null,
  385. // Toast
  386. toastMsg: '',
  387. toastVisible: false,
  388. _toastTimer: null,
  389. // Screen share (WebRTC)
  390. _pc: null,
  391. _iceQueue: [],
  392. get progressPct() {
  393. if (!this.duration) return 0;
  394. return Math.min((this.currentTime / this.duration) * 100, 100);
  395. },
  396. // ── Init ─────────────────────────────────────────────────────
  397. async init() {
  398. this._audio = document.getElementById('acAudio');
  399. this._video = document.getElementById('acVideo');
  400. this._screen = document.getElementById('acScreen');
  401. // Fullscreen change listener
  402. const self = this;
  403. document.addEventListener('fullscreenchange', () => {
  404. self.isFullscreen = !!document.fullscreenElement;
  405. });
  406. // Auto-hide toolbar
  407. document.addEventListener('mousemove', () => self.showToolbar());
  408. // Create room
  409. try {
  410. const res = await fetch(ao_root + 'api/arozcast/create', { method: 'POST' });
  411. const data = await res.json();
  412. if (data.error) throw new Error(data.error);
  413. this.roomCode = data.code;
  414. this.codeDigits = this.roomCode.split('').map((v, i) => ({ v, i }));
  415. this.loading = false;
  416. this._connectWs();
  417. } catch(e) {
  418. this.loadingMsg = 'Failed to create room: ' + e.message;
  419. }
  420. // Close room when window unloads
  421. window.addEventListener('beforeunload', () => {
  422. self._closing = true;
  423. if (this.roomCode) {
  424. navigator.sendBeacon(ao_root + 'api/arozcast/close?code=' + this.roomCode);
  425. }
  426. if (this.ws) this.ws.close();
  427. });
  428. },
  429. // ── WebSocket connection ──────────────────────────────────────
  430. _connectWs() {
  431. const wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + this.roomCode, window.location.href);
  432. wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
  433. const url = wsUrl.toString();
  434. const self = this;
  435. // Detach stale handlers from the old socket before replacing it.
  436. // This prevents a dying connection from firing onmessage if it
  437. // receives a relayed frame during the server-side cleanup window.
  438. if (this.ws) {
  439. this.ws.onopen = null;
  440. this.ws.onclose = null;
  441. this.ws.onmessage = null;
  442. }
  443. this.ws = new WebSocket(url);
  444. this.ws.onopen = () => {
  445. // Start periodic status broadcast
  446. self._startStatusBroadcast();
  447. };
  448. this.ws.onclose = () => {
  449. clearInterval(self._statusInterval);
  450. if (!self._closing && self.roomCode) {
  451. setTimeout(() => { self._connectWs(); }, 2000);
  452. }
  453. };
  454. this.ws.onmessage = (evt) => {
  455. try {
  456. const msg = JSON.parse(evt.data);
  457. self._handleMessage(msg);
  458. } catch(e) {}
  459. };
  460. },
  461. _startStatusBroadcast() {
  462. const self = this;
  463. clearInterval(this._statusInterval);
  464. this._statusInterval = setInterval(() => {
  465. self._sendStatus();
  466. }, 3000);
  467. },
  468. _sendStatus() {
  469. if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
  470. this.ws.send(JSON.stringify({
  471. topic: 'status.update',
  472. payload: {
  473. currentTime: this.currentTime,
  474. duration: this.duration,
  475. isPlaying: this.isPlaying,
  476. volume: this.volume,
  477. isMuted: this.isMuted,
  478. peerCount: this.peerCount
  479. }
  480. }));
  481. },
  482. // ── Message handler ───────────────────────────────────────────
  483. _handleMessage(msg) {
  484. // _touchPeer() is called ONLY for sender-originated topics.
  485. // The receiver emits 'status.update' and 'media.ended' itself; receiving
  486. // those (e.g. via a brief double-connection during WS reconnect) must
  487. // not be mistaken for a connected sender.
  488. switch(msg.topic) {
  489. case 'media.load':
  490. this._touchPeer();
  491. this._loadMedia(msg.payload);
  492. break;
  493. case 'media.play':
  494. this._touchPeer();
  495. this._play();
  496. break;
  497. case 'media.pause':
  498. this._touchPeer();
  499. this._pause();
  500. break;
  501. case 'media.seek':
  502. this._touchPeer();
  503. this._seek(msg.payload.time);
  504. break;
  505. case 'media.seekrel': {
  506. this._touchPeer();
  507. const el = this.mediaType === 'audio' ? this._audio : this._video;
  508. const t = Math.max(0, Math.min((el.duration || 0), el.currentTime + (msg.payload.delta || 0)));
  509. this._seek(t);
  510. break;
  511. }
  512. case 'media.volume':
  513. this._touchPeer();
  514. this._setVolume(msg.payload.volume, msg.payload.muted);
  515. break;
  516. case 'media.repeat':
  517. this._touchPeer();
  518. this._setRepeat(msg.payload.mode || 'none');
  519. break;
  520. case 'media.stop':
  521. this._touchPeer();
  522. this._stop();
  523. break;
  524. case 'peer.hello':
  525. this._touchPeer();
  526. this.peerCount = 1;
  527. break;
  528. case 'peer.heartbeat':
  529. this._touchPeer();
  530. break;
  531. case 'screen.start':
  532. this._touchPeer();
  533. this._prepareScreen();
  534. break;
  535. case 'webrtc.offer':
  536. this._touchPeer();
  537. this._handleOffer(msg.payload);
  538. break;
  539. case 'webrtc.ice':
  540. this._touchPeer();
  541. if (this._pc) {
  542. if (this._pc.remoteDescription) {
  543. this._pc.addIceCandidate(msg.payload.candidate).catch(() => {});
  544. } else {
  545. this._iceQueue.push(msg.payload.candidate);
  546. }
  547. } else {
  548. this._iceQueue.push(msg.payload.candidate);
  549. }
  550. break;
  551. case 'screen.stop':
  552. this._touchPeer();
  553. this._stopScreen();
  554. break;
  555. // 'status.update' and 'media.ended' are sent BY this receiver —
  556. // ignore silently if received (loopback via duplicate connection).
  557. }
  558. },
  559. _touchPeer() {
  560. const self = this;
  561. this.peerCount = 1;
  562. this.senderState = 'connected';
  563. clearTimeout(this._peerTimer);
  564. this._peerTimer = setTimeout(() => {
  565. self.peerCount = 0;
  566. self.senderState = 'disconnected';
  567. // Intentionally do NOT stop playback — sender may be reconnecting
  568. self._showToast('Sender disconnected — playback continues');
  569. }, 12000);
  570. },
  571. // ── Media controls ────────────────────────────────────────────
  572. _loadMedia(track) {
  573. this.currentTrack = track;
  574. this.currentTime = 0;
  575. this.duration = 0;
  576. this.isPlaying = false;
  577. const type = track.type || 'audio';
  578. this.mediaType = type;
  579. // Prefer the URL provided by the sender (respects transcoding API etc.);
  580. // fall back to direct media API if not supplied.
  581. const fileUrl = track.src || (ao_root + 'media?file=' + encodeURIComponent(track.filepath));
  582. const startTime = parseFloat(track.startTime) || 0;
  583. const self = this;
  584. if (type === 'audio') {
  585. this.coverSrc = ao_root + 'system/file_system/loadThumbnail?bytes=true&vpath=' + encodeURIComponent(track.filepath);
  586. this._audio.loop = (this.repeatMode === 'one');
  587. this._audio.src = fileUrl;
  588. this._audio.load();
  589. // Apply volume and seek inside loadedmetadata so browser-assigned
  590. // defaults cannot overwrite our values after load() returns.
  591. this._audio.addEventListener('loadedmetadata', () => {
  592. self._audio.volume = self.volume / 100;
  593. self._audio.muted = self.isMuted;
  594. if (startTime > 0) self._audio.currentTime = startTime;
  595. }, { once: true });
  596. this._audio.play().catch(() => {});
  597. this.isPlaying = true;
  598. this._video.pause();
  599. this._video.removeAttribute('src');
  600. } else if (type === 'video') {
  601. this._video.loop = (this.repeatMode === 'one');
  602. this._video.src = fileUrl;
  603. this._video.load();
  604. this._video.addEventListener('loadedmetadata', () => {
  605. self._video.volume = self.volume / 100;
  606. self._video.muted = self.isMuted;
  607. if (startTime > 0) self._video.currentTime = startTime;
  608. }, { once: true });
  609. this._video.play().catch(() => {});
  610. this.isPlaying = true;
  611. this._audio.pause();
  612. this._audio.removeAttribute('src');
  613. } else if (type === 'photo') {
  614. this.photoSrc = fileUrl;
  615. this._audio.pause();
  616. this._audio.removeAttribute('src');
  617. this._video.pause();
  618. this._video.removeAttribute('src');
  619. this.isPlaying = false;
  620. }
  621. this._showToast('Now showing: ' + track.name);
  622. },
  623. _play() {
  624. if (this.mediaType === 'audio') {
  625. this._audio.play().catch(() => {});
  626. } else if (this.mediaType === 'video') {
  627. this._video.play().catch(() => {});
  628. }
  629. this.isPlaying = true;
  630. },
  631. _pause() {
  632. if (this.mediaType === 'audio') this._audio.pause();
  633. else if (this.mediaType === 'video') this._video.pause();
  634. this.isPlaying = false;
  635. },
  636. _seek(time) {
  637. if (this.mediaType === 'audio') {
  638. this._audio.currentTime = time;
  639. } else if (this.mediaType === 'video') {
  640. this._video.currentTime = time;
  641. }
  642. this.currentTime = time;
  643. },
  644. _setVolume(vol, muted) {
  645. this.volume = vol;
  646. this.isMuted = muted;
  647. const v = vol / 100;
  648. this._audio.volume = v;
  649. this._audio.muted = muted;
  650. this._video.volume = v;
  651. this._video.muted = muted;
  652. },
  653. _setRepeat(mode) {
  654. this.repeatMode = mode;
  655. // Native browser loop only for single-track repeat — the browser
  656. // suppresses the 'ended' event when loop=true, which is fine for 'one'
  657. // (no sender action needed). For 'all', the sender drives playlist
  658. // advancement via media.ended → nextTrack, so we must NOT set loop=true.
  659. const loop = (mode === 'one');
  660. this._audio.loop = loop;
  661. this._video.loop = loop;
  662. },
  663. _stop() {
  664. this._audio.pause();
  665. this._audio.removeAttribute('src');
  666. this._video.pause();
  667. this._video.removeAttribute('src');
  668. this.currentTrack = null;
  669. this.isPlaying = false;
  670. this.currentTime = 0;
  671. this.duration = 0;
  672. },
  673. // ── Screen share (WebRTC) ─────────────────────────────────────
  674. _prepareScreen() {
  675. if (this._pc) { this._pc.close(); this._pc = null; }
  676. this._iceQueue = [];
  677. this._audio.pause();
  678. this._audio.removeAttribute('src');
  679. this._video.pause();
  680. this._video.removeAttribute('src');
  681. this.currentTrack = { name: 'Screen Share', type: 'screen' };
  682. this.mediaType = 'screen';
  683. this.isPlaying = false;
  684. this.currentTime = 0;
  685. this.duration = 0;
  686. },
  687. async _handleOffer(payload) {
  688. const self = this;
  689. if (this._pc) { this._pc.close(); }
  690. this._iceQueue = [];
  691. const pc = new RTCPeerConnection({
  692. iceServers: [
  693. { urls: 'stun:stun.l.google.com:19302' },
  694. { urls: 'stun:stun1.l.google.com:19302' },
  695. ]
  696. });
  697. // Assign early so arriving webrtc.ice messages are queued correctly
  698. this._pc = pc;
  699. pc.onicecandidate = (e) => {
  700. if (e.candidate && self.ws && self.ws.readyState === WebSocket.OPEN) {
  701. self.ws.send(JSON.stringify({
  702. topic: 'webrtc.ice',
  703. payload: { candidate: e.candidate.toJSON() }
  704. }));
  705. }
  706. };
  707. pc.ontrack = (e) => {
  708. if (!self._screen.srcObject) {
  709. self._screen.srcObject = e.streams[0];
  710. self._screen.muted = self.isMuted;
  711. self._screen.play().catch(() => {});
  712. self._showToast('Screen share started');
  713. }
  714. };
  715. pc.onconnectionstatechange = () => {
  716. if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
  717. self._stopScreen();
  718. }
  719. };
  720. try {
  721. await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
  722. // Drain ICE candidates that arrived before setRemoteDescription completed
  723. for (const c of this._iceQueue) {
  724. await pc.addIceCandidate(c).catch(() => {});
  725. }
  726. this._iceQueue = [];
  727. const answer = await pc.createAnswer();
  728. await pc.setLocalDescription(answer);
  729. if (self.ws && self.ws.readyState === WebSocket.OPEN) {
  730. self.ws.send(JSON.stringify({
  731. topic: 'webrtc.answer',
  732. payload: { sdp: answer.sdp, type: answer.type }
  733. }));
  734. }
  735. } catch (e) {
  736. self._showToast('Screen share setup failed');
  737. self._stopScreen();
  738. }
  739. },
  740. _stopScreen() {
  741. if (this._pc) { this._pc.close(); this._pc = null; }
  742. this._iceQueue = [];
  743. if (this._screen) { this._screen.srcObject = null; }
  744. this.currentTrack = null;
  745. this.mediaType = 'audio';
  746. this.isPlaying = false;
  747. this.currentTime = 0;
  748. this.duration = 0;
  749. this._showToast('Screen share ended');
  750. },
  751. // ── Audio/Video event handlers ────────────────────────────────
  752. onAudioTimeUpdate() {
  753. this.currentTime = this._audio.currentTime;
  754. },
  755. onAudioMeta() {
  756. this.duration = this._audio.duration || 0;
  757. },
  758. onVideoTimeUpdate() {
  759. this.currentTime = this._video.currentTime;
  760. },
  761. onVideoMeta() {
  762. this.duration = this._video.duration || 0;
  763. },
  764. sendStatus() { this._sendStatus(); },
  765. onMediaEnded() {
  766. this.isPlaying = false;
  767. this._sendStatus();
  768. if (this.ws && this.ws.readyState === WebSocket.OPEN) {
  769. this.ws.send(JSON.stringify({ topic: 'media.ended', payload: {} }));
  770. }
  771. },
  772. // ── Toolbar & UI ─────────────────────────────────────────────
  773. showToolbar() {
  774. this.toolbarHidden = false;
  775. clearTimeout(this._toolbarTimer);
  776. const self = this;
  777. this._toolbarTimer = setTimeout(() => {
  778. if (self.currentTrack && (self.mediaType === 'video' || self.mediaType === 'screen')) {
  779. self.toolbarHidden = true;
  780. }
  781. }, 3000);
  782. },
  783. toggleFullscreen() {
  784. if (!document.fullscreenElement) {
  785. document.documentElement.requestFullscreen().catch(() => {});
  786. } else {
  787. document.exitFullscreen().catch(() => {});
  788. }
  789. },
  790. toggleMute() {
  791. this.isMuted = !this.isMuted;
  792. this._audio.muted = this.isMuted;
  793. this._video.muted = this.isMuted;
  794. this._screen.muted = this.isMuted;
  795. },
  796. // ── Toast ─────────────────────────────────────────────────────
  797. _showToast(msg) {
  798. this.toastMsg = msg;
  799. this.toastVisible = true;
  800. clearTimeout(this._toastTimer);
  801. const self = this;
  802. this._toastTimer = setTimeout(() => { self.toastVisible = false; }, 3000);
  803. },
  804. };
  805. }
  806. </script>
  807. </body>
  808. </html>