@jackuait/blok 0.16.1 → 0.18.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.
Files changed (609) hide show
  1. package/dist/blok.cjs +1 -1
  2. package/dist/blok.iife.js +4 -4
  3. package/dist/blok.mjs +2 -2
  4. package/dist/blok.umd.js +4 -4
  5. package/dist/chunks/APEv2Parser-4KTzNuV0.cjs +1 -0
  6. package/dist/chunks/APEv2Parser-WvRvu4zL.mjs +187 -0
  7. package/dist/chunks/AbstractID3Parser-DM9acVTg.mjs +34 -0
  8. package/dist/chunks/AbstractID3Parser-o6LG6Iyj.cjs +1 -0
  9. package/dist/chunks/AiffParser-C_VaWeb5.mjs +109 -0
  10. package/dist/chunks/AiffParser-DVmZ6jG0.cjs +1 -0
  11. package/dist/chunks/AsfParser-CH5F_9Wp.mjs +419 -0
  12. package/dist/chunks/AsfParser-Dwm_MRPg.cjs +1 -0
  13. package/dist/chunks/BasicParser-BFTgNkp-.mjs +743 -0
  14. package/dist/chunks/BasicParser-CLAKKNEs.cjs +1 -0
  15. package/dist/chunks/DsdiffParser-BAdYwB7y.mjs +108 -0
  16. package/dist/chunks/DsdiffParser-DRFw1wX0.cjs +1 -0
  17. package/dist/chunks/DsfParser-DWMLfBH1.mjs +61 -0
  18. package/dist/chunks/DsfParser-DjS_tBuv.cjs +1 -0
  19. package/dist/chunks/FlacParser-BdKXrY7L.mjs +255 -0
  20. package/dist/chunks/FlacParser-CPQ0VqXv.cjs +1 -0
  21. package/dist/chunks/ID3v1Parser-CPjEHype.cjs +1 -0
  22. package/dist/chunks/ID3v1Parser-DoxlznrE.mjs +75 -0
  23. package/dist/chunks/ID3v2Parser-BIrxzjBm.mjs +518 -0
  24. package/dist/chunks/ID3v2Parser-CMd_Ayt7.cjs +1 -0
  25. package/dist/chunks/ID3v2Token-Cc901eYL.cjs +1 -0
  26. package/dist/chunks/ID3v2Token-D5v9lCfb.mjs +107 -0
  27. package/dist/chunks/MP4Parser-0KMJ-q2k.mjs +811 -0
  28. package/dist/chunks/MP4Parser-B8kU67Xi.cjs +1 -0
  29. package/dist/chunks/MatroskaParser-CzZTyUP-.cjs +1 -0
  30. package/dist/chunks/MatroskaParser-fxFuCddu.mjs +814 -0
  31. package/dist/chunks/MpegParser-DX5r4iE7.cjs +1 -0
  32. package/dist/chunks/MpegParser-R-6QiuhU.mjs +513 -0
  33. package/dist/chunks/MusepackParser-DFLoF47O.mjs +187 -0
  34. package/dist/chunks/MusepackParser-OgeWmD5G.cjs +1 -0
  35. package/dist/chunks/OggParser-C1qP2qC7.mjs +251 -0
  36. package/dist/chunks/OggParser-DjscM3J-.cjs +1 -0
  37. package/dist/chunks/Util-BL3wIJcu.cjs +1 -0
  38. package/dist/chunks/Util-DiBcLCh8.mjs +109 -0
  39. package/dist/chunks/WavPackParser-3J7J92gh.cjs +1 -0
  40. package/dist/chunks/WavPackParser-Ck8xXsNf.mjs +116 -0
  41. package/dist/chunks/WaveParser-CiNJ-5JL.cjs +1 -0
  42. package/dist/chunks/WaveParser-hxQlWijT.mjs +187 -0
  43. package/dist/chunks/{blok-DEVVs3xk.cjs → blok-B7IRrvgX.cjs} +11 -11
  44. package/dist/chunks/{blok-D1e9swtF.mjs → blok-BoAuTqli.mjs} +776 -780
  45. package/dist/chunks/{constants-DfGAwzgk.mjs → constants-BO6zLfL2.mjs} +274 -274
  46. package/dist/chunks/constants-BWoyU4yv.cjs +606 -0
  47. package/dist/chunks/core-CqiEzfU3.mjs +292 -0
  48. package/dist/chunks/core-DdDpt2u7.mjs +3931 -0
  49. package/dist/chunks/core-YGVcFb_q.cjs +1 -0
  50. package/dist/chunks/core-uWDvHWK7.cjs +6 -0
  51. package/dist/chunks/{i18next-loader-BID4jAFG.cjs → i18next-loader-BBPMegxc.cjs} +1 -1
  52. package/dist/chunks/{i18next-loader-BH_a6G9t.mjs → i18next-loader-K-ir8UKk.mjs} +1 -1
  53. package/dist/chunks/{lightweight-i18n-B0VO8Rft.mjs → lightweight-i18n-DJpMke7a.mjs} +59 -0
  54. package/dist/chunks/lightweight-i18n-LFW8S8u-.cjs +1 -0
  55. package/dist/{messages-Ck7HWiG_.mjs → chunks/messages-3jkCmB_A.mjs} +60 -1
  56. package/dist/{messages-6RcfJ59a.mjs → chunks/messages-5339ApgH.mjs} +60 -1
  57. package/dist/{messages-CWdh59uz2.mjs → chunks/messages-7LWW3BVh.mjs} +60 -1
  58. package/dist/chunks/messages-9B1F-k48.cjs +1 -0
  59. package/dist/chunks/messages-Au1UpgkX.cjs +1 -0
  60. package/dist/chunks/messages-B-u5B7tY.cjs +1 -0
  61. package/dist/chunks/{messages-Bz_fTG46.mjs → messages-B01D7aHY.mjs} +60 -1
  62. package/dist/chunks/{messages-CEslBsO62.mjs → messages-B2iaIrIu.mjs} +60 -1
  63. package/dist/chunks/messages-B6794_2J.cjs +1 -0
  64. package/dist/chunks/{messages-BDNdC6-X.cjs → messages-B6dbtt1c.cjs} +1 -1
  65. package/dist/chunks/{messages-CVHSfosB.cjs → messages-B6yMdb3B.cjs} +1 -1
  66. package/dist/chunks/{messages-f1NcMMTR2.cjs → messages-B7st3eF_.cjs} +1 -1
  67. package/dist/chunks/{messages-D2nTfhRa2.cjs → messages-BHCepNsI.cjs} +1 -1
  68. package/dist/chunks/{messages-DB6z9ybS.cjs → messages-BHdKd7pH.cjs} +1 -1
  69. package/dist/{messages-C-XKbE2c.mjs → chunks/messages-BO-L5oN9.mjs} +60 -1
  70. package/dist/chunks/{messages-DDnB1EFE2.mjs → messages-BRTWvTAP.mjs} +60 -1
  71. package/dist/{messages-CUsdyCrN2.mjs → chunks/messages-BUvlOqll.mjs} +60 -1
  72. package/dist/chunks/messages-BV22YIZ_.cjs +1 -0
  73. package/dist/chunks/messages-BXp_U3uS.cjs +1 -0
  74. package/dist/{messages-BfGAGIg-2.mjs → chunks/messages-BYCHzM7F.mjs} +60 -1
  75. package/dist/chunks/messages-B_cHWyWC.cjs +1 -0
  76. package/dist/{messages-BbFJ2r9p.mjs → chunks/messages-B_d857cA.mjs} +60 -1
  77. package/dist/chunks/messages-BaVZgTrw.cjs +1 -0
  78. package/dist/{messages-DaoTSrJW.mjs → chunks/messages-Bb80Csl3.mjs} +60 -1
  79. package/dist/{messages-C1d7I7f_2.mjs → chunks/messages-BbZz_Anm.mjs} +60 -1
  80. package/dist/{messages-kb2GSl-V.mjs → chunks/messages-BdL8bmTL.mjs} +60 -1
  81. package/dist/chunks/{messages-CcvcAZOt.mjs → messages-BeS9EOEO.mjs} +60 -1
  82. package/dist/chunks/{messages-BG1Tn41d.cjs → messages-BjYhwRk7.cjs} +1 -1
  83. package/dist/chunks/{messages-CUxk6xrQ.cjs → messages-BkgBVEiE.cjs} +1 -1
  84. package/dist/chunks/{messages-BhTV7PCN.mjs → messages-Bmq--WTx.mjs} +60 -1
  85. package/dist/{messages-Z8HMjhe9.mjs → chunks/messages-Bp0LdGKj.mjs} +60 -1
  86. package/dist/chunks/{messages-Bq7pLz_h2.mjs → messages-BpMWMH9_.mjs} +60 -1
  87. package/dist/chunks/{messages-C-N1XBvh2.cjs → messages-BsTzdfZF.cjs} +1 -1
  88. package/dist/chunks/{messages-p5w1kuc5.cjs → messages-BveiGA2x.cjs} +1 -1
  89. package/dist/{messages-CSVEbRhT.mjs → chunks/messages-BwOUSDJR.mjs} +60 -1
  90. package/dist/chunks/messages-ByNR9m9W.cjs +1 -0
  91. package/dist/{messages-B8BXCJBA.mjs → chunks/messages-Byn9-dhb2.mjs} +60 -1
  92. package/dist/chunks/{messages-DaqNOQ8p.mjs → messages-C0swL9CY.mjs} +60 -1
  93. package/dist/chunks/{messages-CO_hj1x6.cjs → messages-C2ptZOWh.cjs} +1 -1
  94. package/dist/chunks/{messages-Dp5C7vWz.mjs → messages-C3rGCBi_.mjs} +60 -1
  95. package/dist/{messages-BytEaMMs.mjs → chunks/messages-C8_r3iTR2.mjs} +60 -1
  96. package/dist/chunks/{messages-Dp5SW9D-.mjs → messages-CATH_uz1.mjs} +60 -1
  97. package/dist/chunks/{messages-Bi3snvmH2.cjs → messages-CBbgEXuO.cjs} +1 -1
  98. package/dist/chunks/messages-CEySIig9.cjs +1 -0
  99. package/dist/chunks/messages-CGdCpNZ62.cjs +1 -0
  100. package/dist/chunks/{messages-CpJW0LBR.cjs → messages-CH9SBR-i.cjs} +1 -1
  101. package/dist/{messages-2pXizYBd.mjs → chunks/messages-CI2W7P-j.mjs} +60 -1
  102. package/dist/chunks/{messages-CHizzxUv.mjs → messages-CIBYzzFy.mjs} +60 -1
  103. package/dist/chunks/{messages-Cz7AbEFm.cjs → messages-CJFSq8Fl.cjs} +1 -1
  104. package/dist/chunks/{messages-DG8Lmqf52.cjs → messages-CM8uUnPx.cjs} +1 -1
  105. package/dist/chunks/{messages-Cf5mu1AT.cjs → messages-COx3hDcP.cjs} +1 -1
  106. package/dist/chunks/messages-CQT8TtiF.cjs +1 -0
  107. package/dist/chunks/{messages-DubJ63UL.mjs → messages-CRUb9o5k.mjs} +60 -1
  108. package/dist/chunks/messages-CT8djlV6.cjs +1 -0
  109. package/dist/{messages-BYyRYlTQ2.mjs → chunks/messages-CUPup5e3.mjs} +60 -1
  110. package/dist/chunks/{messages-BSyXBvUi2.cjs → messages-CVrOxu3J.cjs} +1 -1
  111. package/dist/chunks/messages-CWFSMzug2.cjs +1 -0
  112. package/dist/{messages-BXgGBpmY.mjs → chunks/messages-CX0tJpOb.mjs} +60 -1
  113. package/dist/chunks/{messages-xMrks-Gg.mjs → messages-CXk-iM-i.mjs} +60 -1
  114. package/dist/chunks/{messages-B-3_M44j.cjs → messages-CZJZ-au3.cjs} +1 -1
  115. package/dist/chunks/messages-CZddldga.cjs +1 -0
  116. package/dist/{messages-BmKhZE7I.mjs → chunks/messages-Ca9zIrBo.mjs} +60 -1
  117. package/dist/{messages-D1jUHOKr.mjs → chunks/messages-CbyVDo8y.mjs} +60 -1
  118. package/dist/chunks/{messages-CZQuqbNz2.cjs → messages-CcsM8Q7g.cjs} +1 -1
  119. package/dist/chunks/{messages-B3xnntgx2.mjs → messages-CeIzWL_4.mjs} +60 -1
  120. package/dist/{messages-DPZsygzY.mjs → chunks/messages-CeLv1whu2.mjs} +60 -1
  121. package/dist/chunks/messages-Ciq5_PsP.cjs +1 -0
  122. package/dist/{messages-qjir1Zx9.mjs → chunks/messages-CjCn5IsF.mjs} +60 -1
  123. package/dist/chunks/messages-CjfEt9bg.cjs +1 -0
  124. package/dist/{messages-S8uz2_bw2.mjs → chunks/messages-Ck1-ddbo.mjs} +60 -1
  125. package/dist/{messages-zoHZbvbe.mjs → chunks/messages-CkosWGGH.mjs} +60 -1
  126. package/dist/chunks/{messages-Bh3E53-f2.mjs → messages-Cl_pM31G.mjs} +60 -1
  127. package/dist/chunks/messages-CmsAbu_A.cjs +1 -0
  128. package/dist/chunks/messages-CqRtDVFO.cjs +1 -0
  129. package/dist/chunks/{messages-jAgR8EPP.cjs → messages-CuMtymEs.cjs} +1 -1
  130. package/dist/chunks/{messages-CSc7Lh_3.mjs → messages-Cvg_uTfy.mjs} +60 -1
  131. package/dist/chunks/messages-Cy-2_lIA.cjs +1 -0
  132. package/dist/{messages-B_XoP1A3.mjs → chunks/messages-D-QWtvWg.mjs} +60 -1
  133. package/dist/{messages-TfPmsrxh.mjs → chunks/messages-D-p-omWy.mjs} +60 -1
  134. package/dist/{messages-Bj8K6_bv.mjs → chunks/messages-D-s4_vnU.mjs} +60 -1
  135. package/dist/chunks/messages-D41fO84y.cjs +1 -0
  136. package/dist/{messages-BXs-Zwhx.mjs → chunks/messages-D4u2PSQw.mjs} +60 -1
  137. package/dist/chunks/messages-D7H9JUDr.cjs +1 -0
  138. package/dist/chunks/{messages-Bttb4o0k.cjs → messages-D7sU-NqL.cjs} +1 -1
  139. package/dist/chunks/{messages-Dml6u25s2.cjs → messages-DCwrXfck.cjs} +1 -1
  140. package/dist/{messages-CS6vG53A.mjs → chunks/messages-DDJkNbJy.mjs} +60 -1
  141. package/dist/{messages-BWzRZ5Un.mjs → chunks/messages-DDX6I6MX.mjs} +60 -1
  142. package/dist/chunks/messages-DEg5FAg1.cjs +1 -0
  143. package/dist/chunks/{messages-Bs9JOJDc.cjs → messages-DFHNw6PQ.cjs} +1 -1
  144. package/dist/chunks/{messages-M2lNlKhI2.mjs → messages-DHv7knYB.mjs} +60 -1
  145. package/dist/chunks/{messages-BxRX_hY12.cjs → messages-DIFwztFo.cjs} +1 -1
  146. package/dist/chunks/{messages-UoKgx7_8.cjs → messages-DIetNXTx.cjs} +1 -1
  147. package/dist/{messages-CdmaH2dA2.mjs → chunks/messages-DNtAzacv.mjs} +60 -1
  148. package/dist/chunks/messages-DOqJ6ogK.cjs +1 -0
  149. package/dist/chunks/messages-DQJcAz4s.cjs +1 -0
  150. package/dist/{messages-CzabNeP5.mjs → chunks/messages-DQfi2DoY.mjs} +60 -1
  151. package/dist/chunks/{messages-De0Fkl0K.mjs → messages-DQr7bXMD.mjs} +60 -1
  152. package/dist/chunks/messages-DT-JnTnw.cjs +1 -0
  153. package/dist/chunks/{messages-C2-v6HdR.cjs → messages-DUWCkLYR.cjs} +1 -1
  154. package/dist/chunks/{messages-BItPVbHx.cjs → messages-Da8yFc7l.cjs} +1 -1
  155. package/dist/chunks/{messages-BzC8TVYC.mjs → messages-DgF8OkNr.mjs} +60 -1
  156. package/dist/{messages-Dw7P8bMa2.mjs → chunks/messages-DiODNXET.mjs} +60 -1
  157. package/dist/{messages-CJsMZvUv.mjs → chunks/messages-DjZoCJ00.mjs} +60 -1
  158. package/dist/chunks/messages-DjrfovG0.cjs +1 -0
  159. package/dist/chunks/messages-DmjccFHq.cjs +1 -0
  160. package/dist/chunks/{messages-Vbqmvxs1.cjs → messages-DpNmnBjK.cjs} +1 -1
  161. package/dist/chunks/messages-DtaPHADA.cjs +1 -0
  162. package/dist/{messages-DFKu1WGX2.mjs → chunks/messages-DuD4ut1b.mjs} +60 -1
  163. package/dist/chunks/{messages-BqM6l_JM2.mjs → messages-DuHFGMCJ.mjs} +60 -1
  164. package/dist/{messages-tcfLP2b2.mjs → chunks/messages-Dwl878eS.mjs} +60 -1
  165. package/dist/chunks/{messages-BVuodJIJ.cjs → messages-ETJ7tQ9O.cjs} +1 -1
  166. package/dist/chunks/messages-EsX3eikv.cjs +1 -0
  167. package/dist/{messages-B4cc5O5K.mjs → chunks/messages-HJnG9r2l.mjs} +60 -1
  168. package/dist/chunks/{messages-Ckej-nnJ.cjs → messages-Hfo6uCIC.cjs} +1 -1
  169. package/dist/chunks/{messages-CT025FgD2.cjs → messages-IqNWRaNS.cjs} +1 -1
  170. package/dist/chunks/{messages-Cd-FaIr_2.mjs → messages-MPe9FjYe.mjs} +60 -1
  171. package/dist/chunks/messages-NJAjMFXd.cjs +1 -0
  172. package/dist/{messages-B0n-WDK72.mjs → chunks/messages-QCaF3d92.mjs} +60 -1
  173. package/dist/chunks/messages-SozFksSf.cjs +1 -0
  174. package/dist/chunks/messages-UXS3zL9A2.cjs +1 -0
  175. package/dist/chunks/{messages-5tb-mSXS.cjs → messages-VeyvtTce.cjs} +1 -1
  176. package/dist/chunks/{messages-BGoPxZ5n.mjs → messages-Xh-c7d4C.mjs} +60 -1
  177. package/dist/chunks/{messages-BfYWRC3o.cjs → messages-Z4LYgfWc.cjs} +1 -1
  178. package/dist/{messages-BCGyLzFB2.mjs → chunks/messages-ZMG7qQ3D.mjs} +60 -1
  179. package/dist/chunks/{messages-LWyC-1ol.mjs → messages-cSBZ5qVe.mjs} +60 -1
  180. package/dist/chunks/{messages-XM0y9kXy2.cjs → messages-gh5G15J1.cjs} +1 -1
  181. package/dist/{messages-DGwIRpOH2.mjs → chunks/messages-jEbEE90_.mjs} +60 -1
  182. package/dist/{messages-Cg3w36Bc.mjs → chunks/messages-kqnzps-X.mjs} +60 -1
  183. package/dist/chunks/{messages-S_Bmo_Qc.mjs → messages-m-A6ArIM.mjs} +60 -1
  184. package/dist/chunks/{messages-BHE1mg5A2.mjs → messages-t26x8wxB.mjs} +60 -1
  185. package/dist/chunks/{messages-s9I-JHCa.cjs → messages-tR0BdeVG.cjs} +1 -1
  186. package/dist/chunks/{messages-CxVi9cnz.mjs → messages-typO_i_k.mjs} +60 -1
  187. package/dist/chunks/{messages-Beh905t72.mjs → messages-ypIHI9Fr.mjs} +60 -1
  188. package/dist/{messages-ChivLKDg2.mjs → chunks/messages-zPUQsksJ.mjs} +60 -1
  189. package/dist/chunks/{tools-vX3wCW3a.mjs → tools-DRwLS-6t.mjs} +4223 -2323
  190. package/dist/chunks/tools-DXagFf6I.cjs +208 -0
  191. package/dist/chunks/types-BNsalfLy.cjs +1 -0
  192. package/dist/chunks/types-gj36fzRQ.mjs +28 -0
  193. package/dist/chunks/webm-muxer-BEeYNPIp.cjs +5 -0
  194. package/dist/chunks/webm-muxer-Doy8JtlV.mjs +864 -0
  195. package/dist/full.cjs +1 -1
  196. package/dist/full.mjs +10 -10
  197. package/dist/locales.mjs +126 -67
  198. package/dist/{chunks/messages-14GzwdH92.mjs → messages-4drvqPN8.mjs} +60 -1
  199. package/dist/{chunks/messages-CZfG1cmA2.mjs → messages-8__-vX3U.mjs} +60 -1
  200. package/dist/{chunks/messages-DyuKfskv.mjs → messages-Ao2vCNAH2.mjs} +60 -1
  201. package/dist/{messages-eFz25q1T.mjs → messages-B13GEFFu.mjs} +60 -1
  202. package/dist/{messages-C5sc4wjR.mjs → messages-B2YnZIU2.mjs} +60 -1
  203. package/dist/{chunks/messages-dZdV8VYV.mjs → messages-BI5B9bxE.mjs} +60 -1
  204. package/dist/{chunks/messages-Cc56SxY7.mjs → messages-BIgvPd6I2.mjs} +60 -1
  205. package/dist/{messages-Bd2Q-tnQ.mjs → messages-BKG0QHVl.mjs} +60 -1
  206. package/dist/{messages-DvSC5JGC2.mjs → messages-BKGg4XYm2.mjs} +60 -1
  207. package/dist/{chunks/messages-Bnm45Rmp.mjs → messages-BOWDzfkH.mjs} +60 -1
  208. package/dist/{messages-BOEP2ly_.mjs → messages-BQaiScU1.mjs} +60 -1
  209. package/dist/{chunks/messages-GXQbRrHa2.mjs → messages-BQhHthxq2.mjs} +60 -1
  210. package/dist/{chunks/messages-Cx_kyYnf2.mjs → messages-BRLWpOnP.mjs} +60 -1
  211. package/dist/{chunks/messages-CdOX6JHb2.mjs → messages-BarhGl2S2.mjs} +60 -1
  212. package/dist/{chunks/messages-PtDEkfSR2.mjs → messages-BgPDPIkO.mjs} +60 -1
  213. package/dist/{chunks/messages-BQKWm9xB.mjs → messages-BlvF3pot.mjs} +60 -1
  214. package/dist/{chunks/messages-C4Wacagz.mjs → messages-Bmu3rJwO.mjs} +60 -1
  215. package/dist/{chunks/messages-C62iC79d2.mjs → messages-BpwhHkFb2.mjs} +60 -1
  216. package/dist/{messages-DSmNNjPb.mjs → messages-BvutXmgt.mjs} +60 -1
  217. package/dist/{chunks/messages-DwU7qOBO2.mjs → messages-BwCgJUdq.mjs} +60 -1
  218. package/dist/{messages-BCeeFrNO.mjs → messages-C5IE9mXm.mjs} +60 -1
  219. package/dist/{chunks/messages-EI0ZLMIG.mjs → messages-C6R0bzW5.mjs} +60 -1
  220. package/dist/{chunks/messages-CZ4Jcn6y.mjs → messages-C9Kq8259.mjs} +60 -1
  221. package/dist/{messages-Nbssq1-N.mjs → messages-CAnOyxrm.mjs} +60 -1
  222. package/dist/{chunks/messages-DQb9YRoO2.mjs → messages-CDueJTe8.mjs} +60 -1
  223. package/dist/{chunks/messages-CpC5Cv_s.mjs → messages-CEGqNXnP.mjs} +60 -1
  224. package/dist/{messages-CWWIbebM2.mjs → messages-CF5MeYEt2.mjs} +60 -1
  225. package/dist/{chunks/messages-ID7kZQyc.mjs → messages-CGiVoFTi.mjs} +60 -1
  226. package/dist/{chunks/messages-B5eo-pGw.mjs → messages-CQzOV7VE2.mjs} +60 -1
  227. package/dist/{chunks/messages-DwAl9zoh2.mjs → messages-CUFac9Qe.mjs} +60 -1
  228. package/dist/{messages-DMH0zRig2.mjs → messages-CUcUO5bC2.mjs} +60 -1
  229. package/dist/{messages-CGZPFOEr.mjs → messages-CcF4SO9W.mjs} +60 -1
  230. package/dist/{messages-gJWgJtGo.mjs → messages-CeqpzGl9.mjs} +60 -1
  231. package/dist/{messages-BEnINcno2.mjs → messages-Cg6a88Bb2.mjs} +60 -1
  232. package/dist/{chunks/messages-BxhJFFrR.mjs → messages-Ch-SytN8.mjs} +60 -1
  233. package/dist/{chunks/messages-DzZZDveu.mjs → messages-Cn3uvFq3.mjs} +60 -1
  234. package/dist/{messages-CoSj68Ef2.mjs → messages-CqZgsrVP2.mjs} +60 -1
  235. package/dist/{messages-BrkXHD2D2.mjs → messages-CskLLTiG2.mjs} +60 -1
  236. package/dist/{chunks/messages-BSstxb1m2.mjs → messages-CusWGsl42.mjs} +60 -1
  237. package/dist/{messages-BInBVzW42.mjs → messages-Cvq2EFPT2.mjs} +60 -1
  238. package/dist/{messages-DfnSbN512.mjs → messages-CwvHO8gp2.mjs} +60 -1
  239. package/dist/{chunks/messages-DjSuzS2m2.mjs → messages-D4dovFzY2.mjs} +60 -1
  240. package/dist/{messages-BkNVOuvO2.mjs → messages-D9qgYMU82.mjs} +60 -1
  241. package/dist/{chunks/messages-D8qYNZ_T.mjs → messages-DGAWjKQZ.mjs} +60 -1
  242. package/dist/{messages-GUgmAfdB.mjs → messages-DR2JBU-H.mjs} +60 -1
  243. package/dist/{messages-iJKNW8hW2.mjs → messages-Da1woCgt2.mjs} +60 -1
  244. package/dist/{chunks/messages-DgSRzW-Y.mjs → messages-DbE-riR52.mjs} +60 -1
  245. package/dist/{chunks/messages-8vGF8iWb.mjs → messages-DcQNeoPB.mjs} +60 -1
  246. package/dist/{messages-C2pz0mJs2.mjs → messages-Dq_RdEny2.mjs} +60 -1
  247. package/dist/{chunks/messages-Vdmc6CaN.mjs → messages-Grhv5SE_.mjs} +60 -1
  248. package/dist/{messages-CX2ioEBc2.mjs → messages-HVgCKIww2.mjs} +60 -1
  249. package/dist/{messages-C9XTx39w2.mjs → messages-KyJQtOkh2.mjs} +60 -1
  250. package/dist/{messages-DPzZM5FG.mjs → messages-L4sYfXpM.mjs} +60 -1
  251. package/dist/{chunks/messages-Ced9OY9X.mjs → messages-P5B61L1J2.mjs} +60 -1
  252. package/dist/{chunks/messages-m_zk_AR3.mjs → messages-RXQKVFCJ.mjs} +60 -1
  253. package/dist/{chunks/messages-B94JmzMj.mjs → messages-UJx6PvCL.mjs} +60 -1
  254. package/dist/{chunks/messages-D1gz17wt.mjs → messages-VIQ8vjvS.mjs} +60 -1
  255. package/dist/{chunks/messages-BClegbPD.mjs → messages-bN4Zmk2m.mjs} +60 -1
  256. package/dist/{chunks/messages-DuOkV5T_.mjs → messages-fAEKzdUq.mjs} +60 -1
  257. package/dist/{chunks/messages-Cn0B5LU1.mjs → messages-gTUy3xjb.mjs} +60 -1
  258. package/dist/{chunks/messages-n1T_9w-Z.mjs → messages-hGop1gbF.mjs} +60 -1
  259. package/dist/{chunks/messages-DFUn1PHK2.mjs → messages-jQsl7cga2.mjs} +60 -1
  260. package/dist/{chunks/messages-h43rfIyt2.mjs → messages-jsrzDVZy2.mjs} +60 -1
  261. package/dist/{chunks/messages-BXmg4Rer2.mjs → messages-lnxRz5rM.mjs} +60 -1
  262. package/dist/{chunks/messages-CkRbSW3z2.mjs → messages-wmS6Z2m82.mjs} +60 -1
  263. package/dist/{messages-2jeEK-EB.mjs → messages-yuNINyS1.mjs} +60 -1
  264. package/dist/{messages-CsHwUgSZ.mjs → messages-zOl6jpOa.mjs} +60 -1
  265. package/dist/react.cjs +1 -1
  266. package/dist/react.mjs +1 -1
  267. package/dist/tools.cjs +1 -1
  268. package/dist/tools.mjs +3 -3
  269. package/dist/vendor.LICENSE.txt +395 -1
  270. package/package.json +3 -1
  271. package/src/components/i18n/locales/am/messages.json +60 -1
  272. package/src/components/i18n/locales/ar/messages.json +60 -1
  273. package/src/components/i18n/locales/az/messages.json +60 -1
  274. package/src/components/i18n/locales/bg/messages.json +60 -1
  275. package/src/components/i18n/locales/bn/messages.json +60 -1
  276. package/src/components/i18n/locales/bs/messages.json +60 -1
  277. package/src/components/i18n/locales/cs/messages.json +60 -1
  278. package/src/components/i18n/locales/da/messages.json +60 -1
  279. package/src/components/i18n/locales/de/messages.json +60 -1
  280. package/src/components/i18n/locales/dv/messages.json +60 -1
  281. package/src/components/i18n/locales/el/messages.json +60 -1
  282. package/src/components/i18n/locales/en/messages.json +59 -0
  283. package/src/components/i18n/locales/es/messages.json +60 -1
  284. package/src/components/i18n/locales/et/messages.json +60 -1
  285. package/src/components/i18n/locales/fa/messages.json +60 -1
  286. package/src/components/i18n/locales/fi/messages.json +60 -1
  287. package/src/components/i18n/locales/fil/messages.json +60 -1
  288. package/src/components/i18n/locales/fr/messages.json +60 -1
  289. package/src/components/i18n/locales/gu/messages.json +60 -1
  290. package/src/components/i18n/locales/he/messages.json +60 -1
  291. package/src/components/i18n/locales/hi/messages.json +60 -1
  292. package/src/components/i18n/locales/hr/messages.json +60 -1
  293. package/src/components/i18n/locales/hu/messages.json +60 -1
  294. package/src/components/i18n/locales/hy/messages.json +60 -1
  295. package/src/components/i18n/locales/id/messages.json +60 -1
  296. package/src/components/i18n/locales/it/messages.json +60 -1
  297. package/src/components/i18n/locales/ja/messages.json +60 -1
  298. package/src/components/i18n/locales/ka/messages.json +60 -1
  299. package/src/components/i18n/locales/km/messages.json +60 -1
  300. package/src/components/i18n/locales/kn/messages.json +60 -1
  301. package/src/components/i18n/locales/ko/messages.json +60 -1
  302. package/src/components/i18n/locales/ku/messages.json +60 -1
  303. package/src/components/i18n/locales/lo/messages.json +60 -1
  304. package/src/components/i18n/locales/lt/messages.json +60 -1
  305. package/src/components/i18n/locales/lv/messages.json +60 -1
  306. package/src/components/i18n/locales/mk/messages.json +60 -1
  307. package/src/components/i18n/locales/ml/messages.json +60 -1
  308. package/src/components/i18n/locales/mn/messages.json +60 -1
  309. package/src/components/i18n/locales/mr/messages.json +60 -1
  310. package/src/components/i18n/locales/ms/messages.json +60 -1
  311. package/src/components/i18n/locales/my/messages.json +60 -1
  312. package/src/components/i18n/locales/ne/messages.json +60 -1
  313. package/src/components/i18n/locales/nl/messages.json +60 -1
  314. package/src/components/i18n/locales/no/messages.json +60 -1
  315. package/src/components/i18n/locales/pa/messages.json +60 -1
  316. package/src/components/i18n/locales/pl/messages.json +60 -1
  317. package/src/components/i18n/locales/ps/messages.json +60 -1
  318. package/src/components/i18n/locales/pt/messages.json +60 -1
  319. package/src/components/i18n/locales/ro/messages.json +60 -1
  320. package/src/components/i18n/locales/ru/messages.json +60 -1
  321. package/src/components/i18n/locales/sd/messages.json +60 -1
  322. package/src/components/i18n/locales/si/messages.json +60 -1
  323. package/src/components/i18n/locales/sk/messages.json +60 -1
  324. package/src/components/i18n/locales/sl/messages.json +60 -1
  325. package/src/components/i18n/locales/sq/messages.json +60 -1
  326. package/src/components/i18n/locales/sr/messages.json +60 -1
  327. package/src/components/i18n/locales/sv/messages.json +60 -1
  328. package/src/components/i18n/locales/sw/messages.json +60 -1
  329. package/src/components/i18n/locales/ta/messages.json +60 -1
  330. package/src/components/i18n/locales/te/messages.json +60 -1
  331. package/src/components/i18n/locales/th/messages.json +60 -1
  332. package/src/components/i18n/locales/tr/messages.json +60 -1
  333. package/src/components/i18n/locales/ug/messages.json +60 -1
  334. package/src/components/i18n/locales/uk/messages.json +60 -1
  335. package/src/components/i18n/locales/ur/messages.json +60 -1
  336. package/src/components/i18n/locales/vi/messages.json +60 -1
  337. package/src/components/i18n/locales/yi/messages.json +60 -1
  338. package/src/components/i18n/locales/zh/messages.json +60 -1
  339. package/src/components/icons/index.ts +106 -0
  340. package/src/components/modules/paste/handlers/pattern-handler.ts +4 -7
  341. package/src/components/utils/format-bytes.ts +15 -0
  342. package/src/components/utils/max-size.ts +36 -0
  343. package/src/components/utils/media-empty-state.ts +4 -13
  344. package/src/components/utils/mime-match.ts +22 -0
  345. package/src/components/utils/upload-error-message.ts +43 -0
  346. package/src/styles/audio.css +528 -0
  347. package/src/styles/colors.css +31 -0
  348. package/src/styles/keyframes.css +8 -0
  349. package/src/styles/main.css +3 -0
  350. package/src/styles/resize-cue.css +26 -0
  351. package/src/styles/video.css +1221 -0
  352. package/src/tools/audio/constants.ts +21 -0
  353. package/src/tools/audio/controls.ts +416 -0
  354. package/src/tools/audio/empty-state.ts +45 -0
  355. package/src/tools/audio/i18n.ts +13 -0
  356. package/src/tools/audio/index.ts +482 -0
  357. package/src/tools/audio/metadata.ts +56 -0
  358. package/src/tools/audio/ui.ts +104 -0
  359. package/src/tools/audio/uploader.ts +76 -0
  360. package/src/tools/audio/waveform.ts +171 -0
  361. package/src/tools/file/constants.ts +5 -3
  362. package/src/tools/file/index.ts +45 -4
  363. package/src/tools/file/uploader.ts +5 -2
  364. package/src/tools/image/constants.ts +3 -8
  365. package/src/tools/image/converted-uploader.ts +30 -0
  366. package/src/tools/image/gif-to-webm.ts +170 -0
  367. package/src/tools/image/index.ts +125 -3
  368. package/src/tools/image/resizer.ts +39 -6
  369. package/src/tools/image/uploader.ts +4 -2
  370. package/src/tools/image/uploading-state.ts +2 -1
  371. package/src/tools/index.ts +4 -0
  372. package/src/tools/video/constants.ts +19 -0
  373. package/src/tools/video/controls.ts +1448 -0
  374. package/src/tools/video/empty-state.ts +45 -0
  375. package/src/tools/video/i18n.ts +13 -0
  376. package/src/tools/video/index.ts +521 -0
  377. package/src/tools/video/ui.ts +83 -0
  378. package/src/tools/video/uploader.ts +76 -0
  379. package/types/configs/blok-config.d.ts +2 -9
  380. package/types/tools/audio.d.ts +40 -0
  381. package/types/tools/file.d.ts +9 -3
  382. package/types/tools/image.d.ts +15 -3
  383. package/types/tools/max-size.d.ts +12 -0
  384. package/types/tools/video.d.ts +75 -0
  385. package/types/tools-entry.d.ts +9 -0
  386. package/dist/chunks/constants-B2Lcd7Kk.cjs +0 -539
  387. package/dist/chunks/lightweight-i18n-Clk37huC.cjs +0 -1
  388. package/dist/chunks/messages-04T5j1ww2.cjs +0 -1
  389. package/dist/chunks/messages-0OyEWZDW.cjs +0 -1
  390. package/dist/chunks/messages-3iHkF7Vy.cjs +0 -1
  391. package/dist/chunks/messages-98gouyMa.cjs +0 -1
  392. package/dist/chunks/messages-BAgSlZpS.cjs +0 -1
  393. package/dist/chunks/messages-BJ7clJXu2.cjs +0 -1
  394. package/dist/chunks/messages-BPYQEADI.cjs +0 -1
  395. package/dist/chunks/messages-BSxgqWqe.cjs +0 -1
  396. package/dist/chunks/messages-BdjiMHzU2.cjs +0 -1
  397. package/dist/chunks/messages-Br3ERe172.cjs +0 -1
  398. package/dist/chunks/messages-BtEDKVO7.cjs +0 -1
  399. package/dist/chunks/messages-BtevAuQS2.cjs +0 -1
  400. package/dist/chunks/messages-C6Xc3L-B2.cjs +0 -1
  401. package/dist/chunks/messages-CEquPedm.cjs +0 -1
  402. package/dist/chunks/messages-CFo-pG-t2.cjs +0 -1
  403. package/dist/chunks/messages-CXlSU8cQ2.cjs +0 -1
  404. package/dist/chunks/messages-Cf8u0aln2.cjs +0 -1
  405. package/dist/chunks/messages-Cg6qicee.cjs +0 -1
  406. package/dist/chunks/messages-CgGwGZs22.cjs +0 -1
  407. package/dist/chunks/messages-Cgg1HHSo.cjs +0 -1
  408. package/dist/chunks/messages-Ck6jEhXV.cjs +0 -1
  409. package/dist/chunks/messages-Cx1kdFH_2.cjs +0 -1
  410. package/dist/chunks/messages-DNYtMplL2.cjs +0 -1
  411. package/dist/chunks/messages-DOBcfgRF.cjs +0 -1
  412. package/dist/chunks/messages-DWeyV2N3.cjs +0 -1
  413. package/dist/chunks/messages-DkINYHo4.cjs +0 -1
  414. package/dist/chunks/messages-DsWTIuli2.cjs +0 -1
  415. package/dist/chunks/messages-JLj0J5so.cjs +0 -1
  416. package/dist/chunks/messages-o3omNKfH.cjs +0 -1
  417. package/dist/chunks/messages-p-1X5oFM2.cjs +0 -1
  418. package/dist/chunks/messages-poLdFEEs2.cjs +0 -1
  419. package/dist/chunks/messages-px_8gpHT.cjs +0 -1
  420. package/dist/chunks/messages-vdupz5T0.cjs +0 -1
  421. package/dist/chunks/tools-DiTb45BS.cjs +0 -208
  422. /package/dist/chunks/{am-B37aLxvu.cjs → am-Dc8fykoi.cjs} +0 -0
  423. /package/dist/chunks/{am-D7yfVqJS.mjs → am-DyT_H_W5.mjs} +0 -0
  424. /package/dist/chunks/{ar-ySo_KDqd.cjs → ar-B4htcHv2.cjs} +0 -0
  425. /package/dist/chunks/{ar-D00SixXA.mjs → ar-DxcVoX26.mjs} +0 -0
  426. /package/dist/chunks/{az-nCoWuGBd.cjs → az-CddDVVCC.cjs} +0 -0
  427. /package/dist/chunks/{az-BmIwKcoq.mjs → az-Ck_jWtwp.mjs} +0 -0
  428. /package/dist/chunks/{bg-BKB8_J3v.cjs → bg-BExJFlqx.cjs} +0 -0
  429. /package/dist/chunks/{bg-BTZaB21W.mjs → bg-C4_DlJ7m.mjs} +0 -0
  430. /package/dist/chunks/{bn-BQiBKtpR.cjs → bn-B3CWOlDr.cjs} +0 -0
  431. /package/dist/chunks/{bn-BVpf0XL6.mjs → bn-D0PWjX6h.mjs} +0 -0
  432. /package/dist/chunks/{bs-BSz4cPth.mjs → bs-BjqCqh8B.mjs} +0 -0
  433. /package/dist/chunks/{bs-D_lekdEb.cjs → bs-BylFYMjB.cjs} +0 -0
  434. /package/dist/chunks/{cs-Dv8Jg_Fa.cjs → cs-BzXww59F.cjs} +0 -0
  435. /package/dist/chunks/{cs-p5s4GudH.mjs → cs-R_vXAqRw.mjs} +0 -0
  436. /package/dist/chunks/{da-D33rjz-t.cjs → da-Be_YGjP-.cjs} +0 -0
  437. /package/dist/chunks/{da-BqJa8BMd.mjs → da-BuzQWEfl.mjs} +0 -0
  438. /package/dist/chunks/{de-BDDktkfI.mjs → de-BuXrJCQB.mjs} +0 -0
  439. /package/dist/chunks/{de-I8YFq0Ej.cjs → de-CuXVxRU3.cjs} +0 -0
  440. /package/dist/chunks/{el-BInM0nQo.cjs → el-CkLmGPX-.cjs} +0 -0
  441. /package/dist/chunks/{el-R63G08P0.mjs → el-d_XP5H44.mjs} +0 -0
  442. /package/dist/chunks/{es-Cj_AHhb9.cjs → es-CpevBSzX.cjs} +0 -0
  443. /package/dist/chunks/{es-BleH_dHg.mjs → es-D79FXk9q.mjs} +0 -0
  444. /package/dist/chunks/{et-BuISCVFl.mjs → et-BmawyEOM.mjs} +0 -0
  445. /package/dist/chunks/{et-BkMzN_qG.cjs → et-Dm53D0Mw.cjs} +0 -0
  446. /package/dist/chunks/{fa-C3VQIfEJ.cjs → fa-BLZQhg44.cjs} +0 -0
  447. /package/dist/chunks/{fa-CI9f3My0.mjs → fa-Cyzee-uD.mjs} +0 -0
  448. /package/dist/chunks/{fi-p8xxtchQ.cjs → fi-CktzJP0P.cjs} +0 -0
  449. /package/dist/chunks/{fi-C0_Jyps9.mjs → fi-zVk232gP.mjs} +0 -0
  450. /package/dist/chunks/{fil-DC8VTmfd.cjs → fil-BGEDSfA3.cjs} +0 -0
  451. /package/dist/chunks/{fil-BZsJ4YZG.mjs → fil-DQcS9VcY.mjs} +0 -0
  452. /package/dist/chunks/{fr-BjeioA2N.mjs → fr-CUoxzxMy.mjs} +0 -0
  453. /package/dist/chunks/{fr-Cbstr5ii.cjs → fr-DRFIW1vC.cjs} +0 -0
  454. /package/dist/chunks/{gu-CNi9WnhQ.mjs → gu-BSL13JVA.mjs} +0 -0
  455. /package/dist/chunks/{gu-DpPoUPem.cjs → gu-DCZgQPh1.cjs} +0 -0
  456. /package/dist/chunks/{he-bU_Id5XQ.mjs → he-B4fsDXlD.mjs} +0 -0
  457. /package/dist/chunks/{he-Rlv-PVKK.cjs → he-CrJki_kT.cjs} +0 -0
  458. /package/dist/chunks/{hi-CfLaLKZL.cjs → hi-B7MYHNXb.cjs} +0 -0
  459. /package/dist/chunks/{hi-CYYjJgoQ.mjs → hi-Df_jRvP-.mjs} +0 -0
  460. /package/dist/chunks/{hr-B6eFd_4M.cjs → hr-CHeiPUjb.cjs} +0 -0
  461. /package/dist/chunks/{hr-CerKCstu.mjs → hr-DC4ZNI8T.mjs} +0 -0
  462. /package/dist/chunks/{hu-DNYoHMic.mjs → hu-ClC3NxqC.mjs} +0 -0
  463. /package/dist/chunks/{hu-DQcisouf.cjs → hu-x4Ga7znM.cjs} +0 -0
  464. /package/dist/chunks/{hy-x9QOWTT6.cjs → hy-dXGxcAjA.cjs} +0 -0
  465. /package/dist/chunks/{hy-N0XBqVv_.mjs → hy-fpToCZeQ.mjs} +0 -0
  466. /package/dist/chunks/{id-G7ASv812.cjs → id-BETj6SOy.cjs} +0 -0
  467. /package/dist/chunks/{id-dmUIQB07.mjs → id-zXVZ_-tD.mjs} +0 -0
  468. /package/dist/chunks/{it-DXDwm7Aj.cjs → it-CblE5NNx.cjs} +0 -0
  469. /package/dist/chunks/{it-G9Rg2LaN.mjs → it-Cp7EdTdT.mjs} +0 -0
  470. /package/dist/chunks/{ja-DHMurrnk.cjs → ja-D4CP2G3w.cjs} +0 -0
  471. /package/dist/chunks/{ja-C1Nou_k5.mjs → ja-DoaPxTyV.mjs} +0 -0
  472. /package/dist/chunks/{ka-P0_q1_oW.cjs → ka-C6DxbxxH.cjs} +0 -0
  473. /package/dist/chunks/{ka-9rtmTdhq.mjs → ka-CwoyhuWu.mjs} +0 -0
  474. /package/dist/chunks/{km-DedsOdPY.cjs → km-BdZJI-qn.cjs} +0 -0
  475. /package/dist/chunks/{km-T8J0lGcC.mjs → km-D8jrJ9sv.mjs} +0 -0
  476. /package/dist/chunks/{kn-CAGP6H3x.cjs → kn-DsGMDyW7.cjs} +0 -0
  477. /package/dist/chunks/{kn-CMb5Cv2d.mjs → kn-IsuZc30u.mjs} +0 -0
  478. /package/dist/chunks/{ko-3D0kPtnu.cjs → ko-CFVvGmVE.cjs} +0 -0
  479. /package/dist/chunks/{ko-DulVmkjC.mjs → ko-DxAtBe72.mjs} +0 -0
  480. /package/dist/chunks/{ku-BYzD3GnU.cjs → ku-B-43xPpS.cjs} +0 -0
  481. /package/dist/chunks/{ku-C5mlSVfC.mjs → ku-Da-1BcEg.mjs} +0 -0
  482. /package/dist/chunks/{lo-D9ixPNJD.cjs → lo-DtVyNkq4.cjs} +0 -0
  483. /package/dist/chunks/{lo-Cw2hfj84.mjs → lo-p9PUOxXA.mjs} +0 -0
  484. /package/dist/chunks/{lt-CzfEJdVd.cjs → lt-BaHJn9M4.cjs} +0 -0
  485. /package/dist/chunks/{lt-Ceg9NAdI.mjs → lt-C4lGuzQq.mjs} +0 -0
  486. /package/dist/chunks/{lv-3CXzQoic.mjs → lv-B5Cp4JEf.mjs} +0 -0
  487. /package/dist/chunks/{lv-DWyBbTHI.cjs → lv-BHXmLxXV.cjs} +0 -0
  488. /package/dist/chunks/{mk-CEbNT0Oe.mjs → mk-B5rDdPUj.mjs} +0 -0
  489. /package/dist/chunks/{mk-Anl5AUSU.cjs → mk-BhwKijB_.cjs} +0 -0
  490. /package/dist/chunks/{ml-B1QmnbTX.cjs → ml-DxZv3Mzc.cjs} +0 -0
  491. /package/dist/chunks/{ml-DaF_zn1h.mjs → ml-gamedfCk.mjs} +0 -0
  492. /package/dist/chunks/{mn-DLpgZmIl.mjs → mn-BbhhPusH.mjs} +0 -0
  493. /package/dist/chunks/{mn-DM3AWPQI.cjs → mn-Bzi4YTMA.cjs} +0 -0
  494. /package/dist/chunks/{mr-CPCMiBsx.cjs → mr-BENatxZg.cjs} +0 -0
  495. /package/dist/chunks/{mr-C-d3CWSD.mjs → mr-DO_kNyu7.mjs} +0 -0
  496. /package/dist/chunks/{ms-BKg5zJ6g.cjs → ms-BVop2DJc.cjs} +0 -0
  497. /package/dist/chunks/{ms-D3Zil5Dz.mjs → ms-eIjFVRpo.mjs} +0 -0
  498. /package/dist/chunks/{my-HzQdQq6H.mjs → my-nf0gvOOX.mjs} +0 -0
  499. /package/dist/chunks/{my-BhrbZPAE.cjs → my-qLePPAWv.cjs} +0 -0
  500. /package/dist/chunks/{ne-Dw6kUPkS.mjs → ne-DOaubYly.mjs} +0 -0
  501. /package/dist/chunks/{ne-BsEUv3Vp.cjs → ne-DfryA0M8.cjs} +0 -0
  502. /package/dist/chunks/{nl-C1jFx32v.cjs → nl-BehqWHBh.cjs} +0 -0
  503. /package/dist/chunks/{nl-D8QQ6wmJ.mjs → nl-VHkGK6MK.mjs} +0 -0
  504. /package/dist/chunks/{no-BWEOGeAo.cjs → no-CR9Fxz1X.cjs} +0 -0
  505. /package/dist/chunks/{no-Rg2uuKdV.mjs → no-CdtSCHJr.mjs} +0 -0
  506. /package/dist/chunks/{notifier-BOB8o7IG.cjs → notifier-CFYHvVe5.cjs} +0 -0
  507. /package/dist/chunks/{notifier-CEnelp-K.mjs → notifier-DJ3Shryh.mjs} +0 -0
  508. /package/dist/chunks/{pa-BitCteAA.cjs → pa-DgC4LF8q.cjs} +0 -0
  509. /package/dist/chunks/{pa-BSwIZvA0.mjs → pa-TMcUvGQV.mjs} +0 -0
  510. /package/dist/chunks/{pl-FhXomIGp.mjs → pl-BfQYJ9y_.mjs} +0 -0
  511. /package/dist/chunks/{pl-CbCrHaA6.cjs → pl-a2nheY_J.cjs} +0 -0
  512. /package/dist/chunks/{prism-bash-CZ6DBzIk.mjs → prism-bash-D4zr_u8t.mjs} +0 -0
  513. /package/dist/chunks/{prism-bash-qitAYJtY.cjs → prism-bash-DDQ2u01V.cjs} +0 -0
  514. /package/dist/chunks/{prism-c-nnJdAhPL.mjs → prism-c-CVyw1BOu.mjs} +0 -0
  515. /package/dist/chunks/{prism-c-BLyB6e2K.cjs → prism-c-DfVjTtJG.cjs} +0 -0
  516. /package/dist/chunks/{prism-cpp-DYNCFS3K.mjs → prism-cpp-CrBJjuu5.mjs} +0 -0
  517. /package/dist/chunks/{prism-cpp-OSmmdLuG.cjs → prism-cpp-DqxJDZfM.cjs} +0 -0
  518. /package/dist/chunks/{prism-csharp-16T9Ewqf.cjs → prism-csharp-BbJ6Sp-b.cjs} +0 -0
  519. /package/dist/chunks/{prism-csharp-Dxs2I4ua.mjs → prism-csharp-lrMxvYpL.mjs} +0 -0
  520. /package/dist/chunks/{prism-css-DwTbu-Ar.mjs → prism-css-VQTID_HR.mjs} +0 -0
  521. /package/dist/chunks/{prism-css-CMFNwvrI.cjs → prism-css-w2kan9uc.cjs} +0 -0
  522. /package/dist/chunks/{prism-dart-DX2YtTGt.cjs → prism-dart-B374T6OY.cjs} +0 -0
  523. /package/dist/chunks/{prism-dart-Cqhjl8Zt.mjs → prism-dart-Q-9Z_W9B.mjs} +0 -0
  524. /package/dist/chunks/{prism-docker-CF7dhvYh.mjs → prism-docker-DWAQ3dDD.mjs} +0 -0
  525. /package/dist/chunks/{prism-docker-CR4lKITl.cjs → prism-docker-DsFNc9i3.cjs} +0 -0
  526. /package/dist/chunks/{prism-go-DUV4QMTT.cjs → prism-go-C4e52XbO.cjs} +0 -0
  527. /package/dist/chunks/{prism-go-h9KLdAE9.mjs → prism-go-CrXe2RS9.mjs} +0 -0
  528. /package/dist/chunks/{prism-graphql-CaH0gD6e.mjs → prism-graphql-BG5mfdhd.mjs} +0 -0
  529. /package/dist/chunks/{prism-graphql-Cw4U0rOD.cjs → prism-graphql-DzWE15JS.cjs} +0 -0
  530. /package/dist/chunks/{prism-java-BpQ0ReAC.cjs → prism-java-Bth7ny8U.cjs} +0 -0
  531. /package/dist/chunks/{prism-java-DvKEtgSw.mjs → prism-java-_m72Vn1f.mjs} +0 -0
  532. /package/dist/chunks/{prism-javascript-D2ecWeeN.mjs → prism-javascript-B74it_3Q.mjs} +0 -0
  533. /package/dist/chunks/{prism-javascript-ELk9U7yv.cjs → prism-javascript-CZHuhUqG.cjs} +0 -0
  534. /package/dist/chunks/{prism-json-BdNUnMGb.cjs → prism-json-C7yjuPaa.cjs} +0 -0
  535. /package/dist/chunks/{prism-json-eqh92QZC.mjs → prism-json-CFsf0S_6.mjs} +0 -0
  536. /package/dist/chunks/{prism-kotlin-22Gr2SxK.mjs → prism-kotlin-BTT9anu1.mjs} +0 -0
  537. /package/dist/chunks/{prism-kotlin-WNUAhuoa.cjs → prism-kotlin-BwlhXHxp.cjs} +0 -0
  538. /package/dist/chunks/{prism-latex-DKge5vOJ.mjs → prism-latex-CL8zDJwm.mjs} +0 -0
  539. /package/dist/chunks/{prism-latex-BtyYjHKE.cjs → prism-latex-dRhujmiF.cjs} +0 -0
  540. /package/dist/chunks/{prism-lua-DhVb-IBs.cjs → prism-lua-CC8iHZy0.cjs} +0 -0
  541. /package/dist/chunks/{prism-lua-BySMQWvB.mjs → prism-lua-D6JJ7p-A.mjs} +0 -0
  542. /package/dist/chunks/{prism-markdown-Bvv1_ifb.mjs → prism-markdown-D6D8BprH.mjs} +0 -0
  543. /package/dist/chunks/{prism-markdown-DonI20ta.cjs → prism-markdown-DMW4QHo8.cjs} +0 -0
  544. /package/dist/chunks/{prism-markup-DaGlO8wQ.cjs → prism-markup-DmQlWhRx.cjs} +0 -0
  545. /package/dist/chunks/{prism-markup-DPDFrSBO.mjs → prism-markup-fK-1ADZ0.mjs} +0 -0
  546. /package/dist/chunks/{prism-markup-templating-DKZ_F63l.cjs → prism-markup-templating-CKDQaPgk.cjs} +0 -0
  547. /package/dist/chunks/{prism-markup-templating-B1u1wroF.mjs → prism-markup-templating-DfRjv0mn.mjs} +0 -0
  548. /package/dist/chunks/{prism-php-V_A4SMVC.cjs → prism-php-BJVLLh_W.cjs} +0 -0
  549. /package/dist/chunks/{prism-php-CjjweiQK.mjs → prism-php-DffX-wRi.mjs} +0 -0
  550. /package/dist/chunks/{prism-python-6eBEySe7.mjs → prism-python-2BrUkjbB.mjs} +0 -0
  551. /package/dist/chunks/{prism-python-DLqiSiAQ.cjs → prism-python-Bhf5rXub.cjs} +0 -0
  552. /package/dist/chunks/{prism-r-uOZv1a7d.mjs → prism-r-CU9se1Na.mjs} +0 -0
  553. /package/dist/chunks/{prism-r-zh9-I3-j.cjs → prism-r-ChFoLzAP.cjs} +0 -0
  554. /package/dist/chunks/{prism-ruby-M4mhoFF5.cjs → prism-ruby-BvGZJs6t.cjs} +0 -0
  555. /package/dist/chunks/{prism-ruby-Cr-3Y89S.mjs → prism-ruby-DuQ4syPd.mjs} +0 -0
  556. /package/dist/chunks/{prism-rust-g2dmV4wL.cjs → prism-rust-6eFXbDzh.cjs} +0 -0
  557. /package/dist/chunks/{prism-rust-DxlMe9N8.mjs → prism-rust-DRurCx4W.mjs} +0 -0
  558. /package/dist/chunks/{prism-scala-byYaK-Hs.mjs → prism-scala-CU8N2Vpe.mjs} +0 -0
  559. /package/dist/chunks/{prism-scala-DdSRpf_v.cjs → prism-scala-De4Tbjij.cjs} +0 -0
  560. /package/dist/chunks/{prism-sql-W4S9Ag3H.mjs → prism-sql-Au3VVcw1.mjs} +0 -0
  561. /package/dist/chunks/{prism-sql-BjDuRopl.cjs → prism-sql-_JMPMWu-.cjs} +0 -0
  562. /package/dist/chunks/{prism-swift-KjqbhTlS.mjs → prism-swift-B-r5qNUT.mjs} +0 -0
  563. /package/dist/chunks/{prism-swift-CqOmCgZp.cjs → prism-swift-DdWKYFH_.cjs} +0 -0
  564. /package/dist/chunks/{prism-typescript-CjqcJxVT.mjs → prism-typescript-BKj96rmj.mjs} +0 -0
  565. /package/dist/chunks/{prism-typescript-DGlZWX5K.cjs → prism-typescript-DYxN8s4t.cjs} +0 -0
  566. /package/dist/chunks/{prism-yaml-W11WcDBZ.mjs → prism-yaml-Bk31kvSd.mjs} +0 -0
  567. /package/dist/chunks/{prism-yaml-HqOYDhYZ.cjs → prism-yaml-CAlRa1Lr.cjs} +0 -0
  568. /package/dist/chunks/{ps-BoBtbrB6.mjs → ps-Dv4lWz5A.mjs} +0 -0
  569. /package/dist/chunks/{ps-4sPZuv3g.cjs → ps-f5JFPQUh.cjs} +0 -0
  570. /package/dist/chunks/{pt-BYA5NOw_.mjs → pt-CtgGcY6X.mjs} +0 -0
  571. /package/dist/chunks/{pt-CSzjho-V.cjs → pt-I7qQ_R8a.cjs} +0 -0
  572. /package/dist/chunks/{ro-C9tN3AFF.cjs → ro-BNo9EVkV.cjs} +0 -0
  573. /package/dist/chunks/{ro-mtobtMpD.mjs → ro-Dxs4TSRR.mjs} +0 -0
  574. /package/dist/chunks/{ru-B791LPq3.mjs → ru-B5A2vGqQ.mjs} +0 -0
  575. /package/dist/chunks/{ru-BYPO5wNO.cjs → ru-Q0YDL_7B.cjs} +0 -0
  576. /package/dist/chunks/{sd-BWuglgs_.cjs → sd-56aE3AgX.cjs} +0 -0
  577. /package/dist/chunks/{sd-C8ngG92I.mjs → sd-DcBvM0yY.mjs} +0 -0
  578. /package/dist/chunks/{si-IX2P_DGB.cjs → si-B1k5DPe9.cjs} +0 -0
  579. /package/dist/chunks/{si-C_q8DxVt.mjs → si-JTOFsZCj.mjs} +0 -0
  580. /package/dist/chunks/{sk-HJJlM9uq.mjs → sk-B0LtwsAp.mjs} +0 -0
  581. /package/dist/chunks/{sk-CxtyIOlr.cjs → sk-DKWs0Fly.cjs} +0 -0
  582. /package/dist/chunks/{sl-CPjYG80u.cjs → sl-3aOunask.cjs} +0 -0
  583. /package/dist/chunks/{sl-kB1f5rPn.mjs → sl-CE_B7j6P.mjs} +0 -0
  584. /package/dist/chunks/{sq-Do6UyHn9.cjs → sq-BjtxgRqp.cjs} +0 -0
  585. /package/dist/chunks/{sq-CD0W0D6z.mjs → sq-DG9X-8jF.mjs} +0 -0
  586. /package/dist/chunks/{sr-BcyT-MWS.cjs → sr-DLHLW9qM.cjs} +0 -0
  587. /package/dist/chunks/{sr-lLJu9TsT.mjs → sr-LJKaTtNM.mjs} +0 -0
  588. /package/dist/chunks/{sv-C3GL-a0k.mjs → sv-Cm3jSTt-.mjs} +0 -0
  589. /package/dist/chunks/{sv-Bsz7ersx.cjs → sv-DJ6Kywac.cjs} +0 -0
  590. /package/dist/chunks/{sw-CUElHZxL.cjs → sw-BnvydNkI.cjs} +0 -0
  591. /package/dist/chunks/{sw-D6-DZ14-.mjs → sw-C2rm3bwF.mjs} +0 -0
  592. /package/dist/chunks/{ta-BZy78kBk.mjs → ta-5k36G0kU.mjs} +0 -0
  593. /package/dist/chunks/{ta-D0DQba-y.cjs → ta-e_T5Y_TH.cjs} +0 -0
  594. /package/dist/chunks/{te-DPMvJyXg.cjs → te-DJd9mdke.cjs} +0 -0
  595. /package/dist/chunks/{te-vigCVKdg.mjs → te-Qw6ZgfXC.mjs} +0 -0
  596. /package/dist/chunks/{th-DtcrIt9J.cjs → th-BFvK-kez.cjs} +0 -0
  597. /package/dist/chunks/{th-CGc0RLWB.mjs → th-DFCnkeU5.mjs} +0 -0
  598. /package/dist/chunks/{tr-BrEFaVNA.cjs → tr-Bio2iyuW.cjs} +0 -0
  599. /package/dist/chunks/{tr-Cgkf5aJo.mjs → tr-QoYDqZZE.mjs} +0 -0
  600. /package/dist/chunks/{ug-BVK7qBs4.mjs → ug-CYVvHjDo.mjs} +0 -0
  601. /package/dist/chunks/{ug-CqZXfhXi.cjs → ug-_E9uEBC5.cjs} +0 -0
  602. /package/dist/chunks/{uk-DgReWEge.cjs → uk-CiihPsrk.cjs} +0 -0
  603. /package/dist/chunks/{uk-CSPaCalw.mjs → uk-KrD9Hdx7.mjs} +0 -0
  604. /package/dist/chunks/{ur-ByrwJnY1.cjs → ur-B9A13pT4.cjs} +0 -0
  605. /package/dist/chunks/{ur-VpFFsATs.mjs → ur-Bgtl4YGa.mjs} +0 -0
  606. /package/dist/chunks/{vi-CWQtvATS.mjs → vi-CwW9u4Ww.mjs} +0 -0
  607. /package/dist/chunks/{vi-lUD3w7gP.cjs → vi-l243K5Tb.cjs} +0 -0
  608. /package/dist/chunks/{zh-akJj2cdF.mjs → zh-BdCF4Hao.mjs} +0 -0
  609. /package/dist/chunks/{zh-fSp1qOne.cjs → zh-DPZR_81P.cjs} +0 -0
