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