@leavepulse/ui 0.82.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) 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-kRVTuSZd.js → ContextMenuItems-h-apPY2d.js} +1 -1
  4. package/dist/chunks/{DropzoneLinkField-CvMUu8HQ.js → DropzoneLinkField-_Op8b_ee.js} +1 -1
  5. package/dist/chunks/{FieldLeading-CNN_5Dp1.js → FieldLeading-mOMcwNqi.js} +1 -1
  6. package/dist/chunks/{FieldWrapper-BBJPEVHr.js → FieldWrapper-DtT-AIcn.js} +1 -1
  7. package/dist/chunks/{IconCloseButton-C59_etp2.js → IconCloseButton-YmisskyZ.js} +1 -1
  8. package/dist/chunks/{LayoutCanvas-BIY74SUe.js → LayoutCanvas-BrISZEBr.js} +1 -1
  9. package/dist/chunks/{LayoutNode-CTlKlhb-.js → LayoutNode-CwMQ8ACa.js} +1 -1
  10. package/dist/chunks/{LpActionBar-Cowb-28D.js → LpActionBar-CVVC6YbC.js} +1 -1
  11. package/dist/chunks/{LpAddressInput-nTJlUQ6K.js → LpAddressInput-tiuJ-bru.js} +7 -7
  12. package/dist/chunks/{LpAlert-DhZdjekN.js → LpAlert-8-x4H_Nk.js} +1 -1
  13. package/dist/chunks/{LpAppShell-CK9hTgQ1.js → LpAppShell-BW2YMq-A.js} +4 -4
  14. package/dist/chunks/{LpAutocomplete--PaZkQoU.js → LpAutocomplete-BZODIops.js} +4 -4
  15. package/dist/chunks/{LpAvatar-DVCld_L2.js → LpAvatar-BZq4nH53.js} +1 -1
  16. package/dist/chunks/{LpBadge-DXvpxk6z.js → LpBadge-Cz0pSSUu.js} +1 -1
  17. package/dist/chunks/{LpBottomNav-COQiBNUN.js → LpBottomNav-DVdR5Mry.js} +1 -1
  18. package/dist/chunks/{LpBreadcrumbs-Dt1O5G4O.js → LpBreadcrumbs-B2jphAel.js} +1 -1
  19. package/dist/chunks/{LpCalendar-C8Xlukd2.js → LpCalendar-C0_N5LVT.js} +1 -1
  20. package/dist/chunks/{LpCheckbox-Dpybd3oV.js → LpCheckbox-CwqLNdw4.js} +2 -2
  21. package/dist/chunks/{LpCodeBlock-C1Hu5RyT.js → LpCodeBlock-Dcss6bnl.js} +2 -2
  22. package/dist/chunks/{LpCodeDisplay-BVruT3-A.js → LpCodeDisplay-CLms1zDY.js} +4 -4
  23. package/dist/chunks/{LpColorPicker-BK-NRaGK.js → LpColorPicker-CbIY450X.js} +3 -3
  24. package/dist/chunks/{LpConfirmDialog-2CQFKtqU.js → LpConfirmDialog-RmP4z6va.js} +2 -2
  25. package/dist/chunks/{LpConfirmHost-CTkSytVl.js → LpConfirmHost-DheBgRMB.js} +1 -1
  26. package/dist/chunks/{LpContextMenu-DcHPdW-t.js → LpContextMenu-DIYhlap8.js} +2 -2
  27. package/dist/chunks/{LpContextSwitcher-C_nnDbeI.js → LpContextSwitcher-fDA6ERl-.js} +2 -2
  28. package/dist/chunks/{LpCopyButton-De7_elaO.js → LpCopyButton-DSqKFGEb.js} +1 -1
  29. package/dist/chunks/{LpDataList-BjZPfQjO.js → LpDataList-f9UjSx9m.js} +2 -2
  30. package/dist/chunks/{LpDatePicker-wOrHd0fg.js → LpDatePicker-3kDQLVbg.js} +5 -5
  31. package/dist/chunks/{LpDateRangePicker-CVMvvo5t.js → LpDateRangePicker-DUPiTt0_.js} +3 -3
  32. package/dist/chunks/{LpDisclosure-CtQ63LeO.js → LpDisclosure-C5WZjhbQ.js} +1 -1
  33. package/dist/chunks/{LpDrawer-TZBPzyOM.js → LpDrawer-CkYvIGIa.js} +1 -1
  34. package/dist/chunks/{LpDropdownMenu-DL9LgkS8.js → LpDropdownMenu-zSsK2Xra.js} +6 -3
  35. package/dist/chunks/{LpEditable-DuPE2tkl.js → LpEditable-CP7Y_lKg.js} +2 -2
  36. package/dist/chunks/{LpEmptyState-W1U_tKg-.js → LpEmptyState-dsc5x-ya.js} +1 -1
  37. package/dist/chunks/{LpFieldNotice-BLKlYLwu.js → LpFieldNotice-TOTbWvgv.js} +1 -1
  38. package/dist/chunks/{LpFileTree-CEiRIpj1.js → LpFileTree-BPjnOTln.js} +4 -4
  39. package/dist/chunks/{LpFileTreeNode-C0292AU2.js → LpFileTreeNode-_-MiWmie.js} +4 -4
  40. package/dist/chunks/{LpFormField-Bhgi0idH.js → LpFormField-BJwD1iEF.js} +1 -1
  41. package/dist/chunks/{LpHintLayer-D8ZKsvsT.js → LpHintLayer-CmfGnxYu.js} +1 -1
  42. package/dist/chunks/{LpHints-DbIV-wk4.js → LpHints-C2Xvc1Fd.js} +3 -3
  43. package/dist/chunks/{LpIcon-CytpcLmS.js → LpIcon-CoOLz1oJ.js} +1 -1
  44. package/dist/chunks/{LpInfraNode-8cGoY87Q.js → LpInfraNode-CW3qsM-U.js} +2 -2
  45. package/dist/chunks/{LpInput-P2C12X7s.js → LpInput-BVWhkPuN.js} +3 -3
  46. package/dist/chunks/{LpInspector-DjfMa4tD.js → LpInspector-REmoX_es.js} +4 -4
  47. package/dist/chunks/{LpLightbox-BEQtCz0m.js → LpLightbox-C3FFTYP5.js} +65 -20
  48. package/dist/chunks/{LpLogInspector-CyxK1d-V.js → LpLogInspector-Cs8Z_fM0.js} +2 -2
  49. package/dist/chunks/{LpLogViewer-CZrc_HG7.js → LpLogViewer-DQRGe-ZK.js} +6 -6
  50. package/dist/chunks/{LpMapPicker-CbTfFcEu.js → LpMapPicker-CQnQPgKm.js} +1 -1
  51. package/dist/chunks/{LpMediaGallery-C_SwNR4y.js → LpMediaGallery-DkKqTy9P.js} +20 -8
  52. package/dist/chunks/{LpModal-B6Yt0G7E.js → LpModal-DwejR7VP.js} +1 -1
  53. package/dist/chunks/{LpModalNav-CMYWEiUs.js → LpModalNav-D1-dSe6f.js} +3 -3
  54. package/dist/chunks/{LpNotificationBell-BJKPx0X1.js → LpNotificationBell-EKLkN0sp.js} +4 -4
  55. package/dist/chunks/{LpNotificationFeed-orWISVez.js → LpNotificationFeed-Dc-yQ4NH.js} +4 -4
  56. package/dist/chunks/{LpNumberField-C5MeLsoE.js → LpNumberField-DvUjEMp-.js} +3 -3
  57. package/dist/chunks/{LpOtpInput-6SCQ64uK.js → LpOtpInput-xvDO14f4.js} +1 -1
  58. package/dist/chunks/{LpPagination-CkwbdFa9.js → LpPagination-BFZY8dFn.js} +1 -1
  59. package/dist/chunks/{LpPaletteTheme-RWgbWFOz.js → LpPaletteTheme-EBJv0Bwz.js} +2 -2
  60. package/dist/chunks/{LpPasswordInput-DJBRo5lt.js → LpPasswordInput-DRJR3fF1.js} +2 -2
  61. package/dist/chunks/{LpPhoneInput-DcmcJwos.js → LpPhoneInput-BGOOtUoW.js} +3 -3
  62. package/dist/chunks/{LpPie-DmMlhgo2.js → LpPie-Deyn8h5J.js} +2 -2
  63. package/dist/chunks/{LpPipeline-BXcp2shV.js → LpPipeline-9ChjeNh5.js} +2 -2
  64. package/dist/chunks/{LpQuota-CtEiqRg4.js → LpQuota-CNh5eF_p.js} +1 -1
  65. package/dist/chunks/{LpRadioGroup-Ubkk_eSq.js → LpRadioGroup-Br2Nl9Gr.js} +2 -2
  66. package/dist/chunks/{LpRangeCalendar-DCswVYbU.js → LpRangeCalendar-D66Y3U_w.js} +1 -1
  67. package/dist/chunks/{LpRangeSlider-DM1qy5Tp.js → LpRangeSlider-eX-fIlOw.js} +1 -1
  68. package/dist/chunks/{LpRepeater-BMo2Eabn.js → LpRepeater-0XvnlVd_.js} +2 -2
  69. package/dist/chunks/{LpSecretField-SSRGVqRc.js → LpSecretField-1RCooChq.js} +5 -5
  70. package/dist/chunks/{LpSection-C3bTxD7t.js → LpSection-B9gbJJcP.js} +1 -1
  71. package/dist/chunks/{LpSegmented-C1DF6m6A.js → LpSegmented-hIjGzGTo.js} +2 -2
  72. package/dist/chunks/{LpSelect-DvDr1VPn.js → LpSelect-DugubwTc.js} +4 -4
  73. package/dist/chunks/{LpSidebar-Bql881r6.js → LpSidebar-DevSoN8W.js} +4 -4
  74. package/dist/chunks/{LpSidebarNav-CKx2KJvr.js → LpSidebarNav-DwQQ3KXe.js} +3 -3
  75. package/dist/chunks/{LpSidebarRow-BMOL9kOG.js → LpSidebarRow-CAGgtJCS.js} +3 -3
  76. package/dist/chunks/{LpSlider-DsSMe4b5.js → LpSlider-1fHpVxpB.js} +1 -1
  77. package/dist/chunks/{LpStat-CQYYDMS6.js → LpStat-Cm9-FaWQ.js} +2 -2
  78. package/dist/chunks/{LpStatus-g5nSJfU3.js → LpStatus-CE8FSAT1.js} +1 -1
  79. package/dist/chunks/{LpStepMark-seVNOuNJ.js → LpStepMark-BWSlV69R.js} +1 -1
  80. package/dist/chunks/{LpStepper-D6qEXihZ.js → LpStepper-1D0ZA7Nx.js} +1 -1
  81. package/dist/chunks/{LpSwitch-DM5CVm31.js → LpSwitch-D_kvE0oQ.js} +1 -1
  82. package/dist/chunks/{LpTabFace-ZWwelqRU.js → LpTabFace-CpbFKi-N.js} +2 -2
  83. package/dist/chunks/{LpTableFilter-CWU9Dmk-.js → LpTableFilter-BYHyCu38.js} +2 -2
  84. package/dist/chunks/{LpTagsInput-C2qVF2ax.js → LpTagsInput-Cqm38t5N.js} +5 -5
  85. package/dist/chunks/{LpTextarea-C3EoV_xv.js → LpTextarea-DaSKBkiV.js} +1 -1
  86. package/dist/chunks/{LpThemeStudio-BIBahaIs.js → LpThemeStudio-BNdfi-Ds.js} +5 -5
  87. package/dist/chunks/{LpThemeSwitcher-D4MqFLGS.js → LpThemeSwitcher-CbC1nqBP.js} +3 -3
  88. package/dist/chunks/{LpTimeField-DPOkbvyP.js → LpTimeField-CCfPCWPT.js} +2 -2
  89. package/dist/chunks/{LpToaster-Dj1VKxFv.js → LpToaster-BhlI7h-h.js} +2 -2
  90. package/dist/chunks/{LpTopologyCanvas-Bspzwm7x.js → LpTopologyCanvas-DlKbu7E5.js} +4 -4
  91. package/dist/chunks/{LpTour-CwpVdVN-.js → LpTour-CSLerA1B.js} +1 -1
  92. package/dist/chunks/{LpTruncate-JSaIW25q.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-RVcP436Z.js → LpWindow-YnF_knSj.js} +2 -2
  96. package/dist/chunks/{LpWorkspaceSwitcher-D0WOz1Fg.js → LpWorkspaceSwitcher-B-5UU6o4.js} +3 -3
  97. package/dist/chunks/{MenuTitle-D8Umab23.js → MenuTitle-BnZbFSwq.js} +1 -1
  98. package/dist/chunks/{OptionContent-DCnX_KRK.js → OptionContent-D5e_eklA.js} +1 -1
  99. package/dist/chunks/{PickerField-ChjY27bm.js → PickerField-KpHiowNI.js} +1 -1
  100. package/dist/chunks/{RevealToggle-MaL7PEjr.js → RevealToggle-CE1hAIJB.js} +1 -1
  101. package/dist/chunks/SteadyText-CwH-kduj.js +25 -0
  102. package/dist/chunks/{TourCard-oXZSpHKb.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 +19 -5
  213. package/dist/components/LpVideoTrim.vue.js +596 -445
  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/lightbox.d.ts +13 -0
  230. package/dist/components/lightbox.js +11 -1
  231. package/dist/components/menuWhen.js +1 -1
  232. package/dist/components/videoBackdrop.d.ts +15 -2
  233. package/dist/components/videoBackdrop.js +8 -2
  234. package/dist/components/videoMotion.d.ts +28 -0
  235. package/dist/components/videoMotion.js +26 -0
  236. package/dist/components/videoTrim.d.ts +2 -9
  237. package/dist/components/videoTrim.js +0 -6
  238. package/dist/composables/useVideoMedia.d.ts +74 -0
  239. package/dist/composables/useVideoMedia.js +291 -0
  240. package/dist/icons-all.js +1 -1
  241. package/dist/index.d.ts +4 -1
  242. package/dist/index.js +173 -168
  243. package/package.json +5 -2
  244. package/src/component-names.ts +1 -0
  245. package/src/components/LpDropdownMenu.vue +4 -0
  246. package/src/components/LpLightbox.vue +61 -8
  247. package/src/components/LpMediaGallery.vue +19 -2
  248. package/src/components/LpVideoBackdrop.vue +1 -7
  249. package/src/components/LpVideoPlayer.vue +774 -0
  250. package/src/components/LpVideoTrim.vue +437 -295
  251. package/src/components/SteadyText.vue +21 -0
  252. package/src/components/VideoFlash.vue +52 -0
  253. package/src/components/VideoThumb.vue +66 -0
  254. package/src/components/VideoVolume.vue +63 -0
  255. package/src/components/lightbox.ts +23 -0
  256. package/src/components/videoBackdrop.ts +20 -3
  257. package/src/components/videoMotion.ts +38 -0
  258. package/src/components/videoTrim.ts +2 -14
  259. package/src/composables/useVideoMedia.ts +411 -0
  260. package/src/index.ts +4 -1
  261. package/src/kit-icons.ts +1 -1
  262. package/dist/assets/LpVideoTrim-Ci6IIHW0.css +0 -8
  263. package/dist/chunks/icons-BOdqDUii.js +0 -59
@@ -24,24 +24,33 @@ export default { inheritAttrs: false }
24
24
  * handle let go, a key, a typed time), for the save.
