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:
+161
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,17 +12,31 @@ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user