Compare commits

..

8 commits

21 changed files with 308 additions and 282 deletions

View file

@ -1,5 +1,12 @@
## [Unreleased]
## v3.0.0
### Fixed
* feat: center live capture in pointer
### Changed
* refactor: change organisation
* feat: use MJPEG for live capture
## v2.1.0
### Added
* add live on pointer block

View file

@ -12,11 +12,12 @@ all: build
deps:
go install github.com/GeertJohan/go.rice/rice@latest
rice embed-go
cd cmd && rice embed-go
.PHONY:
build: deps
CGO_ENABLED=$(CGO_ENABLED) GOARCH=amd64 GOOS=linux $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(LINUX_BIN) -ldflags="$(LDFLAGS)" -gcflags="$(GCFLAGS)" -asmflags="$(ASMFLAGS)"
CGO_ENABLED=$(CGO_ENABLED) GOARCH=amd64 GOOS=linux $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(LINUX_BIN) -ldflags="$(LDFLAGS)" -gcflags="$(GCFLAGS)" -asmflags="$(ASMFLAGS)" -tags=static_build ./cmd/
watch:
gowatch -o build/app-live-linux-amd64 -args='./config.yaml'
test -f cmd/rice-box.go && rm cmd/rice-box.go || true
gowatch -o build/app-live-linux-amd64 -args='./config.yaml' -p ./cmd/main.go

View file