25
25
  * - `maxClip` and `minClip` bound the length: a dragged handle stops at the
26
26
  * limit, while I, O and a typed time move the other end along.
27
+ * - `#actions` puts the app's buttons over the picture's top right corner (a
28
+ * bin, a download), with `buttonClass` to look like the editor's own.
27
29
  */
28
30
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
29
31
  import { startPointerDrag } from "../composables/pointerDrag"
30
- import { isHTMLElement } from "../composables/realm"
32
+ import { isHTMLElement, nodeWindow } from "../composables/realm"
31
33
  import { useLabels } from "../composables/useLpConfig"
32
34
  import { useMergedAttrs } from "../composables/useMergedClass"
33
35
  import { useResizeObserver } from "../composables/useResizeObserver"
34
36
  import { usePrefersReducedMotion } from "../composables/useReducedMotion"
37
+ import { useVideoMedia } from "../composables/useVideoMedia"
35
38
  import LpButton from "./LpButton.vue"
36
39
  import LpIcon from "./LpIcon.vue"
40
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
37
41
  import LpInput from "./LpInput.vue"
38
- import { formatClipTime } from "./videoBackdrop"
42
+ import SteadyText from "./SteadyText.vue"
43
+ import VideoFlash from "./VideoFlash.vue"
44
+ import VideoVolume from "./VideoVolume.vue"
45
+ import { FADE, FADE_SCALE, ICON_SWAP, OVERLAY_BUTTON } from "./videoMotion"
46
+ import { useClipboard } from "../composables/useClipboard"
47
+ import { useToast } from "../composables/useToast"
48
+ import { formatClipTime, widestClipTime } from "./videoBackdrop"
39
49
  import { FrameSampler, stillSpan } from "./videoFrames"
