1
0
Fork 0
muzika-gromche/Frontend/src/components/timeline/Timeline.vue

234 lines
8.6 KiB
Vue
Raw Normal View History

WIP: Add frontend web app player & editor in Vue 3 + Vite TODO: - implement viewing & editing. - Add links to deployment, and CHANGELOG. style.css package.json vite config .vscode eslint use --cache .vscode add vite-css-modules editorconfig tsconfig and updated vue-tsc (fixes most of the type checking bugs) fix last type errors audiowaveform gitignore ESLint ESLint: ignore autogenerated JSON lint:fix tsconfig and vite config migrate icon generating script to TS eslint src/lib/ eslint stores eslint src/*.ts eslint audio pnpm update update icon eslint ahh import new tracks json instructions on jq codenames codenames.json fix styles broken by import order eslint audio app error screen footer copyright year global header loading screen transition search field preview track info inspector control controls controls range controls impl controls index eslint no-console off AudioTrack view inspector cards and sliders more controls master volume slider playhead library page player page timeline markers timeline markers header tick timestamp timeline clip index clip empty clip lyrics clip palette clip fadeout clip default import order timeline timeline panel timeline track header timeline trackview clip view clip audio audio waveform scrollsync easy lints eslint store eslint no mutating props off pnpm catalog off add unhead dep use head eslint inspector eslint easy minor stuff eslint audiowaveform easy fix eslint use :key with v-for fix audio waveforms inspector makes more sense season remove debug inspector
2025-11-27 14:57:28 +00:00
<script setup lang="ts">
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState'
import type { UseZoomAxis } from '@/lib/useZoomAxis'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useId, useTemplateRef, watch } from 'vue'
import ZoomSlider from '@/components/library/ZoomSlider.vue'
import ScrollSync from '@/components/scrollsync/ScrollSync.vue'
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue'
import Playhead from '@/components/timeline/Playhead.vue'
import { onInputKeyStroke } from '@/lib/onInputKeyStroke'
import { useTimelineScrubbing } from '@/lib/useTimelineScrubbing'
import { useVeiwportWheel } from '@/lib/useVeiwportWheel'
import { bindTwoWay, toPx } from '@/lib/vue'
import { useTimelineStore } from '@/store/TimelineStore'
import TimelineMarkers from './markers/TimelineMarkers.vue'
import TimelineTrackHeader from './TimelineTrackHeader.vue'
import TimelineTrackView from './TimelineTrackView.vue'
const {
rightSidebar,
} = defineProps<{
rightSidebar: UseOptionalWidgetStateReturn
}>()
const timeline = useTimelineStore()
const {
headerHeight,
sidebarWidth,
viewportScrollOffsetTop,
viewportScrollOffsetLeft,
contentWidthIncludingEmptySpacePx,
visibleTracks,
} = storeToRefs(timeline)
// nested composable marked with markRaw
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
const timelineScrollGroup = useId()
const timelineRootElement = useTemplateRef('timelineRootElement')
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView')
const timelineScrollViewBounding = useElementBounding(timelineScrollView)
watch(timelineScrollViewBounding.width, (value) => {
timeline.viewportWidth = value
})
watch(timelineScrollViewBounding.height, (value) => {
timeline.viewportHeight = value
})
const {
arrivedState: timelineScrollViewArrivedState,
x: timelineScrollViewOffsetLeft,
y: timelineScrollViewOffsetTop,
} = useScroll(() => timelineScrollView.value?.$el)
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop)
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft)
useVeiwportWheel(timelineRootElement, {
axisHorizontal: viewportZoomHorizontal,
axisVertical: viewportZoomVertical,
scrollOffsetLeft: timelineScrollViewOffsetLeft,
})
// Shift+Z - reset zoom
onInputKeyStroke(event => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
timeline.zoomToggleBetweenWholeAndLoop()
event.preventDefault()
})
const scrubbing = useTemplateRef('scrubbing')
useTimelineScrubbing(scrubbing)
</script>
<template>
<div
ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
}"
>
<!-- top left corner, contains zoom controls -->
<div
class="toolbar-background tw:max-w-full tw:flex tw:flex-row tw:flex-nowrap tw:items-center"
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);"
>
<ZoomSlider :axis="viewportZoomHorizontal" class="tw:flex-1" />
</div>
<!-- left sidebar with timeline track names -->
<ScrollSync
:group="timelineScrollGroup" :vertical="true" class="toolbar-background scrollbar-none"
style="grid-row: 2; grid-column: 1; border-right: var(--view-separator-border);"
>
<template v-for="timelineTrack in visibleTracks" :key="timelineTrack.name">
<TimelineTrackHeader :timeline-track />
</template>
</ScrollSync>
<!-- header with timestamps -->
<ScrollSync
:group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);"
>
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
<TimelineHeader />
</div>
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
<!-- </Playhead> -->
</ScrollSync>
<!-- timeline content -->
<ScrollSync
ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;"
>
<!-- timeline content wrapper for good measure -->
<div
class="tw:relative tw:overflow-hidden tw:min-h-full"
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }"
>
<!-- timeline markers -->
<TimelineMarkers />
<!-- timeline tracks -->
<div>
<template v-for="timelineTrack in visibleTracks" :key="timelineTrack.name">
<TimelineTrackView :timeline-track />
</template>
</div>
</div>
</ScrollSync>
<!-- horizontal bars of scroll shadow, on top of sidebar and content, but under playhead -->
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 2; grid-column: 1 / 3;">
<div
class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.top }"
>
<div class="tw:h-4 tw:w-full shadow-bottom" />
</div>
<div
class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.bottom }"
>
<div class="tw:h-4 tw:w-full shadow-top" />
</div>
</div>
<!-- playhead -->
<ScrollSync
:group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
style="grid-row: 1 / 3; grid-column: 2;"
>
<div
class="tw:h-full tw:relative tw:overflow-hidden"
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }"
>
<!-- actuals playback position -->
<Playhead :position-seconds="timeline.playheadPosition" :knob="true">
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
</Playhead>
</div>
</ScrollSync>
<!-- cursor on hover -->
<!-- <Playhead :position="cursorPosition" :timelineWidth="timelineWidth" :knob="false"
:hidden="cursorPositionSeconds === null || isDragging">
<Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" />
</Playhead> -->
<!-- vertical bars of scroll shadow, on top of header, content AND playhead -->
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1 / -1; grid-column: 2;">
<div
class="tw:absolute tw:top-0 tw:left-0 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.left }"
>
<div class="tw:w-4 tw:h-full shadow-right" />
</div>
<div
class="tw:absolute tw:top-0 tw:right-4 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.right }"
>
<div class="tw:w-4 tw:h-full shadow-left" />
</div>
</div>
<!-- empty cell at the top right -->
<div
v-if="rightSidebar.visible.value" class="toolbar-background"
style="grid-row: 1; grid-column: 3; border-bottom: var(--view-separator-border); border-left: var(--view-separator-border);"
/>
<!-- right sidebar with vertical zoom slider -->
<div
v-if="rightSidebar.visible.value"
class="toolbar-background tw:size-full tw:min-h-0 tw:py-2 tw:flex tw:flex-col tw:items-center"
style="grid-row: 2; grid-column: 3; border-left: var(--view-separator-border);"
>
<ZoomSlider :axis="viewportZoomVertical" orientation="vertical" class="tw:w-full tw:min-h-0" />
</div>
</div>
</template>
<style scoped>
.shadow-top,
.shadow-right,
.shadow-bottom,
.shadow-left {
--shadow-darkest: rgba(0, 0, 0, 0.3);
}
.shadow-top {
background-image: linear-gradient(to top, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
.shadow-right {
background-image: linear-gradient(to right, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
.shadow-bottom {
background-image: linear-gradient(to bottom, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
.shadow-left {
background-image: linear-gradient(to left, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
</style>