@leavepulse/ui 0.81.0 → 0.83.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 (270) hide show
  1. package/dist/assets/LpVideoTrim-BcGs3Sh0.css +8 -0
  2. package/dist/assets/VideoFlash-VuPkC7QV.css +23 -0
  3. package/dist/chunks/{ContextMenuItems-DLK4q-mm.js → ContextMenuItems-h-apPY2d.js} +1 -1
  4. package/dist/chunks/{DropzoneLinkField-CcJu8De3.js → DropzoneLinkField-_Op8b_ee.js} +1 -1
  5. package/dist/chunks/{FieldLeading-CldGLINn.js → FieldLeading-mOMcwNqi.js} +1 -1
  6. package/dist/chunks/{FieldWrapper-Cj8ZgoKp.js → FieldWrapper-DtT-AIcn.js} +1 -1
  7. package/dist/chunks/{IconCloseButton-CV4iHRpy.js → IconCloseButton-YmisskyZ.js} +1 -1
  8. package/dist/chunks/{LayoutCanvas-BQbq7Ekw.js → LayoutCanvas-BrISZEBr.js} +1 -1
  9. package/dist/chunks/{LayoutNode-BMedRk3g.js → LayoutNode-CwMQ8ACa.js} +1 -1
  10. package/dist/chunks/{LpActionBar-C7g52Cyo.js → LpActionBar-CVVC6YbC.js} +1 -1
  11. package/dist/chunks/{LpAddressInput-1uakhQk5.js → LpAddressInput-tiuJ-bru.js} +7 -7
  12. package/dist/chunks/{LpAlert-DWFeR82L.js → LpAlert-8-x4H_Nk.js} +1 -1
  13. package/dist/chunks/{LpAppShell-BBINfN1v.js → LpAppShell-BW2YMq-A.js} +4 -4
  14. package/dist/chunks/{LpAutocomplete-DY3LvpB9.js → LpAutocomplete-BZODIops.js} +4 -4
  15. package/dist/chunks/{LpAvatar-CEZmMcGh.js → LpAvatar-BZq4nH53.js} +1 -1
  16. package/dist/chunks/{LpBadge-C_VqLHtg.js → LpBadge-Cz0pSSUu.js} +1 -1
  17. package/dist/chunks/{LpBottomNav-4I89sPDZ.js → LpBottomNav-DVdR5Mry.js} +1 -1
  18. package/dist/chunks/{LpBreadcrumbs-1BBzrfxG.js → LpBreadcrumbs-B2jphAel.js} +1 -1
  19. package/dist/chunks/{LpCalendar-D2dcXjUK.js → LpCalendar-C0_N5LVT.js} +1 -1
  20. package/dist/chunks/{LpCheckbox-CN-MEnbX.js → LpCheckbox-CwqLNdw4.js} +2 -2
  21. package/dist/chunks/{LpCodeBlock-Cj7zk4QE.js → LpCodeBlock-Dcss6bnl.js} +2 -2
  22. package/dist/chunks/{LpCodeDisplay-COzosm77.js → LpCodeDisplay-CLms1zDY.js} +4 -4
  23. package/dist/chunks/{LpColorPicker-BezW6DX1.js → LpColorPicker-CbIY450X.js} +3 -3
  24. package/dist/chunks/{LpConfirmDialog-D9GkDMYr.js → LpConfirmDialog-RmP4z6va.js} +2 -2
  25. package/dist/chunks/{LpConfirmHost-BH7kf0dm.js → LpConfirmHost-DheBgRMB.js} +1 -1
  26. package/dist/chunks/{LpContextMenu-bn5bc9t7.js → LpContextMenu-DIYhlap8.js} +2 -2
  27. package/dist/chunks/{LpContextSwitcher-CGr6ZAuq.js → LpContextSwitcher-fDA6ERl-.js} +2 -2
  28. package/dist/chunks/{LpCopyButton-BSr6YaFR.js → LpCopyButton-DSqKFGEb.js} +1 -1
  29. package/dist/chunks/{LpDataList-Cv7ZUlyy.js → LpDataList-f9UjSx9m.js} +2 -2
  30. package/dist/chunks/{LpDatePicker-CR7vsb5M.js → LpDatePicker-3kDQLVbg.js} +5 -5
  31. package/dist/chunks/{LpDateRangePicker-B9fRO3G6.js → LpDateRangePicker-DUPiTt0_.js} +3 -3
  32. package/dist/chunks/{LpDisclosure-CE4mneaE.js → LpDisclosure-C5WZjhbQ.js} +1 -1
  33. package/dist/chunks/{LpDrawer-DU_5MqBz.js → LpDrawer-CkYvIGIa.js} +1 -1
  34. package/dist/chunks/{LpDropdownMenu-_u6GPWhp.js → LpDropdownMenu-zSsK2Xra.js} +6 -3
  35. package/dist/chunks/{LpEditable-DYoMayjt.js → LpEditable-CP7Y_lKg.js} +2 -2
  36. package/dist/chunks/{LpEmptyState-DpdHeSfz.js → LpEmptyState-dsc5x-ya.js} +1 -1
  37. package/dist/chunks/{LpFieldNotice-DONFCWV7.js → LpFieldNotice-TOTbWvgv.js} +1 -1
  38. package/dist/chunks/{LpFileTree-f5ktGqsN.js → LpFileTree-BPjnOTln.js} +4 -4
  39. package/dist/chunks/{LpFileTreeNode-nbp-JwV9.js → LpFileTreeNode-_-MiWmie.js} +4 -4
  40. package/dist/chunks/{LpFormField-Bz50_LZW.js → LpFormField-BJwD1iEF.js} +1 -1
  41. package/dist/chunks/{LpHintLayer-B6bA5Cb_.js → LpHintLayer-CmfGnxYu.js} +1 -1
  42. package/dist/chunks/{LpHints-DyJ5hVcr.js → LpHints-C2Xvc1Fd.js} +3 -3
  43. package/dist/chunks/{LpIcon-BCQLuHSP.js → LpIcon-CoOLz1oJ.js} +1 -1
  44. package/dist/chunks/{LpInfraNode-BS1RXYAs.js → LpInfraNode-CW3qsM-U.js} +2 -2
  45. package/dist/chunks/{LpInput-BjbXdjcW.js → LpInput-BVWhkPuN.js} +3 -3
  46. package/dist/chunks/{LpInspector-BIyJyM6r.js → LpInspector-REmoX_es.js} +4 -4
  47. package/dist/chunks/{LpLightbox-CSAJuUaJ.js → LpLightbox-C3FFTYP5.js} +65 -20
  48. package/dist/chunks/{LpLogInspector-OJEz1vod.js → LpLogInspector-Cs8Z_fM0.js} +2 -2
  49. package/dist/chunks/{LpLogViewer-CRLwQpk0.js → LpLogViewer-DQRGe-ZK.js} +6 -6
  50. package/dist/chunks/{LpMapPicker-DW2EnrCJ.js → LpMapPicker-CQnQPgKm.js} +1 -1
  51. package/dist/chunks/{LpMediaGallery-BNEYctDP.js → LpMediaGallery-DkKqTy9P.js} +20 -8
  52. package/dist/chunks/{LpModal-DdTqdf4x.js → LpModal-DwejR7VP.js} +1 -1
  53. package/dist/chunks/{LpModalNav-BL7Dp0om.js → LpModalNav-D1-dSe6f.js} +3 -3
  54. package/dist/chunks/{LpNotificationBell-d0F6MWxD.js → LpNotificationBell-EKLkN0sp.js} +4 -4
  55. package/dist/chunks/{LpNotificationFeed-BIe5lapb.js → LpNotificationFeed-Dc-yQ4NH.js} +4 -4
  56. package/dist/chunks/{LpNumberField-D5Dx-hzD.js → LpNumberField-DvUjEMp-.js} +3 -3
  57. package/dist/chunks/{LpOtpInput-BVl74Lfl.js → LpOtpInput-xvDO14f4.js} +1 -1
  58. package/dist/chunks/{LpPagination-D0wb-Uru.js → LpPagination-BFZY8dFn.js} +1 -1
  59. package/dist/chunks/{LpPaletteTheme-DdilCBXA.js → LpPaletteTheme-EBJv0Bwz.js} +2 -2
  60. package/dist/chunks/{LpPasswordInput-DUtqWMkA.js → LpPasswordInput-DRJR3fF1.js} +2 -2
  61. package/dist/chunks/{LpPhoneInput-CalRjkqc.js → LpPhoneInput-BGOOtUoW.js} +3 -3
  62. package/dist/chunks/{LpPie-BVAEmVBT.js → LpPie-Deyn8h5J.js} +2 -2
  63. package/dist/chunks/{LpPipeline-Dk7_sRP1.js → LpPipeline-9ChjeNh5.js} +2 -2
  64. package/dist/chunks/{LpQuota-DKx0IZYA.js → LpQuota-CNh5eF_p.js} +1 -1
  65. package/dist/chunks/{LpRadioGroup-E3SP6j9h.js → LpRadioGroup-Br2Nl9Gr.js} +2 -2
  66. package/dist/chunks/{LpRangeCalendar-BFJFaen4.js → LpRangeCalendar-D66Y3U_w.js} +1 -1
  67. package/dist/chunks/{LpRangeSlider-b6ipywRB.js → LpRangeSlider-eX-fIlOw.js} +1 -1
  68. package/dist/chunks/{LpRepeater-BLrw62Bh.js → LpRepeater-0XvnlVd_.js} +2 -2
  69. package/dist/chunks/{LpSecretField-BV8BNjKc.js → LpSecretField-1RCooChq.js} +5 -5
  70. package/dist/chunks/{LpSection-CghNNxIv.js → LpSection-B9gbJJcP.js} +1 -1
  71. package/dist/chunks/{LpSegmented-DjvrHL_G.js → LpSegmented-hIjGzGTo.js} +2 -2
  72. package/dist/chunks/{LpSelect-DYPimzAZ.js → LpSelect-DugubwTc.js} +4 -4
  73. package/dist/chunks/{LpSidebar-D3S8rYFf.js → LpSidebar-DevSoN8W.js} +4 -4
  74. package/dist/chunks/{LpSidebarNav-Dlse8vxT.js → LpSidebarNav-DwQQ3KXe.js} +3 -3
  75. package/dist/chunks/{LpSidebarRow-Cqd_6tGG.js → LpSidebarRow-CAGgtJCS.js} +3 -3
  76. package/dist/chunks/{LpSlider-B9XIC75O.js → LpSlider-1fHpVxpB.js} +1 -1
  77. package/dist/chunks/{LpStat-kyLRmX-m.js → LpStat-Cm9-FaWQ.js} +2 -2
  78. package/dist/chunks/{LpStatus-szB2dWzM.js → LpStatus-CE8FSAT1.js} +1 -1
  79. package/dist/chunks/{LpStepMark-DP3CDSm9.js → LpStepMark-BWSlV69R.js} +1 -1
  80. package/dist/chunks/{LpStepper-BS3iaJIz.js → LpStepper-1D0ZA7Nx.js} +1 -1
  81. package/dist/chunks/{LpSwitch-DRFy8RNr.js → LpSwitch-D_kvE0oQ.js} +1 -1
  82. package/dist/chunks/{LpTabFace-C9-OperL.js → LpTabFace-CpbFKi-N.js} +2 -2
  83. package/dist/chunks/{LpTableFilter-CEIwLupk.js → LpTableFilter-BYHyCu38.js} +2 -2
  84. package/dist/chunks/{LpTagsInput-ClDeF_aU.js → LpTagsInput-Cqm38t5N.js} +5 -5
  85. package/dist/chunks/{LpTextarea-DEY6pUBy.js → LpTextarea-DaSKBkiV.js} +1 -1
  86. package/dist/chunks/{LpThemeStudio-WY72Z1Db.js → LpThemeStudio-BNdfi-Ds.js} +5 -5
  87. package/dist/chunks/{LpThemeSwitcher-9uCH9c-K.js → LpThemeSwitcher-CbC1nqBP.js} +3 -3
  88. package/dist/chunks/{LpTimeField-BmgxCzQO.js → LpTimeField-CCfPCWPT.js} +2 -2
  89. package/dist/chunks/{LpToaster-DLKO-efn.js → LpToaster-BhlI7h-h.js} +2 -2
  90. package/dist/chunks/{LpTopologyCanvas-B-PhPa7k.js → LpTopologyCanvas-DlKbu7E5.js} +4 -4
  91. package/dist/chunks/{LpTour-ODlBcat2.js → LpTour-CSLerA1B.js} +1 -1
  92. package/dist/chunks/{LpTruncate--nhT0iUo.js → LpTruncate-Ct26BSYB.js} +1 -1
  93. package/dist/chunks/{LpVideoBackdrop-Ct4St4ZL.js → LpVideoBackdrop-57i8RY8g.js} +1 -2
  94. package/dist/chunks/LpVideoPlayer-DcWd9DDW.js +813 -0
  95. package/dist/chunks/{LpWindow-DJxE8Zl-.js → LpWindow-YnF_knSj.js} +2 -2
  96. package/dist/chunks/{LpWorkspaceSwitcher-Bpf41Bw5.js → LpWorkspaceSwitcher-B-5UU6o4.js} +3 -3
  97. package/dist/chunks/{MenuTitle-CMjvLsvC.js → MenuTitle-BnZbFSwq.js} +1 -1
  98. package/dist/chunks/{OptionContent-DY8NaG8s.js → OptionContent-D5e_eklA.js} +1 -1
  99. package/dist/chunks/{PickerField-CgfkaBgg.js → PickerField-KpHiowNI.js} +1 -1
  100. package/dist/chunks/{RevealToggle-CPMXxaKv.js → RevealToggle-CE1hAIJB.js} +1 -1
  101. package/dist/chunks/SteadyText-CwH-kduj.js +25 -0
  102. package/dist/chunks/{TourCard-D4b3lMHi.js → TourCard-t2csiTDS.js} +1 -1
  103. package/dist/chunks/VideoThumb-Csf9bUz3.js +67 -0
  104. package/dist/chunks/VideoVolume-B4_DNmNi.js +68 -0
  105. package/dist/chunks/icons-CdSlCrle.js +59 -0
  106. package/dist/component-names.d.ts +1 -1
  107. package/dist/components/ContextMenuItems.vue.js +1 -1
  108. package/dist/components/DropzoneLinkField.vue.js +1 -1
  109. package/dist/components/FieldLeading.vue.js +1 -1
  110. package/dist/components/FieldWrapper.vue.js +1 -1
  111. package/dist/components/IconCloseButton.vue.js +1 -1
  112. package/dist/components/LayoutCanvas.vue.js +1 -1
  113. package/dist/components/LayoutNode.vue.js +1 -1
  114. package/dist/components/LpActionBar.vue.js +1 -1
  115. package/dist/components/LpAddressInput.vue.js +1 -1
  116. package/dist/components/LpAlert.vue.js +1 -1
  117. package/dist/components/LpAppShell.vue.js +1 -1
  118. package/dist/components/LpAutocomplete.vue.js +1 -1
  119. package/dist/components/LpAvatar.vue.js +1 -1
  120. package/dist/components/LpBadge.vue.js +1 -1
  121. package/dist/components/LpBottomNav.vue.js +1 -1
  122. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  123. package/dist/components/LpCalendar.vue.js +1 -1
  124. package/dist/components/LpChart.vue.js +2 -2
  125. package/dist/components/LpCheckbox.vue.js +1 -1
  126. package/dist/components/LpCodeBlock.vue.js +1 -1
  127. package/dist/components/LpCodeDisplay.vue.js +1 -1
  128. package/dist/components/LpColorPicker.vue.js +1 -1
  129. package/dist/components/LpCommandPalette.vue.js +2 -2
  130. package/dist/components/LpConfirmDialog.vue.js +1 -1
  131. package/dist/components/LpConfirmHost.vue.js +1 -1
  132. package/dist/components/LpContextMenu.vue.js +1 -1
  133. package/dist/components/LpContextSwitcher.vue.js +1 -1
  134. package/dist/components/LpCopyButton.vue.js +1 -1
  135. package/dist/components/LpDataList.vue.js +1 -1
  136. package/dist/components/LpDatePicker.vue.js +1 -1
  137. package/dist/components/LpDateRangePicker.vue.js +1 -1
  138. package/dist/components/LpDisclosure.vue.js +1 -1
  139. package/dist/components/LpDock.vue.js +1 -1
  140. package/dist/components/LpDrawer.vue.js +1 -1
  141. package/dist/components/LpDropdownMenu.vue.d.ts +5 -1
  142. package/dist/components/LpDropdownMenu.vue.js +1 -1
  143. package/dist/components/LpDropzone.vue.js +2 -2
  144. package/dist/components/LpEditable.vue.js +1 -1
  145. package/dist/components/LpEmptyState.vue.js +1 -1
  146. package/dist/components/LpFieldNotice.vue.js +1 -1
  147. package/dist/components/LpFileTree.vue.js +1 -1
  148. package/dist/components/LpFileTreeNode.vue.js +1 -1
  149. package/dist/components/LpFormField.vue.js +1 -1
  150. package/dist/components/LpHintLayer.vue.js +1 -1
  151. package/dist/components/LpHints.vue.js +1 -1
  152. package/dist/components/LpIcon.vue.js +1 -1
  153. package/dist/components/LpImageEditor.vue.js +1 -1
  154. package/dist/components/LpInfraNode.vue.js +1 -1
  155. package/dist/components/LpInput.vue.js +1 -1
  156. package/dist/components/LpInspector.vue.js +1 -1
  157. package/dist/components/LpLightbox.vue.js +1 -1
  158. package/dist/components/LpLogInspector.vue.js +1 -1
  159. package/dist/components/LpLogViewer.vue.js +1 -1
  160. package/dist/components/LpMapPicker.vue.js +1 -1
  161. package/dist/components/LpMediaGallery.vue.d.ts +1 -1
  162. package/dist/components/LpMediaGallery.vue.js +1 -1
  163. package/dist/components/LpModal.vue.js +1 -1
  164. package/dist/components/LpModalNav.vue.js +1 -1
  165. package/dist/components/LpNotificationBell.vue.js +1 -1
  166. package/dist/components/LpNotificationFeed.vue.js +1 -1
  167. package/dist/components/LpNumberField.vue.js +1 -1
  168. package/dist/components/LpOtpInput.vue.js +1 -1
  169. package/dist/components/LpPagination.vue.js +1 -1
  170. package/dist/components/LpPaletteFacts.vue.js +1 -1
  171. package/dist/components/LpPaletteHeader.vue.js +2 -2
  172. package/dist/components/LpPaletteTheme.vue.js +1 -1
  173. package/dist/components/LpPasswordInput.vue.js +1 -1
  174. package/dist/components/LpPhoneInput.vue.js +1 -1
  175. package/dist/components/LpPie.vue.js +1 -1
  176. package/dist/components/LpPipeline.vue.js +1 -1
  177. package/dist/components/LpQuota.vue.js +1 -1
  178. package/dist/components/LpRadioGroup.vue.js +1 -1
  179. package/dist/components/LpRangeCalendar.vue.js +1 -1
  180. package/dist/components/LpRangeSlider.vue.js +1 -1
  181. package/dist/components/LpRepeater.vue.js +1 -1
  182. package/dist/components/LpSecretField.vue.js +1 -1
  183. package/dist/components/LpSection.vue.js +1 -1
  184. package/dist/components/LpSegmented.vue.js +1 -1
  185. package/dist/components/LpSelect.vue.js +1 -1
  186. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  187. package/dist/components/LpSidebar.vue.js +1 -1
  188. package/dist/components/LpSidebarNav.vue.js +1 -1
  189. package/dist/components/LpSidebarRow.vue.js +1 -1
  190. package/dist/components/LpSlider.vue.js +1 -1
  191. package/dist/components/LpStat.vue.js +1 -1
  192. package/dist/components/LpStatus.vue.js +1 -1
  193. package/dist/components/LpStepMark.vue.js +1 -1
  194. package/dist/components/LpStepper.vue.js +1 -1
  195. package/dist/components/LpSwitch.vue.js +1 -1
  196. package/dist/components/LpTabFace.vue.js +1 -1
  197. package/dist/components/LpTable.vue.js +9 -9
  198. package/dist/components/LpTableFilter.vue.js +1 -1
  199. package/dist/components/LpTabs.vue.js +2 -2
  200. package/dist/components/LpTagsInput.vue.js +1 -1
  201. package/dist/components/LpTextarea.vue.js +1 -1
  202. package/dist/components/LpThemeStudio.vue.js +1 -1
  203. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  204. package/dist/components/LpTimeField.vue.js +1 -1
  205. package/dist/components/LpToaster.vue.js +1 -1
  206. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  207. package/dist/components/LpTour.vue.js +1 -1
  208. package/dist/components/LpTruncate.vue.js +1 -1
  209. package/dist/components/LpVideoBackdrop.vue.js +1 -1
  210. package/dist/components/LpVideoPlayer.vue.d.ts +111 -0
  211. package/dist/components/LpVideoPlayer.vue.js +4 -0
  212. package/dist/components/LpVideoTrim.vue.d.ts +82 -0
  213. package/dist/components/LpVideoTrim.vue.js +1106 -0
  214. package/dist/components/LpWindow.vue.js +1 -1
  215. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  216. package/dist/components/MenuTitle.vue.js +1 -1
  217. package/dist/components/OptionContent.vue.js +1 -1
  218. package/dist/components/PickerField.vue.js +1 -1
  219. package/dist/components/RevealToggle.vue.js +1 -1
  220. package/dist/components/SteadyText.vue.d.ts +7 -0
  221. package/dist/components/SteadyText.vue.js +4 -0
  222. package/dist/components/TourCard.vue.js +1 -1
  223. package/dist/components/VideoFlash.vue.d.ts +10 -0
  224. package/dist/components/VideoFlash.vue.js +33 -0
  225. package/dist/components/VideoThumb.vue.d.ts +12 -0
  226. package/dist/components/VideoThumb.vue.js +4 -0
  227. package/dist/components/VideoVolume.vue.d.ts +15 -0
  228. package/dist/components/VideoVolume.vue.js +4 -0
  229. package/dist/components/chartWindow.js +4 -8
  230. package/dist/components/lightbox.d.ts +13 -0
  231. package/dist/components/lightbox.js +11 -1
  232. package/dist/components/menuWhen.js +1 -1
  233. package/dist/components/videoBackdrop.d.ts +15 -2
  234. package/dist/components/videoBackdrop.js +8 -2
  235. package/dist/components/videoFrames.d.ts +37 -0
  236. package/dist/components/videoFrames.js +134 -0
  237. package/dist/components/videoMotion.d.ts +28 -0
  238. package/dist/components/videoMotion.js +26 -0
  239. package/dist/components/videoTrim.d.ts +68 -0
  240. package/dist/components/videoTrim.js +121 -0
  241. package/dist/composables/span.d.ts +10 -0
  242. package/dist/composables/span.js +15 -0
  243. package/dist/composables/useVideoMedia.d.ts +74 -0
  244. package/dist/composables/useVideoMedia.js +291 -0
  245. package/dist/icons-all.js +1 -1
  246. package/dist/index.d.ts +7 -1
  247. package/dist/index.js +186 -174
  248. package/package.json +1 -1
  249. package/src/component-names.ts +2 -0
  250. package/src/components/LpDropdownMenu.vue +4 -0
  251. package/src/components/LpLightbox.vue +61 -8
  252. package/src/components/LpMediaGallery.vue +19 -2
  253. package/src/components/LpVideoBackdrop.vue +1 -7
  254. package/src/components/LpVideoPlayer.vue +774 -0
  255. package/src/components/LpVideoTrim.vue +1066 -0
  256. package/src/components/SteadyText.vue +21 -0
  257. package/src/components/VideoFlash.vue +52 -0
  258. package/src/components/VideoThumb.vue +66 -0
  259. package/src/components/VideoVolume.vue +63 -0
  260. package/src/components/chartWindow.ts +4 -8
  261. package/src/components/lightbox.ts +23 -0
  262. package/src/components/videoBackdrop.ts +20 -3
  263. package/src/components/videoFrames.ts +171 -0
  264. package/src/components/videoMotion.ts +38 -0
  265. package/src/components/videoTrim.ts +186 -0
  266. package/src/composables/span.ts +21 -0
  267. package/src/composables/useVideoMedia.ts +411 -0
  268. package/src/index.ts +7 -1
  269. package/src/kit-icons.ts +1 -1
  270. package/dist/chunks/icons-C1vppR4f.js +0 -59