40
50
  import {
41
51
  adjustRegion,
42
52
  containRect,
43
53
  dragEnd,
44
- mediaErrorCause,
45
54
  normalizeRange,
46
55
  parseClipTime,
47
56
  regionBetween,
@@ -74,11 +83,15 @@ const props = withDefaults(
74
83
  region?: FrameRegion | null
75
84
  /** Frames per second, for the size of a frame step. */
76
85
  frameRate?: number
86
+ /** More items for the right-click menu, given the time it was opened at (under the pointer on the timeline, the playhead elsewhere). */
87
+ menuItems?: (at: number) => ContextMenuItemDef[]
77
88
  disabled?: boolean
78
89
  labels?: Partial<Record<
79
90
  | "play" | "pause" | "stepBack" | "stepForward" | "mute" | "unmute" | "zoomIn" | "zoomOut" | "zoomFit"
80
91
  | "start" | "end" | "position" | "length" | "left" | "overview" | "timeline"
81
- | "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime",
92
+ | "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime"
93
+ | "menuAt" | "setStart" | "setEnd" | "goStart" | "goEnd" | "zoomHere" | "volume"
94
+ | "copyTime" | "copyFrame" | "copiedTime" | "copiedFrame" | "copyFailed",
82
95
  string
83
96
  >>
84
97
  }>(),
@@ -119,11 +132,24 @@ const text = useLabels(
119
132
  failed: "This video couldn't be loaded.",
120
133
  unsupported: "This browser can't play this video's format.",
121
134
  invalidTime: "Type a time like 1:05.5",
135
+ /** `{time}` is replaced by the time the menu was opened at. */
136
+ menuAt: "At {time}",
137
+ setStart: "Start here",
138
+ setEnd: "End here",
139
+ goStart: "Go to the clip's start",
140
+ goEnd: "Go to the clip's end",
141
+ zoomHere: "Zoom in here",
142
+ volume: "Volume",
143
+ copyTime: "Copy time",
144
+ copyFrame: "Copy frame",
145
+ copiedTime: "Time copied",
146
+ copiedFrame: "Frame copied",
147
+ copyFailed: "Couldn't copy",
122
148
  },
123
149
  () => props.labels,
124
150
  )
125
151
 
126
- const { class: rootClass, attrs: rest } = useMergedAttrs("flex flex-col gap-3 select-none")
152
+ const { class: rootClass, attrs: rest } = useMergedAttrs("flex w-full min-w-0 flex-col gap-3 select-none")
127
153
  const reduced = usePrefersReducedMotion()
128
154
 
129
155
  const videoEl = ref<HTMLVideoElement | null>(null)
@@ -131,12 +157,14 @@ const samplerEl = ref<HTMLVideoElement | null>(null)
131
157
  const stageEl = ref<HTMLElement | null>(null)
132
158
  const trackEl = ref<HTMLElement | null>(null)
133
159
 
134
- const duration = ref(0)
135
- const failure = ref<VideoTrimErrorCause | null>(null)
136
- const time = ref(0)
137
- const playing = ref(false)
138
- const muted = ref(false)
139
- const videoSize = ref({ width: 0, height: 0 })
160
+ const media = useVideoMedia(videoEl, {
161
+ src: () => props.src,
162
+ onFrame: followPlayback,
163
+ onError: (cause) => emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed }),
164
+ })
165
+ const { duration, failure, time, playing, wantsPlay, muted, size: videoSize, seek } = media
166
+ /* Every pause the editor makes itself is for a seek, a step or a drag, where the frame must be exact: no easing. */
167
+ const pause = () => media.pause({ instant: true })
140
168
  const stageSize = ref({ width: 0, height: 0 })
141
169
  const trackWidth = ref(0)
142
170
  const ready = computed(() => duration.value > 0 && !failure.value)
@@ -177,112 +205,26 @@ function put(next: TimeRange, commit: boolean) {
177
205
 
178
206
  const clipLength = computed(() => local.value[1] - local.value[0])
179
207
 
180
- /* ---------- media ---------- */
181
-
182
- let seekTarget: number | null = null
183
- let seekBusy = false
184
- /*
185
- * One seek at a time, the latest wins: a drag asks for a position each pointer
186
- * move, and WebKitGTK's pipeline stalls on a seek started inside another.
187
- */
188
- function seek(t: number) {
189
- const video = videoEl.value
190
- const target = Math.min(Math.max(0, t), duration.value || t)
191
- time.value = target
192
- if (!video) return
193
- if (seekBusy) {
194
- seekTarget = target
195
- return
196
- }
197
- seekBusy = true
198
- video.currentTime = target
199
- }
200
- function onSeeked() {
201
- seekBusy = false
202
- if (seekTarget !== null) {
203
- const next = seekTarget
204
- seekTarget = null
205
- seek(next)
206
- } else if (videoEl.value && !playing.value) time.value = videoEl.value.currentTime
207
- }
208
-
209
- let probingDuration = false
210
- function onMetadata() {
211
- const video = videoEl.value
212
- if (!video) return
213
- videoSize.value = { width: video.videoWidth, height: video.videoHeight }
214
- if (Number.isFinite(video.duration)) {
215
- duration.value = video.duration
216
- return
217
- }
218
- // A MediaRecorder WebM carries no duration until its end has been seen: seek far past it once to learn it.
219
- probingDuration = true
220
- video.currentTime = 1e101
221
- }
222
- function onDurationChange() {
223
- const video = videoEl.value
224
- if (!video || !Number.isFinite(video.duration) || !video.duration) return
225
- duration.value = video.duration
226
- if (probingDuration) {
227
- probingDuration = false
228
- video.currentTime = 0
229
- }
230
- }
231
- async function onError() {
232
- const video = videoEl.value
233
- if (!video?.error) return
234
- const src = props.src
235
- let cause = mediaErrorCause(video.error)
236
- // Chromium answers a 404, or a login page served in place of an expired session's video, with "format not supported" too.
237
- if (cause === "unsupported") cause = await probeSource(src)
238
- if (src !== props.src) return
239
- failure.value = cause
240
- emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed })
241
- }
242
- /* One byte of the address: whether it answers with media at all. Unknown (another origin without CORS) stays "unsupported". */
243
- async function probeSource(src: string): Promise<VideoTrimErrorCause> {
244
- try {
245
- const res = await fetch(src, { headers: { Range: "bytes=0-0" }, credentials: "same-origin" })
246
- void res.body?.cancel()
247
- const type = res.headers.get("content-type") ?? ""
248
- return res.ok && !/^text\/|json|xml/.test(type) ? "unsupported" : "load"
249
- } catch {
250
- return "unsupported"
251
- }
252
- }
208
+ /* ---------- playback ---------- */
253
209
 
254
210
  /** Playback that began inside the range ends at its end, so Play previews the clip. */
255
211
  let stopAtEnd = false
