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 @@
</div>
</div>
<div class="row">
<!-- Loading spinner -->
<div v-if="show_spinner" class="md-5 p-5 text-center spinner-container">
<div class="row preview-stage-row" v-if="hasPreviewFile || show_spinner">
<div class="col-12" ref="previewStageWrapper">
<div class="preview-stage" :class="{'is-resizing': isResizingStage}" :style="previewStageStyle">
<div v-if="show_spinner" class="preview-stage-spinner">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- 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">
<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">
<source :type="previewFormat" :src="preview.file.media" @error="mediaError">
</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>
<!-- 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 -->
<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">
<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"/>
......@@ -40,7 +43,7 @@
</div>
<!-- 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 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 + '%'}">
......@@ -60,7 +63,7 @@
</div>
<!-- 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">
<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"/>
......@@ -110,7 +113,17 @@ export default {
show_spinner: false,
media_loaded: 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: {
......@@ -180,6 +193,7 @@ export default {
this.media_loaded = true
this.media_error = false
this.checkClipSize()
this.scheduleStageMeasure()
if (this.$refs.video && this.preview.clip) {
this.$refs.video.currentTime = this.preview.clip.start
}
......@@ -194,10 +208,12 @@ export default {
this.media_loaded = true
this.media_error = false
this.checkClipSize()
this.scheduleStageMeasure()
},
mediaError() {
this.show_spinner = false
this.media_error = true
this.scheduleStageMeasure()
},
checkClipSize() {
if (this.$refs.clip && this.$refs.clip.clientWidth < 80) {
......@@ -367,6 +383,109 @@ export default {
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']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile'])
},
......@@ -401,8 +520,30 @@ export default {
} else {
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: {
'preview.file': function() {
this.media_error = false
......@@ -410,6 +551,7 @@ export default {
this.show_spinner = true
this.media_loaded = false
}
this.scheduleStageMeasure()
if (this.$refs.video) {
this.is_paused = true
this.$refs.video.pause()
......@@ -427,14 +569,119 @@ export default {
},
beforeUnmount() {
this.detachGlobalDragListeners()
this.detachStageResizeListeners()
window.removeEventListener('resize', this.onWindowResize)
if (this.stageMeasureRaf) {
cancelAnimationFrame(this.stageMeasureRaf)
this.stageMeasureRaf = null
}
}
}
</script>
<style scoped>
video {
.preview-stage-row {
margin-bottom: 1rem;
}
.preview-stage {
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%;
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 {
background-color: #000000;
......@@ -495,12 +742,4 @@ video {
.img-thumbnail {
width: 100%!important;
}
.spinner-border {
margin-top: 100px;
width: 4em;
height: 4em;
}
.spinner-container {
min-height: 300px;
}
</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