@@ -0,0 +1,774 @@
1
+ <script lang="ts">
2
+ export default { inheritAttrs: false }
3
+ </script>
4
+
5
+ <script setup lang="ts">
6
+ /*
7
+ * A video with the kit's own controls instead of each browser's: the same
8
+ * look in Chromium, Firefox and the launcher's WebKitGTK, in both themes.
9
+ *
10
+ * <LpVideoPlayer :src="url" :poster="thumb" title="Report #42" />
11
+ *
12
+ * - A seek bar with what is buffered, and under the pointer the time and a
13
+ * still of that moment (taken from a second element only once someone
14
+ * hovers, so a video nobody scrubs costs nothing extra).
15
+ * - Volume and speed, remembered between videos on this device and shared
16
+ * with LpVideoTrim (`remember`).
17
+ * - Fullscreen of the whole player, so the controls come along; menus open
18
+ * inside it there, since a page-level popup would sit behind it. An iPhone,
19
+ * which fullscreens only a bare video, gets the system player.
20
+ * - Picture in picture where the browser has it; captions from <track>
21
+ * children (the default slot goes inside the <video>).
22
+ * - Controls step aside while it plays and come back on any movement, key or
23
+ * focus; they stay while paused, while a menu is open or a pointer is on them.
24
+ * - Keys, with the player focused: Space or K play, J/L skip, ←/→ nudge, ↑/↓
25
+ * volume, M mute, F fullscreen, C captions, 0–9 a tenth of the way, < and >
26
+ * speed. A double tap at either side of a touch screen skips. A skip is ten
27
+ * seconds and a nudge five on a long video, less on a short one.
28
+ * - A narrow player keeps its bar on one line by dropping, in turn, the skip
29
+ * buttons, picture in picture and speed: all three stay in the right-click
30
+ * menu and on the keys.
31
+ * - It plays by range requests from its address, so a long file costs what is
32
+ * watched, and a same-origin address keeps the session cookie.
33
+ * - `#actions` puts the app's buttons over the picture's top right corner,
34
+ * `#controls` into the bar before the speed; both get `buttonClass` so they
35
+ * look like the player's own:
36
+ *
37
+ * <template #actions="{ buttonClass }">
38
+ * <LpButton variant="ghost" size="sm" square :class="buttonClass" aria-label="Remove" @click="clear">
39
+ * <LpIcon name="lucide:trash-2" :size="16" />
40
+ * </LpButton>
41
+ * </template>
42
+ */
43
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
44
+ import { startPointerDrag } from "../composables/pointerDrag"
45
+ import { isHTMLElement, nodeWindow } from "../composables/realm"
46
+ import { useClipboard } from "../composables/useClipboard"
47
+ import { useLabels } from "../composables/useLpConfig"
48
+ import { useMergedAttrs } from "../composables/useMergedClass"
49
+ import { useToast } from "../composables/useToast"
50
+ import { useVideoMedia, type VideoErrorCause } from "../composables/useVideoMedia"
51
+ import { provideView, useView } from "../composables/useView"
52
+ import LpButton from "./LpButton.vue"
53
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
54
+ import LpDropdownMenu from "./LpDropdownMenu.vue"
55
+ import LpIcon from "./LpIcon.vue"
56
+ import LpLoader from "./LpLoader.vue"
57
+ import SteadyText from "./SteadyText.vue"
58
+ import VideoFlash from "./VideoFlash.vue"
59
+ import VideoVolume from "./VideoVolume.vue"
60
+ import { formatClipTime, widestClipTime } from "./videoBackdrop"
61
+ import { FrameSampler } from "./videoFrames"
62
+ import { FADE, FADE_SCALE, ICON_SWAP, OVERLAY_BUTTON } from "./videoMotion"
63
+
64
+ const props = withDefaults(
65
+ defineProps<{
66
+ src: string
67
+ poster?: string
68
+ /** Names the player for assistive tech: what the video is. */
69
+ title?: string
70
+ /** Starts on its own; a browser that refuses sound-on autoplay leaves the play button. */
71
+ autoplay?: boolean
72
+ loop?: boolean
73
+ /** Starts muted (and so lets autoplay through). */
74
+ muted?: boolean
75
+ /** Seconds to start from. */
76
+ startAt?: number
77
+ /** Speeds offered in the menu. */
78
+ rates?: number[]
79
+ /** Keep volume and speed between videos on this device; false forgets them. */
80
+ remember?: boolean
81
+ /** A still of the moment under the pointer on the seek bar. */
82
+ preview?: boolean
83
+ /**
84
+ * Fit inside the box the player is given (a lightbox's stage) at the
85
+ * video's own proportions, as a picture with object-fit: contain does,
86
+ * instead of taking the full width at its own height. The parent sets the
87
+ * size; a portrait phone video stands tall and narrow in a wide box.
88
+ */
89
+ fit?: boolean
90
+ /** More items for the right-click menu. */
91
+ menuItems?: () => ContextMenuItemDef[]
92
+ labels?: Partial<Record<
93
+ | "player" | "play" | "pause" | "replay" | "mute" | "unmute" | "volume" | "speed" | "normalSpeed"
94
+ | "fullscreen" | "exitFullscreen" | "pip" | "exitPip" | "captions" | "seek" | "failed" | "unsupported"
95
+ | "back" | "forward" | "rewind" | "fastForward" | "menuAt" | "copyTime" | "copyFrame" | "copiedTime" | "copiedFrame" | "copyFailed",
96
+ string
97
+ >>
98
+ }>(),
99
+ {
100
+ poster: undefined,
101
+ title: undefined,
102
+ startAt: 0,
103
+ rates: () => [0.5, 0.75, 1, 1.25, 1.5, 2],
104
+ remember: true,
105
+ preview: true,
106
+ menuItems: undefined,
107
+ labels: () => ({}),
108
+ },
109
+ )
110
+
111
+ const emit = defineEmits<{
112
+ (e: "play"): void
113
+ (e: "pause"): void
114
+ (e: "ended"): void
115
+ (e: "error", error: { cause: VideoErrorCause; message: string }): void
116
+ }>()
117
+
118
+ const text = useLabels(
119
+ "videoPlayer",
120
+ {
121
+ player: "Video player — Space plays, arrows seek, F fullscreen",
122
+ play: "Play",
123
+ pause: "Pause",
124
+ replay: "Play again",
125
+ mute: "Mute",
126
+ unmute: "Unmute",
127
+ volume: "Volume",
128
+ speed: "Speed",
129
+ normalSpeed: "Normal",
130
+ fullscreen: "Full screen",
131
+ exitFullscreen: "Exit full screen",
132
+ pip: "Picture in picture",
133
+ exitPip: "Exit picture in picture",
134
+ captions: "Captions",
135
+ seek: "Seek",
136
+ failed: "This video couldn't be loaded.",
137
+ unsupported: "This browser can't play this video's format.",
138
+ /** `{n}` is replaced by seconds. */
139
+ back: "−{n} s",
140
+ forward: "+{n} s",
141
+ /** `{n}` is replaced by seconds. */
142
+ rewind: "Back {n} seconds",
143
+ fastForward: "Forward {n} seconds",
144
+ /** `{time}` is replaced by the playhead's time. */
145
+ menuAt: "At {time}",
146
+ copyTime: "Copy time",
147
+ copyFrame: "Copy frame",
148
+ copiedTime: "Time copied",
149
+ copiedFrame: "Frame copied",
150
+ copyFailed: "Couldn't copy",
151
+ },
152
+ () => props.labels,
153
+ )
154
+
155
+ const rootEl = ref<HTMLElement | null>(null)
156
+ const videoEl = ref<HTMLVideoElement | null>(null)
157
+
158
+ const media = useVideoMedia(videoEl, {
159
+ src: () => props.src,
160
+ remember: () => props.remember,
161
+ onError: (cause) => emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed }),
162
+ })
163
+ const { duration, time, playing, wantsPlay, waiting, ended, muted, rate, buffered, failure, size } = media
164
+ const ready = computed(() => duration.value > 0 && !failure.value)
165
+ const started = ref(false)
166
+ const widest = computed(() => widestClipTime(duration.value))
167
+
168
+ let prepared = ""
169
+ watch(ready, (ok) => {
170
+ if (!ok || prepared === props.src) return
171
+ prepared = props.src
172
+ if (props.startAt > 0) media.seek(props.startAt)
173
+ if (props.autoplay) media.play()
174
+ })
175
+ watch(() => props.src, () => {
176
+ started.value = false
177
+ })
178
+ watch(playing, (on) => {
179
+ if (on) {
180
+ started.value = true
181
+ emit("play")
182
+ } else emit("pause")
183
+ })
184
+ watch(ended, (on) => {
185
+ if (on) emit("ended")
186
+ })
187
+
188
+ /* ---------- controls coming and going ---------- */
189
+
190
+ const HIDE_AFTER_MS = 2500
191
+ const awake = ref(true)
192
+ const menuOpen = ref(false)
193
+ const overControls = ref(false)
194
+ const scrubbing = ref(false)
195
+ let sleepTimer: ReturnType<typeof setTimeout> | undefined
196
+ const showControls = computed(
197
+ () => !playing.value || awake.value || menuOpen.value || overControls.value || scrubbing.value || !!failure.value,
198
+ )
199
+ function wake() {
200
+ awake.value = true
201
+ clearTimeout(sleepTimer)
202
+ sleepTimer = setTimeout(() => (awake.value = false), HIDE_AFTER_MS)
203
+ }
204
+ watch(playing, (on) => on && wake())
205
+
206
+ /* A word over the picture for a key that has nothing else to show (a skip, the volume, the speed). */
207
+ const osd = ref("")
208
+ let osdTimer: ReturnType<typeof setTimeout> | undefined
209
+ function say(message: string) {
210
+ osd.value = message
211
+ clearTimeout(osdTimer)
212
+ osdTimer = setTimeout(() => (osd.value = ""), 700)
213
+ }
214
+
215
+ /* ---------- actions ---------- */
216
+
217
+ /* A press that plays or pauses is answered in the middle of the picture too (VideoFlash). */
218
+ const flash = ref<{ icon: string; n: number } | null>(null)
219
+ function togglePlayback() {
220
+ if (!ready.value) return
221
+ media.toggle()
222
+ flash.value = { icon: wantsPlay.value ? "lucide:play" : "lucide:pause", n: (flash.value?.n ?? 0) + 1 }
223
+ }
224
+
225
+ /**
226
+ * How far a skip goes (the buttons, J and L, a double tap at a side) and an
227
+ * arrow, by the video's length: ten seconds through a six-second clip only
228
+ * ever lands on its start or its end, and five per arrow skips most of it.
229
+ */
230
+ const SKIP = computed(() => (duration.value >= 30 ? 10 : duration.value >= 10 ? 5 : 2))
231
+ const NUDGE = computed(() => (duration.value >= 30 ? 5 : duration.value >= 10 ? 2 : 1))
232
+ function skip(seconds: number) {
233
+ if (!ready.value) return
234
+ media.seek(time.value + seconds)
235
+ say((seconds < 0 ? text.value.back : text.value.forward).replace("{n}", String(Math.abs(seconds))))
236
+ }
237
+ function setVolumeBy(delta: number) {
238
+ media.setVolume(media.volume.value + delta)
239
+ say(`${Math.round(media.volume.value * 100)}%`)
240
+ }
241
+ const rateLabel = (r: number) => (r === 1 ? text.value.normalSpeed : `${r}×`)
242
+ function setRate(r: number) {
243
+ media.setRate(r)
244
+ say(`${r}×`)
245
+ }
246
+ function stepRate(direction: 1 | -1) {
247
+ const list = [...props.rates].sort((a, b) => a - b)
248
+ const at = list.findIndex((r) => r >= rate.value - 1e-6)
249
+ const next = list[Math.min(list.length - 1, Math.max(0, (at < 0 ? list.length - 1 : at) + direction))]
250
+ if (next !== undefined) setRate(next)
251
+ }
252
+
253
+ const { copy } = useClipboard()
254
+ const { error: toastError } = useToast()
255
+ function copyTime() {
256
+ void copy(formatClipTime(time.value, "fixed"), { toast: text.value.copiedTime, errorToast: text.value.copyFailed })
257
+ }
258
+ async function copyFrame() {
259
+ const blob = await media.captureFrame()
260
+ if (blob) void copy(blob, { toast: text.value.copiedFrame, errorToast: text.value.copyFailed })
261
+ else toastError(text.value.copyFailed)
262
+ }
263
+
264
+ /* ---------- fullscreen, picture in picture, captions ---------- */
265
+
266
+ type FullscreenHost = HTMLElement & { webkitRequestFullscreen?: () => void }
267
+ type FullscreenDoc = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void }
268
+ type AppleVideo = HTMLVideoElement & {
269
+ webkitEnterFullscreen?: () => void
270
+ webkitSupportsPresentationMode?: (mode: string) => boolean
271
+ webkitSetPresentationMode?: (mode: string) => void
272
+ webkitPresentationMode?: string
273
+ }
274
+
275
+ const fullscreen = ref(false)
276
+ const canFullscreen = ref(false)
277
+ const pip = ref(false)
278
+ const canPip = ref(false)
279
+ const hasCaptions = ref(false)
280
+ const captionsOn = ref(false)
281
+
282
+ /*
283
+ * While the player fills the screen the browser shows nothing outside it, so
284
+ * the kit's menus and tips, which open at the page's body, would be hidden.
285
+ * They open inside the player then.
286
+ */
287
+ const outer = useView()
288
+ provideView(
289
+ computed(() => {
290
+ const root = rootEl.value
291
+ if (!fullscreen.value || !root) return outer.value
292
+ return { window: nodeWindow(root), document: root.ownerDocument, portal: root }
293
+ }),
294
+ )
295
+
296
+ function syncFullscreen() {
297
+ const doc = rootEl.value?.ownerDocument as FullscreenDoc | undefined
298
+ fullscreen.value = !!doc && (doc.fullscreenElement ?? doc.webkitFullscreenElement) === rootEl.value
299
+ }
300
+ function toggleFullscreen() {
301
+ const root = rootEl.value as FullscreenHost | null
302
+ const video = videoEl.value as AppleVideo | null
303
+ if (!root || !video) return
304
+ const doc = root.ownerDocument as FullscreenDoc
305
+ if (fullscreen.value) {
306
+ if (doc.exitFullscreen) void doc.exitFullscreen().catch(() => {})
307
+ else doc.webkitExitFullscreen?.()
308
+ } else if (root.requestFullscreen) void root.requestFullscreen().catch(() => {})
309
+ else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen()
310
+ else video.webkitEnterFullscreen?.()
311
+ }
312
+
313
+ function syncPip() {
314
+ const video = videoEl.value as AppleVideo | null
315
+ if (!video) return
316
+ pip.value = video.ownerDocument.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture"
317
+ }
318
+ function togglePip() {
319
+ const video = videoEl.value as AppleVideo | null
320
+ if (!video) return
321
+ if (!video.requestPictureInPicture && video.webkitSetPresentationMode) {
322
+ video.webkitSetPresentationMode(pip.value ? "inline" : "picture-in-picture")
323
+ } else if (pip.value) void video.ownerDocument.exitPictureInPicture().catch(() => {})
324
+ else void video.requestPictureInPicture().catch(() => {})
325
+ }
326
+
327
+ function captionTrack(): TextTrack | null {
328
+ const tracks = videoEl.value?.textTracks
329
+ if (!tracks) return null
330
+ for (let i = 0; i < tracks.length; i++) {
331
+ const track = tracks[i]!
332
+ if (track.kind === "subtitles" || track.kind === "captions") return track
333
+ }
334
+ return null
335
+ }
336
+ function syncCaptions() {
337
+ const track = captionTrack()
338
+ hasCaptions.value = !!track
339
+ captionsOn.value = track?.mode === "showing"
340
+ }
341
+ function toggleCaptions() {
342
+ const track = captionTrack()
343
+ if (!track) return
344
+ track.mode = track.mode === "showing" ? "disabled" : "showing"
345
+ syncCaptions()
346
+ }
347
+
348
+ /* ---------- seek bar ---------- */
349
+
350
+ const barEl = ref<HTMLElement | null>(null)
351
+ /** Fraction of the bar under the pointer, or null when it is not over it. */
352
+ const hover = ref<number | null>(null)
353
+ const pct = (t: number) => `${duration.value ? (t / duration.value) * 100 : 0}%`
354
+
355
+ function fractionAt(clientX: number): number {
356
+ const box = barEl.value?.getBoundingClientRect()
357
+ if (!box || !box.width) return 0
358
+ return Math.min(1, Math.max(0, (clientX - box.left) / box.width))
359
+ }
360
+ function onBarDown(event: PointerEvent) {
361
+ if (!ready.value || event.button !== 0) return
362
+ const wasPlaying = media.wantsPlay.value
363
+ media.pause({ instant: true })
364
+ scrubbing.value = true
365
+ media.seek(fractionAt(event.clientX) * duration.value)
366
+ startPointerDrag(event, {
367
+ move: (e) => {
368
+ hover.value = fractionAt(e.clientX)
369
+ media.seek(hover.value * duration.value)
370
+ },
371
+ end: () => {
372
+ scrubbing.value = false
373
+ if (wasPlaying) media.play()
374
+ },
375
+ })
376
+ }
377
+ function onBarHover(event: PointerEvent) {
378
+ if (!ready.value) return
379
+ hover.value = fractionAt(event.clientX)
380
+ if (props.preview && event.pointerType === "mouse") wantPreview(hover.value * duration.value)
381
+ }
382
+
383
+ /* The still under the pointer: a second, hidden element, mounted the first time someone hovers. */
384
+ const PREVIEW = { width: 160, height: 90 }
385
+ const samplerEl = ref<HTMLVideoElement | null>(null)
386
+ const previewWanted = ref(false)
387
+ const previewFrame = shallowRef<HTMLCanvasElement | null>(null)
388
+ let sampler: FrameSampler | null = null
389
+ let previewKey = ""
390
+ function wantPreview(t: number) {
391
+ if (!previewWanted.value) {
392
+ previewWanted.value = true
393
+ void nextTick(() => {
394
+ if (!samplerEl.value) return
395
+ sampler = new FrameSampler(samplerEl.value, () => PREVIEW, (key, canvas) => {
396
+ if (key === previewKey) previewFrame.value = canvas
397
+ })
398
+ wantPreview(t)
399
+ })
400
+ return
401
+ }
402
+ // A still a second (or a two-hundredth of a long video) apart is the same still: the cache answers it.
403
+ const grain = Math.max(1, duration.value / 200)
404
+ const at = Math.round(t / grain) * grain
405
+ previewKey = String(at)
406
+ const hit = sampler?.cached(previewKey)
407
+ if (hit) previewFrame.value = hit
408
+ else sampler?.want([{ key: previewKey, time: at }])
409
+ }
410
+ function mountPreview(el: Element | null) {
411
+ if (isHTMLElement(el) && previewFrame.value && previewFrame.value.parentElement !== el) el.replaceChildren(previewFrame.value)
412
+ }
413
+ const previewSize = computed(() => {
414
+ const { width, height } = size.value
415
+ return width && height ? { width: PREVIEW.width, height: Math.round((PREVIEW.width * height) / width) } : PREVIEW
416
+ })
417
+
418
+ /* ---------- keys and taps ---------- */
419
+
420
+ function onKeydown(event: KeyboardEvent) {
421
+ const target = event.target as HTMLElement
422
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
423
+ if (target.closest("input, textarea, select, [contenteditable='true']")) return
424
+ // The volume slider and the seek bar's own keys stay theirs.
425
+ if (target.getAttribute("role") === "slider" && target !== barEl.value) return
426
+ const key = event.key
427
+ const actions: Record<string, () => void> = {
428
+ " ": togglePlayback,
429
+ k: togglePlayback,
430
+ j: () => skip(-SKIP.value),
431
+ l: () => skip(SKIP.value),
432
+ ArrowLeft: () => skip(-NUDGE.value),
433
+ ArrowRight: () => skip(NUDGE.value),
434
+ ArrowUp: () => setVolumeBy(0.1),
435
+ ArrowDown: () => setVolumeBy(-0.1),
436
+ m: () => media.setMuted(!muted.value),
437
+ f: () => toggleFullscreen(),
438
+ c: () => toggleCaptions(),
439
+ Home: () => media.seek(0),
440
+ End: () => media.seek(duration.value),
441
+ "<": () => stepRate(-1),
442
+ ">": () => stepRate(1),
443
+ }
444
+ const digit = /^[0-9]$/.test(key) ? () => media.seek((Number(key) / 10) * duration.value) : undefined
445
+ const run = actions[key.length === 1 ? key.toLowerCase() : key] ?? actions[key] ?? digit
446
+ // A button's own Space and Enter stay the button's.
447
+ if (!run || (key === " " && target.tagName === "BUTTON") || (!ready.value && key !== " ")) return
448
+ event.preventDefault()
449
+ run()
450
+ wake()
451
+ }
452
+
453
+ /*
454
+ * A mouse click plays and pauses, a double click fills the screen. On a touch
455
+ * screen a tap shows or hides the controls, and a double tap at either side
456
+ * skips ten seconds, as phone players do.
457
+ */
458
+ let lastTap = { at: 0, side: 0 }
459
+ let tapTimer: ReturnType<typeof setTimeout> | undefined
460
+ function onPictureUp(event: PointerEvent) {
461
+ if (event.pointerType !== "touch" || !ready.value) return
462
+ const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
463
+ const x = (event.clientX - box.left) / box.width
464
+ const side = x < 1 / 3 ? -1 : x > 2 / 3 ? 1 : 0
465
+ const now = event.timeStamp
466
+ if (now - lastTap.at < 300 && side !== 0 && side === lastTap.side) {
467
+ clearTimeout(tapTimer)
468
+ skip(side * SKIP.value)
469
+ lastTap = { at: 0, side: 0 }
470
+ return
471
+ }
472
+ lastTap = { at: now, side }
473
+ clearTimeout(tapTimer)
474
+ tapTimer = setTimeout(() => {
475
+ if (showControls.value && playing.value) awake.value = false
476
+ else wake()
477
+ }, 300)
478
+ }
479
+ function onPictureClick(event: MouseEvent) {
480
+ if ((event as PointerEvent).pointerType === "touch") return
481
+ togglePlayback()
482
+ }
483
+
484
+ /* ---------- menus ---------- */
485
+
486
+ const rateItems = computed<ContextMenuItemDef[]>(() =>
487
+ props.rates.map((r) => ({ label: rateLabel(r), icon: Math.abs(r - rate.value) < 1e-6 ? "lucide:check" : undefined, onSelect: () => setRate(r) })),
488
+ )
489
+ const menu = computed<ContextMenuItemDef[]>(() => {
490
+ if (!ready.value) return []
491
+ const items: ContextMenuItemDef[] = [
492
+ { label: wantsPlay.value ? text.value.pause : text.value.play, icon: wantsPlay.value ? "lucide:pause" : "lucide:play", shortcut: "Space", onSelect: () => media.toggle() },
493
+ { label: muted.value ? text.value.unmute : text.value.mute, icon: muted.value ? "lucide:volume-2" : "lucide:volume-x", shortcut: "M", onSelect: () => media.setMuted(!muted.value) },
494
+ { label: text.value.rewind.replace("{n}", String(SKIP.value)), icon: "lucide:rewind", shortcut: "J", separatorBefore: true, onSelect: () => skip(-SKIP.value) },
495
+ { label: text.value.fastForward.replace("{n}", String(SKIP.value)), icon: "lucide:fast-forward", shortcut: "L", onSelect: () => skip(SKIP.value) },
496
+ { label: text.value.speed, icon: "lucide:gauge", separatorBefore: true, children: rateItems.value },
497
+ ]
498
+ if (hasCaptions.value) items.push({ label: text.value.captions, icon: "lucide:captions", shortcut: "C", checked: captionsOn.value, onSelect: toggleCaptions })
499
+ if (canPip.value) items.push({ label: pip.value ? text.value.exitPip : text.value.pip, icon: "lucide:picture-in-picture-2", onSelect: togglePip })
500
+ if (canFullscreen.value) items.push({ label: fullscreen.value ? text.value.exitFullscreen : text.value.fullscreen, icon: fullscreen.value ? "lucide:minimize" : "lucide:maximize", shortcut: "F", onSelect: toggleFullscreen })
501
+ items.push(
502
+ { label: text.value.copyTime, icon: "lucide:clock", separatorBefore: true, onSelect: copyTime },
503
+ { label: text.value.copyFrame, icon: "lucide:image", onSelect: () => void copyFrame() },
504
+ )
505
+ const extra = props.menuItems?.() ?? []
506
+ return [...items, ...extra.map((item, i) => (i === 0 ? { ...item, separatorBefore: true } : item))]
507
+ })
508
+ const menuTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(time.value, "fixed")))
509
+
510
+ /* ---------- mount ---------- */
511
+
512
+ onMounted(() => {
513
+ const root = rootEl.value as FullscreenHost | null
514
+ const video = videoEl.value as AppleVideo | null
515
+ if (!root || !video) return
516
+ const doc = root.ownerDocument
517
+ canFullscreen.value = !!(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen)
518
+ canPip.value = (!!doc.pictureInPictureEnabled && !video.disablePictureInPicture) || !!video.webkitSupportsPresentationMode?.("picture-in-picture")
519
+ doc.addEventListener("fullscreenchange", syncFullscreen)
520
+ doc.addEventListener("webkitfullscreenchange", syncFullscreen)
521
+ video.textTracks.addEventListener?.("addtrack", syncCaptions)
522
+ video.textTracks.addEventListener?.("change", syncCaptions)
523
+ syncCaptions()
524
+ })
525
+ onBeforeUnmount(() => {
526
+ clearTimeout(sleepTimer)
527
+ clearTimeout(osdTimer)
528
+ clearTimeout(tapTimer)
529
+ sampler?.dispose()
530
+ const doc = rootEl.value?.ownerDocument
531
+ doc?.removeEventListener("fullscreenchange", syncFullscreen)
532
+ doc?.removeEventListener("webkitfullscreenchange", syncFullscreen)
533
+ const stills = samplerEl.value
534
+ if (stills) {
535
+ stills.removeAttribute("src")
536
+ stills.load()
537
+ }
538
+ })
539
+
540
+ defineExpose({
541
+ play: media.play,
542
+ pause: media.pause,
543
+ toggle: togglePlayback,
544
+ seek: media.seek,
545
+ currentTime: () => time.value,
546
+ /** The frame on screen as a PNG, or null where it cannot be read back. */
547
+ captureFrame: media.captureFrame,
548
+ })
549
+
550
+ /* The frame: the root itself, or under `fit` a box inside it sized to the video's proportions. */
551
+ const FRAME = "overflow-hidden rounded-card bg-black"
552
+ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
553
+ props.fit
554
+ ? "group/player relative flex size-full items-center justify-center text-white outline-none select-none [container-type:size] focus-visible:ring-2 focus-visible:ring-ring"
555
+ : `group/player relative block w-full ${FRAME} text-white outline-none select-none focus-visible:ring-2 focus-visible:ring-ring`,
556
+ )
557
+ const ratio = computed(() => (size.value.width && size.value.height ? size.value.width / size.value.height : 16 / 9))
558
+ const aspect = computed(() => (size.value.width && size.value.height ? `${size.value.width} / ${size.value.height}` : "16 / 9"))
559
+ /*
560
+ * Under `fit`, as large as the box allows at the video's proportions, but not
561
+ * past its own size, as the lightbox does with a picture: a 320 × 180 clip
562
+ * blown up to a screen is a smear. Never narrower than the controls need.
563
+ */
564
+ const frameStyle = computed(() => {
565
+ if (!props.fit || fullscreen.value) return undefined
566
+ const natural = size.value.width ? `max(${size.value.width}px, 28rem)` : "100cqw"
567
+ return { width: `min(100cqw, calc(100cqh * ${ratio.value}), ${natural})` }
568
+ })
569
+ </script>
570
+
571
+ <template>
572
+ <div
573
+ ref="rootEl"
574
+ :class="[rootClass, fullscreen ? 'rounded-none' : '', playing && !showControls ? 'cursor-none' : '']"
575
+ v-bind="rest"
576
+ tabindex="0"
577
+ role="region"
578
+ :aria-label="title ?? text.player"
579
+ :data-fullscreen="fullscreen || undefined"
580
+ @keydown="onKeydown"
581
+ @pointermove="wake"
582
+ @focusin="wake"
583
+ >
584
+ <!-- The menu's trigger is a wrapper of its own: reka's as-child puts its ref on the element it wraps. -->
585
+ <LpContextMenu :items="menu" :title="menuTitle" always>
586
+ <!-- A size container, so the bar gives up what does not fit a narrow player (see the controls below). -->
587
+ <div class="@container" :class="fit && !fullscreen ? FRAME : ''" :style="frameStyle" data-lp-video-frame>
588
+ <div class="relative w-full" :class="fullscreen ? 'h-dvh' : ''" :style="fullscreen ? undefined : { aspectRatio: aspect }">
589
+ <video
590
+ ref="videoEl"
591
+ class="absolute inset-0 size-full object-contain"
592
+ :src="src"
593
+ :poster="poster"
594
+ :loop="loop"
595
+ :muted="$props.muted"
596
+ preload="metadata"
597
+ playsinline
598
+ v-on="media.listeners"
599
+ @enterpictureinpicture="syncPip"
600
+ @leavepictureinpicture="syncPip"
601
+ @webkitpresentationmodechanged="syncPip"
602
+ @click="onPictureClick"
603
+ @dblclick="canFullscreen && toggleFullscreen()"
604
+ @pointerup="onPictureUp"
605
+ ><slot /></video>
606
+ <VideoFlash :flash="flash" />
607
+ <video
608
+ v-if="previewWanted"
609
+ ref="samplerEl"
610
+ class="pointer-events-none absolute size-px opacity-0"
611
+ :src="src"
612
+ preload="metadata"
613
+ muted
614
+ playsinline
615
+ aria-hidden="true"
616
+ tabindex="-1"
617
+ />
618
+
619
+ <!-- The big play button: before the first play and after the end, where a click anywhere is not yet obvious. -->
620
+ <Transition v-bind="FADE_SCALE">
621
+ <button
622
+ v-if="ready && (!started || ended) && !wantsPlay"
623
+ type="button"
624
+ class="absolute top-1/2 left-1/2 flex size-16 -translate-1/2 items-center justify-center rounded-full bg-black/45 text-white shadow-lg ring-1 ring-white/20 outline-none transition-[background-color,scale] duration-(--duration-fast) ease-(--ease-emphasized) hover:scale-105 hover:bg-black/60 focus-visible:ring-2 focus-visible:ring-ring active:scale-95 motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
625
+ :aria-label="ended ? text.replay : text.play"
626
+ tabindex="-1"
627
+ @click.stop="togglePlayback"
628
+ >
629
+ <LpIcon :name="ended ? 'lucide:rotate-ccw' : 'lucide:play'" :size="28" :class="ended ? '' : 'translate-x-0.5'" />
630
+ </button>
631
+ </Transition>
632
+
633
+ <Transition v-bind="FADE">
634
+ <div v-if="waiting && playing" class="pointer-events-none absolute inset-0 flex items-center justify-center">
635
+ <LpLoader size="lg" class="text-white" />
636
+ </div>
637
+ </Transition>
638
+
639
+ <Transition v-bind="FADE">
640
+ <span
641
+ v-if="osd"
642
+ :key="osd"
643
+ class="pointer-events-none absolute top-4 left-1/2 -translate-x-1/2 rounded-pill bg-black/55 px-3 py-1 text-sm font-medium tabular-nums text-white"
644
+ aria-live="polite"
645
+ >{{ osd }}</span>
646
+ </Transition>
647
+
648
+ <Transition v-bind="FADE">
649
+ <div
650
+ v-if="failure"
651
+ role="alert"
652
+ class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
653
+ >
654
+ <LpIcon name="lucide:video-off" :size="32" />
655
+ <p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
656
+ </div>
657
+ </Transition>
658
+
659
+ <!--
660
+ The app's own actions over the picture's top right corner (a bin that
661
+ takes the video back out of a dropzone, a download): they come and go
662
+ with the controls, over a shade of their own.
663
+ -->
664
+ <div
665
+ v-if="$slots.actions"
666
+ class="absolute inset-x-0 top-0 flex justify-end gap-0.5 bg-linear-to-b from-black/55 to-transparent px-2 pt-2 pb-8 transition-opacity duration-(--duration-medium) ease-(--ease-emphasized) motion-reduce:transition-none"
667
+ :class="showControls ? '' : 'pointer-events-none opacity-0'"
668
+ @pointerenter="overControls = true"
669
+ @pointerleave="overControls = false"
670
+ >
671
+ <slot name="actions" :button-class="OVERLAY_BUTTON" />
672
+ </div>
673
+
674
+ <!-- Controls, over a shade that keeps them readable on a bright frame. -->
675
+ <div
676
+ class="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/75 via-black/35 to-transparent px-3 pt-12 pb-2 transition-opacity duration-(--duration-medium) ease-(--ease-emphasized) motion-reduce:transition-none"
677
+ :class="showControls && !failure ? '' : 'pointer-events-none opacity-0'"
678
+ data-lp-player-controls
679
+ @pointerenter="overControls = true"
680
+ @pointerleave="overControls = false; hover = null"
681
+ >
682
+ <!-- Seek bar: a taller strip than it looks, so it is easy to catch; it thickens under the pointer. -->
683
+ <div
684
+ ref="barEl"
685
+ class="group/seek relative flex h-4 cursor-pointer touch-none items-center outline-none"
686
+ role="slider"
687
+ tabindex="0"
688
+ :aria-label="text.seek"
689
+ :aria-valuemin="0"
690
+ :aria-valuemax="Math.round(duration * 10) / 10"
691
+ :aria-valuenow="Math.round(time * 10) / 10"
692
+ :aria-valuetext="formatClipTime(time, 'fixed')"
693
+ :aria-disabled="!ready || undefined"
694
+ @pointerdown="onBarDown"
695
+ @pointermove="onBarHover"
696
+ @pointerleave="!scrubbing && (hover = null)"
697
+ >
698
+ <div class="relative h-1 w-full overflow-hidden rounded-pill bg-white/25 transition-[height] duration-(--duration-fast) ease-(--ease-emphasized) group-hover/seek:h-1.5 group-focus-visible/seek:h-1.5 motion-reduce:transition-none">
699
+ <span v-for="(b, i) in buffered" :key="i" class="absolute inset-y-0 bg-white/35" :style="{ left: `${b[0] * 100}%`, width: `${(b[1] - b[0]) * 100}%` }" />
700
+ <span
701
+ v-if="hover !== null"
702
+ class="absolute inset-y-0 left-0 bg-white/25"
703
+ :style="{ width: `${hover * 100}%` }"
704
+ />
705
+ <span class="absolute inset-y-0 left-0 bg-white" :style="{ width: pct(time) }" />
706
+ </div>
707
+ <span
708
+ class="pointer-events-none absolute top-1/2 size-3 -translate-1/2 scale-0 rounded-full bg-white shadow transition-transform duration-(--duration-fast) ease-(--ease-emphasized) group-hover/seek:scale-100 group-focus-visible/seek:scale-100 group-focus-visible/seek:ring-2 group-focus-visible/seek:ring-ring motion-reduce:transition-none"
709
+ :class="scrubbing ? 'scale-100' : ''"
710
+ :style="{ left: pct(time) }"
711
+ />
712
+ <Transition v-bind="FADE">
713
+ <div
714
+ v-if="hover !== null && ready"
715
+ class="pointer-events-none absolute bottom-full mb-2 flex -translate-x-1/2 flex-col items-center gap-1"
716
+ :style="{ left: `clamp(${preview && previewFrame ? previewSize.width / 2 : 28}px, ${hover * 100}%, calc(100% - ${preview && previewFrame ? previewSize.width / 2 : 28}px))` }"
717
+ >
718
+ <div
719
+ v-if="preview && previewFrame"
720
+ :ref="(el) => mountPreview(el as Element | null)"
721
+ class="overflow-hidden rounded-control bg-black shadow-lg ring-1 ring-white/20 [&>canvas]:size-full [&>canvas]:object-cover"
722
+ :style="{ width: `${previewSize.width}px`, height: `${previewSize.height}px` }"
723
+ />
724
+ <span class="rounded-pill bg-black/70 px-2 py-0.5 font-mono text-xs text-white">{{ formatClipTime(hover * duration, "fixed") }}</span>
725
+ </div>
726
+ </Transition>
727
+ </div>
728
+
729
+ <div class="mt-1 flex items-center gap-0.5">
730
+ <LpButton size="sm" variant="ghost" square :class="OVERLAY_BUTTON" :disabled="!ready" :aria-label="wantsPlay ? text.pause : ended ? text.replay : text.play" :title="wantsPlay ? text.pause : text.play" @click="media.toggle()">
731
+ <Transition v-bind="ICON_SWAP">
732
+ <LpIcon :key="wantsPlay ? 'pause' : ended ? 'replay' : 'play'" :name="wantsPlay ? 'lucide:pause' : ended ? 'lucide:rotate-ccw' : 'lucide:play'" :size="16" />
733
+ </Transition>
734
+ </LpButton>
735
+ <!-- On a narrow player the bar sheds, in this order, what the right-click menu and the keys also give: skips, picture in picture, speed. -->
736
+ <LpButton size="sm" variant="ghost" square :class="[OVERLAY_BUTTON, '@max-[26rem]:hidden']" :disabled="!ready" :aria-label="text.rewind.replace('{n}', String(SKIP))" :title="text.rewind.replace('{n}', String(SKIP))" @click="skip(-SKIP)">
737
+ <LpIcon name="lucide:rewind" :size="16" />
738
+ </LpButton>
739
+ <LpButton size="sm" variant="ghost" square :class="[OVERLAY_BUTTON, '@max-[26rem]:hidden']" :disabled="!ready" :aria-label="text.fastForward.replace('{n}', String(SKIP))" :title="text.fastForward.replace('{n}', String(SKIP))" @click="skip(SKIP)">
740
+ <LpIcon name="lucide:fast-forward" :size="16" />
741
+ </LpButton>
742
+ <VideoVolume :media="media" :labels="text" :disabled="!ready" overlay />
743
+ <span class="min-w-0 truncate px-1.5 font-mono text-xs text-white/70">
744
+ <SteadyText class="text-white" :text="formatClipTime(time, 'fixed')" :widest="widest" /> / {{ formatClipTime(duration, "fixed") }}
745
+ </span>
746
+ <span class="flex-1" />
747
+ <Transition v-bind="FADE_SCALE">
748
+ <LpButton v-if="hasCaptions" size="sm" variant="ghost" square :class="[OVERLAY_BUTTON, captionsOn ? 'bg-white/15 text-white' : '']" :aria-label="text.captions" :title="text.captions" :aria-pressed="captionsOn" @click="toggleCaptions">
749
+ <LpIcon name="lucide:captions" :size="16" />
750
+ </LpButton>
751
+ </Transition>
752
+ <slot name="controls" :button-class="OVERLAY_BUTTON" />
753
+ <LpDropdownMenu :items="rateItems" :title="text.speed" @update:open="(open: boolean) => (menuOpen = open)">
754
+ <template #trigger>
755
+ <LpButton size="sm" variant="ghost" :class="['min-w-12 px-2 font-mono text-xs @max-[18rem]:hidden', OVERLAY_BUTTON]" :disabled="!ready" :aria-label="text.speed" :title="text.speed">
756
+ <SteadyText :text="`${rate}×`" :widest="`${Math.max(...rates)}×`.replace(/\d/g, '0')" />
757
+ </LpButton>
758
+ </template>
759
+ </LpDropdownMenu>
760
+ <LpButton v-if="canPip" size="sm" variant="ghost" square :class="[OVERLAY_BUTTON, '@max-[22rem]:hidden']" :disabled="!ready" :aria-label="pip ? text.exitPip : text.pip" :title="pip ? text.exitPip : text.pip" :aria-pressed="pip" @click="togglePip">
761
+ <LpIcon name="lucide:picture-in-picture-2" :size="16" />
762
+ </LpButton>
763
+ <LpButton v-if="canFullscreen" size="sm" variant="ghost" square :class="OVERLAY_BUTTON" :aria-label="fullscreen ? text.exitFullscreen : text.fullscreen" :title="fullscreen ? text.exitFullscreen : text.fullscreen" @click="toggleFullscreen">
764
+ <Transition v-bind="ICON_SWAP">
765
+ <LpIcon :key="fullscreen ? 'min' : 'max'" :name="fullscreen ? 'lucide:minimize' : 'lucide:maximize'" :size="16" />
766
+ </Transition>
767
+ </LpButton>
768
+ </div>
769
+ </div>
770
+ </div>
771
+ </div>
772
+ </LpContextMenu>
773
+ </div>
774
+ </template>