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
+
+ {#each $activeList as item, idx}
+ {#if isVideo(item)}
+
+ {:else if isVideoNode(item)}
+
+ {/if}
+ {/each}
+
+
+
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