256
- let frame = 0
257
- function tick() {
258
- frame = 0
259
- const video = videoEl.value
260
- if (!video || !playing.value) return
261
- time.value = video.currentTime
262
- if (stopAtEnd && video.currentTime >= local.value[1]) {
263
- video.pause()
212
+ function followPlayback() {
213
+ if (stopAtEnd && time.value >= local.value[1]) {
214
+ pause()
264
215
  seek(local.value[1])
265
216
  return
266
217
  }
267
218
  if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value, 0.05)
268
- frame = requestAnimationFrame(tick)
269
- }
270
- function onPlay() {
271
- playing.value = true
272
- if (!frame) frame = requestAnimationFrame(tick)
273
- }
274
- function onPause() {
275
- playing.value = false
276
- if (frame) cancelAnimationFrame(frame)
277
- frame = 0
278
- if (videoEl.value && !seekBusy) time.value = videoEl.value.currentTime
279
219
  }
280
220
 
221
+ /* A press that plays or pauses is answered in the middle of the picture too (VideoFlash). */
222
+ const flash = ref<{ icon: string; n: number } | null>(null)
281
223
  function togglePlay() {
282
- const video = videoEl.value
283
- if (!video || off.value) return
284
- if (!video.paused) {
285
- video.pause()
224
+ if (!videoEl.value || off.value) return
225
+ flash.value = { icon: wantsPlay.value ? "lucide:pause" : "lucide:play", n: (flash.value?.n ?? 0) + 1 }
226
+ if (wantsPlay.value) {
227
+ media.pause()
286
228
  return
287
229
  }
288
230
  const [a, b] = local.value
@@ -290,10 +232,7 @@ function togglePlay() {
290
232
  if (from >= b - 1e-3 && from <= b + 1e-3) from = a
291
233
  stopAtEnd = from >= a - 1e-3 && from < b
292
234
  if (from !== time.value) seek(from)
293
- void video.play().catch(() => {})
294
- }
295
- function pause() {
296
- videoEl.value?.pause()
235
+ media.play()
297
236
  }
298
237
  function step(seconds: number) {
299
238
  if (off.value) return
@@ -302,12 +241,29 @@ function step(seconds: number) {
302
241
  if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
303
242
  }
304
243
  function toggleMute() {
305
- muted.value = !muted.value
306
- if (videoEl.value) videoEl.value.muted = muted.value
244
+ media.setMuted(!muted.value)
307
245
  }
308
246
 
309
247
  const frameStep = computed(() => 1 / Math.max(1, props.frameRate))
310
248
 
249
+ /* The window the component renders in: an LpFrame's own, inside one. */
250
+ const frameWindow = () => nodeWindow(trackEl.value ?? videoEl.value!)
251
+
252
+ /*
253
+ * Positions on the timeline glide to a value set from elsewhere and follow at
254
+ * once while something moves them continuously: the same split as the kit's
255
+ * sliders (SLIDER_GLIDE), over `left` and `width` here.
256
+ */
257
+ const TRACK_GLIDE =
258
+ "[&_[data-glide]]:transition-[left,width] [&_[data-glide]]:duration-(--duration-medium) [&_[data-glide]]:ease-(--ease-emphasized) data-[live]:[&_[data-glide]]:transition-none motion-reduce:[&_[data-glide]]:transition-none"
259
+ /* The first placement, once the length is known, is not a change: it lands where it belongs instead of travelling there from zero. */
260
+ const placed = ref(false)
261
+ watch(ready, (ok) => {
262
+ placed.value = false
263
+ if (ok) frameWindow().requestAnimationFrame(() => frameWindow().requestAnimationFrame(() => (placed.value = true)))
264
+ })
265
+ const live = computed(() => !placed.value || dragging.value !== null || scrubbing.value || pinching.value || wheeling.value || playing.value)
266
+
311
267
  /* ---------- timeline view ---------- */
312
268
 
313
269
  /* The narrowest view: a couple of seconds across still leaves frames a few pixels apart. */
@@ -329,7 +285,12 @@ function timeAt(clientX: number): number {
329
285
  return view.value.start + ((clientX - box.left) / box.width) * span.value
330
286
  }
331
287
 
332
- function zoomBy(factor: number, anchor = time.value) {
288
+ /** Zoom around the playhead while it is in sight, else around the middle: a zoom must never carry the view off to somewhere else. */
289
+ function zoomAnchor(): number {
290
+ const { start, end } = view.value
291
+ return time.value >= start && time.value <= end ? time.value : (start + end) / 2
292
+ }
293
+ function zoomBy(factor: number, anchor = zoomAnchor()) {
333
294
  if (!duration.value) return
334
295
  view.value = zoomView(view.value, anchor, factor, duration.value, MIN_SPAN)
335
296
  }
@@ -337,6 +298,15 @@ function fit() {
337
298
  view.value = { start: 0, end: duration.value }
338
299
  }
339
300
 
301
+ /* A wheel or touchpad sends a stream of small steps: each follows at once, as a drag does, rather than easing after the last. */
302
+ const wheeling = ref(false)
303
+ let wheelTimer: ReturnType<typeof setTimeout> | undefined
304
+ function markWheel() {
305
+ wheeling.value = true
306
+ clearTimeout(wheelTimer)
307
+ wheelTimer = setTimeout(() => (wheeling.value = false), 160)
308
+ }
309
+
340
310
  function onWheel(event: WheelEvent) {
341
311
  if (!ready.value) return
342
312
  const dx = event.shiftKey ? event.deltaY : event.deltaX
@@ -344,12 +314,21 @@ function onWheel(event: WheelEvent) {
344
314
  if (Math.abs(dx) > Math.abs(event.deltaY) || event.shiftKey) {
345
315
  if (!zoomed.value) return
346
316
  event.preventDefault()
317
+ markWheel()
347
318
  view.value = shiftView(view.value, (dx / Math.max(1, trackWidth.value)) * span.value, duration.value)
348
319
  return
349
320
  }
350
321
  event.preventDefault()
351
322
  const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1
352
- zoomBy(Math.exp((-event.deltaY * unit) / (event.ctrlKey ? 100 : 300)), timeAt(event.clientX))
323
+ const delta = event.deltaY * unit
324
+ /*
325
+ * A mouse wheel moves in notches (a line at a time, or a hundred pixels or
326
+ * so): each notch glides to its zoom like a button press. A touchpad or a
327
+ * pinch sends a stream of small steps that the view follows at once.
328
+ */
329
+ const notch = event.deltaMode !== 0 || (!event.ctrlKey && Math.abs(delta) >= 50)
330
+ if (!notch) markWheel()
331
+ zoomBy(Math.exp(-delta / (event.ctrlKey ? 100 : 300)), timeAt(event.clientX))
353
332
  }
354
333
 
355
334
  const marks = computed(() => (ready.value ? ticks(view.value, tickStep(span.value, trackWidth.value)) : []))
@@ -359,9 +338,10 @@ const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
359
338
 
360
339
  const pointers = new Map<number, number>()
361
340
  let pinch: { distance: number; anchor: number } | null = null
341
+ const pinching = ref(false)
362
342
  let edgeFrame = 0
363
343
  let lastX = 0
364
- let scrubbing = false
344
+ const scrubbing = ref(false)
365
345
 
366
346
  function onHandleDown(end: 0 | 1, event: PointerEvent) {
367
347
  if (off.value || event.button !== 0) return
@@ -379,7 +359,7 @@ function onHandleDown(end: 0 | 1, event: PointerEvent) {
379
359
  },
380
360
  end: () => {
381
361
  dragging.value = null
382
- if (edgeFrame) cancelAnimationFrame(edgeFrame)
362
+ if (edgeFrame) frameWindow().cancelAnimationFrame(edgeFrame)
383
363
  edgeFrame = 0
384
364
  emit("change", local.value)
385
365
  },
@@ -399,8 +379,8 @@ function edgeScroll() {
399
379
  const box = trackEl.value?.getBoundingClientRect()
400
380
  if (!box) return
401
381
  const over = lastX < box.left ? lastX - box.left : lastX > box.right ? lastX - box.right : 0
402
- if (!over || (dragging.value === null && !scrubbing)) return
403
- edgeFrame = requestAnimationFrame(() => {
382
+ if (!over || (dragging.value === null && !scrubbing.value)) return
383
+ edgeFrame = frameWindow().requestAnimationFrame(() => {
404
384
  edgeFrame = 0
405
385
  view.value = shiftView(view.value, (Math.max(-60, Math.min(60, over)) / 6 / Math.max(1, box.width)) * span.value, duration.value)
406
386
  if (dragging.value !== null) moveHandle()
@@ -414,16 +394,17 @@ function onTrackDown(event: PointerEvent) {
414
394
  ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
415
395
  pointers.set(event.pointerId, event.clientX)
416
396
  if (pointers.size === 2) {
417
- scrubbing = false
397
+ scrubbing.value = false
418
398
  const [a, b] = [...pointers.values()] as [number, number]
419
399
  pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
400
+ pinching.value = true
420
401
  return
421
402
  }
422
403
  if (pointers.size > 2) return
423
404
  event.preventDefault()
424
405
  trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
425
406
  pause()
426
- scrubbing = true
407
+ scrubbing.value = true
427
408
  lastX = event.clientX
428
409
  seek(timeAt(event.clientX))
429
410
  }
@@ -437,15 +418,18 @@ function onTrackMove(event: PointerEvent) {
437
418
  pinch.distance = distance
438
419
  return
439
420
  }
440
- if (!scrubbing) return
421
+ if (!scrubbing.value) return
441
422
  lastX = event.clientX
442
423
  seek(timeAt(event.clientX))
443
424
  edgeScroll()
444
425
  }
445
426
  function onTrackUp(event: PointerEvent) {
446
427
  pointers.delete(event.pointerId)
447
- if (pointers.size < 2) pinch = null
448
- if (!pointers.size) scrubbing = false
428
+ if (pointers.size < 2) {
429
+ pinch = null
430
+ pinching.value = false
431
+ }
432
+ if (!pointers.size) scrubbing.value = false
449
433
  }
450
434
 
451
435
  /* The overview: press or drag to centre the view there. */
@@ -493,10 +477,44 @@ watch(stills, (list) => {
493
477
  }, 120)
494
478
  })
495
479
 
496
- function mountFrame(key: string, el: Element | null) {
480
+ /*
481
+ * A still in its tile. Until its own frame is taken, the tile shows a copy of
482
+ * the nearest one already taken (from any zoom), so zooming never empties the
483
+ * strip; the real frame then fades in over that stand-in.
484
+ */
485
+ function mountFrame(still: { key: string; time: number }, el: Element | null) {
497
486
  if (!isHTMLElement(el)) return
498
- const canvas = frames.value.get(key) ?? sampler?.cached(key)
499
- if (canvas && canvas.parentElement !== el) el.replaceChildren(canvas)
487
+ const canvas = frames.value.get(still.key) ?? sampler?.cached(still.key)
488
+ if (canvas) {
489
+ if (canvas.parentElement === el) return
490
+ const standIns = [...el.children]
491
+ el.append(canvas)
492
+ if (!standIns.length || reduced.value) standIns.forEach((c) => c.remove())
493
+ else canvas.addEventListener("animationend", () => standIns.forEach((c) => c.remove()), { once: true })
494
+ return
495
+ }
496
+ if (el.children.length) return
497
+ const near = nearestFrame(still.time)
498
+ if (!near) return
499
+ const copy = el.ownerDocument.createElement("canvas")
500
+ copy.width = near.width
501
+ copy.height = near.height
502
+ copy.getContext("2d")?.drawImage(near, 0, 0)
503
+ copy.dataset.lpStandIn = ""
504
+ el.append(copy)
505
+ }
506
+ const frameTimes = new Map<string, number>()
507
+ function nearestFrame(t: number): HTMLCanvasElement | undefined {
508
+ let best: HTMLCanvasElement | undefined
509
+ let gap = Infinity
510
+ for (const [key, canvas] of frames.value) {
511
+ const d = Math.abs((frameTimes.get(key) ?? Infinity) - t)
512
+ if (d < gap) {
513
+ gap = d
514
+ best = canvas
515
+ }
516
+ }
517
+ return best
500
518
  }
501
519
 
502
520
  /* ---------- region ---------- */
@@ -555,6 +573,14 @@ function onKeydown(event: KeyboardEvent) {
555
573
  if (event.defaultPrevented || event.ctrlKey || event.metaKey || target.closest("input, textarea, select, [contenteditable='true']")) return
556
574
  const handle = target.dataset.lpHandle
557
575
  const end = handle === "0" ? 0 : handle === "1" ? 1 : null
576
+ // Another slider inside (the volume) owns its arrows.
577
+ if (target.getAttribute("role") === "slider" && end === null && target.dataset.lpPlayhead === undefined) return
578
+ if ((event.key === "m" || event.key === "M") && !event.altKey) {
579
+ if (!ready.value) return
580
+ event.preventDefault()
581
+ toggleMute()
582
+ return
583
+ }
558
584
  const big = event.shiftKey ? 1 : frameStep.value
559
585
  const delta: Record<string, number> = { ArrowLeft: -big, ArrowRight: big, ArrowDown: -big, ArrowUp: big, PageDown: -10, PageUp: 10 }
560
586
  if (event.key === " " && target.tagName !== "BUTTON") {
@@ -586,7 +612,7 @@ function onKeydown(event: KeyboardEvent) {
586
612
 
587
613
  const drafts = ref<[string | null, string | null]>([null, null])
588
614
  const invalid = ref<[boolean, boolean]>([false, false])
589
- const fieldText = (end: 0 | 1) => drafts.value[end] ?? formatClipTime(local.value[end], true)
615
+ const fieldText = (end: 0 | 1) => drafts.value[end] ?? formatClipTime(local.value[end], "fixed")
590
616
  function onFieldInput(end: 0 | 1, value: string) {
591
617
  drafts.value[end] = value
592
618
  invalid.value[end] = false
@@ -618,6 +644,8 @@ function startSampler() {
618
644
  sampler?.dispose()
619
645
  sampler = samplerEl.value
620
646
  ? new FrameSampler(samplerEl.value, () => ({ width: stillWidth.value, height: STILL_HEIGHT }), (key, canvas) => {
647
+ const still = stills.value.find((s) => s.key === key)
648
+ if (still) frameTimes.set(key, still.time)
621
649
  const next = new Map(frames.value)
622
650
  next.set(key, canvas)
623
651
  if (next.size > 240) next.delete(next.keys().next().value as string)
@@ -629,12 +657,7 @@ function startSampler() {
629
657
  watch(
630
658
  () => props.src,
631
659
  () => {
632
- duration.value = 0
633
- failure.value = null
634
- time.value = 0
635
660
  frames.value = new Map()
636
- seekBusy = false
637
- seekTarget = null
638
661
  void nextTick(startSampler)
639
662
  },
640
663
  )
@@ -643,14 +666,12 @@ onMounted(startSampler)
643
666
  onBeforeUnmount(() => {
644
667
  sampler?.dispose()
645
668
  clearTimeout(stillTimer)
646
- if (frame) cancelAnimationFrame(frame)
647
- if (edgeFrame) cancelAnimationFrame(edgeFrame)
648
- // Stop both streams from fetching on: an element that is merely detached keeps its connection.
649
- for (const el of [videoEl.value, samplerEl.value]) {
650
- if (!el) continue
651
- el.pause()
652
- el.removeAttribute("src")
653
- el.load()
669
+ if (edgeFrame) frameWindow().cancelAnimationFrame(edgeFrame)
670
+ // The stills' element too: one merely detached keeps its connection open.
671
+ const stillsEl = samplerEl.value
672
+ if (stillsEl) {
673
+ stillsEl.removeAttribute("src")
674
+ stillsEl.load()
654
675
  }
655
676
  })
656
677
 
@@ -662,23 +683,99 @@ defineExpose({
662
683
  currentTime: () => time.value,
663
684
  })
664
685
 
665
- const handleText = (t: number) => formatClipTime(t, true)
686
+ /* ---------- right-click ---------- */
687
+
688
+ const { copy } = useClipboard()
689
+ const { error: toastError } = useToast()
690
+
691
+ /* The picture's own menu: what is done to the video and to the frame on screen. */
692
+ const videoMenu = computed<ContextMenuItemDef[]>(() => {
693
+ if (!ready.value || props.disabled) return []
694
+ const items: ContextMenuItemDef[] = [
695
+ { label: wantsPlay.value ? text.value.pause : text.value.play, icon: wantsPlay.value ? "lucide:pause" : "lucide:play", shortcut: "Space", onSelect: togglePlay },
696
+ { label: muted.value ? text.value.unmute : text.value.mute, icon: muted.value ? "lucide:volume-2" : "lucide:volume-x", shortcut: "M", onSelect: toggleMute },
697
+ { label: text.value.stepBack, icon: "lucide:step-back", shortcut: "ArrowLeft", separatorBefore: true, onSelect: () => step(-frameStep.value) },
698
+ { label: text.value.stepForward, icon: "lucide:step-forward", shortcut: "ArrowRight", onSelect: () => step(frameStep.value) },
699
+ { label: text.value.copyTime, icon: "lucide:clock", separatorBefore: true, onSelect: () => void copy(formatClipTime(time.value, "fixed"), { toast: text.value.copiedTime, errorToast: text.value.copyFailed }) },
700
+ { label: text.value.copyFrame, icon: "lucide:image", onSelect: copyFrame },
701
+ ]
702
+ if (props.regionSelect && props.region) items.push({ label: text.value.regionClear, icon: "lucide:x", separatorBefore: true, onSelect: () => emit("update:region", null) })
703
+ return items
704
+ })
705
+ const clockTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(time.value, "fixed")))
706
+
707
+ async function copyFrame() {
708
+ const blob = await media.captureFrame()
709
+ if (blob) void copy(blob, { toast: text.value.copiedFrame, errorToast: text.value.copyFailed })
710
+ else toastError(text.value.copyFailed)
711
+ }
712
+
713
+ /** Where the timeline's menu was opened: the moment under the pointer. */
714
+ const menuAt = ref(0)
715
+ function onTimeContextMenu(event: MouseEvent) {
716
+ const overview = (event.target as Element).closest?.("[data-lp-overview]")
717
+ if (overview) {
718
+ const box = overview.getBoundingClientRect()
719
+ menuAt.value = ((event.clientX - box.left) / Math.max(1, box.width)) * duration.value
720
+ } else menuAt.value = timeAt(event.clientX)
721
+ menuAt.value = Math.min(duration.value, Math.max(0, menuAt.value))
722
+ }
723
+ /* The timeline's menu: what is done at a moment. */
724
+ const timeMenu = computed<ContextMenuItemDef[]>(() => {
725
+ if (!ready.value || props.disabled) return []
726
+ const at = menuAt.value
727
+ const items: ContextMenuItemDef[] = [
728
+ {
729
+ label: text.value.setStart,
730
+ icon: "lucide:arrow-left-to-line",
731
+ shortcut: "I",
732
+ onSelect: () => {
733
+ put(setEnd(local.value, 0, at, limits.value), true)
734
+ seek(at)
735
+ },
736
+ },
737
+ {
738
+ label: text.value.setEnd,
739
+ icon: "lucide:arrow-right-to-line",
740
+ shortcut: "O",
741
+ onSelect: () => {
742
+ put(setEnd(local.value, 1, at, limits.value), true)
743
+ seek(at)
744
+ },
745
+ },
746
+ { label: text.value.goStart, separatorBefore: true, onSelect: () => seek(local.value[0]) },
747
+ { label: text.value.goEnd, onSelect: () => seek(local.value[1]) },
748
+ zoomed.value
749
+ ? { label: text.value.zoomFit, icon: "lucide:maximize-2", separatorBefore: true, onSelect: fit }
750
+ : { label: text.value.zoomHere, icon: "lucide:zoom-in", separatorBefore: true, disabled: !canZoomIn.value, onSelect: () => zoomBy(4, at) },
751
+ ]
752
+ const extra = props.menuItems?.(at) ?? []
753
+ return [...items, ...extra.map((item, i) => (i === 0 ? { ...item, separatorBefore: true } : item))]
754
+ })
755
+ const menuTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(menuAt.value, "fixed")))
756
+
757
+ const handleText = (t: number) => formatClipTime(t, "fixed")
758
+ const widest = computed(() => widestClipTime(duration.value))
759
+ const leftOf = (left: string, max: string) => text.value.left.replace("{left}", left).replace("{max}", max)
666
760
  const leftText = computed(() =>
667
761
  Number.isFinite(limits.value.maxClip)
668
- ? text.value.left
669
- .replace("{left}", formatClipTime(Math.max(0, limits.value.maxClip - clipLength.value), true))
670
- .replace("{max}", formatClipTime(limits.value.maxClip, true))
762
+ ? leftOf(formatClipTime(Math.max(0, limits.value.maxClip - clipLength.value), "fixed"), formatClipTime(limits.value.maxClip, "fixed"))
671
763
  : "",
672
764
  )
765
+ const widestLeft = computed(() => leftOf(widestClipTime(limits.value.maxClip), widestClipTime(limits.value.maxClip)))
673
766
  </script>
674
767
 
675
768
  <template>
676
769
  <div :class="rootClass" v-bind="rest" :data-disabled="disabled || undefined" @keydown="onKeydown">
677
- <!-- The picture. Its own controls are off: the region is drawn on it, and the keys are the editor's. -->
678
- <div
679
- ref="stageEl"
680
- class="relative aspect-video w-full overflow-hidden rounded-card bg-black"
681
- >
770
+ <!--
771
+ The picture. Its own controls are off: the region is drawn on it, and the
772
+ keys are the editor's. A right-click here is about the picture (play, the
773
+ sound, the frame); one on the timeline below is about a moment.
774
+ -->
775
+ <!-- The menu's trigger is a wrapper of its own: reka's as-child puts its ref on the element it wraps, which would take the stage's. -->
776
+ <LpContextMenu :items="videoMenu" :title="clockTitle" always>
777
+ <div>
778
+ <div ref="stageEl" class="relative aspect-video w-full overflow-hidden rounded-card bg-black">
682
779
  <video
683
780
  ref="videoEl"
684
781
  class="absolute inset-0 size-full object-contain"
@@ -686,15 +783,10 @@ const leftText = computed(() =>
686
783
  :poster="poster"
687
784
  preload="metadata"
688
785
  playsinline
689
- :muted="muted"
690
- @loadedmetadata="onMetadata"
691
- @durationchange="onDurationChange"
692
- @seeked="onSeeked"
693
- @play="onPlay"
694
- @pause="onPause"
695
- @error="onError"
786
+ v-on="media.listeners"
696
787
  @click="!regionSelect && togglePlay()"
697
788
  />
789
+ <VideoFlash :flash="flash" />
698
790
  <!-- Stills come from a second element: seeking this one would move the picture under the person. -->
699
791
  <video
700
792
  ref="samplerEl"
@@ -706,54 +798,76 @@ const leftText = computed(() =>
706
798
  aria-hidden="true"
707
799
  tabindex="-1"
708
800
  />
709
- <div
710
- v-if="regionSelect && ready"
711
- class="absolute cursor-crosshair touch-none"
712
- :style="{ left: `${picture.x}px`, top: `${picture.y}px`, width: `${picture.width}px`, height: `${picture.height}px` }"
713
- data-lp-region-layer
714
- @pointerdown="onRegionDown('draw', $event)"
715
- >
801
+ <Transition v-bind="FADE">
716
802
  <div
717
- v-if="shownRegion"
718
- class="absolute cursor-move rounded-inner border-2 border-dashed border-white bg-brand/25 shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] outline-none focus-visible:ring-2 focus-visible:ring-ring"
719
- :style="{ left: `${shownRegion.x * 100}%`, top: `${shownRegion.y * 100}%`, width: `${shownRegion.w * 100}%`, height: `${shownRegion.h * 100}%` }"
720
- tabindex="0"
721
- role="group"
722
- :aria-label="text.region"
723
- data-lp-region
724
- @pointerdown="onRegionDown('move', $event)"
725
- @keydown="onRegionKey"
803
+ v-if="regionSelect && ready"
804
+ class="absolute cursor-crosshair touch-none"
805
+ :style="{ left: `${picture.x}px`, top: `${picture.y}px`, width: `${picture.width}px`, height: `${picture.height}px` }"
806
+ data-lp-region-layer
807
+ @pointerdown="onRegionDown('draw', $event)"
726
808
  >
727
- <span
728
- v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
729
- :key="corner"
730
- class="absolute grid size-6 place-items-center pointer-coarse:size-11"
731
- :class="[
732
- corner[0] === 'n' ? 'top-0 -translate-y-1/2' : 'bottom-0 translate-y-1/2',
733
- corner[1] === 'w' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2',
734
- corner === 'nw' || corner === 'se' ? 'cursor-nwse-resize' : 'cursor-nesw-resize',
735
- ]"
736
- :data-lp-corner="corner"
737
- @pointerdown="onRegionDown(corner, $event)"
738
- >
739
- <span class="size-3 rounded-pill border-2 border-white bg-brand shadow" />
740
- </span>
809
+ <!-- Comes in under the pointer that draws it (no growing in: the drag already does that) and leaves with the dimming around it. -->
810
+ <Transition v-bind="FADE_SCALE" :enter-from-class="draft ? 'opacity-0' : FADE_SCALE.enterFromClass">
811
+ <div
812
+ v-if="shownRegion"
813
+ class="absolute cursor-move rounded-inner border-2 border-dashed border-white/90 bg-white/10 shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] outline-none transition-[left,top,width,height] duration-(--duration-medium) ease-(--ease-emphasized) focus-visible:ring-2 focus-visible:ring-ring data-[live]:transition-none motion-reduce:transition-none"
814
+ :data-live="draft ? '' : undefined"
815
+ :style="{ left: `${shownRegion.x * 100}%`, top: `${shownRegion.y * 100}%`, width: `${shownRegion.w * 100}%`, height: `${shownRegion.h * 100}%` }"
816
+ tabindex="0"
817
+ role="group"
818
+ :aria-label="text.region"
819
+ data-lp-region
820
+ @pointerdown="onRegionDown('move', $event)"
821
+ @keydown="onRegionKey"
822
+ >
823
+ <span
824
+ v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
825
+ :key="corner"
826
+ class="absolute grid size-6 place-items-center pointer-coarse:size-11"
827
+ :class="[
828
+ corner[0] === 'n' ? 'top-0 -translate-y-1/2' : 'bottom-0 translate-y-1/2',
829
+ corner[1] === 'w' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2',
830
+ corner === 'nw' || corner === 'se' ? 'cursor-nwse-resize' : 'cursor-nesw-resize',
831
+ ]"
832
+ :data-lp-corner="corner"
833
+ @pointerdown="onRegionDown(corner, $event)"
834
+ >
835
+ <span class="size-3 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)]" />
836
+ </span>
837
+ </div>
838
+ </Transition>
741
839
  </div>
840
+ </Transition>
841
+ <Transition v-bind="FADE">
842
+ <span
843
+ v-if="regionSelect && ready && !shownRegion && !playing && !disabled"
844
+ class="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-pill bg-black/55 px-3 py-1 text-xs whitespace-nowrap text-white/85"
845
+ >{{ text.regionHint }}</span>
846
+ </Transition>
847
+ <!-- The app's own actions over the picture's top right corner: a bin that takes the video back out of a dropzone, say. -->
848
+ <div v-if="$slots.actions" class="absolute top-2 right-2 flex gap-0.5 rounded-control bg-black/45">
849
+ <slot name="actions" :button-class="OVERLAY_BUTTON" />
742
850
  </div>
743
- <div
744
- v-if="failure"
745
- role="alert"
746
- class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
747
- >
748
- <LpIcon name="lucide:video-off" :size="32" />
749
- <p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
750
- </div>
851
+ <Transition v-bind="FADE">
852
+ <div
853
+ v-if="failure"
854
+ role="alert"
855
+ class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
856
+ >
857
+ <LpIcon name="lucide:video-off" :size="32" />
858
+ <p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
859
+ </div>
860
+ </Transition>
751
861
  </div>
862
+ </div>
863
+ </LpContextMenu>
752
864
 
753
- <!-- Transport and zoom. -->
754
- <div class="flex flex-wrap items-center gap-1">
755
- <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="playing ? text.pause : text.play" :title="playing ? text.pause : text.play" @click="togglePlay">
756
- <LpIcon :name="playing ? 'lucide:pause' : 'lucide:play'" :size="16" />
865
+ <!-- Transport, sound and zoom. One line at every width: a row that wraps re-wraps as its numbers change. -->
866
+ <div class="flex items-center gap-0.5">
867
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="wantsPlay ? text.pause : text.play" :title="wantsPlay ? text.pause : text.play" @click="togglePlay">
868
+ <Transition v-bind="ICON_SWAP">
869
+ <LpIcon :key="wantsPlay ? 'pause' : 'play'" :name="wantsPlay ? 'lucide:pause' : 'lucide:play'" :size="16" />
870
+ </Transition>
757
871
  </LpButton>
758
872
  <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepBack" :title="text.stepBack" @click="step(-frameStep)">
759
873
  <LpIcon name="lucide:step-back" :size="16" />
@@ -761,38 +875,45 @@ const leftText = computed(() =>
761
875
  <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepForward" :title="text.stepForward" @click="step(frameStep)">
762
876
  <LpIcon name="lucide:step-forward" :size="16" />
763
877
  </LpButton>
764
- <span class="px-1.5 font-mono text-xs text-muted tabular-nums" data-lp-clock>
765
- <span class="text-ink">{{ formatClipTime(time, true) }}</span> / {{ formatClipTime(duration, true) }}
878
+ <span class="min-w-0 truncate px-1.5 font-mono text-xs text-muted" data-lp-clock>
879
+ <SteadyText class="text-ink" :text="formatClipTime(time, 'fixed')" :widest="widest" /> / {{ formatClipTime(duration, "fixed") }}
766
880
  </span>
767
- <LpButton size="sm" variant="ghost" square :disabled="!ready" :aria-label="muted ? text.unmute : text.mute" :title="muted ? text.unmute : text.mute" :aria-pressed="muted" @click="toggleMute">
768
- <LpIcon :name="muted ? 'lucide:volume-x' : 'lucide:volume-2'" :size="16" />
769
- </LpButton>
881
+ <VideoVolume :media="media" :labels="text" :disabled="!ready" />
770
882
  <span class="flex-1" />
883
+ <Transition v-bind="FADE_SCALE">
884
+ <LpButton v-if="zoomed" size="sm" variant="ghost" square :disabled="off" :aria-label="text.zoomFit" :title="text.zoomFit" @click="fit">
885
+ <LpIcon name="lucide:maximize-2" :size="16" />
886
+ </LpButton>
887
+ </Transition>
771
888
  <LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomOut" :title="text.zoomOut" @click="zoomBy(0.5)">
772
889
  <LpIcon name="lucide:zoom-out" :size="16" />
773
890
  </LpButton>
774
891
  <LpButton size="sm" variant="ghost" square :disabled="off || !canZoomIn" :aria-label="text.zoomIn" :title="text.zoomIn" @click="zoomBy(2)">
775
892
  <LpIcon name="lucide:zoom-in" :size="16" />
776
893
  </LpButton>
777
- <LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomFit" :title="text.zoomFit" @click="fit">
778
- <LpIcon name="lucide:maximize-2" :size="16" />
779
- </LpButton>
780
894
  </div>
781
895
 
782
- <!-- Ruler, stills, range and playhead. -->
783
- <div class="relative" @wheel="onWheel">
784
- <div class="relative h-4 overflow-hidden text-[10px] text-muted tabular-nums" aria-hidden="true">
896
+ <!--
897
+ Ruler, stills, range and playhead. Positions glide when they are set from
898
+ elsewhere (a key, a typed time, a zoom button) and follow the pointer with
899
+ no delay while it moves them, as the kit's sliders do. Marks and stills
900
+ that come into view or leave it fade rather than blink.
901
+ -->
902
+ <LpContextMenu :items="timeMenu" :title="menuTitle" always>
903
+ <div class="relative" :class="TRACK_GLIDE" :data-live="live || undefined" @wheel="onWheel" @contextmenu="onTimeContextMenu">
904
+ <TransitionGroup tag="div" class="relative h-4 overflow-hidden text-[10px] text-muted tabular-nums" aria-hidden="true" v-bind="FADE">
785
905
  <span
786
906
  v-for="m in marks"
787
907
  :key="m"
788
908
  class="absolute top-0 whitespace-nowrap"
789
909
  :class="xOf(m) < 16 ? '' : xOf(m) > trackWidth - 16 ? '-translate-x-full' : '-translate-x-1/2'"
790
910
  :style="{ left: pct(m) }"
911
+ data-glide
791
912
  >{{ formatClipTime(m, markDigits) }}</span>
792
- </div>
913
+ </TransitionGroup>
793
914
  <div
794
915
  ref="trackEl"
795
- class="relative h-14 touch-none rounded-control bg-surface-soft"
916
+ class="relative h-14 touch-none rounded-control bg-surface-soft transition-opacity duration-(--duration-fast) motion-reduce:transition-none"
796
917
  :class="off ? 'opacity-60' : 'cursor-text'"
797
918
  :aria-label="text.timeline"
798
919
  role="group"
@@ -802,53 +923,63 @@ const leftText = computed(() =>
802
923
  @pointercancel="onTrackUp"
803
924
  >
804
925
  <div class="absolute inset-0 overflow-hidden rounded-control">
805
- <div
806
- v-for="s in stills"
807
- :key="s.key"
808
- :ref="(el) => mountFrame(s.key, el as Element | null)"
809
- class="lp-trim-still absolute inset-y-0 overflow-hidden border-r border-black/20 [&>canvas]:size-full [&>canvas]:object-cover"
810
- :class="{ 'lp-trim-still-in': !reduced }"
811
- :style="{ left: s.left, width: s.width }"
812
- :data-lp-still="frames.has(s.key) ? 'ready' : 'pending'"
813
- />
926
+ <TransitionGroup v-bind="FADE">
927
+ <div
928
+ v-for="s in stills"
929
+ :key="s.key"
930
+ :ref="(el) => mountFrame(s, el as Element | null)"
931
+ class="absolute inset-y-0 overflow-hidden [&>canvas]:absolute [&>canvas]:inset-0 [&>canvas]:size-full [&>canvas]:object-cover"
932
+ :class="{ 'lp-trim-still-in': !reduced }"
933
+ :style="{ left: s.left, width: s.width }"
934
+ :data-lp-still="frames.has(s.key) ? 'ready' : 'pending'"
935
+ data-glide
936
+ />
937
+ </TransitionGroup>
814
938
  <!-- What falls outside the clip is dimmed, not hidden: it is still where the clip could move. -->
815
- <div class="absolute inset-y-0 left-0 bg-black/55" :style="{ width: `${Math.max(0, xOf(local[0]))}px` }" />
816
- <div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left: `${Math.min(trackWidth, Math.max(0, xOf(local[1])))}px` }" />
939
+ <div class="absolute inset-y-0 left-0 bg-black/55" :style="{ width: pct(local[0]) }" data-glide />
940
+ <div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left: pct(local[1]) }" data-glide />
817
941
  </div>
942
+ <!-- The range and its grips arrive with the video's length, faded in rather than popped onto the strip. -->
818
943
  <div
819
- v-if="ready"
820
- class="pointer-events-none absolute inset-y-0 border-y-2 border-brand"
821
- :style="{ left: pct(local[0]), width: `${(clipLength / span) * 100}%` }"
822
- data-lp-range
823
- />
824
- <div
825
- v-for="end in ([0, 1] as const)"
826
- v-show="ready"
827
- :key="end"
828
- class="group/handle absolute inset-y-0 z-10 flex w-6 touch-none items-stretch outline-none pointer-coarse:w-11"
829
- :class="[end === 0 ? 'justify-start' : '-translate-x-full justify-end', off ? '' : 'cursor-ew-resize']"
830
- :style="{ left: pct(local[end]) }"
831
- role="slider"
832
- :tabindex="off ? -1 : 0"
833
- :aria-label="end === 0 ? text.start : text.end"
834
- :aria-valuemin="0"
835
- :aria-valuemax="Math.round(duration * 1000) / 1000"
836
- :aria-valuenow="local[end]"
837
- :aria-valuetext="handleText(local[end])"
838
- :aria-disabled="off || undefined"
839
- :data-lp-handle="end"
840
- @pointerdown="onHandleDown(end, $event)"
944
+ class="pointer-events-none absolute inset-0 transition-opacity duration-(--duration-medium) ease-(--ease-emphasized) motion-reduce:transition-none"
945
+ :class="ready ? '' : 'opacity-0'"
841
946
  >
842
- <span
843
- class="flex w-3 items-center justify-center bg-brand transition-[width] duration-(--duration-fast) group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring motion-reduce:transition-none"
844
- :class="end === 0 ? 'rounded-l-control' : 'rounded-r-control'"
947
+ <div
948
+ class="absolute inset-y-0 rounded-control border-y-2 border-white/85 shadow-[0_0_0_1px_rgb(0_0_0/0.25)]"
949
+ :style="{ left: pct(local[0]), width: `${(clipLength / span) * 100}%` }"
950
+ data-lp-range
951
+ data-glide
952
+ />
953
+ <!-- A grip as on LpChart's overview: narrow and neutral, lit only while it is held, hovered or focused. -->
954
+ <div
955
+ v-for="end in ([0, 1] as const)"
956
+ :key="end"
957
+ class="group/handle absolute inset-y-0 z-10 flex w-6 -translate-x-1/2 touch-none justify-center outline-none pointer-coarse:w-11"
958
+ :class="[off ? '' : 'cursor-ew-resize', ready ? 'pointer-events-auto' : '']"
959
+ :style="{ left: pct(local[end]) }"
960
+ role="slider"
961
+ :tabindex="off ? -1 : 0"
962
+ :aria-label="end === 0 ? text.start : text.end"
963
+ :aria-valuemin="0"
964
+ :aria-valuemax="Math.round(duration * 1000) / 1000"
965
+ :aria-valuenow="local[end]"
966
+ :aria-valuetext="handleText(local[end])"
967
+ :aria-disabled="off || undefined"
968
+ :aria-hidden="ready ? undefined : 'true'"
969
+ :data-lp-handle="end"
970
+ :data-active="dragging === end || undefined"
971
+ data-glide
972
+ @pointerdown="onHandleDown(end, $event)"
845
973
  >
846
- <span class="h-5 w-0.5 rounded-pill bg-white/85" />
847
- </span>
974
+ <span
975
+ class="my-1 flex w-2 items-center justify-center rounded-inner bg-white/90 shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)] transition-[background-color,scale] duration-(--duration-fast) ease-(--ease-emphasized) group-hover/handle:bg-white group-data-active/handle:scale-x-125 group-data-active/handle:bg-white group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring motion-reduce:transition-none motion-reduce:group-data-active/handle:scale-x-100"
976
+ >
977
+ <span class="h-4 w-px rounded-pill bg-black/35" />
978
+ </span>
979
+ </div>
848
980
  </div>
849
981
  <div
850
- v-show="ready && time >= view.start && time <= view.end"
851
- class="absolute -top-4 bottom-0 z-20 w-0 outline-none"
982
+ class="group/playhead absolute -top-4 bottom-0 z-20 w-0 outline-none"
852
983
  :style="{ left: pct(time) }"
853
984
  role="slider"
854
985
  :tabindex="off ? -1 : 0"
@@ -858,64 +989,75 @@ const leftText = computed(() =>
858
989
  :aria-valuenow="Math.round(time * 1000) / 1000"
859
990
  :aria-valuetext="handleText(time)"
860
991
  data-lp-playhead
992
+ :data-glide="playing ? undefined : ''"
861
993
  >
862
- <span class="pointer-events-none absolute inset-y-0 -left-px w-0.5 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.45)]" />
863
- <span class="pointer-events-none absolute -top-0.5 -left-1.5 size-3 rounded-pill border-2 border-white bg-ink shadow [[data-lp-playhead]:focus-visible_&]:ring-2 [[data-lp-playhead]:focus-visible_&]:ring-ring" />
994
+ <!-- Fades out past either edge of a zoomed view instead of vanishing there. -->
995
+ <span
996
+ class="pointer-events-none absolute inset-0 transition-opacity duration-(--duration-fast) motion-reduce:transition-none"
997
+ :class="ready && time >= view.start && time <= view.end ? '' : 'opacity-0'"
998
+ >
999
+ <span class="absolute inset-y-0 -left-px w-0.5 rounded-pill bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]" />
1000
+ <span class="absolute -top-0.5 -left-1.5 size-3 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)] group-focus-visible/playhead:ring-2 group-focus-visible/playhead:ring-ring" />
1001
+ </span>
864
1002
  </div>
865
1003
  </div>
866
- <!-- The whole video, for finding the way back while zoomed in. -->
867
- <div
868
- v-if="zoomed"
869
- class="relative mt-1.5 h-2 cursor-pointer touch-none rounded-pill bg-surface-soft"
870
- :aria-label="text.overview"
871
- role="presentation"
872
- data-lp-overview
873
- @pointerdown="onOverviewDown"
874
- >
875
- <span class="absolute inset-y-0 rounded-pill bg-brand/45" :style="{ left: `${(local[0] / duration) * 100}%`, width: `${(clipLength / duration) * 100}%` }" />
876
- <span class="absolute -inset-y-0.5 rounded-pill border-2 border-ink/70" :style="{ left: `${(view.start / duration) * 100}%`, width: `${(span / duration) * 100}%` }" />
877
- </div>
1004
+ <!-- The whole video while zoomed in, drawn like LpChart's overview: the window it shows, the clip within it. -->
1005
+ <Transition v-bind="FADE">
1006
+ <div
1007
+ v-if="zoomed"
1008
+ class="relative mt-1.5 h-2.5 cursor-pointer touch-none rounded-pill bg-surface-soft"
1009
+ :aria-label="text.overview"
1010
+ role="presentation"
1011
+ data-lp-overview
1012
+ @pointerdown="onOverviewDown"
1013
+ >
1014
+ <span class="absolute inset-y-0.5 rounded-pill bg-line-strong" :style="{ left: `${(local[0] / duration) * 100}%`, width: `${(clipLength / duration) * 100}%` }" data-glide />
1015
+ <span class="absolute inset-y-0 rounded-pill border border-ink/60" :style="{ left: `${(view.start / duration) * 100}%`, width: `${(span / duration) * 100}%` }" data-glide />
1016
+ </div>
1017
+ </Transition>
878
1018
  </div>
1019
+ </LpContextMenu>
879
1020
 
880
- <!-- Exact times, typed. -->
881
- <div class="flex flex-wrap items-end gap-x-3 gap-y-2 text-xs">
882
- <label v-for="end in ([0, 1] as const)" :key="end" class="flex flex-col gap-1">
883
- <span class="text-muted">{{ end === 0 ? text.start : text.end }}</span>
884
- <LpInput
885
- size="sm"
886
- class="w-28 font-mono tabular-nums"
887
- :model-value="fieldText(end)"
888
- :disabled="off"
889
- :invalid="invalid[end]"
890
- :title="invalid[end] ? text.invalidTime : undefined"
891
- inputmode="decimal"
892
- spellcheck="false"
893
- autocomplete="off"
894
- :data-lp-time="end"
895
- @update:model-value="onFieldInput(end, $event)"
896
- @blur="commitField(end)"
897
- @keydown.enter.prevent="commitField(end)"
898
- @keydown.esc="revertField(end)"
899
- />
900
- </label>
901
- <div class="flex flex-col gap-1 pb-1.5">
1021
+ <!-- Exact times, typed, and the clip's length. Every value holds its width, so nothing on the line moves as they change. -->
1022
+ <div class="flex flex-wrap items-end gap-x-3 gap-y-2">
1023
+ <LpInput
1024
+ v-for="end in ([0, 1] as const)"
1025
+ :key="end"
1026
+ size="sm"
1027
+ class="w-28"
1028
+ :label="end === 0 ? text.start : text.end"
1029
+ :model-value="fieldText(end)"
1030
+ :disabled="off"
1031
+ :invalid="invalid[end]"
1032
+ :title="invalid[end] ? text.invalidTime : undefined"
1033
+ inputmode="decimal"
1034
+ spellcheck="false"
1035
+ autocomplete="off"
1036
+ :data-lp-time="end"
1037
+ @update:model-value="onFieldInput(end, $event)"
1038
+ @blur="commitField(end)"
1039
+ @keydown.enter.prevent="commitField(end)"
1040
+ @keydown.esc="revertField(end)"
1041
+ />
1042
+ <div class="flex flex-col gap-0.5 pb-0.5 text-xs">
902
1043
  <span class="text-muted">{{ text.length }}</span>
903
- <span class="font-mono text-sm text-ink tabular-nums" data-lp-length>{{ formatClipTime(clipLength, true) }}</span>
1044
+ <span class="font-mono text-sm text-ink" data-lp-length><SteadyText :text="formatClipTime(clipLength, 'fixed')" :widest="widest" /></span>
904
1045
  </div>
905
- <span v-if="leftText" class="pb-2 text-muted" data-lp-left>{{ leftText }}</span>
1046
+ <Transition v-bind="FADE">
1047
+ <span v-if="leftText" class="pb-1 font-mono text-xs text-muted" data-lp-left><SteadyText :text="leftText" :widest="widestLeft" /></span>
1048
+ </Transition>
906
1049
  <span class="flex-1" />
907
- <template v-if="regionSelect">
908
- <LpButton v-if="region" size="sm" variant="outline" :disabled="disabled" @click="emit('update:region', null)">
1050
+ <Transition v-bind="FADE_SCALE">
1051
+ <LpButton v-if="regionSelect" size="sm" variant="outline" :disabled="disabled || !region" @click="emit('update:region', null)">
909
1052
  <LpIcon name="lucide:x" :size="14" />{{ text.regionClear }}
910
1053
  </LpButton>
911
- <span v-else class="pb-2 text-muted">{{ text.regionHint }}</span>
912
- </template>
1054
+ </Transition>
913
1055
  </div>
914
1056
  </div>
915
1057
  </template>
916
1058
 
917
1059
  <style scoped>
918
- .lp-trim-still-in > :deep(canvas) {
1060
+ .lp-trim-still-in > :deep(canvas:not([data-lp-stand-in])) {
919
1061
  animation: lp-trim-still var(--duration-medium) var(--ease-emphasized);
920
1062
  }
921
1063
  @keyframes lp-trim-still {