@@ -0,0 +1,1448 @@
1
+ import {
2
+ IconChevronLeft,
3
+ IconChevronRight,
4
+ IconExpandFullscreen,
5
+ IconMinus,
6
+ IconPlayerBackward,
7
+ IconPlayerForward,
8
+ IconPlayerFullscreenExit,
9
+ IconPlayerLoop,
10
+ IconPlayerPause,
11
+ IconPlayerPip,
12
+ IconPlayerPlay,
13
+ IconPlayerSettings,
14
+ IconPlayerSpeed,
15
+ IconPlayerTheater,
16
+ IconPlayerVolume,
17
+ IconPlayerVolumeMute,
18
+ IconPlus,
19
+ } from '../../components/icons';
20
+ import { promoteToTopLayer, removeFromTopLayer, supportsPopoverAPI } from '../../components/utils/top-layer';
21
+ import type { VideoGlow } from '../../../types/tools/video';
22
+
23
+ /** Minimal storage seam (localStorage-shaped) for volume + position persistence. */
24
+ export interface VideoStorage {
25
+ getItem(key: string): string | null;
26
+ setItem(key: string, value: string): void;
27
+ removeItem(key: string): void;
28
+ }
29
+
30
+ export interface ControlsOptions {
31
+ video: HTMLVideoElement;
32
+ figure: HTMLElement;
33
+ /** Defaults to window.localStorage; pass null to disable persistence. */
34
+ storage?: VideoStorage | null;
35
+ /** Ambient glow intensity behind the player. Default 'minimal'. */
36
+ glow?: VideoGlow;
37
+ /** Initial loop state (persisted via the block's Loop tune). Default false. */
38
+ loop?: boolean;
39
+ }
40
+
41
+ export interface ControlsHandle {
42
+ element: HTMLElement;
43
+ /** Enter/leave theater (cinema) mode programmatically — used by the tool to
44
+ * re-apply theater after a re-render. */
45
+ setTheater(on: boolean): void;
46
+ destroy(): void;
47
+ }
48
+
49
+ /** Format a seconds value into `m:ss` (e.g. 125 → "2:05"). */
50
+ export function formatTime(seconds: number): string {
51
+ if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
52
+ const total = Math.floor(seconds);
53
+ const mins = Math.floor(total / 60);
54
+ const secs = total % 60;
55
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
56
+ }
57
+
58
+ /**
59
+ * Buffered-ahead percentage (0..100) for the loaded bar. Picks the buffered
60
+ * range that contains `currentTime`; failing that, the last range ending before
61
+ * it (so a forward gap still shows real progress). Returns 0 for missing/empty
62
+ * ranges or a non-positive/non-finite duration (e.g. a live stream).
63
+ */
64
+ export function bufferedPct(buffered: TimeRanges | null, currentTime: number, duration: number): number {
65
+ if (!buffered || buffered.length === 0) return 0;
66
+ if (!Number.isFinite(duration) || duration <= 0) return 0;
67
+ const ranges = Array.from({ length: buffered.length }, (_, i) => ({
68
+ start: buffered.start(i),
69
+ end: buffered.end(i),
70
+ }));
71
+ // Prefer the range straddling the playhead; else the last range that ended
72
+ // before it (a forward gap still shows real progress).
73
+ const containing = ranges.find((r) => r.start <= currentTime && currentTime <= r.end);
74
+ const before = ranges.filter((r) => r.end <= currentTime).at(-1);
75
+ const end = containing?.end ?? before?.end ?? 0;
76
+ return Math.min(100, Math.max(0, (end / duration) * 100));
77
+ }
78
+
79
+ /** Clamp a 0..1 scrubber ratio to a time, guarding non-finite duration. */
80
+ export function timeAtRatio(ratio: number, duration: number): number {
81
+ if (!Number.isFinite(duration) || duration <= 0) return 0;
82
+ return Math.min(1, Math.max(0, ratio)) * duration;
83
+ }
84
+
85
+ /** 0..1 ratio of a pointer's x within a track rect; 0 when the rect has no width. */
86
+ export function ratioFromPointer(clientX: number, rect: { left: number; width: number }): number {
87
+ if (!rect.width) return 0;
88
+ return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
89
+ }
90
+
91
+ function button(action: string, label: string, icon: string, extraClass = ''): HTMLButtonElement {
92
+ const btn = document.createElement('button');
93
+ btn.type = 'button';
94
+ btn.setAttribute('data-action', action);
95
+ btn.setAttribute('aria-label', label);
96
+ btn.className = `blok-video-controls__btn ${extraClass}`.trim();
97
+ btn.innerHTML = icon;
98
+ return btn;
99
+ }
100
+
101
+ /**
102
+ * Build an Airbnb-inspired custom control surface for a `<video>` element and
103
+ * wire it to the media. The native `controls` attribute is intentionally
104
+ * absent — this fully replaces it (play/pause, scrubber, time, volume,
105
+ * fullscreen). Returns the control element plus a teardown that detaches every
106
+ * media listener.
107
+ */
108
+ export function attachControls({ video, figure, storage, glow = 'minimal', loop = false }: ControlsOptions): ControlsHandle {
109
+ const root = document.createElement('div');
110
+ root.className = 'blok-video-controls';
111
+ root.setAttribute('data-role', 'video-controls');
112
+
113
+ // Momentary centre burst — flashes the action's glyph (à la native players)
114
+ // each time playback toggles, then animates out.
115
+ const burst = document.createElement('div');
116
+ burst.className = 'blok-video-controls__burst';
117
+ burst.setAttribute('data-role', 'play-burst');
118
+ burst.setAttribute('aria-hidden', 'true');
119
+ root.appendChild(burst);
120
+
121
+ // Press-and-hold speed badge — surfaces "2×" while the user holds the video
122
+ // down to scrub through at double speed (à la mobile YouTube), then fades.
123
+ const speedBadge = document.createElement('div');
124
+ speedBadge.className = 'blok-video-controls__speed';
125
+ speedBadge.setAttribute('data-role', 'speed-badge');
126
+ speedBadge.setAttribute('aria-hidden', 'true');
127
+ speedBadge.innerHTML = `<span class="blok-video-controls__speed-label">2×</span>${IconPlayerForward}`;
128
+ root.appendChild(speedBadge);
129
+
130
+ // Arrow-key seek indicator — a frosted pill that flashes in from the left or
131
+ // right edge when the user nudges playback back/forward with the arrow keys.
132
+ const seekFlash = document.createElement('div');
133
+ seekFlash.className = 'blok-video-controls__seek-flash';
134
+ seekFlash.setAttribute('data-role', 'seek-flash');
135
+ seekFlash.setAttribute('aria-hidden', 'true');
136
+ root.appendChild(seekFlash);
137
+
138
+ // Large centre play affordance — shown while paused at the very start (à la
139
+ // native players); a click begins playback.
140
+ const centerPlay = document.createElement('button');
141
+ centerPlay.type = 'button';
142
+ centerPlay.className = 'blok-video-controls__center';
143
+ centerPlay.setAttribute('data-role', 'center-play');
144
+ centerPlay.setAttribute('aria-label', 'Play');
145
+ centerPlay.innerHTML = IconPlayerPlay;
146
+ root.appendChild(centerPlay);
147
+
148
+ // Buffering spinner — toggled by the media's waiting/playing/canplay events.
149
+ const spinner = document.createElement('div');
150
+ spinner.className = 'blok-video-controls__spinner';
151
+ spinner.setAttribute('data-role', 'buffer-spinner');
152
+ spinner.setAttribute('data-active', 'false');
153
+ spinner.setAttribute('aria-hidden', 'true');
154
+ root.appendChild(spinner);
155
+
156
+ // Bottom scrim + control bar.
157
+ const bar = document.createElement('div');
158
+ bar.className = 'blok-video-controls__bar';
159
+
160
+ const playToggle = button('play-toggle', 'Play', IconPlayerPlay);
161
+
162
+ const seek = document.createElement('input');
163
+ seek.type = 'range';
164
+ seek.min = '0';
165
+ seek.max = '0';
166
+ seek.step = 'any';
167
+ seek.value = '0';
168
+ seek.className = 'blok-video-controls__seek';
169
+ seek.setAttribute('data-role', 'seek');
170
+ seek.setAttribute('aria-label', 'Seek');
171
+
172
+ // The range input can't host a child fill, so wrap it: a buffered (loaded)
173
+ // layer sits behind the input's transparent track, and a hover tooltip floats
174
+ // above. The wrap carries the flex-grow the bare input used to.
175
+ const seekWrap = document.createElement('div');
176
+ seekWrap.className = 'blok-video-controls__seek-wrap';
177
+ seekWrap.setAttribute('data-role', 'seek-wrap');
178
+
179
+ const seekBuffered = document.createElement('div');
180
+ seekBuffered.className = 'blok-video-controls__buffered';
181
+ seekBuffered.setAttribute('data-role', 'seek-buffered');
182
+ seekBuffered.setAttribute('aria-hidden', 'true');
183
+
184
+ const seekTooltip = document.createElement('div');
185
+ seekTooltip.className = 'blok-video-controls__seek-tooltip';
186
+ seekTooltip.setAttribute('data-role', 'seek-tooltip');
187
+ seekTooltip.setAttribute('aria-hidden', 'true');
188
+
189
+ // Frame preview — a canvas the hover handler paints with the frame at the
190
+ // pointed-to time, stacked above the timecode label.
191
+ const seekThumb = document.createElement('canvas');
192
+ seekThumb.className = 'blok-video-controls__seek-thumb';
193
+ seekThumb.setAttribute('data-role', 'seek-thumb');
194
+ seekThumb.setAttribute('aria-hidden', 'true');
195
+
196
+ const seekTime = document.createElement('span');
197
+ seekTime.className = 'blok-video-controls__seek-time';
198
+ seekTime.setAttribute('data-role', 'seek-time');
199
+
200
+ seekTooltip.append(seekThumb, seekTime);
201
+ seekWrap.append(seekBuffered, seek, seekTooltip);
202
+
203
+ const time = document.createElement('span');
204
+ time.className = 'blok-video-controls__time';
205
+ time.setAttribute('data-role', 'time');
206
+ time.setAttribute('role', 'button');
207
+ time.setAttribute('tabindex', '0');
208
+ time.textContent = '0:00 / 0:00';
209
+
210
+ const muteToggle = button('mute-toggle', 'Mute', IconPlayerVolume);
211
+ muteToggle.setAttribute('aria-pressed', 'false');
212
+
213
+ const volume = document.createElement('input');
214
+ volume.type = 'range';
215
+ volume.min = '0';
216
+ volume.max = '1';
217
+ volume.step = '0.05';
218
+ volume.value = '1';
219
+ volume.className = 'blok-video-controls__volume';
220
+ volume.setAttribute('data-role', 'volume');
221
+ volume.setAttribute('aria-label', 'Volume');
222
+
223
+ const fullscreen = button('fullscreen', 'Fullscreen', IconExpandFullscreen);
224
+
225
+ const volumeWrap = document.createElement('div');
226
+ volumeWrap.className = 'blok-video-controls__volume-wrap';
227
+ volumeWrap.append(muteToggle, volume);
228
+
229
+ bar.append(playToggle, volumeWrap, time, seekWrap, fullscreen);
230
+ root.appendChild(bar);
231
+
232
+ // Thin elapsed line pinned to the player's bottom edge — reads the shared
233
+ // `--blok-seek-pct` and is revealed by CSS only while the control bar is
234
+ // hidden (the auto-hide bucket sets that state).
235
+ const miniProgress = document.createElement('div');
236
+ miniProgress.className = 'blok-video-controls__mini';
237
+ miniProgress.setAttribute('data-role', 'mini-progress');
238
+ miniProgress.setAttribute('aria-hidden', 'true');
239
+ root.appendChild(miniProgress);
240
+
241
+ // Fullscreen title bar — the block's caption surfaced at the top of the
242
+ // immersive surface (à la YouTube), fading in/out with the control bar. Stays
243
+ // `hidden` outside fullscreen, so it never bleeds into the inline player.
244
+ const titleBar = document.createElement('div');
245
+ titleBar.className = 'blok-video-controls__title';
246
+ titleBar.setAttribute('data-role', 'video-title');
247
+ titleBar.hidden = true;
248
+ root.appendChild(titleBar);
249
+
250
+ // ----- state sync -----
251
+ // `media` aliases the param so the property writes below are not flagged as
252
+ // parameter reassignment.
253
+ const media = video;
254
+ // Seed the live loop state from the persisted Loop tune so the gear/context
255
+ // menus open already reflecting it (otherwise they'd read "Off" while looping).
256
+ media.loop = loop;
257
+ const state = {
258
+ playing: false,
259
+ selectedRate: 1,
260
+ timeMode: 'elapsed' as 'elapsed' | 'remaining',
261
+ idleTimer: 0,
262
+ };
263
+
264
+ const setPlaying = (next: boolean): void => {
265
+ state.playing = next;
266
+ figure.setAttribute('data-playing', String(next));
267
+ const icon = next ? IconPlayerPause : IconPlayerPlay;
268
+ playToggle.innerHTML = icon;
269
+ playToggle.setAttribute('aria-label', next ? 'Pause' : 'Play');
270
+ };
271
+ setPlaying(false);
272
+
273
+ const renderTime = (): void => {
274
+ if (state.timeMode === 'remaining') {
275
+ const dur = Number.isFinite(video.duration) ? video.duration : 0;
276
+ time.textContent = `-${formatTime(Math.max(0, dur - video.currentTime))}`;
277
+ } else {
278
+ time.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
279
+ }
280
+ };
281
+ // Click / Enter / Space on the time label flips elapsed ↔ remaining.
282
+ const toggleTimeMode = (): void => {
283
+ state.timeMode = state.timeMode === 'elapsed' ? 'remaining' : 'elapsed';
284
+ renderTime();
285
+ };
286
+
287
+ // Centre play visibility + buffering spinner.
288
+ const updateCenter = (): void => {
289
+ centerPlay.hidden = state.playing || video.currentTime > 0;
290
+ };
291
+ const onWaiting = (): void => spinner.setAttribute('data-active', 'true');
292
+ const onPlayingMedia = (): void => { spinner.setAttribute('data-active', 'false'); updateCenter(); };
293
+ const onCanPlay = (): void => spinner.setAttribute('data-active', 'false');
294
+
295
+ // Paint the elapsed portion of the scrubber with the accent colour. The prop
296
+ // lives on `root` so both the seek gradient (inherited) and the bottom
297
+ // mini-progress bar read the same value.
298
+ const paintSeek = (): void => {
299
+ const max = Number(seek.max) || 0;
300
+ const pct = max > 0 ? (Number(seek.value) / max) * 100 : 0;
301
+ root.style.setProperty('--blok-seek-pct', `${pct}%`);
302
+ };
303
+
304
+ // Paint the buffered-ahead portion behind the elapsed fill.
305
+ const paintBuffered = (): void => {
306
+ const pct = bufferedPct(video.buffered, video.currentTime, video.duration);
307
+ seekBuffered.style.setProperty('--blok-buffered-pct', `${pct}%`);
308
+ };
309
+
310
+ const onLoadedMetadata = (): void => {
311
+ const dur = Number.isFinite(video.duration) ? video.duration : 0;
312
+ seek.max = String(dur);
313
+ paintSeek();
314
+ paintBuffered();
315
+ renderTime();
316
+ };
317
+ const onTimeUpdate = (): void => {
318
+ seek.value = String(video.currentTime);
319
+ paintSeek();
320
+ paintBuffered();
321
+ renderTime();
322
+ };
323
+ const onProgress = (): void => paintBuffered();
324
+
325
+ // The browser fires `timeupdate` only ~4×/s, so painting the scrubber off it
326
+ // makes the fill lurch forward in visible steps. While the video is actually
327
+ // playing we drive the elapsed fill from `requestAnimationFrame` instead, so
328
+ // it glides at the display's refresh rate. `timeupdate` stays the source of
329
+ // truth for the time label and buffered bar; this loop only smooths the fill.
330
+ // We skip the loop's writes while the user is dragging the thumb so a slow
331
+ // seek can't yank it back to a stale `currentTime`.
332
+ const seekLoop = { raf: 0, scrubbing: false };
333
+ const tickSeek = (): void => {
334
+ if (!seekLoop.scrubbing) seek.value = String(video.currentTime);
335
+ paintSeek();
336
+ seekLoop.raf = requestAnimationFrame(tickSeek);
337
+ };
338
+ const startSeekLoop = (): void => {
339
+ if (seekLoop.raf) return;
340
+ seekLoop.raf = requestAnimationFrame(tickSeek);
341
+ };
342
+ const stopSeekLoop = (): void => {
343
+ if (!seekLoop.raf) return;
344
+ cancelAnimationFrame(seekLoop.raf);
345
+ seekLoop.raf = 0;
346
+ };
347
+ const onSeekScrubStart = (): void => { seekLoop.scrubbing = true; };
348
+ const onSeekScrubEnd = (): void => { seekLoop.scrubbing = false; };
349
+
350
+ // ----- hover frame preview -----
351
+ // A throwaway second <video>, sharing the source, is seeked to the hovered time
352
+ // and painted into the tooltip canvas — a client-side stand-in for the sprite
353
+ // sheets native players ship. Created lazily on first hover so we don't pull the
354
+ // bytes twice until someone actually scrubs.
355
+ const THUMB_W = 160;
356
+ const preview: {
357
+ el: HTMLVideoElement | null;
358
+ ready: boolean;
359
+ busy: boolean;
360
+ pendingTime: number;
361
+ } = {
362
+ el: null,
363
+ ready: false,
364
+ busy: false,
365
+ pendingTime: -1,
366
+ };
367
+ const drawPreviewFrame = (): void => {
368
+ preview.busy = false;
369
+ const ctx = seekThumb.getContext('2d');
370
+ if (ctx && preview.el && preview.el.videoWidth > 0) {
371
+ seekThumb.width = THUMB_W;
372
+ seekThumb.height = Math.round(THUMB_W * (preview.el.videoHeight / preview.el.videoWidth));
373
+ ctx.drawImage(preview.el, 0, 0, seekThumb.width, seekThumb.height);
374
+ seekThumb.setAttribute('data-ready', 'true');
375
+ }
376
+ // The pointer may have moved on while we were seeking — chase the latest time.
377
+ if (preview.pendingTime >= 0 && preview.el && Math.abs(preview.el.currentTime - preview.pendingTime) > 0.05) {
378
+ seekPreviewTo(preview.pendingTime);
379
+ }
380
+ };
381
+ function seekPreviewTo(time: number): void {
382
+ preview.pendingTime = time;
383
+ if (!preview.el || !preview.ready || preview.busy) return;
384
+ preview.busy = true;
385
+ preview.el.currentTime = time;
386
+ }
387
+ const ensurePreview = (): void => {
388
+ if (preview.el) return;
389
+ const src = video.currentSrc || video.getAttribute('src') || '';
390
+ if (!src) return;
391
+ const el = document.createElement('video');
392
+ el.className = 'blok-video-controls__preview-source';
393
+ el.setAttribute('data-role', 'seek-preview-source');
394
+ el.muted = true;
395
+ el.preload = 'auto';
396
+ if (video.crossOrigin) el.crossOrigin = video.crossOrigin;
397
+ el.src = src;
398
+ el.addEventListener('loadeddata', () => {
399
+ preview.ready = true;
400
+ if (preview.pendingTime >= 0) seekPreviewTo(preview.pendingTime);
401
+ });
402
+ el.addEventListener('seeked', drawPreviewFrame);
403
+ preview.el = el;
404
+ root.appendChild(el);
405
+ };
406
+
407
+ // Float the hover tooltip over the scrubber at the cursor, reading the time at
408
+ // that position. jsdom yields a zero rect, so the position is a no-op there
409
+ // but the text wiring still proves out.
410
+ const onSeekHover = (event: MouseEvent): void => {
411
+ const rect = seek.getBoundingClientRect();
412
+ const ratio = ratioFromPointer(event.clientX, rect);
413
+ const time = timeAtRatio(ratio, video.duration);
414
+ seekTime.textContent = formatTime(time);
415
+ seekTooltip.style.setProperty('--blok-tooltip-x', `${ratio * 100}%`);
416
+ seekTooltip.setAttribute('aria-hidden', 'false');
417
+ if (Number.isFinite(video.duration) && video.duration > 0) {
418
+ ensurePreview();
419
+ seekPreviewTo(time);
420
+ }
421
+ };
422
+ const onSeekHoverLeave = (): void => {
423
+ seekTooltip.setAttribute('aria-hidden', 'true');
424
+ preview.pendingTime = -1;
425
+ };
426
+ const onPlay = (): void => setPlaying(true);
427
+ const onPause = (): void => setPlaying(false);
428
+ // Pop the mute glyph when the user flips audible↔muted (à la native players).
429
+ const isMuted = (): boolean => video.muted || video.volume === 0;
430
+ const bumpMute = (): void => {
431
+ muteToggle.classList.remove('is-bumped');
432
+ void muteToggle.offsetWidth;
433
+ muteToggle.classList.add('is-bumped');
434
+ };
435
+ const onVolumeChange = (): void => {
436
+ const muted = isMuted();
437
+ muteToggle.setAttribute('aria-pressed', String(muted));
438
+ muteToggle.innerHTML = muted ? IconPlayerVolumeMute : IconPlayerVolume;
439
+ muteToggle.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
440
+ // muting parks the slider at the very beginning; unmuted tracks the real volume
441
+ volume.value = muted ? '0' : String(video.volume);
442
+ // paint the filled (already-set) portion to the left of the thumb
443
+ volume.style.setProperty('--blok-vol-pct', `${Number(volume.value) * 100}%`);
444
+ };
445
+ // Run a user-driven sound change, syncing UI and popping the glyph only when the
446
+ // audible↔muted state actually flips. Captures state before the mutation because
447
+ // the media's own volumechange event fires synchronously inside it.
448
+ const withMuteFlip = (mutate: () => void): void => {
449
+ const was = isMuted();
450
+ mutate();
451
+ onVolumeChange();
452
+ if (isMuted() !== was) bumpMute();
453
+ };
454
+ const onMuteBumpEnd = (): void => muteToggle.classList.remove('is-bumped');
455
+
456
+ const onFullscreenChange = (): void => {
457
+ const isFull = document.fullscreenElement === figure;
458
+ figure.setAttribute('data-fullscreen', String(isFull));
459
+ fullscreen.innerHTML = isFull ? IconPlayerFullscreenExit : IconExpandFullscreen;
460
+ fullscreen.setAttribute('aria-label', isFull ? 'Exit fullscreen' : 'Fullscreen');
461
+ // Lift the live caption into the top title bar on entry; clear it on exit so
462
+ // it can pick up later edits next time. Empty caption → no bar.
463
+ if (isFull) {
464
+ const text = figure.querySelector('[data-role="video-caption"]')?.textContent?.trim() ?? '';
465
+ titleBar.textContent = text;
466
+ titleBar.hidden = text.length === 0;
467
+ } else {
468
+ titleBar.hidden = true;
469
+ titleBar.textContent = '';
470
+ }
471
+ };
472
+
473
+ // Flash the just-taken action's glyph in the centre, restarting the CSS
474
+ // animation each call (remove class → force reflow → re-add).
475
+ const flashBurst = (willPlay: boolean): void => {
476
+ burst.innerHTML = willPlay ? IconPlayerPlay : IconPlayerPause;
477
+ burst.classList.remove('is-active');
478
+ void burst.offsetWidth;
479
+ burst.classList.add('is-active');
480
+ };
481
+ // Clear on the disc's own animation only — the radiating ring (::after) ends
482
+ // separately and must not cut the disc short. jsdom fires a bare Event with no
483
+ // animationName, so an empty name still clears (keeps the unit test honest).
484
+ const onBurstEnd = (event: AnimationEvent): void => {
485
+ if (event.animationName && event.animationName !== 'blok-video-burst') return;
486
+ burst.classList.remove('is-active');
487
+ };
488
+
489
+ // ----- intent -----
490
+ const togglePlay = (): void => {
491
+ const willPlay = !state.playing;
492
+ if (state.playing) media.pause();
493
+ else void media.play();
494
+ flashBurst(willPlay);
495
+ };
496
+
497
+ // ----- press-and-hold to play at 2× -----
498
+ // Holding the video past a short threshold ramps playback to double speed and
499
+ // keeps it playing; releasing restores 1×. A press shorter than the threshold
500
+ // stays a plain click (play/pause toggle).
501
+ const HOLD_MS = 220;
502
+ const hold = { timer: 0, active: false, suppressClick: false };
503
+
504
+ const engageHold = (): void => {
505
+ hold.active = true;
506
+ if (!state.playing) void media.play();
507
+ media.playbackRate = 2;
508
+ speedBadge.classList.add('is-active');
509
+ };
510
+ // Drop back to 1× and clear any pending threshold timer. Does not, on its own,
511
+ // swallow the trailing click — only a real pointerup release does (below).
512
+ const releaseHold = (): void => {
513
+ if (hold.timer) { clearTimeout(hold.timer); hold.timer = 0; }
514
+ if (!hold.active) return;
515
+ hold.active = false;
516
+ // Restore the gear-menu-selected rate, not a hardcoded 1× — so holding to
517
+ // peek at 2× returns to whatever speed the user actually chose.
518
+ media.playbackRate = state.selectedRate;
519
+ speedBadge.classList.remove('is-active');
520
+ };
521
+ const onPointerDown = (event: PointerEvent): void => {
522
+ if (event.button) return; // primary button only
523
+ // A press that begins while the settings menu is open is a dismiss gesture: the
524
+ // document mousedown closes the menu, and this press must neither toggle playback
525
+ // nor engage press-and-hold. Flag the trailing click for suppression, skip the
526
+ // hold timer, and let the outside-click handler do the closing.
527
+ if (!menu.hidden) { hold.suppressClick = true; return; }
528
+ hold.timer = window.setTimeout(engageHold, HOLD_MS);
529
+ };
530
+ const onPointerUp = (): void => {
531
+ // A pointerup after an engaged hold is followed by a click — suppress that
532
+ // one click so the gesture doesn't also toggle play/pause.
533
+ if (hold.active) hold.suppressClick = true;
534
+ releaseHold();
535
+ };
536
+ const onVideoClick = (): void => {
537
+ if (hold.suppressClick) { hold.suppressClick = false; return; }
538
+ togglePlay();
539
+ };
540
+ const onSeekInput = (): void => {
541
+ media.currentTime = Number(seek.value);
542
+ paintSeek();
543
+ };
544
+
545
+ // ----- keyboard seeking + control -----
546
+ const SEEK_STEP = 5;
547
+ const BIG_SEEK_STEP = 10;
548
+ const VOL_STEP = 0.05;
549
+ const FRAME = 1 / 30; // assumes ~30fps — the media API exposes no true frame duration
550
+ const SPEED_STEP = 0.25;
551
+ const SPEED_MIN = 0.25;
552
+ const SPEED_MAX = 2;
553
+ // Flash the seek pill in from the side matching the jump: rewind sits left
554
+ // (icon then label), skip sits right (label then icon). Re-arm per press. The
555
+ // label magnitude is parameterised so ±5s arrows and ±10s j/l read truthfully.
556
+ const flashSeek = (side: 'back' | 'forward', seconds: number = SEEK_STEP): void => {
557
+ const icon = side === 'forward' ? IconPlayerForward : IconPlayerBackward;
558
+ const label = `<span class="blok-video-controls__seek-flash-label">${seconds}s</span>`;
559
+ seekFlash.setAttribute('data-side', side);
560
+ seekFlash.innerHTML = side === 'forward' ? `${label}${icon}` : `${icon}${label}`;
561
+ seekFlash.classList.remove('is-active');
562
+ void seekFlash.offsetWidth;
563
+ seekFlash.classList.add('is-active');
564
+ };
565
+ // Clear on the pill's own slide animation only — the glyph marquee (svg) ends
566
+ // separately and must not cut the pill short. jsdom fires a bare Event with no
567
+ // animationName, so an empty name still clears (keeps the unit test honest).
568
+ const onSeekFlashEnd = (event: AnimationEvent): void => {
569
+ if (event.animationName && !event.animationName.startsWith('blok-video-seek-flash-')) return;
570
+ seekFlash.classList.remove('is-active');
571
+ };
572
+ // Absolute clamped scrub — no directional flash (used by %/Home/End/frame step).
573
+ const seekTo = (t: number): void => {
574
+ const dur = Number.isFinite(media.duration) ? media.duration : 0;
575
+ const next = Math.min(dur || Infinity, Math.max(0, t));
576
+ media.currentTime = next;
577
+ seek.value = String(next);
578
+ paintSeek();
579
+ renderTime();
580
+ };
581
+ // Relative nudge with a directional pill reading the jump magnitude.
582
+ const seekBy = (delta: number): void => {
583
+ seekTo(media.currentTime + delta);
584
+ flashSeek(delta < 0 ? 'back' : 'forward', Math.abs(delta));
585
+ };
586
+ const changeVolume = (delta: number): void => {
587
+ withMuteFlip(() => {
588
+ const next = Math.min(1, Math.max(0, media.volume + delta));
589
+ media.volume = next;
590
+ media.muted = next === 0;
591
+ volume.value = String(next);
592
+ });
593
+ };
594
+ const stepSpeed = (delta: number): void => {
595
+ media.playbackRate = Math.min(SPEED_MAX, Math.max(SPEED_MIN, media.playbackRate + delta));
596
+ };
597
+ const onVideoKeydown = (event: KeyboardEvent): void => {
598
+ // Bail on meta/ctrl/alt chords — but NOT Shift, which `>`/`<` (speed) need.
599
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
600
+ const dur = Number.isFinite(media.duration) ? media.duration : 0;
601
+ switch (event.key) {
602
+ case 'ArrowRight': event.preventDefault(); seekBy(SEEK_STEP); break;
603
+ case 'ArrowLeft': event.preventDefault(); seekBy(-SEEK_STEP); break;
604
+ case 'l': event.preventDefault(); seekBy(BIG_SEEK_STEP); break;
605
+ case 'j': event.preventDefault(); seekBy(-BIG_SEEK_STEP); break;
606
+ case ' ':
607
+ case 'Spacebar':
608
+ case 'k': event.preventDefault(); togglePlay(); break;
609
+ case 'm': event.preventDefault(); onMuteClick(); break;
610
+ case 'f': event.preventDefault(); onFullscreen(); break;
611
+ case 'c': event.preventDefault(); break; // reserved — captions not implemented yet
612
+ case 'ArrowUp': event.preventDefault(); changeVolume(VOL_STEP); break;
613
+ case 'ArrowDown': event.preventDefault(); changeVolume(-VOL_STEP); break;
614
+ case 'Home': event.preventDefault(); seekTo(0); break;
615
+ case 'End': event.preventDefault(); seekTo(dur || media.currentTime); break;
616
+ case '.': event.preventDefault(); if (!state.playing) seekTo(media.currentTime + FRAME); break;
617
+ case ',': event.preventDefault(); if (!state.playing) seekTo(media.currentTime - FRAME); break;
618
+ case '>': event.preventDefault(); stepSpeed(SPEED_STEP); break;
619
+ case '<': event.preventDefault(); stepSpeed(-SPEED_STEP); break;
620
+ default:
621
+ if (event.key.length === 1 && event.key >= '0' && event.key <= '9' && dur > 0) {
622
+ event.preventDefault();
623
+ seekTo((dur * Number(event.key)) / 10);
624
+ }
625
+ }
626
+ };
627
+ const onMuteClick = (): void => {
628
+ withMuteFlip(() => { media.muted = !media.muted; });
629
+ };
630
+ const onVolumeInput = (): void => {
631
+ withMuteFlip(() => {
632
+ const v = Number(volume.value);
633
+ media.volume = v;
634
+ media.muted = v === 0;
635
+ });
636
+ };
637
+ const onFullscreen = (): void => {
638
+ if (document.fullscreenElement === figure) void document.exitFullscreen?.();
639
+ else void figure.requestFullscreen?.();
640
+ };
641
+
642
+ // ----- gear settings menu (speed / loop) -----
643
+ // Viewer-accessible in-player popover (the block ☰ menu is editor-only), modelled
644
+ // on YouTube's settings: a main pane of labelled rows, each carrying its current
645
+ // value, that slides sideways into a dedicated submenu (speeds with a leading
646
+ // check, à la YouTube). Built here so it shares the player's closure state.
647
+ const SPEED_LABEL = (rate: number): string => `${rate}×`;
648
+ // Honoured by the preset-jump glide and the ambient sampler — both opt out of motion.
649
+ const reducedMotion = !!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
650
+
651
+ // Leading glyph for a main-pane row — gives each setting a scannable icon
652
+ // so the menu reads as a crafted panel, not a bare text list.
653
+ const menuIcon = (svg: string): HTMLSpanElement => {
654
+ const icon = document.createElement('span');
655
+ icon.className = 'blok-video-controls__menu-icon';
656
+ icon.setAttribute('aria-hidden', 'true');
657
+ icon.innerHTML = svg;
658
+ return icon;
659
+ };
660
+
661
+ // A main-pane row: optional leading icon, label, current value + trailing
662
+ // chevron. `valueRole` tags the value span so it can be read/updated by name.
663
+ const navRow = (action: string, label: string, valueRole: string, iconSvg?: string): {
664
+ row: HTMLButtonElement;
665
+ value: HTMLElement;
666
+ } => {
667
+ const row = document.createElement('button');
668
+ row.type = 'button';
669
+ row.className = 'blok-video-controls__menu-row';
670
+ row.setAttribute('data-action', action);
671
+ row.setAttribute('role', 'menuitem');
672
+ row.setAttribute('aria-haspopup', 'menu');
673
+ const children: HTMLElement[] = [];
674
+ if (iconSvg) children.push(menuIcon(iconSvg));
675
+ const text = document.createElement('span');
676
+ text.className = 'blok-video-controls__menu-label';
677
+ text.textContent = label;
678
+ const value = document.createElement('span');
679
+ value.className = 'blok-video-controls__menu-value';
680
+ value.setAttribute('data-role', valueRole);
681
+ const chevron = document.createElement('span');
682
+ chevron.className = 'blok-video-controls__menu-chevron';
683
+ chevron.setAttribute('aria-hidden', 'true');
684
+ chevron.innerHTML = IconChevronRight;
685
+ children.push(text, value, chevron);
686
+ row.append(...children);
687
+ return { row, value };
688
+ };
689
+
690
+ const gear = button('gear', 'Settings', IconPlayerSettings);
691
+ gear.setAttribute('aria-haspopup', 'menu');
692
+ gear.setAttribute('aria-expanded', 'false');
693
+
694
+ const menu = document.createElement('div');
695
+ menu.className = 'blok-video-controls__menu';
696
+ menu.setAttribute('data-role', 'playback-menu');
697
+ menu.setAttribute('role', 'menu');
698
+ menu.setAttribute('data-view', 'main');
699
+ menu.hidden = true;
700
+ // The panes ride a 200%-wide track, so this overflow:hidden box is horizontally
701
+ // scrollable. Focusing a control in the off-screen right-half pane (open speed,
702
+ // pick a rate) makes the browser auto-scroll the menu to reveal it, and that
703
+ // scroll fights the transform-based slide — leaving the tall speed pane shoved
704
+ // into view beside the card. The slide is the only thing that should move the
705
+ // panes, so pin the scroll back to the origin whenever the browser nudges it.
706
+ menu.addEventListener('scroll', () => {
707
+ if (menu.scrollLeft) menu.scrollLeft = 0;
708
+ if (menu.scrollTop) menu.scrollTop = 0;
709
+ });
710
+
711
+ // Two stacked panes ride a horizontal track; `data-view` slides between them.
712
+ const track = document.createElement('div');
713
+ track.className = 'blok-video-controls__menu-track';
714
+
715
+ const mainPane = document.createElement('div');
716
+ mainPane.className = 'blok-video-controls__menu-pane';
717
+ mainPane.setAttribute('data-role', 'menu-main');
718
+
719
+ const speedPane = document.createElement('div');
720
+ speedPane.className = 'blok-video-controls__menu-pane';
721
+ speedPane.setAttribute('data-role', 'menu-speed');
722
+
723
+ const menuWrap = document.createElement('div');
724
+ menuWrap.className = 'blok-video-controls__menu-wrap';
725
+ menuWrap.append(gear);
726
+
727
+ // Slide the track and grow/shrink the menu to fit the active pane — the height
728
+ // tween is what gives the YouTube settings menu its springy resize.
729
+ const showView = (view: 'main' | 'speed'): void => {
730
+ menu.setAttribute('data-view', view);
731
+ const pane = view === 'speed' ? speedPane : mainPane;
732
+ // The parked pane is only clipped from sight by overflow — without `inert`
733
+ // its rows stay in the tab order and AT tree, so keyboard/screen-reader users
734
+ // land on invisible controls. Inert the off-screen pane, free the active one.
735
+ mainPane.toggleAttribute('inert', view !== 'main');
736
+ speedPane.toggleAttribute('inert', view !== 'speed');
737
+ if (!menu.hidden) {
738
+ // The menu is box-sizing: border-box, so an inline height shrinks the content
739
+ // box by its own padding + border. Add that chrome back, or the active pane
740
+ // overflows and the trailing row (Loop) is clipped at the bottom edge.
741
+ const px = (value: string): number => parseFloat(value) || 0;
742
+ const cs = getComputedStyle(menu);
743
+ const chrome = px(cs.paddingTop) + px(cs.paddingBottom) + px(cs.borderTopWidth) + px(cs.borderBottomWidth);
744
+ menu.style.height = `${pane.scrollHeight + chrome}px`;
745
+ }
746
+ };
747
+
748
+ // --- main pane: "Playback speed ›" and the Loop toggle ---
749
+ const { row: speedNav, value: speedValue } = navRow('open-speed', 'Playback speed', 'menu-value-speed', IconPlayerSpeed);
750
+ speedValue.textContent = SPEED_LABEL(state.selectedRate);
751
+ speedNav.addEventListener('click', () => showView('speed'));
752
+
753
+ const loopRow = document.createElement('button');
754
+ loopRow.type = 'button';
755
+ loopRow.className = 'blok-video-controls__menu-row';
756
+ loopRow.setAttribute('data-action', 'loop');
757
+ loopRow.setAttribute('role', 'menuitemcheckbox');
758
+ loopRow.setAttribute('aria-checked', String(loop));
759
+ const loopLabel = document.createElement('span');
760
+ loopLabel.className = 'blok-video-controls__menu-label';
761
+ loopLabel.textContent = 'Loop';
762
+ const loopValue = document.createElement('span');
763
+ loopValue.className = 'blok-video-controls__menu-value';
764
+ loopValue.setAttribute('data-role', 'menu-value-loop');
765
+ loopValue.textContent = loop ? 'On' : 'Off';
766
+ // Empty chevron slot keeps "Off" aligned under the speed row's value.
767
+ const loopSpacer = document.createElement('span');
768
+ loopSpacer.className = 'blok-video-controls__menu-chevron';
769
+ loopSpacer.setAttribute('aria-hidden', 'true');
770
+ loopRow.append(menuIcon(IconPlayerLoop), loopLabel, loopValue, loopSpacer);
771
+ loopRow.addEventListener('click', () => {
772
+ media.loop = !media.loop;
773
+ loopRow.setAttribute('aria-checked', String(media.loop));
774
+ loopValue.textContent = media.loop ? 'On' : 'Off';
775
+ });
776
+
777
+ mainPane.append(speedNav, loopRow);
778
+
779
+ // --- speed pane: YouTube-style control — back header + live readout + −/+
780
+ // steppers flanking a continuous slider + a row of quick-jump preset chips.
781
+ // It's a control panel, not a menu list, so the pane is a labelled group. ---
782
+ // SPEED_MIN / SPEED_MAX are shared with the Shift+./Shift+, keyboard shortcut above.
783
+ const SPEED_SLIDER_STEP = 0.05;
784
+ const SPEED_PRESETS = [0.5, 1, 1.5, 2];
785
+ // 0.05 steps accumulate binary-float error (1 + 0.05×3 = 1.1500000000000001);
786
+ // snap every rate to 2dp so the readout text and the chip === checks stay exact.
787
+ const clampRate = (rate: number): number =>
788
+ Math.min(SPEED_MAX, Math.max(SPEED_MIN, Math.round(rate * 100) / 100));
789
+
790
+ speedPane.setAttribute('role', 'group');
791
+ speedPane.setAttribute('aria-label', 'Playback speed');
792
+
793
+ const speedBack = document.createElement('button');
794
+ speedBack.type = 'button';
795
+ speedBack.className = 'blok-video-controls__menu-row blok-video-controls__menu-back';
796
+ speedBack.setAttribute('data-action', 'speed-back');
797
+ speedBack.setAttribute('aria-label', 'Back');
798
+ const backChevron = document.createElement('span');
799
+ backChevron.className = 'blok-video-controls__menu-chevron';
800
+ backChevron.setAttribute('aria-hidden', 'true');
801
+ backChevron.innerHTML = IconChevronLeft;
802
+ const backLabel = document.createElement('span');
803
+ backLabel.className = 'blok-video-controls__menu-label';
804
+ backLabel.textContent = 'Playback speed';
805
+ speedBack.append(backChevron, backLabel);
806
+ speedBack.addEventListener('click', () => showView('main'));
807
+
808
+ // Big live value, à la YouTube's "1.00x" — slider announces itself, so hide it from AT.
809
+ const speedReadout = document.createElement('div');
810
+ speedReadout.className = 'blok-video-controls__speed-readout';
811
+ speedReadout.setAttribute('data-role', 'speed-readout');
812
+ speedReadout.setAttribute('aria-hidden', 'true');
813
+ speedReadout.textContent = SPEED_LABEL(state.selectedRate);
814
+
815
+ const speedStepper = (action: string, icon: string, label: string): HTMLButtonElement => {
816
+ const btn = document.createElement('button');
817
+ btn.type = 'button';
818
+ btn.className = 'blok-video-controls__speed-step';
819
+ btn.setAttribute('data-action', action);
820
+ btn.setAttribute('aria-label', label);
821
+ btn.innerHTML = icon;
822
+ return btn;
823
+ };
824
+ const speedDec = speedStepper('speed-dec', IconMinus, 'Decrease playback speed');
825
+ const speedInc = speedStepper('speed-inc', IconPlus, 'Increase playback speed');
826
+
827
+ const speedSlider = document.createElement('input');
828
+ speedSlider.type = 'range';
829
+ speedSlider.className = 'blok-video-controls__speed-slider';
830
+ speedSlider.setAttribute('data-role', 'speed-slider');
831
+ speedSlider.min = String(SPEED_MIN);
832
+ speedSlider.max = String(SPEED_MAX);
833
+ speedSlider.step = String(SPEED_SLIDER_STEP);
834
+ speedSlider.value = String(state.selectedRate);
835
+ speedSlider.setAttribute('aria-label', 'Playback speed');
836
+ speedSlider.setAttribute('aria-valuetext', SPEED_LABEL(state.selectedRate));
837
+ speedSlider.addEventListener('input', () => setRate(Number(speedSlider.value)));
838
+ // Drive the elapsed-fill gradient from JS, exactly like the volume slider.
839
+ const speedFillPct = (rate: number): string => `${((rate - SPEED_MIN) / (SPEED_MAX - SPEED_MIN)) * 100}%`;
840
+ speedSlider.style.setProperty('--blok-speed-pct', speedFillPct(state.selectedRate));
841
+
842
+ const speedSliderRow = document.createElement('div');
843
+ speedSliderRow.className = 'blok-video-controls__speed-slider-row';
844
+ speedSliderRow.append(speedDec, speedSlider, speedInc);
845
+
846
+ // Presets are quick-jump shortcuts, not a selection: plain buttons that nudge the
847
+ // slider to a round rate. No radio/aria-checked — the slider is the value control.
848
+ const speedChips = SPEED_PRESETS.map((rate) => {
849
+ const chip = document.createElement('button');
850
+ chip.type = 'button';
851
+ chip.className = 'blok-video-controls__speed-chip';
852
+ chip.setAttribute('data-action', `speed-${rate}`);
853
+ chip.textContent = `${rate}×`;
854
+ chip.addEventListener('click', () => {
855
+ const from = state.selectedRate;
856
+ setRate(rate);
857
+ glideSpeedThumb(from, rate);
858
+ });
859
+ return chip;
860
+ });
861
+ const speedChipRow = document.createElement('div');
862
+ speedChipRow.className = 'blok-video-controls__speed-chips';
863
+ speedChipRow.setAttribute('aria-label', 'Speed presets');
864
+ speedChipRow.append(...speedChips);
865
+
866
+ speedPane.append(speedBack, speedReadout, speedSliderRow, speedChipRow);
867
+
868
+ // A preset-jump glide in flight (rAF id; 0 = idle). Any direct rate change — drag,
869
+ // stepper, or another preset — cancels it so the thumb never has two owners.
870
+ const speedGlide = { raf: 0 };
871
+ const setRate = (rate: number): void => {
872
+ cancelAnimationFrame(speedGlide.raf);
873
+ const next = clampRate(rate);
874
+ state.selectedRate = next;
875
+ media.playbackRate = next;
876
+ const label = SPEED_LABEL(next);
877
+ speedReadout.textContent = label;
878
+ speedValue.textContent = label;
879
+ speedSlider.value = String(next);
880
+ speedSlider.setAttribute('aria-valuetext', label);
881
+ speedSlider.style.setProperty('--blok-speed-pct', speedFillPct(next));
882
+ speedDec.disabled = next <= SPEED_MIN;
883
+ speedInc.disabled = next >= SPEED_MAX;
884
+ };
885
+ // Clicking a preset applies the rate instantly (setRate, above) but glides the thumb +
886
+ // fill from the old position into place rather than teleporting. A native range thumb
887
+ // can't be CSS-transitioned — its position IS the value — so tween the value over a few
888
+ // frames (easeOutCubic). Reduced-motion keeps the instant jump setRate already made.
889
+ const glideSpeedThumb = (from: number, to: number): void => {
890
+ cancelAnimationFrame(speedGlide.raf);
891
+ if (reducedMotion || from === to) return;
892
+ const startedAt = performance.now();
893
+ const paint = (rate: number): void => {
894
+ speedSlider.value = String(rate);
895
+ speedSlider.style.setProperty('--blok-speed-pct', speedFillPct(rate));
896
+ };
897
+ paint(from);
898
+ const tween = (now: number): void => {
899
+ const t = Math.min(1, (now - startedAt) / 240);
900
+ const eased = 1 - (1 - t) ** 3;
901
+ paint(from + (to - from) * eased);
902
+ if (t < 1) { speedGlide.raf = requestAnimationFrame(tween); return; }
903
+ speedGlide.raf = 0;
904
+ paint(to);
905
+ };
906
+ speedGlide.raf = requestAnimationFrame(tween);
907
+ };
908
+ speedDec.addEventListener('click', () => setRate(state.selectedRate - SPEED_SLIDER_STEP));
909
+ speedInc.addEventListener('click', () => setRate(state.selectedRate + SPEED_SLIDER_STEP));
910
+ speedDec.disabled = state.selectedRate <= SPEED_MIN;
911
+ speedInc.disabled = state.selectedRate >= SPEED_MAX;
912
+
913
+ track.append(mainPane, speedPane);
914
+ menu.append(track);
915
+ bar.insertBefore(menuWrap, fullscreen);
916
+ // The menu lives on the figure, NOT inside the overflow:hidden media box, so a tall
917
+ // speed submenu spills outside a short player instead of being clipped to its frame.
918
+ // It is then anchored to the gear by hand on each open / viewport change.
919
+ figure.appendChild(menu);
920
+
921
+ const MENU_GAP = 8;
922
+ const positionMenu = (): void => {
923
+ const g = gear.getBoundingClientRect();
924
+ const f = figure.getBoundingClientRect();
925
+ // Right edge tracks the gear's right edge; bottom sits MENU_GAP above the gear,
926
+ // so the card grows upward — out of the player when there isn't room below the bar.
927
+ menu.style.right = `${f.right - g.right}px`;
928
+ menu.style.bottom = `${f.bottom - g.top + MENU_GAP}px`;
929
+ };
930
+
931
+ const onMenuOutside = (event: MouseEvent): void => {
932
+ if (menu.hidden) return;
933
+ const target = event.target as Node | null;
934
+ // Keep open for clicks on the gear (menuWrap) or anywhere inside the menu itself —
935
+ // the menu is no longer a descendant of menuWrap, so it must be checked separately.
936
+ if (target && (menuWrap.contains(target) || menu.contains(target))) return;
937
+ closeMenu();
938
+ };
939
+ const openMenu = (): void => {
940
+ if (!menu.hidden) return;
941
+ menu.hidden = false;
942
+ // Snap straight to the main pane: if it was closed on the speed view, suppress the
943
+ // track-slide + height tween for this frame so it opens clean instead of replaying
944
+ // the slide-back from the previous state. Transitions resume for in-menu navigation.
945
+ menu.style.transition = 'none';
946
+ track.style.transition = 'none';
947
+ showView('main'); // always reopen on the top-level pane, like YouTube
948
+ void menu.offsetHeight; // flush the snap before transitions come back
949
+ menu.style.transition = '';
950
+ track.style.transition = '';
951
+ positionMenu();
952
+ gear.setAttribute('aria-expanded', 'true');
953
+ document.addEventListener('mousedown', onMenuOutside);
954
+ window.addEventListener('resize', positionMenu);
955
+ };
956
+ const closeMenu = (): void => {
957
+ if (menu.hidden) return;
958
+ menu.hidden = true;
959
+ gear.setAttribute('aria-expanded', 'false');
960
+ document.removeEventListener('mousedown', onMenuOutside);
961
+ window.removeEventListener('resize', positionMenu);
962
+ };
963
+ gear.addEventListener('click', (event) => {
964
+ event.stopPropagation();
965
+ if (menu.hidden) openMenu();
966
+ else closeMenu();
967
+ });
968
+
969
+ // ----- view modes: ambient glow / theater / picture-in-picture -----
970
+ // Ambient — a blurred canvas behind the figure sampled from the current frame.
971
+ // Off under reduced motion; the pixel sampling itself is live-verify only.
972
+ const ambientCanvas = document.createElement('canvas');
973
+ ambientCanvas.className = 'blok-video-controls__ambient';
974
+ ambientCanvas.setAttribute('data-role', 'video-ambient');
975
+ ambientCanvas.setAttribute('aria-hidden', 'true');
976
+ figure.insertBefore(ambientCanvas, figure.firstChild);
977
+
978
+ ambientCanvas.setAttribute('data-ambient', reducedMotion ? 'off' : 'on');
979
+ // `data-glow` is the user's chosen intensity (more / less / none) — drives the
980
+ // peak opacity in CSS; 'none' hides the canvas and skips sampling entirely.
981
+ ambientCanvas.setAttribute('data-glow', glow);
982
+ // `data-active` drives the glow's opacity: it fades in when the video starts
983
+ // and fades back out the instant it pauses, so a frozen frame never lingers.
984
+ ambientCanvas.setAttribute('data-active', 'false');
985
+ const ambient = { raf: 0 };
986
+ const sampleAmbient = (): void => {
987
+ const ctx = ambientCanvas.getContext('2d');
988
+ if (ctx && video.videoWidth) {
989
+ ambientCanvas.width = 32;
990
+ ambientCanvas.height = 18;
991
+ ctx.drawImage(video, 0, 0, 32, 18);
992
+ }
993
+ ambient.raf = requestAnimationFrame(sampleAmbient);
994
+ };
995
+ const startAmbient = (): void => {
996
+ if (reducedMotion || glow === 'none') return;
997
+ ambientCanvas.setAttribute('data-active', 'true');
998
+ if (ambient.raf) return;
999
+ ambient.raf = requestAnimationFrame(sampleAmbient);
1000
+ };
1001
+ const stopAmbient = (): void => {
1002
+ ambientCanvas.setAttribute('data-active', 'false');
1003
+ if (!ambient.raf) return;
1004
+ cancelAnimationFrame(ambient.raf);
1005
+ ambient.raf = 0;
1006
+ };
1007
+
1008
+ // Theater — an ephemeral presentation toggle. Promotes the figure into the
1009
+ // browser top layer via the Popover API (manual mode) so it sits above all
1010
+ // editor chrome, centres in the viewport, and gets a real ::backdrop. Dismiss
1011
+ // is driven explicitly — an outside pointer-down or Escape — so it behaves the
1012
+ // same with or without the Popover API and never races browser light-dismiss.
1013
+ // It never touches the inline width, so the saved resize width is preserved.
1014
+ const canPopover = supportsPopoverAPI();
1015
+ const theaterBtn = button('theater', 'Theater mode', IconPlayerTheater);
1016
+ theaterBtn.setAttribute('aria-pressed', 'false');
1017
+ // `inlineRect` is the VIDEO's grid-slot rect, captured on enter (before
1018
+ // data-theater promotes it to fixed/centre) and reused to land the exit morph.
1019
+ const theater = { on: false, inlineRect: null as DOMRect | null };
1020
+ const onTheaterKey = (event: KeyboardEvent): void => {
1021
+ if (event.key !== 'Escape') return;
1022
+ // Cancel the browser's own Escape/close-watcher handling for the top-layer
1023
+ // popover. Left to run, it disturbs the popover the same tick our dismiss
1024
+ // animates, leaving the exit a scale-less slide that snaps small at the end
1025
+ // (clicking outside the card has no such handler and morphs cleanly). The
1026
+ // listener is on `window` in capture so this fires BEFORE the browser's
1027
+ // close-watcher claims the event — a document/bubble listener is too late.
1028
+ event.preventDefault();
1029
+ event.stopImmediatePropagation();
1030
+ // Defer the dismiss out of the keydown tick. Starting the FLIP synchronously
1031
+ // inside Escape handling makes Chromium drop the morph's scale (the card
1032
+ // slides at full size then snaps small); run a frame later — once the
1033
+ // browser's top-layer Escape handling has settled — and it scales cleanly,
1034
+ // exactly like dismissing via an outside pointer-down.
1035
+ requestAnimationFrame(() => setTheater(false));
1036
+ };
1037
+ // Click anywhere off the cinema card (the dimmed backdrop) closes it.
1038
+ const onTheaterOutside = (event: Event): void => {
1039
+ const target = event.target as Node | null;
1040
+ if (target && !figure.contains(target)) setTheater(false);
1041
+ };
1042
+ // The figure jumps from its inline grid slot to the fixed, top-layer centre the
1043
+ // instant the popover opens, so a keyframe scale-nudge leaves that big position
1044
+ // jump unanimated (reads as a teleport). We FLIP instead: snap the centred card
1045
+ // back onto the inline rect, then grow it into the centre, reversing on exit.
1046
+ //
1047
+ // Driven by the Web Animations API, NOT a CSS transition primed over rAF. The
1048
+ // keyframes name the start (inline) and end (centre) explicitly, so the morph
1049
+ // never depends on the browser reading a "from" value off the last painted
1050
+ // frame. Under load — promoting the whole player to the top layer, decoding the
1051
+ // video at the new size — that paint is delayed, so a transition would start
1052
+ // from a half-laid-out intermediate: the "snap to a wrong size, hold, then jump"
1053
+ // we kept chasing. WAAPI transform animations also run on the compositor, immune
1054
+ // to that main-thread jank, which is what made the opening read laggy/stepped.
1055
+ const canAnimate = typeof figure.animate === 'function';
1056
+ const flip = { anim: null as Animation | null };
1057
+ const cancelFlip = (): void => {
1058
+ if (flip.anim) { flip.anim.cancel(); flip.anim = null; }
1059
+ };
1060
+ const collapsed = (from: DOMRect, to: DOMRect): string =>
1061
+ `translate(${to.left - from.left}px, ${to.top - from.top}px) scale(${to.width / from.width}, ${to.height / from.height})`;
1062
+ const morph = (
1063
+ from: string,
1064
+ to: string,
1065
+ opts: { duration: number; easing: string; fill?: FillMode; done?: () => void },
1066
+ ): void => {
1067
+ cancelFlip();
1068
+ const anim = figure.animate(
1069
+ [
1070
+ { transformOrigin: 'top left', transform: from },
1071
+ { transformOrigin: 'top left', transform: to },
1072
+ ],
1073
+ { duration: opts.duration, easing: opts.easing, fill: opts.fill ?? 'none' },
1074
+ );
1075
+ flip.anim = anim;
1076
+ anim.onfinish = (): void => {
1077
+ if (flip.anim === anim) flip.anim = null;
1078
+ // Run teardown FIRST (it swaps the figure back to its inline flow position),
1079
+ // THEN cancel so the fill:'forwards' transform is dropped in the same frame —
1080
+ // no flash to centre, and no collapsed transform left stuck on the figure
1081
+ // (which would freeze the inline video shrunk and displaced below its slot).
1082
+ opts.done?.();
1083
+ anim.cancel();
1084
+ };
1085
+ };
1086
+ const enterTheater = (): void => {
1087
+ // Measure the VIDEO, not the figure: the figure is taller inline (the caption
1088
+ // sits in-flow) but is pure 16:9 in theater (caption lifted out), so a
1089
+ // figure-based FLIP scales x and y by different factors and squishes the video
1090
+ // through the whole morph, snapping to true proportions only at the end ("weird
1091
+ // at the end"). The video is a clean 16:9 in both states → uniform scale. It
1092
+ // shares the figure's top-left (it sits at the figure top), so the transform —
1093
+ // still applied to the figure — lands the video exactly on its slot.
1094
+ //
1095
+ // Measure BEFORE data-theater promotes the figure: the attribute applies
1096
+ // position:fixed + full width, so a later read returns the centred rect and the
1097
+ // morph collapses to a zero delta.
1098
+ const inlineRect = video.getBoundingClientRect();
1099
+ theater.inlineRect = inlineRect;
1100
+ // Reserve the figure's vacated height on its slot. Promoting the figure to the
1101
+ // fixed top layer pulls it out of flow, collapsing the slot to zero — without
1102
+ // this the rest of the document shifts up to fill the gap (and back on exit).
1103
+ const slot = figure.parentElement;
1104
+ if (slot) slot.style.minHeight = `${figure.getBoundingClientRect().height}px`;
1105
+ figure.setAttribute('data-theater', 'true');
1106
+ if (!canPopover) return; // popover-less fallback: the CSS keyframe owns it
1107
+ // Route promotion through the Top-Layer chokepoint so the data-blok-top-layer
1108
+ // marker (and its UA-popover CSS reset) is applied centrally. The theater rules
1109
+ // keyed on [data-theater="true"] out-specify that generic reset, so the centred
1110
+ // fixed layout survives. On failure, unwind the marker/attribute it set.
1111
+ if (!figure.matches(':popover-open') && !promoteToTopLayer(figure)) {
1112
+ removeFromTopLayer(figure);
1113
+
1114
+ return;
1115
+ }
1116
+ if (reducedMotion || !canAnimate) return;
1117
+ const centre = video.getBoundingClientRect();
1118
+ if (!inlineRect.width || !centre.width) return;
1119
+ // The grow ends at the natural centred transform (fill: 'none' reverts cleanly).
1120
+ morph(collapsed(centre, inlineRect), 'translate(0px, 0px) scale(1, 1)', {
1121
+ duration: 480,
1122
+ easing: 'cubic-bezier(0.33, 1, 0.68, 1)',
1123
+ });
1124
+ };
1125
+ const leaveTheater = (): void => {
1126
+ // Tear down atomically (one paint) so the card never flashes back to centre.
1127
+ const finalize = (): void => {
1128
+ cancelFlip();
1129
+ removeFromTopLayer(figure);
1130
+ figure.setAttribute('data-theater', 'false');
1131
+ figure.removeAttribute('data-theater-leaving');
1132
+ // Release the reserved slot height so it tracks the figure again.
1133
+ const slot = figure.parentElement;
1134
+ if (slot) slot.style.minHeight = '';
1135
+ };
1136
+ const to = theater.inlineRect;
1137
+ if (!canPopover || reducedMotion || !canAnimate || !to || !figure.matches(':popover-open')) {
1138
+ finalize();
1139
+ return;
1140
+ }
1141
+ const from = video.getBoundingClientRect();
1142
+ if (!from.width) { finalize(); return; }
1143
+ // data-theater-leaving fades the ::backdrop out alongside the card shrink.
1144
+ // fill: 'forwards' holds the shrunk transform until finalize hides the popover,
1145
+ // so the card never snaps back to centre for a frame before teardown.
1146
+ figure.setAttribute('data-theater-leaving', 'true');
1147
+ // Ease-OUT (mirrors the entrance) so the shrink starts moving from frame one and
1148
+ // decelerates into the slot. An ease-in leaves the card visibly frozen at full
1149
+ // size for its first third, then collapses fast — reads as "it suddenly shrinks".
1150
+ morph('translate(0px, 0px) scale(1, 1)', collapsed(from, to), {
1151
+ duration: 360,
1152
+ easing: 'cubic-bezier(0.33, 1, 0.68, 1)',
1153
+ fill: 'forwards',
1154
+ done: finalize,
1155
+ });
1156
+ };
1157
+ const setTheater = (on: boolean): void => {
1158
+ if (on === theater.on) return;
1159
+ theater.on = on;
1160
+ theaterBtn.setAttribute('aria-pressed', String(on));
1161
+ theaterBtn.setAttribute('aria-label', on ? 'Exit theater mode' : 'Theater mode');
1162
+ if (on) {
1163
+ enterTheater();
1164
+ // window + capture: fire before the browser's popover close-watcher claims
1165
+ // Escape (it does so between window- and document-capture), so our
1166
+ // preventDefault actually suppresses it and only our clean dismiss runs.
1167
+ window.addEventListener('keydown', onTheaterKey, true);
1168
+ document.addEventListener('pointerdown', onTheaterOutside);
1169
+ } else {
1170
+ window.removeEventListener('keydown', onTheaterKey, true);
1171
+ document.removeEventListener('pointerdown', onTheaterOutside);
1172
+ leaveTheater();
1173
+ }
1174
+ figure.dispatchEvent(new CustomEvent('blok-video-theater', { detail: { on } }));
1175
+ };
1176
+ theaterBtn.addEventListener('click', () => setTheater(!theater.on));
1177
+ bar.insertBefore(theaterBtn, fullscreen);
1178
+
1179
+ // Picture-in-picture — only mounted when the browser supports it.
1180
+ const pipSupported = Boolean(document.pictureInPictureEnabled);
1181
+ const pipBtn = pipSupported ? button('picture-in-picture', 'Picture-in-Picture', IconPlayerPip) : null;
1182
+ const onEnterPip = (): void => pipBtn?.setAttribute('aria-pressed', 'true');
1183
+ const onLeavePip = (): void => pipBtn?.setAttribute('aria-pressed', 'false');
1184
+ if (pipBtn) {
1185
+ pipBtn.setAttribute('aria-pressed', 'false');
1186
+ pipBtn.addEventListener('click', () => {
1187
+ if (document.pictureInPictureElement === video) void document.exitPictureInPicture?.();
1188
+ else void video.requestPictureInPicture?.();
1189
+ });
1190
+ bar.insertBefore(pipBtn, fullscreen);
1191
+ video.addEventListener('enterpictureinpicture', onEnterPip);
1192
+ video.addEventListener('leavepictureinpicture', onLeavePip);
1193
+ }
1194
+
1195
+ // ----- idle auto-hide -----
1196
+ // Fade the control bar + cursor after a few idle seconds of playback; any
1197
+ // pointer move or focus brings them back. Never hides while paused.
1198
+ const IDLE_MS = 3000;
1199
+ figure.setAttribute('data-controls-hidden', 'false');
1200
+ const hideControls = (): void => {
1201
+ if (state.playing && figure.isConnected) figure.setAttribute('data-controls-hidden', 'true');
1202
+ };
1203
+ const scheduleHide = (): void => {
1204
+ if (state.idleTimer) clearTimeout(state.idleTimer);
1205
+ state.idleTimer = window.setTimeout(hideControls, IDLE_MS);
1206
+ };
1207
+ const revealControls = (): void => {
1208
+ if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = 0; }
1209
+ figure.setAttribute('data-controls-hidden', 'false');
1210
+ if (state.playing) scheduleHide();
1211
+ };
1212
+
1213
+ // ----- right-click context menu + stats overlay -----
1214
+ const ctxMenu = document.createElement('div');
1215
+ ctxMenu.className = 'blok-video-controls__ctx';
1216
+ ctxMenu.setAttribute('data-role', 'video-menu');
1217
+ ctxMenu.setAttribute('role', 'menu');
1218
+ ctxMenu.hidden = true;
1219
+ const ctxItem = (action: string, label: string): HTMLButtonElement => {
1220
+ const item = document.createElement('button');
1221
+ item.type = 'button';
1222
+ item.className = 'blok-video-controls__ctx-item';
1223
+ item.setAttribute('data-action', action);
1224
+ item.setAttribute('role', 'menuitem');
1225
+ item.textContent = label;
1226
+ return item;
1227
+ };
1228
+ const ctxLoop = ctxItem('ctx-loop', 'Loop');
1229
+ ctxLoop.setAttribute('role', 'menuitemcheckbox');
1230
+ const ctxCopy = ctxItem('copy-url', 'Copy video URL');
1231
+ const ctxCopyAt = ctxItem('copy-url-at-time', 'Copy video URL at current time');
1232
+ const ctxStats = ctxItem('stats', 'Stats for nerds');
1233
+ ctxMenu.append(ctxLoop, ctxCopy, ctxCopyAt, ctxStats);
1234
+ root.appendChild(ctxMenu);
1235
+
1236
+ const statsOverlay = document.createElement('div');
1237
+ statsOverlay.className = 'blok-video-controls__stats';
1238
+ statsOverlay.setAttribute('data-role', 'video-stats');
1239
+ statsOverlay.hidden = true;
1240
+ root.appendChild(statsOverlay);
1241
+
1242
+ const clipboardWrite = (text: string): void => { void navigator.clipboard?.writeText?.(text); };
1243
+ const renderStats = (): void => {
1244
+ const quality = video.getVideoPlaybackQuality?.();
1245
+ const dropped = quality ? `${quality.droppedVideoFrames} / ${quality.totalVideoFrames}` : 'n/a';
1246
+ const res = video.videoWidth ? `${video.videoWidth}×${video.videoHeight}` : 'n/a';
1247
+ const bufferedEnd = video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0;
1248
+ const health = Math.max(0, bufferedEnd - video.currentTime).toFixed(1);
1249
+ statsOverlay.innerHTML =
1250
+ `<div>Resolution: ${res}</div>`
1251
+ + `<div>Dropped frames: ${dropped}</div>`
1252
+ + `<div>Buffer health: ${health}s</div>`
1253
+ + `<div>Viewport: ${root.offsetWidth}×${root.offsetHeight}</div>`;
1254
+ };
1255
+ const toggleStats = (): void => {
1256
+ statsOverlay.hidden = !statsOverlay.hidden;
1257
+ if (!statsOverlay.hidden) renderStats();
1258
+ };
1259
+ const refreshStats = (): void => { if (!statsOverlay.hidden) renderStats(); };
1260
+
1261
+ const onCtxOutside = (event: MouseEvent): void => {
1262
+ if (!ctxMenu.contains(event.target as Node)) closeCtxMenu();
1263
+ };
1264
+ const onCtxKeydown = (event: KeyboardEvent): void => {
1265
+ if (event.key === 'Escape') closeCtxMenu();
1266
+ };
1267
+ const closeCtxMenu = (): void => {
1268
+ if (ctxMenu.hidden) return;
1269
+ ctxMenu.hidden = true;
1270
+ document.removeEventListener('mousedown', onCtxOutside);
1271
+ document.removeEventListener('keydown', onCtxKeydown);
1272
+ };
1273
+ const onContextMenu = (event: MouseEvent): void => {
1274
+ event.preventDefault();
1275
+ ctxLoop.setAttribute('aria-checked', String(media.loop));
1276
+ ctxMenu.style.setProperty('--blok-ctx-x', `${event.offsetX}px`);
1277
+ ctxMenu.style.setProperty('--blok-ctx-y', `${event.offsetY}px`);
1278
+ ctxMenu.hidden = false;
1279
+ document.addEventListener('mousedown', onCtxOutside);
1280
+ document.addEventListener('keydown', onCtxKeydown);
1281
+ };
1282
+ ctxLoop.addEventListener('click', () => { media.loop = !media.loop; ctxLoop.setAttribute('aria-checked', String(media.loop)); closeCtxMenu(); });
1283
+ ctxCopy.addEventListener('click', () => { clipboardWrite(video.currentSrc); closeCtxMenu(); });
1284
+ ctxCopyAt.addEventListener('click', () => { clipboardWrite(`${video.currentSrc}#t=${Math.floor(video.currentTime)}`); closeCtxMenu(); });
1285
+ ctxStats.addEventListener('click', () => { toggleStats(); closeCtxMenu(); });
1286
+ video.addEventListener('contextmenu', onContextMenu);
1287
+
1288
+ // ----- volume + position persistence -----
1289
+ const store: VideoStorage | null = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null);
1290
+ const VOL_KEY = 'blok:video:volume';
1291
+ const posKey = (): string | null => (video.currentSrc ? `blok:video:pos:${video.currentSrc}` : null);
1292
+ const safeGet = (key: string): string | null => { try { return store?.getItem(key) ?? null; } catch { return null; } };
1293
+ const safeSet = (key: string, value: string): void => { try { store?.setItem(key, value); } catch { /* private mode / quota */ } };
1294
+ const persistVolume = (): void => safeSet(VOL_KEY, JSON.stringify({ volume: media.volume, muted: media.muted }));
1295
+ const persistPosition = (): void => {
1296
+ const key = posKey();
1297
+ if (key) safeSet(key, String(media.currentTime));
1298
+ };
1299
+ const applyStoredVolume = (raw: string): void => {
1300
+ const parsed = JSON.parse(raw) as { volume?: number; muted?: boolean };
1301
+ if (typeof parsed.volume === 'number') { media.volume = parsed.volume; volume.value = String(parsed.volume); }
1302
+ if (typeof parsed.muted === 'boolean') media.muted = parsed.muted;
1303
+ onVolumeChange();
1304
+ };
1305
+ const restoreState = (): void => {
1306
+ const raw = safeGet(VOL_KEY);
1307
+ if (raw) { try { applyStoredVolume(raw); } catch { /* corrupt entry */ } }
1308
+ const key = posKey();
1309
+ const pos = key ? safeGet(key) : null;
1310
+ if (pos !== null) {
1311
+ const target = Number(pos);
1312
+ const dur = Number.isFinite(media.duration) ? media.duration : 0;
1313
+ if (Number.isFinite(target) && target > 0 && (dur === 0 || target < dur - 5)) media.currentTime = target;
1314
+ }
1315
+ };
1316
+
1317
+ playToggle.addEventListener('click', togglePlay);
1318
+ centerPlay.addEventListener('click', () => { void media.play(); });
1319
+ time.addEventListener('click', (event) => { event.stopPropagation(); toggleTimeMode(); });
1320
+ time.addEventListener('keydown', (event) => {
1321
+ if (event.key === 'Enter' || event.key === ' ') {
1322
+ event.preventDefault();
1323
+ event.stopPropagation();
1324
+ toggleTimeMode();
1325
+ }
1326
+ });
1327
+ // Click anywhere on the video to play/pause (the control bar sits above with
1328
+ // its own pointer-events, so its hits never reach the media). A click after a
1329
+ // press-and-hold is swallowed by onVideoClick.
1330
+ video.addEventListener('click', onVideoClick);
1331
+ video.addEventListener('pointerdown', onPointerDown);
1332
+ video.addEventListener('pointerup', onPointerUp);
1333
+ // Pointer leaving / cancelling restores 1× but is NOT a release, so it must not
1334
+ // suppress the next legitimate click.
1335
+ video.addEventListener('pointerleave', releaseHold);
1336
+ video.addEventListener('pointercancel', releaseHold);
1337
+ video.addEventListener('keydown', onVideoKeydown);
1338
+ seekFlash.addEventListener('animationend', onSeekFlashEnd);
1339
+ burst.addEventListener('animationend', onBurstEnd);
1340
+ seek.addEventListener('input', onSeekInput);
1341
+ seek.addEventListener('pointermove', onSeekHover);
1342
+ seek.addEventListener('pointerleave', onSeekHoverLeave);
1343
+ muteToggle.addEventListener('click', onMuteClick);
1344
+ muteToggle.addEventListener('animationend', onMuteBumpEnd);
1345
+ volume.addEventListener('input', onVolumeInput);
1346
+ fullscreen.addEventListener('click', onFullscreen);
1347
+
1348
+ video.addEventListener('loadedmetadata', onLoadedMetadata);
1349
+ video.addEventListener('timeupdate', onTimeUpdate);
1350
+ video.addEventListener('progress', onProgress);
1351
+ video.addEventListener('play', onPlay);
1352
+ video.addEventListener('play', startSeekLoop);
1353
+ video.addEventListener('pause', stopSeekLoop);
1354
+ seek.addEventListener('pointerdown', onSeekScrubStart);
1355
+ document.addEventListener('pointerup', onSeekScrubEnd);
1356
+ video.addEventListener('play', startAmbient);
1357
+ video.addEventListener('play', updateCenter);
1358
+ video.addEventListener('play', scheduleHide);
1359
+ video.addEventListener('pause', stopAmbient);
1360
+ video.addEventListener('pause', onPause);
1361
+ video.addEventListener('pause', updateCenter);
1362
+ video.addEventListener('pause', revealControls);
1363
+ video.addEventListener('timeupdate', updateCenter);
1364
+ video.addEventListener('timeupdate', persistPosition);
1365
+ video.addEventListener('timeupdate', refreshStats);
1366
+ video.addEventListener('waiting', onWaiting);
1367
+ video.addEventListener('playing', onPlayingMedia);
1368
+ video.addEventListener('canplay', onCanPlay);
1369
+ video.addEventListener('volumechange', onVolumeChange);
1370
+ video.addEventListener('volumechange', persistVolume);
1371
+ video.addEventListener('loadedmetadata', restoreState);
1372
+ figure.addEventListener('pointermove', revealControls);
1373
+ figure.addEventListener('focusin', revealControls);
1374
+ document.addEventListener('fullscreenchange', onFullscreenChange);
1375
+ updateCenter();
1376
+
1377
+ const destroy = (): void => {
1378
+ if (hold.timer) clearTimeout(hold.timer);
1379
+ if (state.idleTimer) clearTimeout(state.idleTimer);
1380
+ document.removeEventListener('mousedown', onMenuOutside);
1381
+ document.removeEventListener('mousedown', onCtxOutside);
1382
+ document.removeEventListener('keydown', onCtxKeydown);
1383
+ window.removeEventListener('keydown', onTheaterKey, true);
1384
+ video.removeEventListener('contextmenu', onContextMenu);
1385
+ cancelAnimationFrame(speedGlide.raf);
1386
+ stopSeekLoop();
1387
+ video.removeEventListener('play', startSeekLoop);
1388
+ video.removeEventListener('pause', stopSeekLoop);
1389
+ seek.removeEventListener('pointerdown', onSeekScrubStart);
1390
+ document.removeEventListener('pointerup', onSeekScrubEnd);
1391
+ stopAmbient();
1392
+ video.removeEventListener('play', startAmbient);
1393
+ video.removeEventListener('play', updateCenter);
1394
+ video.removeEventListener('play', scheduleHide);
1395
+ video.removeEventListener('pause', stopAmbient);
1396
+ video.removeEventListener('pause', updateCenter);
1397
+ video.removeEventListener('pause', revealControls);
1398
+ video.removeEventListener('timeupdate', updateCenter);
1399
+ video.removeEventListener('timeupdate', persistPosition);
1400
+ video.removeEventListener('timeupdate', refreshStats);
1401
+ video.removeEventListener('waiting', onWaiting);
1402
+ video.removeEventListener('playing', onPlayingMedia);
1403
+ video.removeEventListener('canplay', onCanPlay);
1404
+ video.removeEventListener('volumechange', persistVolume);
1405
+ video.removeEventListener('loadedmetadata', restoreState);
1406
+ figure.removeEventListener('pointermove', revealControls);
1407
+ figure.removeEventListener('focusin', revealControls);
1408
+ if (pipBtn) {
1409
+ video.removeEventListener('enterpictureinpicture', onEnterPip);
1410
+ video.removeEventListener('leavepictureinpicture', onLeavePip);
1411
+ }
1412
+ video.removeEventListener('click', onVideoClick);
1413
+ video.removeEventListener('pointerdown', onPointerDown);
1414
+ video.removeEventListener('pointerup', onPointerUp);
1415
+ video.removeEventListener('pointerleave', releaseHold);
1416
+ video.removeEventListener('pointercancel', releaseHold);
1417
+ video.removeEventListener('keydown', onVideoKeydown);
1418
+ seekFlash.removeEventListener('animationend', onSeekFlashEnd);
1419
+ seek.removeEventListener('pointermove', onSeekHover);
1420
+ seek.removeEventListener('pointerleave', onSeekHoverLeave);
1421
+ if (preview.el) {
1422
+ preview.el.removeEventListener('seeked', drawPreviewFrame);
1423
+ preview.el.removeAttribute('src');
1424
+ preview.el.load();
1425
+ preview.el.remove();
1426
+ preview.el = null;
1427
+ }
1428
+ video.removeEventListener('loadedmetadata', onLoadedMetadata);
1429
+ video.removeEventListener('timeupdate', onTimeUpdate);
1430
+ video.removeEventListener('progress', onProgress);
1431
+ video.removeEventListener('play', onPlay);
1432
+ video.removeEventListener('pause', onPause);
1433
+ video.removeEventListener('volumechange', onVolumeChange);
1434
+ muteToggle.removeEventListener('animationend', onMuteBumpEnd);
1435
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
1436
+ document.removeEventListener('pointerdown', onTheaterOutside);
1437
+ cancelFlip();
1438
+ removeFromTopLayer(figure);
1439
+ // Drop any slot height reserved for an in-progress theater session.
1440
+ const slot = figure.parentElement;
1441
+ if (slot) slot.style.minHeight = '';
1442
+ closeMenu(); // drop the resize/outside listeners if the menu was open
1443
+ menu.remove(); // the menu lives on the figure, so root.remove() won't take it
1444
+ root.remove();
1445
+ };
1446
+
1447
+ return { element: root, setTheater, destroy };
1448
+ }