@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.
- package/dist/assets/LpVideoTrim-BcGs3Sh0.css +8 -0
- package/dist/assets/VideoFlash-VuPkC7QV.css +23 -0
- package/dist/chunks/{ContextMenuItems-kRVTuSZd.js → ContextMenuItems-h-apPY2d.js} +1 -1
- package/dist/chunks/{DropzoneLinkField-CvMUu8HQ.js → DropzoneLinkField-_Op8b_ee.js} +1 -1
- package/dist/chunks/{FieldLeading-CNN_5Dp1.js → FieldLeading-mOMcwNqi.js} +1 -1
- package/dist/chunks/{FieldWrapper-BBJPEVHr.js → FieldWrapper-DtT-AIcn.js} +1 -1
- package/dist/chunks/{IconCloseButton-C59_etp2.js → IconCloseButton-YmisskyZ.js} +1 -1
- package/dist/chunks/{LayoutCanvas-BIY74SUe.js → LayoutCanvas-BrISZEBr.js} +1 -1
- package/dist/chunks/{LayoutNode-CTlKlhb-.js → LayoutNode-CwMQ8ACa.js} +1 -1
- package/dist/chunks/{LpActionBar-Cowb-28D.js → LpActionBar-CVVC6YbC.js} +1 -1
- package/dist/chunks/{LpAddressInput-nTJlUQ6K.js → LpAddressInput-tiuJ-bru.js} +7 -7
- package/dist/chunks/{LpAlert-DhZdjekN.js → LpAlert-8-x4H_Nk.js} +1 -1
- package/dist/chunks/{LpAppShell-CK9hTgQ1.js → LpAppShell-BW2YMq-A.js} +4 -4
- package/dist/chunks/{LpAutocomplete--PaZkQoU.js → LpAutocomplete-BZODIops.js} +4 -4
- package/dist/chunks/{LpAvatar-DVCld_L2.js → LpAvatar-BZq4nH53.js} +1 -1
- package/dist/chunks/{LpBadge-DXvpxk6z.js → LpBadge-Cz0pSSUu.js} +1 -1
- package/dist/chunks/{LpBottomNav-COQiBNUN.js → LpBottomNav-DVdR5Mry.js} +1 -1
- package/dist/chunks/{LpBreadcrumbs-Dt1O5G4O.js → LpBreadcrumbs-B2jphAel.js} +1 -1
- package/dist/chunks/{LpCalendar-C8Xlukd2.js → LpCalendar-C0_N5LVT.js} +1 -1
- package/dist/chunks/{LpCheckbox-Dpybd3oV.js → LpCheckbox-CwqLNdw4.js} +2 -2
- package/dist/chunks/{LpCodeBlock-C1Hu5RyT.js → LpCodeBlock-Dcss6bnl.js} +2 -2
- package/dist/chunks/{LpCodeDisplay-BVruT3-A.js → LpCodeDisplay-CLms1zDY.js} +4 -4
- package/dist/chunks/{LpColorPicker-BK-NRaGK.js → LpColorPicker-CbIY450X.js} +3 -3
- package/dist/chunks/{LpConfirmDialog-2CQFKtqU.js → LpConfirmDialog-RmP4z6va.js} +2 -2
- package/dist/chunks/{LpConfirmHost-CTkSytVl.js → LpConfirmHost-DheBgRMB.js} +1 -1
- package/dist/chunks/{LpContextMenu-DcHPdW-t.js → LpContextMenu-DIYhlap8.js} +2 -2
- package/dist/chunks/{LpContextSwitcher-C_nnDbeI.js → LpContextSwitcher-fDA6ERl-.js} +2 -2
- package/dist/chunks/{LpCopyButton-De7_elaO.js → LpCopyButton-DSqKFGEb.js} +1 -1
- package/dist/chunks/{LpDataList-BjZPfQjO.js → LpDataList-f9UjSx9m.js} +2 -2
- package/dist/chunks/{LpDatePicker-wOrHd0fg.js → LpDatePicker-3kDQLVbg.js} +5 -5
- package/dist/chunks/{LpDateRangePicker-CVMvvo5t.js → LpDateRangePicker-DUPiTt0_.js} +3 -3
- package/dist/chunks/{LpDisclosure-CtQ63LeO.js → LpDisclosure-C5WZjhbQ.js} +1 -1
- package/dist/chunks/{LpDrawer-TZBPzyOM.js → LpDrawer-CkYvIGIa.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-DL9LgkS8.js → LpDropdownMenu-zSsK2Xra.js} +6 -3
- package/dist/chunks/{LpEditable-DuPE2tkl.js → LpEditable-CP7Y_lKg.js} +2 -2
- package/dist/chunks/{LpEmptyState-W1U_tKg-.js → LpEmptyState-dsc5x-ya.js} +1 -1
- package/dist/chunks/{LpFieldNotice-BLKlYLwu.js → LpFieldNotice-TOTbWvgv.js} +1 -1
- package/dist/chunks/{LpFileTree-CEiRIpj1.js → LpFileTree-BPjnOTln.js} +4 -4
- package/dist/chunks/{LpFileTreeNode-C0292AU2.js → LpFileTreeNode-_-MiWmie.js} +4 -4
- package/dist/chunks/{LpFormField-Bhgi0idH.js → LpFormField-BJwD1iEF.js} +1 -1
- package/dist/chunks/{LpHintLayer-D8ZKsvsT.js → LpHintLayer-CmfGnxYu.js} +1 -1
- package/dist/chunks/{LpHints-DbIV-wk4.js → LpHints-C2Xvc1Fd.js} +3 -3
- package/dist/chunks/{LpIcon-CytpcLmS.js → LpIcon-CoOLz1oJ.js} +1 -1
- package/dist/chunks/{LpInfraNode-8cGoY87Q.js → LpInfraNode-CW3qsM-U.js} +2 -2
- package/dist/chunks/{LpInput-P2C12X7s.js → LpInput-BVWhkPuN.js} +3 -3
- package/dist/chunks/{LpInspector-DjfMa4tD.js → LpInspector-REmoX_es.js} +4 -4
- package/dist/chunks/{LpLightbox-BEQtCz0m.js → LpLightbox-C3FFTYP5.js} +65 -20
- package/dist/chunks/{LpLogInspector-CyxK1d-V.js → LpLogInspector-Cs8Z_fM0.js} +2 -2
- package/dist/chunks/{LpLogViewer-CZrc_HG7.js → LpLogViewer-DQRGe-ZK.js} +6 -6
- package/dist/chunks/{LpMapPicker-CbTfFcEu.js → LpMapPicker-CQnQPgKm.js} +1 -1
- package/dist/chunks/{LpMediaGallery-C_SwNR4y.js → LpMediaGallery-DkKqTy9P.js} +20 -8
- package/dist/chunks/{LpModal-B6Yt0G7E.js → LpModal-DwejR7VP.js} +1 -1
- package/dist/chunks/{LpModalNav-CMYWEiUs.js → LpModalNav-D1-dSe6f.js} +3 -3
- package/dist/chunks/{LpNotificationBell-BJKPx0X1.js → LpNotificationBell-EKLkN0sp.js} +4 -4
- package/dist/chunks/{LpNotificationFeed-orWISVez.js → LpNotificationFeed-Dc-yQ4NH.js} +4 -4
- package/dist/chunks/{LpNumberField-C5MeLsoE.js → LpNumberField-DvUjEMp-.js} +3 -3
- package/dist/chunks/{LpOtpInput-6SCQ64uK.js → LpOtpInput-xvDO14f4.js} +1 -1
- package/dist/chunks/{LpPagination-CkwbdFa9.js → LpPagination-BFZY8dFn.js} +1 -1
- package/dist/chunks/{LpPaletteTheme-RWgbWFOz.js → LpPaletteTheme-EBJv0Bwz.js} +2 -2
- package/dist/chunks/{LpPasswordInput-DJBRo5lt.js → LpPasswordInput-DRJR3fF1.js} +2 -2
- package/dist/chunks/{LpPhoneInput-DcmcJwos.js → LpPhoneInput-BGOOtUoW.js} +3 -3
- package/dist/chunks/{LpPie-DmMlhgo2.js → LpPie-Deyn8h5J.js} +2 -2
- package/dist/chunks/{LpPipeline-BXcp2shV.js → LpPipeline-9ChjeNh5.js} +2 -2
- package/dist/chunks/{LpQuota-CtEiqRg4.js → LpQuota-CNh5eF_p.js} +1 -1
- package/dist/chunks/{LpRadioGroup-Ubkk_eSq.js → LpRadioGroup-Br2Nl9Gr.js} +2 -2
- package/dist/chunks/{LpRangeCalendar-DCswVYbU.js → LpRangeCalendar-D66Y3U_w.js} +1 -1
- package/dist/chunks/{LpRangeSlider-DM1qy5Tp.js → LpRangeSlider-eX-fIlOw.js} +1 -1
- package/dist/chunks/{LpRepeater-BMo2Eabn.js → LpRepeater-0XvnlVd_.js} +2 -2
- package/dist/chunks/{LpSecretField-SSRGVqRc.js → LpSecretField-1RCooChq.js} +5 -5
- package/dist/chunks/{LpSection-C3bTxD7t.js → LpSection-B9gbJJcP.js} +1 -1
- package/dist/chunks/{LpSegmented-C1DF6m6A.js → LpSegmented-hIjGzGTo.js} +2 -2
- package/dist/chunks/{LpSelect-DvDr1VPn.js → LpSelect-DugubwTc.js} +4 -4
- package/dist/chunks/{LpSidebar-Bql881r6.js → LpSidebar-DevSoN8W.js} +4 -4
- package/dist/chunks/{LpSidebarNav-CKx2KJvr.js → LpSidebarNav-DwQQ3KXe.js} +3 -3
- package/dist/chunks/{LpSidebarRow-BMOL9kOG.js → LpSidebarRow-CAGgtJCS.js} +3 -3
- package/dist/chunks/{LpSlider-DsSMe4b5.js → LpSlider-1fHpVxpB.js} +1 -1
- package/dist/chunks/{LpStat-CQYYDMS6.js → LpStat-Cm9-FaWQ.js} +2 -2
- package/dist/chunks/{LpStatus-g5nSJfU3.js → LpStatus-CE8FSAT1.js} +1 -1
- package/dist/chunks/{LpStepMark-seVNOuNJ.js → LpStepMark-BWSlV69R.js} +1 -1
- package/dist/chunks/{LpStepper-D6qEXihZ.js → LpStepper-1D0ZA7Nx.js} +1 -1
- package/dist/chunks/{LpSwitch-DM5CVm31.js → LpSwitch-D_kvE0oQ.js} +1 -1
- package/dist/chunks/{LpTabFace-ZWwelqRU.js → LpTabFace-CpbFKi-N.js} +2 -2
- package/dist/chunks/{LpTableFilter-CWU9Dmk-.js → LpTableFilter-BYHyCu38.js} +2 -2
- package/dist/chunks/{LpTagsInput-C2qVF2ax.js → LpTagsInput-Cqm38t5N.js} +5 -5
- package/dist/chunks/{LpTextarea-C3EoV_xv.js → LpTextarea-DaSKBkiV.js} +1 -1
- package/dist/chunks/{LpThemeStudio-BIBahaIs.js → LpThemeStudio-BNdfi-Ds.js} +5 -5
- package/dist/chunks/{LpThemeSwitcher-D4MqFLGS.js → LpThemeSwitcher-CbC1nqBP.js} +3 -3
- package/dist/chunks/{LpTimeField-DPOkbvyP.js → LpTimeField-CCfPCWPT.js} +2 -2
- package/dist/chunks/{LpToaster-Dj1VKxFv.js → LpToaster-BhlI7h-h.js} +2 -2
- package/dist/chunks/{LpTopologyCanvas-Bspzwm7x.js → LpTopologyCanvas-DlKbu7E5.js} +4 -4
- package/dist/chunks/{LpTour-CwpVdVN-.js → LpTour-CSLerA1B.js} +1 -1
- package/dist/chunks/{LpTruncate-JSaIW25q.js → LpTruncate-Ct26BSYB.js} +1 -1
- package/dist/chunks/{LpVideoBackdrop-Ct4St4ZL.js → LpVideoBackdrop-57i8RY8g.js} +1 -2
- package/dist/chunks/LpVideoPlayer-DcWd9DDW.js +813 -0
- package/dist/chunks/{LpWindow-RVcP436Z.js → LpWindow-YnF_knSj.js} +2 -2
- package/dist/chunks/{LpWorkspaceSwitcher-D0WOz1Fg.js → LpWorkspaceSwitcher-B-5UU6o4.js} +3 -3
- package/dist/chunks/{MenuTitle-D8Umab23.js → MenuTitle-BnZbFSwq.js} +1 -1
- package/dist/chunks/{OptionContent-DCnX_KRK.js → OptionContent-D5e_eklA.js} +1 -1
- package/dist/chunks/{PickerField-ChjY27bm.js → PickerField-KpHiowNI.js} +1 -1
- package/dist/chunks/{RevealToggle-MaL7PEjr.js → RevealToggle-CE1hAIJB.js} +1 -1
- package/dist/chunks/SteadyText-CwH-kduj.js +25 -0
- package/dist/chunks/{TourCard-oXZSpHKb.js → TourCard-t2csiTDS.js} +1 -1
- package/dist/chunks/VideoThumb-Csf9bUz3.js +67 -0
- package/dist/chunks/VideoVolume-B4_DNmNi.js +68 -0
- package/dist/chunks/icons-CdSlCrle.js +59 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/DropzoneLinkField.vue.js +1 -1
- package/dist/components/FieldLeading.vue.js +1 -1
- package/dist/components/FieldWrapper.vue.js +1 -1
- package/dist/components/IconCloseButton.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBadge.vue.js +1 -1
- package/dist/components/LpBottomNav.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpChart.vue.js +2 -2
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpCodeDisplay.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +2 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpContextSwitcher.vue.js +1 -1
- package/dist/components/LpCopyButton.vue.js +1 -1
- package/dist/components/LpDataList.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDock.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.d.ts +5 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +2 -2
- package/dist/components/LpEditable.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpHintLayer.vue.js +1 -1
- package/dist/components/LpHints.vue.js +1 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +1 -1
- package/dist/components/LpInfraNode.vue.js +1 -1
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpInspector.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogInspector.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNotificationFeed.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpOtpInput.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.js +1 -1
- package/dist/components/LpPaletteHeader.vue.js +2 -2
- package/dist/components/LpPaletteTheme.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPipeline.vue.js +1 -1
- package/dist/components/LpQuota.vue.js +1 -1
- package/dist/components/LpRadioGroup.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRangeSlider.vue.js +1 -1
- package/dist/components/LpRepeater.vue.js +1 -1
- package/dist/components/LpSecretField.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSidebarRow.vue.js +1 -1
- package/dist/components/LpSlider.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStatus.vue.js +1 -1
- package/dist/components/LpStepMark.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTabFace.vue.js +1 -1
- package/dist/components/LpTable.vue.js +9 -9
- package/dist/components/LpTableFilter.vue.js +1 -1
- package/dist/components/LpTabs.vue.js +2 -2
- package/dist/components/LpTagsInput.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeStudio.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/components/LpTruncate.vue.js +1 -1
- package/dist/components/LpVideoBackdrop.vue.js +1 -1
- package/dist/components/LpVideoPlayer.vue.d.ts +111 -0
- package/dist/components/LpVideoPlayer.vue.js +4 -0
- package/dist/components/LpVideoTrim.vue.d.ts +19 -5
- package/dist/components/LpVideoTrim.vue.js +596 -445
- package/dist/components/LpWindow.vue.js +1 -1
- package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
- package/dist/components/MenuTitle.vue.js +1 -1
- package/dist/components/OptionContent.vue.js +1 -1
- package/dist/components/PickerField.vue.js +1 -1
- package/dist/components/RevealToggle.vue.js +1 -1
- package/dist/components/SteadyText.vue.d.ts +7 -0
- package/dist/components/SteadyText.vue.js +4 -0
- package/dist/components/TourCard.vue.js +1 -1
- package/dist/components/VideoFlash.vue.d.ts +10 -0
- package/dist/components/VideoFlash.vue.js +33 -0
- package/dist/components/VideoThumb.vue.d.ts +12 -0
- package/dist/components/VideoThumb.vue.js +4 -0
- package/dist/components/VideoVolume.vue.d.ts +15 -0
- package/dist/components/VideoVolume.vue.js +4 -0
- package/dist/components/lightbox.d.ts +13 -0
- package/dist/components/lightbox.js +11 -1
- package/dist/components/menuWhen.js +1 -1
- package/dist/components/videoBackdrop.d.ts +15 -2
- package/dist/components/videoBackdrop.js +8 -2
- package/dist/components/videoMotion.d.ts +28 -0
- package/dist/components/videoMotion.js +26 -0
- package/dist/components/videoTrim.d.ts +2 -9
- package/dist/components/videoTrim.js +0 -6
- package/dist/composables/useVideoMedia.d.ts +74 -0
- package/dist/composables/useVideoMedia.js +291 -0
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +173 -168
- package/package.json +5 -2
- package/src/component-names.ts +1 -0
- package/src/components/LpDropdownMenu.vue +4 -0
- package/src/components/LpLightbox.vue +61 -8
- package/src/components/LpMediaGallery.vue +19 -2
- package/src/components/LpVideoBackdrop.vue +1 -7
- package/src/components/LpVideoPlayer.vue +774 -0
- package/src/components/LpVideoTrim.vue +437 -295
- package/src/components/SteadyText.vue +21 -0
- package/src/components/VideoFlash.vue +52 -0
- package/src/components/VideoThumb.vue +66 -0
- package/src/components/VideoVolume.vue +63 -0
- package/src/components/lightbox.ts +23 -0
- package/src/components/videoBackdrop.ts +20 -3
- package/src/components/videoMotion.ts +38 -0
- package/src/components/videoTrim.ts +2 -14
- package/src/composables/useVideoMedia.ts +411 -0
- package/src/index.ts +4 -1
- package/src/kit-icons.ts +1 -1
- package/dist/assets/LpVideoTrim-Ci6IIHW0.css +0 -8
- 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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
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
|
-
/* ----------
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
448
|
-
|
|
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
|
-
|
|
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
|
|
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],
|
|
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 (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
<!--
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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="
|
|
718
|
-
class="absolute cursor-
|
|
719
|
-
:style="{ left: `${
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
<
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
755
|
-
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="
|
|
756
|
-
<
|
|
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
|
|
765
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<!--
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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:
|
|
816
|
-
<div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left:
|
|
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
|
-
|
|
820
|
-
class="
|
|
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
|
-
<
|
|
843
|
-
class="
|
|
844
|
-
:
|
|
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
|
|
847
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
<span
|
|
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,
|
|
867
|
-
<
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
|
882
|
-
<
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
<div class="flex flex-col gap-
|
|
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
|
|
1044
|
+
<span class="font-mono text-sm text-ink" data-lp-length><SteadyText :text="formatClipTime(clipLength, 'fixed')" :widest="widest" /></span>
|
|
904
1045
|
</div>
|
|
905
|
-
<
|
|
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
|
-
<
|
|
908
|
-
<LpButton v-if="
|
|
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
|
-
|
|
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 {
|