@ -2,25 +2,15 @@ package main
import (
"crypto/subtle"
"embed"
"fmt"
"html/template"
"net/http"
"os"
rice "github.com/GeertJohan/go.rice"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
var (
templates map[string]*template.Template
//go:embed static
staticFiles embed.FS
//go:embed views/layout views/page
views embed.FS
config Config
actions Actions
"gitnet.fr/deblan/remote-i3wm-go/internal/config"
"gitnet.fr/deblan/remote-i3wm-go/internal/handler"
)
func main() {
@ -32,7 +22,7 @@ func main() {
os.Exit(1)
}
value, err := createConfigFromFile(os.Args[1])
value, err := config.CreateConfigFromFile(os.Args[1])
if err != nil {
fmt.Printf("Configuration error:")
@ -40,14 +30,15 @@ func main() {
os.Exit(1)
}
config = value
conf := value
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
if config.Server.Auth.Username == "" && config.Server.Auth.Password == "" {
if conf.Server.Auth.Username == "" && conf.Server.Auth.Password == "" {
return true, nil
}
isValidUsername := subtle.ConstantTimeCompare([]byte(username), []byte(config.Server.Auth.Username)) == 1
isValidPassword := subtle.ConstantTimeCompare([]byte(password), []byte(config.Server.Auth.Password)) == 1
isValidUsername := subtle.ConstantTimeCompare([]byte(username), []byte(conf.Server.Auth.Username)) == 1
isValidPassword := subtle.ConstantTimeCompare([]byte(password), []byte(conf.Server.Auth.Password)) == 1
if isValidUsername && isValidPassword {
return true, nil
@ -56,21 +47,23 @@ func main() {
return false, nil
}))
assetHandler := http.FileServer(rice.MustFindBox("static").HTTPBox())
actions = createActions()
assetHandler := http.FileServer(rice.MustFindBox("../static").HTTPBox())
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", assetHandler)))
e.GET("/manifest.webmanifest", manifestController)
e.GET("/", homeController)
e.GET("/ws", wsController)
e.GET("/manifest.webmanifest", handler.ManifestHandler)
e.GET("/ws", handler.WsHandler)
e.GET("/capture", handler.CaptureHandler)
e.GET("/", func(c echo.Context) error {
return handler.HomeHandler(c, conf)
})
if config.Server.Tls.Enable == false {
e.Logger.Fatal(e.Start(config.Server.Listen))
if conf.Server.Tls.Enable == false {
e.Logger.Fatal(e.Start(conf.Server.Listen))
} else {
e.Logger.Fatal(e.StartTLS(
config.Server.Listen,
config.Server.Tls.CertFile,
config.Server.Tls.CertKeyFile,
conf.Server.Listen,
conf.Server.Tls.CertFile,
conf.Server.Tls.CertKeyFile,
))
}
}

File diff suppressed because one or more lines are too long

View file

@ -102,7 +102,5 @@ remote:
label: Volume
- label: Desktop
items:
- type: screenshot
label: Screenshot
- type: live_video
label: Live video
- type: capture
label: Capture

View file

@ -1,20 +0,0 @@
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
)
type HomeViewParams struct {
Config Config
Now time.Time
}
func homeController(c echo.Context) error {
return c.HTML(http.StatusOK, view("views/page/home.html", HomeViewParams{
Config: config,
Now: time.Now(),
}))
}

View file

@ -1,4 +1,4 @@
package main
package action
import (
"github.com/gorilla/websocket"
@ -8,16 +8,16 @@ type Actions struct {
Functions map[string]func(ws *websocket.Conn, msg []byte) error
}
func (actions Actions) add(name string, callback func(ws *websocket.Conn, msg []byte) error) {
func (actions Actions) Add(name string, callback func(ws *websocket.Conn, msg []byte) error) {
actions.Functions[name] = callback
}
func (actions Actions) has(name string) bool {
func (actions Actions) Has(name string) bool {
_, exists := actions.Functions[name]
return exists
}
func (actions Actions) exec(name string, ws *websocket.Conn, msg []byte) error {
func (actions Actions) Exec(name string, ws *websocket.Conn, msg []byte) error {
return actions.Functions[name](ws, msg)
}

View file

@ -1,4 +1,4 @@
package main
package config
import (
"os"
@ -44,7 +44,7 @@ type Config struct {
Remote []RemoteItem `yaml:"remote"`
}
func createConfigFromFile(file string) (Config, error) {
func CreateConfigFromFile(file string) (Config, error) {
data, err := os.ReadFile(file)
value := Config{}

View file

@ -0,0 +1,68 @@
package handler
import (
"bytes"
"image/color"
"image/jpeg"
"math"
"net/http"
"time"
"github.com/kbinani/screenshot"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/remote-i3wm-go/internal/pointer"
)
func CaptureHandler(c echo.Context) error {
bounds := screenshot.GetDisplayBounds(0)
switch c.QueryParam("type") {
case "screenshot":
if img, err := screenshot.CaptureRect(bounds); err == nil {
var buf bytes.Buffer
jpeg.Encode(&buf, img, nil)
c.Response().Header().Set("Content-Type", "image/jpeg")
return c.Blob(http.StatusOK, "image/jpeg", buf.Bytes())
}
default:
c.Response().Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
for {
if img, err := screenshot.CaptureRect(bounds); err == nil {
var buf bytes.Buffer
if c.QueryParam("pointer") == "1" {
currentX, currentY := pointer.Positions()
pointerSize := 2 * 16.0
pixelColor := color.RGBA{
R: 255,
G: 0,
B: 0,
A: 255,
}
for x := math.Max(0.0, currentX-pointerSize/2); x <= currentX+3; x++ {
for y := math.Max(0.0, currentY-pointerSize/2); y < currentY+3; y++ {
img.SetRGBA(int(x), int(y), pixelColor)
}
}
}
jpeg.Encode(&buf, img, nil)
_, _ = c.Response().Write([]byte("--frame\r\n"))
c.Response().Write([]byte("Content-Type: image/jpeg\r\n\r\n"))
c.Response().Write(buf.Bytes())
c.Response().Write([]byte("\r\n"))
time.Sleep(33 * time.Millisecond)
}
}
}
return nil
}

22
internal/handler/home.go Normal file
View file

@ -0,0 +1,22 @@
package handler
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/remote-i3wm-go/internal/config"
"gitnet.fr/deblan/remote-i3wm-go/internal/render"
)
type HomeViewParams struct {
Config config.Config
Now time.Time
}
func HomeHandler(c echo.Context, conf config.Config) error {
return c.HTML(http.StatusOK, render.View("page/home.html", HomeViewParams{
Config: conf,
Now: time.Now(),
}))
}

View file

@ -1,8 +1,9 @@
package main
package handler
import (
"github.com/labstack/echo/v4"
"net/http"
"github.com/labstack/echo/v4"
)
type ManifestIcon struct {
@ -23,7 +24,7 @@ type Manifest struct {
Icons []ManifestIcon `json:"icons"`
}
func manifestController(c echo.Context) error {
func ManifestHandler(c echo.Context) error {
manifest := &Manifest{
ShortName: "RWM",
Name: "Remote i3WM",

View file

@ -1,13 +1,9 @@
package main
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"image/color"
"image/jpeg"
"math"
"os/exec"
"regexp"
"strconv"
@ -15,8 +11,9 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/kbinani/screenshot"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/remote-i3wm-go/internal/action"
"gitnet.fr/deblan/remote-i3wm-go/internal/pointer"
)
type Message struct {
@ -61,30 +58,12 @@ func sendMessageResponse(ws *websocket.Conn, r MessageResponse) {
ws.WriteMessage(websocket.TextMessage, value)
}
func getPointerPosition() (float64, float64) {
location := exec.Command("xdotool", "getmouselocation")
output, _ := location.Output()
position := string(output)
currentX := 0.0
currentY := 0.0
for key, value := range strings.Split(position, " ") {
if key == 0 {
currentX, _ = strconv.ParseFloat(strings.Replace(value, "x:", "", 1), 32)
} else if key == 1 {
currentY, _ = strconv.ParseFloat(strings.Replace(value, "y:", "", 1), 32)
}
}
return currentX, currentY
}
func createActions() Actions {
actions := Actions{
func createActions() action.Actions {
actions := action.Actions{
Functions: make(map[string]func(ws *websocket.Conn, msg []byte) error),
}
actions.add("pointer", func(ws *websocket.Conn, msg []byte) error {
actions.Add("pointer", func(ws *websocket.Conn, msg []byte) error {
data := PointerMessageData{}
json.Unmarshal([]byte(msg), &data)
@ -106,7 +85,7 @@ func createActions() Actions {
return cmd.Run()
}
currentX, currentY := getPointerPosition()
currentX, currentY := pointer.Positions()
newX, _ := strconv.ParseFloat(data.X, 32)
newY, _ := strconv.ParseFloat(data.Y, 32)
@ -119,7 +98,7 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("scroll", func(ws *websocket.Conn, msg []byte) error {
actions.Add("scroll", func(ws *websocket.Conn, msg []byte) error {
value := getSimpleMessageValue(msg)
key := ""
@ -139,7 +118,7 @@ func createActions() Actions {
return nil
})
actions.add("workspace", func(ws *websocket.Conn, msg []byte) error {
actions.Add("workspace", func(ws *websocket.Conn, msg []byte) error {
value := getSimpleMessageValue(msg)
if value == "" {
@ -151,7 +130,7 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("volume", func(ws *websocket.Conn, msg []byte) error {
actions.Add("volume", func(ws *websocket.Conn, msg []byte) error {
value := getSimpleMessageValue(msg)
if value == "value" {
@ -204,7 +183,7 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("media", func(ws *websocket.Conn, msg []byte) error {
actions.Add("media", func(ws *websocket.Conn, msg []byte) error {
value := getSimpleMessageValue(msg)
if value == "" {
@ -264,7 +243,7 @@ func createActions() Actions {
return nil
})
actions.add("keys", func(ws *websocket.Conn, msg []byte) error {
actions.Add("keys", func(ws *websocket.Conn, msg []byte) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
if value == "" {
@ -298,7 +277,7 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("key", func(ws *websocket.Conn, msg []byte) error {
actions.Add("key", func(ws *websocket.Conn, msg []byte) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
keys := make(map[string]string)
@ -323,7 +302,7 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("text", func(ws *websocket.Conn, msg []byte) error {
actions.Add("text", func(ws *websocket.Conn, msg []byte) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
if value == "" {
@ -335,63 +314,15 @@ func createActions() Actions {
return cmd.Run()
})
actions.add("screenshot", func(ws *websocket.Conn, msg []byte) error {
data := ScreenshotMessageData{}
json.Unmarshal([]byte(msg), &data)
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return errors.New("Capture error")
}
var quality int
if data.Quality == "lq" {
quality = 10
} else {
quality = 90
}
if data.Pointer {
currentX, currentY := getPointerPosition()
pointerSize := 2 * 16.0
pixelColor := color.RGBA{
R: 255,
G: 0,
B: 0,
A: 255,
}
for x := math.Max(0.0, currentX-pointerSize/2); x <= currentX+3; x++ {
for y := math.Max(0.0, currentY-pointerSize/2); y < currentY+3; y++ {
img.SetRGBA(int(x), int(y), pixelColor)
}
}
}
buff := new(bytes.Buffer)
jpeg.Encode(buff, img, &jpeg.Options{Quality: quality})
sendMessageResponse(ws, MessageResponse{
Type: "screenshot",
Value: toBase64(buff.Bytes()),
})
return nil
})
actions.add("messages", func(ws *websocket.Conn, msg []byte) error {
actions.Add("messages", func(ws *websocket.Conn, msg []byte) error {
data := MessagesData{}
json.Unmarshal([]byte(msg), &data)
for _, value := range data.Value {
msg, _ := json.Marshal(value)
if actions.has(value.Type) {
actions.exec(value.Type, ws, msg)
if actions.Has(value.Type) {
actions.Exec(value.Type, ws, msg)
time.Sleep(400 * time.Millisecond)
}
}
@ -404,9 +335,10 @@ func createActions() Actions {
var (
upgrader = websocket.Upgrader{}
actions = createActions()
)
func wsController(c echo.Context) error {
func WsHandler(c echo.Context) error {
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
@ -426,8 +358,8 @@ func wsController(c echo.Context) error {
message := Message{}
json.Unmarshal([]byte(msg), &message)
if message.Type != "" && actions.has(message.Type) {
actions.exec(message.Type, ws, msg)
if message.Type != "" && actions.Has(message.Type) {
actions.Exec(message.Type, ws, msg)
}
sendMessageResponse(ws, MessageResponse{

View file

@ -0,0 +1,25 @@
package pointer
import (
"os/exec"
"strconv"
"strings"
)
func Positions() (float64, float64) {
location := exec.Command("xdotool", "getmouselocation")
output, _ := location.Output()
position := string(output)
currentX := 0.0
currentY := 0.0
for key, value := range strings.Split(position, " ") {
if key == 0 {
currentX, _ = strconv.ParseFloat(strings.Replace(value, "x:", "", 1), 32)
} else if key == 1 {
currentY, _ = strconv.ParseFloat(strings.Replace(value, "y:", "", 1), 32)
}
}
return currentX, currentY
}

17
internal/render/render.go Normal file
View file

@ -0,0 +1,17 @@
package render
import (
"bytes"
"html/template"
"gitnet.fr/deblan/remote-i3wm-go/templates"
)
func View(viewName string, data any) string {
var render bytes.Buffer
view := template.Must(template.ParseFS(templates.Views, viewName, "layout/base.html"))
view.Execute(&render, data)
return render.String()
}

View file

@ -73,6 +73,7 @@ a {
background: #ccc;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
position: absolute;
width: calc(100% - 50px);
-webkit-touch-callout: none;
@ -157,7 +158,7 @@ a {
display: none;
}
#screenshot img {
.capture-img img {
max-width: 100%;
margin-top: 10px;
cursor: pointer;

View file

@ -7,6 +7,7 @@ let wsLock = false
let isPointerLive = false
let isScreenshotWaiting = null
let isPointerScreenshotWaiting = false
let emptyImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC"
function createWebSocketConnection() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
@ -24,7 +25,7 @@ function createWebSocketConnection() {
window.setTimeout(createWebSocketConnection, 5000)
})
ws.addEventListener('message', function(event) {
unLock()
let data = JSON.parse(event.data)
@ -47,27 +48,6 @@ function createWebSocketConnection() {
return
}
if (data.type === 'screenshot') {
if (isScreenshotWaiting || isScreenshotWaiting === null) {
if (isScreenshotWaiting) {
isScreenshotWaiting = false
}
screenshotImg.setAttribute('src', 'data:image/png;base64, ' + data.value)
}
let pointer = document.querySelector('#pointer')
if (isPointerScreenshotWaiting) {
pointer.style.backgroundImage = `url('data:image/png;base64, ${data.value}')`
isPointerScreenshotWaiting = false
} else {
pointer.style.backgroundImage = 'none'
}
}
return
})
}
@ -163,20 +143,27 @@ function textKeyUpHandler(e) {
function liveTextKeyUpHandler(e) {
const value = e.target.value
const size = value.length
const messages = []
if (size > 0) {
messages.push('{"type":"text","value": "' + (value.replace('"', '\\"')) + '"}')
if (value[size-1] === ' ') {
messages.push('{"type":"key","value":"space"}')
}
}
if (e.keyCode === 8) {
send('{"type":"key","value": "backspace"}')
} else if (e.keyCode === 13) {
send('{"type":"key","value": "enter"}')
} else if (value.length) {
if (value === ' ') {
send('{"type":"key","value": "space"}')
} else {
send('{"type":"text","value": "' + (value.replace('"', '\\"')) + '"}')
}
e.target.value = ''
messages.push('{"type":"key","value": "backspace"}')
} else if (e.keyCode === 13 && size === 0) {
messages.push('{"type":"key","value": "enter"}')
}
send(`{"type":"messages","value":[${messages.join(',')}]}`)
e.target.value = ''
}
function shortcutsSpecialKeysOnChangeHandler(e) {
@ -223,26 +210,10 @@ function pointerTouchStartHandler(e) {
function pointerLiveHandler(e) {
if (!e.target.checked) {
isPointerLive = false
isPointerScreenshotWaiting = null
return
pointer.style.backgroundImage = ""
} else {
pointer.style.backgroundImage = `url("/capture?type=live&pointer=1&${Math.random()}")`
}
isPointerLive = true
let doScreenshot = function() {
if (isPointerLive) {
if (!isPointerScreenshotWaiting) {
isPointerScreenshotWaiting = true
ws.send(`{"type":"screenshot","quality":"lq","pointer":true}`)
}
window.setTimeout(doScreenshot, 300)
}
}
doScreenshot()
}
function pointerTouchMoveHandler(e) {
@ -265,46 +236,30 @@ function pointerTouchMoveHandler(e) {
ws.send(msg)
}
function liveHqClickHandler(e) {
return liveClickHandler(e, 'hq')
function capture(mode) {
}
function liveLqClickHandler(e) {
return liveClickHandler(e, 'lq')
function captureScreenshotClickHandler(e) {
const img = e.target.parentNode.querySelector('.capture-img img')
img.src = "/capture?type=screenshot&" + Math.random()
}
function liveClickHandler(e, quality) {
if (isLive) {
isLive = false
isScreenshotWaiting = null
function captureLiveClickHandler(e) {
const img = e.target.parentNode.querySelector('.capture-img img')
document.querySelector('#live-hq').innerText = 'Live HQ'
document.querySelector('#live-lq').innerText = 'Live LQ'
return
if (img.src.indexOf("live") > -1) {
img.src = emptyImg
} else {
img.src = "/capture?type=live&" + Math.random()
}
isLive = true
e.target.innerText = 'Stop live'
let doScreenshot = function() {
if (isLive) {
if (!isScreenshotWaiting) {
isScreenshotWaiting = true
ws.send(`{"type":"screenshot","quality":"${quality}"}`)
}
window.setTimeout(doScreenshot, 100)
}
}
doScreenshot()
}
function fullscreenHandler(e) {
let element = document.querySelector(e.target.getAttribute('data-target'))
let isFullscreen = parseInt(e.target.getAttribute('data-fullscreen'))
const targetConf = e.target.getAttribute('data-target')
const isFullscreen = parseInt(e.target.getAttribute('data-fullscreen'))
const element = (targetConf === 'this')
? e.target
: document.querySelector(targetConf)
document.querySelector('body').classList.toggle('fullscreen', isFullscreen)
@ -362,7 +317,26 @@ function addListeners() {
addEventListenerOn('#text-clear', 'click', textClearClickHandler)
addEventListenerOn('#text-send', 'click', textSendClickHandler)
addEventListenerOn('#text', 'keyup', textKeyUpHandler)
addEventListenerOn('.live-text', 'keyup', liveTextKeyUpHandler)
Array.from(document.querySelectorAll('.live-text')).forEach((element) => {
element.setAttribute('data-composing', '0')
addEventListenerOn(element, 'compositionstart', (e) => {
element.setAttribute('data-composing', '1')
})
addEventListenerOn(element, 'compositionend', (e) => {
element.setAttribute('data-composing', '0')
})
addEventListenerOn(element, 'keyup', (e) => {
if (element.getAttribute('data-composing') === '1') {
return
}
liveTextKeyUpHandler(e)
})
})
addEventListenerOn(scroller, 'touchstart', scrollerTouchStartHandler)
addEventListenerOn(scroller, 'touchmove', scrollerTouchMoveHandler)
@ -372,8 +346,9 @@ function addListeners() {
addEventListenerOn(pointer, 'touchmove', pointerTouchMoveHandler)
addEventListenerOn('#mouse-screenshot-live input', 'change', pointerLiveHandler)
addEventListenerOn('#live-hq', 'click', liveHqClickHandler)
addEventListenerOn('#live-lq', 'click', liveLqClickHandler)
addEventListenerOn('.capture-live', 'click', captureLiveClickHandler)
addEventListenerOn('.capture-screenshot', 'click', captureScreenshotClickHandler)
addEventListenerOn('.btn-fullscreen', 'click', fullscreenHandler)
}

8
static/static.go Normal file
View file

@ -0,0 +1,8 @@
package static
import "embed"
var (
//go:embed css/* img/* js/*
Files embed.FS
)

View file

@ -30,7 +30,8 @@
{{if eq $value.Type "live_text"}}
<div class="form-group col-12">
<input type="text" class="form-control live-text" name="text">
<input type="text" class="form-control live-text" name="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<pre></pre>
</div>
{{end}}
@ -115,7 +116,7 @@
<input type="checkbox"> Screen
</label>
</div>
<input type="text" id="mouse-text-live" class="form-control live-text" placeholder="Live text">
<input type="text" id="mouse-text-live" class="form-control live-text" placeholder="Live text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
</div/>
<div id="scrollbar"></div>
<div id="pointer"></div>
@ -124,18 +125,17 @@
</div>
{{end}}
{{if eq $value.Type "screenshot"}}
{{if eq $value.Type "capture"}}
<div class="col-12">
<button type="button" data-msg='{"type":"screenshot","quality":"hq"}' class="btn btn-secondary">Screenshot HQ</button>
<button type="button" data-msg='{"type":"screenshot","quality":"lq"}' class="btn btn-secondary">Screenshot LQ</button>
</div>
{{end}}
{{if eq $value.Type "live_video"}}
<div class="col-12">
<button type="button" id="live-hq" class="btn btn-secondary">Live HQ</button>
<button type="button" id="live-lq" class="btn btn-secondary">Live LQ</button>
<div id="screenshot"><img class="btn-fullscreen" data-target="#screenshot img" src="data:image/png; base64, iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC"></div>
<button type="button" class="btn btn-secondary capture-screenshot">
Screenshot
</button>
<button type="button" class="btn btn-secondary capture-live">
Live
</button>
<div class="capture-img">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC" class="btn-fullscreen" data-target="this">
</div>
</div>
{{end}}

8
templates/templates.go Normal file
View file

@ -0,0 +1,8 @@
package templates
import "embed"
var (
//go:embed layout page
Views embed.FS
)

View file

@ -1,20 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"html/template"
)
func view(viewName string, data any) string {
var render bytes.Buffer
view := template.Must(template.ParseFS(views, viewName, "views/layout/base.html"))
view.Execute(&render, data)
return render.String()
}
func toBase64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}