diff --git a/app.go b/app.go index 50efdd0..857f65f 100644 --- a/app.go +++ b/app.go @@ -394,6 +394,9 @@ func (a *App) EnableVideoMenus() { vimCommandsMenu.AddText("Open Search List", keys.Key("/"), func(cd *menu.CallbackData) { wruntime.EventsEmit(a.ctx, video.EVT_OPEN_SEARCH_LIST) }) + vimCommandsMenu.AddText("Search Timeline Clip", keys.Shift("f"), func(cd *menu.CallbackData) { + wruntime.EventsEmit(a.ctx, video.EVT_SEARCH_TIMELINE_CLIP) + }) appMenu := a.AppMenu() appMenu.Items = append(appMenu.Items, &menu.MenuItem{ diff --git a/frontend/src/components/List.svelte b/frontend/src/components/List.svelte new file mode 100644 index 0000000..40b6966 --- /dev/null +++ b/frontend/src/components/List.svelte @@ -0,0 +1,25 @@ + + +
+
+

Results

+ +
+
diff --git a/frontend/src/components/SearchList.svelte b/frontend/src/components/SearchList.svelte index b0990c0..90c5175 100644 --- a/frontend/src/components/SearchList.svelte +++ b/frontend/src/components/SearchList.svelte @@ -1,22 +1,16 @@ + +{#if selected} +
  • + > + [CLIP] + {item.name} +
  • +{:else} +
  • setSearchIdx(idx)} + > + [CLIP] + {item.name} +
  • +{/if} diff --git a/frontend/src/components/VideoNodeItem.svelte b/frontend/src/components/VideoNodeItem.svelte new file mode 100644 index 0000000..300037a --- /dev/null +++ b/frontend/src/components/VideoNodeItem.svelte @@ -0,0 +1,30 @@ + + +{#if selected} +
  • + > + [TIMELINE] + {item.name} +
  • +{:else} +
  • setSearchIdx(idx)} + > + [TIMELINE] + {item.name} +
  • +{/if} diff --git a/frontend/src/lib/searchlist.ts b/frontend/src/lib/searchlist.ts new file mode 100644 index 0000000..ed9becc --- /dev/null +++ b/frontend/src/lib/searchlist.ts @@ -0,0 +1,16 @@ +export function scrollVertical(node: HTMLLIElement) { + const listContainer = document.getElementById("content-wrap"); + const listRect = listContainer.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + + const isNodeVisible = + nodeRect.top >= listRect.top && nodeRect.bottom <= listRect.bottom; + + if (!isNodeVisible) { + const scrollY = nodeRect.top - listRect.top + listContainer.scrollTop; + listContainer.scrollTo({ + top: scrollY, + behavior: "smooth", + }); + } +} diff --git a/frontend/src/lib/timeline.ts b/frontend/src/lib/timeline.ts new file mode 100644 index 0000000..118c2bc --- /dev/null +++ b/frontend/src/lib/timeline.ts @@ -0,0 +1,30 @@ +import { toolingStore } from "../stores"; + +export function handleKeybindTrackClipMove() { + const videoNodeDiv = document + .getElementById(`track-0`) + ?.querySelector(`div:nth-child(${toolingStore.getCursorIdx() + 1})`) + ?.querySelector("div"); + if (videoNodeDiv) { + videoNodeDiv.click(); + scrollToNode(videoNodeDiv); + } +} + +function scrollToNode(node: HTMLDivElement) { + const timelineContainer = document.getElementById("timeline"); + const timelineRect = timelineContainer.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + + const isNodeVisible = + nodeRect.left >= timelineRect.left && nodeRect.right <= timelineRect.right; + + if (!isNodeVisible) { + const scrollX = + nodeRect.left - timelineRect.left + timelineContainer.scrollLeft; + timelineContainer.scrollTo({ + left: scrollX, + behavior: "smooth", + }); + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 5f72985..42b8692 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,3 +1,5 @@ +import type { main, video } from "../../wailsjs/go/models"; + export function formatSecondsToHMS(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -10,3 +12,13 @@ export function formatSecondsToHMS(seconds: number): string { return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; } + +export type ListType = main.Video | video.VideoNode; + +export function isVideoNode(unit: ListType): unit is video.VideoNode { + return (unit as video.VideoNode).losslessexport !== undefined; +} + +export function isVideo(unit: ListType): unit is main.Video { + return (unit as main.Video).duration !== undefined; +} diff --git a/frontend/src/stores.ts b/frontend/src/stores.ts index 31a3dbf..d52ef2e 100644 --- a/frontend/src/stores.ts +++ b/frontend/src/stores.ts @@ -1,5 +1,8 @@ import { derived, get, writable } from "svelte/store"; import type { main, video } from "../wailsjs/go/models"; +import { InsertInterval } from "../wailsjs/go/main/App"; +import { isVideoNode, type ListType } from "./lib/utils"; +import { handleKeybindTrackClipMove } from "./lib/timeline"; export function createBooleanStore(initial: boolean) { const isOpen = writable(initial); @@ -95,7 +98,6 @@ function createFilesytemStore() { } function searchFiles(query: string): main.Video[] { - query = query.toLowerCase(); return get(videoFiles).filter((video) => { return video.name.toLowerCase().includes(query); }); @@ -152,12 +154,12 @@ function createTracksStore() { const { set: setTrackDuration, update: updateTrackDuration } = trackDuration; const { set: setTrackTime } = trackTime; - const addVideoToTrack = ( + function addVideoToTrack( id: number, video: video.VideoNode, pos: number, mode: string = "none", - ) => { + ) { // TODO: handle duplicated keys update((tracks) => { if (tracks.length === 0 || id > tracks.length) { @@ -174,13 +176,13 @@ function createTracksStore() { return tracks; }); updateTrackDuration((tDuration) => (tDuration += video.end - video.start)); - }; + } - const removeAndAddIntervalToTrack = ( + function removeAndAddIntervalToTrack( id: number, pos: number, videoNodes: video.VideoNode[], - ) => { + ) { update((tracks) => { if (pos < 0 || pos > tracks[0].length) { return tracks; @@ -188,9 +190,9 @@ function createTracksStore() { tracks[id].splice(pos, 1, ...videoNodes); return tracks; }); - }; + } - const removeVideoFromTrack = (id: number, videoNode: video.VideoNode) => { + function removeVideoFromTrack(id: number, videoNode: video.VideoNode) { update((tracks) => { if (!tracks[id]) return tracks; tracks[id] = tracks[id].filter((v) => v.id !== videoNode.id); @@ -199,9 +201,9 @@ function createTracksStore() { updateTrackDuration( (tDuration) => (tDuration -= videoNode.end - videoNode.start), ); - }; + } - const removeRIDReferencesFromTrack = (id: number, rid: string) => { + function removeRIDReferencesFromTrack(id: number, rid: string) { let durationRemoved = 0; update((tracks) => { if (!tracks[id]) return tracks; @@ -214,27 +216,27 @@ function createTracksStore() { return tracks; }); updateTrackDuration((tDuration) => (tDuration -= durationRemoved)); - }; + } - const renameClipInTrack = (id: number, pos: number, name: string) => { + function renameClipInTrack(id: number, pos: number, name: string) { update((tracks) => { if (!tracks[id]) return tracks; if (pos < 0 || pos > tracks[0].length) return tracks; tracks[id][pos].name = name; return tracks; }); - }; + } - const toggleLosslessMarkofClip = (id: number, pos: number) => { + function toggleLosslessMarkofClip(id: number, pos: number) { update((tracks) => { if (!tracks[id]) return tracks; if (pos < 0 || pos > tracks[0].length) return tracks; tracks[id][pos].losslessexport = !tracks[id][pos].losslessexport; return tracks; }); - }; + } - const markAllLossless = () => { + function markAllLossless() { update((tracks) => { if (!tracks[0]) return tracks; for (let track of tracks[0]) { @@ -242,9 +244,9 @@ function createTracksStore() { } return tracks; }); - }; + } - const unmarkAllLossless = () => { + function unmarkAllLossless() { update((tracks) => { if (!tracks[0]) return tracks; for (let track of tracks[0]) { @@ -252,17 +254,33 @@ function createTracksStore() { } return tracks; }); - }; + } + + function searchTracks(query: string): video.VideoNode[] { + const sTracks = get(tracks); + if (sTracks.length <= 0) return []; - const resetTrackStore = () => { + return sTracks[0].filter((videoNode) => + videoNode.name.toLowerCase().includes(query), + ); + } + + function getClipPosInTrack(id: string): number { + let sTracks = get(tracks); + if (sTracks.length <= 0) return -1; + return sTracks[0].findIndex((track) => track.id === id); + } + + function resetTrackStore() { set([]); setTrackTime(0); setTrackDuration(0); - }; + } return { subscribe, trackTime, + getClipPosInTrack, setTrackTime, addVideoToTrack, removeVideoFromTrack, @@ -273,6 +291,7 @@ function createTracksStore() { markAllLossless, unmarkAllLossless, trackDuration, + searchTracks, resetTrackStore, }; } @@ -409,6 +428,14 @@ function createVideoToolingStore() { isTrackPlaying; const { set: setTrackZoom, update: updateTrackZoom } = trackZoom; + function getVimMode(): boolean { + return get(vimMode); + } + + function getCursorIdx(): number { + return get(clipCursorIdx); + } + function moveClipCursor(inc: number) { if (!get(vimMode)) return; const numClips = get(numberOfClipsInTrack); @@ -459,9 +486,11 @@ function createVideoToolingStore() { isOpenSearchList, setIsOpenSearchList, vimMode, + getVimMode, setVimMode, updateVimMode, clipCursorIdx, + getCursorIdx, setClipCursorIdx, moveClipCursor, clipRegister, @@ -651,6 +680,104 @@ function createExportOptionsStore() { }; } +function createSearchListStore() { + let searchTerm = writable(""); + let searchIdx = writable(-1); + let activeList = writable([]); + + const { set: setSearchTerm } = searchTerm; + const { set: setSearchIdx, update: updateSearchIdx } = searchIdx; + const { set: setActiveList } = activeList; + + function moveSearchIdx(inc: number) { + const N = get(activeList).length; + if (get(searchIdx) === -1) return; + if (get(searchIdx) + inc < 0) setSearchIdx(N - 1); + else if (get(searchIdx) + inc >= N) setSearchIdx(0); + else setSearchIdx(get(searchIdx) + inc); + } + + function search() { + const query = get(searchTerm).toLowerCase(); + const commandRgx = /^\/([a-z])\s(.*)$/; + const match = query.match(commandRgx); + + if (match) { + switch (match[1]) { + case "x": + setActiveList(trackStore.searchTracks(match[2])); + break; + default: + } + } else { + setActiveList(videoFiles.searchFiles(query)); + } + if (get(activeList).length > 0) setSearchIdx(0); + } + + async function executeAction() { + const idx = get(searchIdx); + const aList = get(activeList); + + if (idx >= 0 && idx < aList.length) { + const node = aList[idx]; + if (isVideoNode(node)) { + let nodeidx = trackStore.getClipPosInTrack(node.id); + if (nodeidx !== -1) { + toolingStore.setClipCursorIdx(nodeidx); + handleKeybindTrackClipMove(); + resetSearchListStore(); + toolingStore.setIsOpenSearchList(false); + toolingStore.setVimMode(true); + } + } else { + videoStore.viewVideo(node); + + InsertInterval( + get(videoStore.source), + node.name, + 0, + node.duration, + get(toolingStore.videoNodePos), + ) + .then((tVideo) => { + trackStore.addVideoToTrack( + 0, + tVideo, + get(toolingStore.videoNodePos), + ); + toolingStore.setVideoNode(tVideo); + toolingStore.setActionMsg("-- CLIP ADDED --"); + videoStore.setVideoSrc(tVideo.rid); + videoStore.setCurrentTime(tVideo.start); + }) + .catch(() => + toolingStore.setActionMsg(`could not insert ${aList[idx].name}`), + ); + } + } + } + + function resetSearchListStore() { + setSearchTerm(""); + setActiveList([]); + setSearchIdx(-1); + } + + return { + activeList, + search, + searchTerm, + setSearchTerm, + searchIdx, + moveSearchIdx, + setSearchIdx, + updateSearchIdx, + executeAction, + resetSearchListStore, + }; +} + export const router = createRouterStore(); export const videoFiles = createFilesytemStore(); export const mainMenuStore = createMainMenuStore(); @@ -660,3 +787,4 @@ export const draggedVideo = createVideoTransferStore(); export const videoStore = createVideoStore(); export const toolingStore = createVideoToolingStore(); export const exportOptionsStore = createExportOptionsStore(); +export const searchListstore = createSearchListStore(); diff --git a/internal/video/video.go b/internal/video/video.go index d06f5be..353e28b 100644 --- a/internal/video/video.go +++ b/internal/video/video.go @@ -86,6 +86,8 @@ const ( EVT_PROXY_ERROR_MSG = "evt_proxy_error_msg" //EVT_ENCODING_PROGRESS: proxy file has been created event EVT_PROXY_FILE_CREATED = "evt_proxy_file_created" + // EVT_SEARCH_TIMELINE_CLIP: opens search list and sets the timeline clip search command (/x) + EVT_SEARCH_TIMELINE_CLIP = "evt_search_timeline_clip" //SCALE_256x256: Resolution 256x256 SCALE_256x256 = "256x256" //SCALE_316_192: Resolution 316x192