Commit c2a0c30c by Jonathan Thomas

Adding resize handle to clip preview widget, and auto height calculation. Also,…

Adding resize handle to clip preview widget, and auto height calculation. Also, fixing some responsive small screen issues on Add Clip / Play buttons and clip segment resize handles.
parent 648b49a6
...@@ -6,29 +6,32 @@ ...@@ -6,29 +6,32 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row preview-stage-row" v-if="hasPreviewFile || show_spinner">
<div class="col-12" ref="previewStageWrapper">
<!-- Loading spinner --> <div class="preview-stage" :class="{'is-resizing': isResizingStage}" :style="previewStageStyle">
<div v-if="show_spinner" class="md-5 p-5 text-center spinner-container"> <div v-if="show_spinner" class="preview-stage-spinner">
<div class="spinner-border" role="status"> <div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
</div> </div>
<video ref="video" v-if="!isImage && hasPreviewFile" v-show="media_loaded" @loadeddata="videoLoaded" @timeupdate="videoTimeUpdate" @pause="togglePause" @play="togglePause" loop preload="" poster="" class="preview-media">
<!-- Preview Video/Image -->
<div class="col-sm-12">
<video ref="video" v-if="!isImage && hasPreviewFile" v-show="media_loaded" @loadeddata="videoLoaded" @timeupdate="videoTimeUpdate" @pause="togglePause" @play="togglePause" loop preload="" poster="" class="video-responsive">
<source :type="previewFormat" :src="preview.file.media" @error="mediaError"> <source :type="previewFormat" :src="preview.file.media" @error="mediaError">
</video> </video>
<img v-if="isImage && hasPreviewFile" v-show="media_loaded" :src="preview.file.media" @load="imageLoaded" @error="mediaError" class="img-fluid img-thumbnail" /> <img v-if="isImage && hasPreviewFile" v-show="media_loaded" :src="preview.file.media" @load="imageLoaded" @error="mediaError" class="preview-media img-thumbnail" />
<button type="button" class="preview-stage-resizer" title="Resize preview height" aria-label="Resize preview height"
@mousedown.prevent="startStageResize"
@touchstart.prevent="startStageResize"
@dblclick.stop.prevent="resetStageHeight">
<span class="visually-hidden">Resize preview height</span>
</button>
</div>
</div> </div>
</div> </div>
<!-- Timeline Container --> <!-- Timeline Container -->
<div v-if="hasPreviewFile && media_loaded" class="d-flex flex-column d-sm-flex flex-sm-row"> <div v-if="hasPreviewFile && media_loaded" class="timeline-controls-row" ref="timelineSection">
<!-- Play/Pause button --> <!-- Play/Pause button -->
<div class="col-sm-1 d-flex flex-column p-1"> <div class="timeline-button timeline-button-left">
<button title="Play/Pause Preview" type="button" class="btn btn-secondary timeline-btn" @click="togglePlayback"> <button title="Play/Pause Preview" type="button" class="btn btn-secondary timeline-btn" @click="togglePlayback">
<svg v-show="is_paused" xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16"> <svg v-show="is_paused" xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16">
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/> <path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/>
...@@ -40,7 +43,7 @@ ...@@ -40,7 +43,7 @@
</div> </div>
<!-- Timeline w/ Clip Trimming --> <!-- Timeline w/ Clip Trimming -->
<div class="col-sm-10 d-flex flex-column p-1"> <div class="timeline-track flex-fill">
<div class="timeline" ref="timeline" @mousedown="seekToCursor($event)" @mousemove="drag($event)" @touchmove="drag($event)" @mouseup="endDrag($event)" @touchend="endDrag($event)"> <div class="timeline" ref="timeline" @mousedown="seekToCursor($event)" @mousemove="drag($event)" @touchmove="drag($event)" @mouseup="endDrag($event)" @touchend="endDrag($event)">
<div title="Drag Playback Position" ref="playhead" class="playhead" @mousedown="startDrag($event, 'playhead')" @touchstart="startDrag($event, 'playhead')" :style="{left: preview.position * 100.0 + '%'}"></div> <div title="Drag Playback Position" ref="playhead" class="playhead" @mousedown="startDrag($event, 'playhead')" @touchstart="startDrag($event, 'playhead')" :style="{left: preview.position * 100.0 + '%'}"></div>
<div title="Drag to Trim Start" ref="start" class="marker marker_left" @click="seekToMarker('start')" @mousedown="startDrag($event, 'start')" @touchstart="startDrag($event, 'start')" :style="{left: preview.start * 100.0 + '%'}"> <div title="Drag to Trim Start" ref="start" class="marker marker_left" @click="seekToMarker('start')" @mousedown="startDrag($event, 'start')" @touchstart="startDrag($event, 'start')" :style="{left: preview.start * 100.0 + '%'}">
...@@ -60,7 +63,7 @@ ...@@ -60,7 +63,7 @@
</div> </div>
<!-- Add Clip button --> <!-- Add Clip button -->
<div class="col-sm-1 d-flex flex-column p-1"> <div class="timeline-button timeline-button-right">
<button :title="actionButtonTitle" type="button" class="btn btn-primary timeline-btn d-flex flex-column justify-content-center align-items-center" @click="createClipBtn"> <button :title="actionButtonTitle" type="button" class="btn btn-primary timeline-btn d-flex flex-column justify-content-center align-items-center" @click="createClipBtn">
<svg v-if="!isEditingClip" xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16"> <svg v-if="!isEditingClip" xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/> <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
...@@ -110,7 +113,17 @@ export default { ...@@ -110,7 +113,17 @@ export default {
show_spinner: false, show_spinner: false,
media_loaded: false, media_loaded: false,
media_error: false, media_error: false,
loaded_file_id: null loaded_file_id: null,
stageHeightAuto: null,
stageHeightManual: null,
stageMeasureRaf: null,
stageResizeMoveHandler: null,
stageResizeEndHandler: null,
stageResizeStartY: 0,
stageResizeStartHeight: 0,
isResizingStage: false,
stageMinHeight: 220,
stageMaxHeight: 720
} }
}, },
methods: { methods: {
...@@ -180,6 +193,7 @@ export default { ...@@ -180,6 +193,7 @@ export default {
this.media_loaded = true this.media_loaded = true
this.media_error = false this.media_error = false
this.checkClipSize() this.checkClipSize()
this.scheduleStageMeasure()
if (this.$refs.video && this.preview.clip) { if (this.$refs.video && this.preview.clip) {
this.$refs.video.currentTime = this.preview.clip.start this.$refs.video.currentTime = this.preview.clip.start
} }
...@@ -194,10 +208,12 @@ export default { ...@@ -194,10 +208,12 @@ export default {
this.media_loaded = true this.media_loaded = true
this.media_error = false this.media_error = false
this.checkClipSize() this.checkClipSize()
this.scheduleStageMeasure()
}, },
mediaError() { mediaError() {
this.show_spinner = false this.show_spinner = false
this.media_error = true this.media_error = true
this.scheduleStageMeasure()
}, },
checkClipSize() { checkClipSize() {
if (this.$refs.clip && this.$refs.clip.clientWidth < 80) { if (this.$refs.clip && this.$refs.clip.clientWidth < 80) {
...@@ -367,6 +383,109 @@ export default { ...@@ -367,6 +383,109 @@ export default {
this.$refs.video.currentTime = clamped * this.$refs.video.duration this.$refs.video.currentTime = clamped * this.$refs.video.duration
} }
}, },
scheduleStageMeasure() {
if (this.stageMeasureRaf) {
cancelAnimationFrame(this.stageMeasureRaf)
}
this.stageMeasureRaf = requestAnimationFrame(() => {
this.stageMeasureRaf = null
this.calculateStageHeight()
})
},
calculateStageHeight() {
if (!this.$refs.previewStageWrapper) {
this.stageHeightAuto = this.stageMinHeight
return
}
const wrapperRect = this.$refs.previewStageWrapper.getBoundingClientRect()
let timelineHeight = 220
if (this.$refs.timelineSection) {
const timelineRect = this.$refs.timelineSection.getBoundingClientRect()
timelineHeight = Math.max(120, timelineRect.height)
}
let available = window.innerHeight - wrapperRect.top - timelineHeight - 32
if (!isFinite(available)) {
available = this.stageMinHeight
}
const clamped = Math.max(this.stageMinHeight, Math.min(this.stageMaxHeight, available))
this.stageHeightAuto = clamped
},
onWindowResize() {
this.scheduleStageMeasure()
},
startStageResize(event) {
if (event && event.type === 'mousedown' && event.detail && event.detail > 1) {
return
}
if (event && event.cancelable) {
event.preventDefault()
}
const clientY = this.getClientY(event)
if (clientY === null) {
return
}
this.isResizingStage = true
this.stageResizeStartY = clientY
this.stageResizeStartHeight = this.currentStageHeight
this.stageHeightManual = this.stageResizeStartHeight
if (!this.stageResizeMoveHandler) {
this.stageResizeMoveHandler = (e) => this.handleStageResize(e)
}
if (!this.stageResizeEndHandler) {
this.stageResizeEndHandler = (e) => this.endStageResize(e)
}
window.addEventListener('mousemove', this.stageResizeMoveHandler)
window.addEventListener('touchmove', this.stageResizeMoveHandler)
window.addEventListener('mouseup', this.stageResizeEndHandler)
window.addEventListener('touchend', this.stageResizeEndHandler)
window.addEventListener('touchcancel', this.stageResizeEndHandler)
},
handleStageResize(event) {
if (event && event.cancelable) {
event.preventDefault()
}
const clientY = this.getClientY(event)
if (clientY === null) {
return
}
const delta = clientY - this.stageResizeStartY
const nextHeight = this.stageResizeStartHeight + delta
this.stageHeightManual = Math.max(this.stageMinHeight, Math.min(this.stageMaxHeight, nextHeight))
},
endStageResize() {
if (!this.isResizingStage) {
return
}
this.isResizingStage = false
this.detachStageResizeListeners()
},
detachStageResizeListeners() {
if (this.stageResizeMoveHandler) {
window.removeEventListener('mousemove', this.stageResizeMoveHandler)
window.removeEventListener('touchmove', this.stageResizeMoveHandler)
}
if (this.stageResizeEndHandler) {
window.removeEventListener('mouseup', this.stageResizeEndHandler)
window.removeEventListener('touchend', this.stageResizeEndHandler)
window.removeEventListener('touchcancel', this.stageResizeEndHandler)
}
},
getClientY(event) {
if (event.touches && event.touches.length) {
return event.touches[0].clientY
}
if (event.changedTouches && event.changedTouches.length) {
return event.changedTouches[0].clientY
}
if (typeof event.clientY === 'number') {
return event.clientY
}
return null
},
resetStageHeight() {
this.stageHeightManual = null
this.scheduleStageMeasure()
},
...mapActions(['createClip', 'editClip', 'moveClip']), ...mapActions(['createClip', 'editClip', 'moveClip']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile']) ...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile'])
}, },
...@@ -401,8 +520,30 @@ export default { ...@@ -401,8 +520,30 @@ export default {
} else { } else {
return `video/${this.preview.file.media.split('.').pop()}` return `video/${this.preview.file.media.split('.').pop()}`
} }
},
currentStageHeight() {
let height = this.stageHeightManual
if (height === null || typeof height === 'undefined') {
height = this.stageHeightAuto
}
if (height === null || typeof height === 'undefined') {
height = this.stageMinHeight
}
return Math.max(this.stageMinHeight, Math.min(this.stageMaxHeight, height))
},
previewStageStyle() {
const height = this.currentStageHeight
return {
minHeight: `${this.stageMinHeight}px`,
maxHeight: `${this.stageMaxHeight}px`,
height: `${height}px`
}
} }
}, },
mounted() {
this.scheduleStageMeasure()
window.addEventListener('resize', this.onWindowResize)
},
watch: { watch: {
'preview.file': function() { 'preview.file': function() {
this.media_error = false this.media_error = false
...@@ -410,6 +551,7 @@ export default { ...@@ -410,6 +551,7 @@ export default {
this.show_spinner = true this.show_spinner = true
this.media_loaded = false this.media_loaded = false
} }
this.scheduleStageMeasure()
if (this.$refs.video) { if (this.$refs.video) {
this.is_paused = true this.is_paused = true
this.$refs.video.pause() this.$refs.video.pause()
...@@ -427,14 +569,119 @@ export default { ...@@ -427,14 +569,119 @@ export default {
}, },
beforeUnmount() { beforeUnmount() {
this.detachGlobalDragListeners() this.detachGlobalDragListeners()
this.detachStageResizeListeners()
window.removeEventListener('resize', this.onWindowResize)
if (this.stageMeasureRaf) {
cancelAnimationFrame(this.stageMeasureRaf)
this.stageMeasureRaf = null
}
} }
} }
</script> </script>
<style scoped> <style scoped>
video { .preview-stage-row {
margin-bottom: 1rem;
}
.preview-stage {
background-color: #000000; background-color: #000000;
border-radius: 8px;
padding: 0.5rem;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
transition: height 0.2s ease;
}
.preview-stage-spinner {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.preview-media {
width: 100%; width: 100%;
height: 100%;
object-fit: contain;
background-color: #000000;
}
.preview-stage-resizer {
position: absolute;
width: 22px;
height: 22px;
bottom: 4px;
right: 4px;
border: none;
background: transparent;
padding: 0;
cursor: ns-resize;
touch-action: none;
}
.preview-stage-resizer::after {
content: '';
position: absolute;
inset: 4px;
border-right: 2px solid rgba(255, 255, 255, 0.6);
border-bottom: 2px solid rgba(255, 255, 255, 0.6);
}
.preview-stage-resizer:focus-visible {
outline: 2px solid #0d6efd;
}
.preview-stage.is-resizing {
cursor: ns-resize;
}
.spinner-border {
margin-top: 0;
width: 4em;
height: 4em;
}
.timeline-controls-row {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.5rem;
margin-bottom: 1.25rem;
flex-wrap: nowrap;
}
.timeline-button {
flex: 0 0 64px;
display: flex;
align-items: center;
}
.timeline-button-left {
justify-content: flex-start;
}
.timeline-button-right {
justify-content: flex-end;
}
.timeline-track {
flex: 1 1 auto;
display: flex;
min-width: 0;
}
@media (max-width: 576px) {
.timeline-controls-row {
margin-bottom: 1rem;
gap: 0.35rem;
}
.timeline-button {
flex-basis: 56px;
}
.preview-stage {
padding: 0.25rem;
}
.preview-stage-resizer {
width: 18px;
height: 18px;
}
.marker svg {
width: 12px;
height: 48px;
}
} }
.timeline { .timeline {
background-color: #000000; background-color: #000000;
...@@ -495,12 +742,4 @@ video { ...@@ -495,12 +742,4 @@ video {
.img-thumbnail { .img-thumbnail {
width: 100%!important; width: 100%!important;
} }
.spinner-border {
margin-top: 100px;
width: 4em;
height: 4em;
}
.spinner-container {
min-height: 300px;
}
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment