feat(wails): add native file dialogs and video streaming

FileService exposes open/save dialogs for video, project JSON, and WAV
export. User-selected videos stream from disk at /localmedia/video with
range request support for seeking in the webview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 22:22:37 -07:00
parent f5feefe844
commit 3a5d9d849f
7 changed files with 271 additions and 3 deletions
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"net/http"
"os"
"path/filepath"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
)
const localMediaVideoPath = "/localmedia/video"
// VideoResult is returned when the user opens a video file.
type VideoResult struct {
URL string `json:"url"`
Name string `json:"name"`
}
// FileService exposes native file dialogs and streams the current video.
type FileService struct {
app *application.App
mu sync.RWMutex
videoPath string
videoMod os.FileInfo
}
func NewFileService(app *application.App) *FileService {
return &FileService{app: app}
}
func (s *FileService) OpenVideo() (VideoResult, error) {
path, err := s.app.Dialog.OpenFile().
SetTitle("Open Video").
CanChooseFiles(true).
AddFilter("Video", "*.mp4;*.mov;*.webm;*.mkv").
AddFilter("All Files", "*").
PromptForSingleSelection()
if err != nil {
return VideoResult{}, err
}
if path == "" {
return VideoResult{}, nil
}
info, err := os.Stat(path)
if err != nil {
return VideoResult{}, err
}
s.mu.Lock()
s.videoPath = path
s.videoMod = info
s.mu.Unlock()
return VideoResult{
URL: localMediaVideoPath,
Name: filepath.Base(path),
}, nil
}
func (s *FileService) OpenProject() (string, error) {
path, err := s.app.Dialog.OpenFile().
SetTitle("Open Project").
CanChooseFiles(true).
AddFilter("JSON", "*.json").
AddFilter("All Files", "*").
PromptForSingleSelection()
if err != nil {
return "", err
}
if path == "" {
return "", nil
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
func (s *FileService) SaveProject(content string, defaultName string) error {
if defaultName == "" {
defaultName = "project.json"
}
path, err := s.app.Dialog.SaveFile().
SetMessage("Save Project").
SetFilename(defaultName).
AddFilter("JSON", "*.json").
AddFilter("All Files", "*").
PromptForSingleSelection()
if err != nil {
return err
}
if path == "" {
return nil
}
return os.WriteFile(path, []byte(content), 0o644)
}
func (s *FileService) SaveAudio(data []byte, defaultName string) error {
if defaultName == "" {
defaultName = "keyboard_track.wav"
}
path, err := s.app.Dialog.SaveFile().
SetMessage("Export Audio").
SetFilename(defaultName).
AddFilter("WAV", "*.wav").
AddFilter("All Files", "*").
PromptForSingleSelection()
if err != nil {
return err
}
if path == "" {
return nil
}
return os.WriteFile(path, data, 0o644)
}
func (s *FileService) serveVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.RLock()
path := s.videoPath
modTime := s.videoMod
s.mu.RUnlock()
if path == "" {
http.NotFound(w, r)
return
}
file, err := os.Open(path)
if err != nil {
http.Error(w, "video unavailable", http.StatusNotFound)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
http.Error(w, "video unavailable", http.StatusNotFound)
return
}
if modTime != nil && !info.ModTime().Equal(modTime.ModTime()) {
s.mu.Lock()
s.videoMod = info
s.mu.Unlock()
}
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
@@ -0,0 +1,9 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+36
View File
@@ -0,0 +1,36 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* FileService exposes native file dialogs and streams the current video.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function OpenProject(): $CancellablePromise<string> {
return $Call.ByID(2194602340);
}
export function OpenVideo(): $CancellablePromise<$models.VideoResult> {
return $Call.ByID(392322154).then(($result: any) => {
return $$createType0($result);
});
}
export function SaveAudio(data: string, defaultName: string): $CancellablePromise<void> {
return $Call.ByID(1785178400, data, defaultName);
}
export function SaveProject(content: string, defaultName: string): $CancellablePromise<void> {
return $Call.ByID(2685219959, content, defaultName);
}
// Private type creation functions
const $$createType0 = $models.VideoResult.createFrom;
+11
View File
@@ -0,0 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as FileService from "./fileservice.js";
export {
FileService
};
export {
VideoResult
} from "./models.js";
+34
View File
@@ -0,0 +1,34 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
/**
* VideoResult is returned when the user opens a video file.
*/
export class VideoResult {
"url": string;
"name": string;
/** Creates a new VideoResult instance. */
constructor($$source: Partial<VideoResult> = {}) {
if (!("url" in $$source)) {
this["url"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new VideoResult instance from a string or object.
*/
static createFrom($$source: any = {}): VideoResult {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new VideoResult($$parsedSource as Partial<VideoResult>);
}
}
+18 -3
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"embed" "embed"
"log" "log"
"net/http"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
) )
@@ -11,21 +12,35 @@ import (
var assets embed.FS var assets embed.FS
func main() { func main() {
var fileService *FileService
app := application.New(application.Options{ app := application.New(application.Options{
Name: "sfxkeeb", Name: "sfxkeeb",
Description: "Annotate keyboard key presses on a video timeline", Description: "Annotate keyboard key presses on a video timeline",
Assets: application.AssetOptions{ Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets), Handler: application.AssetFileServerFS(assets),
Middleware: func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == localMediaVideoPath && fileService != nil {
fileService.serveVideo(w, r)
return
}
next.ServeHTTP(w, r)
})
},
}, },
Mac: application.MacOptions{ Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true, ApplicationShouldTerminateAfterLastWindowClosed: true,
}, },
}) })
fileService = NewFileService(app)
app.RegisterService(application.NewService(fileService))
app.Window.NewWithOptions(application.WebviewWindowOptions{ app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "sfxkeeb", Title: "sfxkeeb",
Width: 1280, Width: 1280,
Height: 900, Height: 900,
MinWidth: 960, MinWidth: 960,
MinHeight: 600, MinHeight: 600,
Mac: application.MacWindow{ Mac: application.MacWindow{