Compare commits

...

18 Commits

Author SHA1 Message Date
0a41b1072a Add LICENSE.md 2025-06-14 22:14:36 +02:00
d7b8ad9976 feat: add editing of remotes 2025-04-15 18:47:43 +02:00
6a9c69a535 feat: add edit command 2025-04-15 16:04:38 +02:00
8f3832657b feat: add download remotes as file 2025-04-15 00:26:34 +02:00
e09d42ecf6 feat: add toggle to display remote control 2025-04-14 23:56:51 +02:00
23245dd549 feat: add remote control to integration view 2025-04-14 23:39:34 +02:00
b0c3a48da6 refactor: improve command constants 2025-04-14 23:38:51 +02:00
f83ffac0ce feat: add get remotes in web interface 2025-04-14 23:37:07 +02:00
83ab71f845 feat: add get remote api endpoint 2025-04-14 23:34:59 +02:00
6940782024 feat: add data channel to webrtc connection 2025-04-14 13:59:59 +02:00
6cefa7392c feat: persisting remotes and commands in database 2025-04-12 16:35:03 +02:00
db1beac033 feat: add sub menu navigation 2025-04-10 22:22:42 +02:00
318185bd4a feat: change objects to instances passed to remotes service 2025-04-10 20:02:10 +02:00
7a20ae1536 feat: add import commands modal 2025-04-10 19:46:54 +02:00
3ba3f5efd5 feat: change dropdown to select in create command 2025-04-08 20:54:16 +02:00
820caf137f feat: add delete remotes 2025-04-08 20:39:34 +02:00
0c6a302a92 fix: new command not listed in create remote modal 2025-04-08 20:07:42 +02:00
01aff1635d feat: implement list-manager 2025-04-08 19:52:57 +02:00
39 changed files with 3040 additions and 497 deletions

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Fritz Heiden
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

75
data/command.go Normal file
View File

@ -0,0 +1,75 @@
package data
const SAMSUNG_PROTOCOL = "samsung"
const NEC_PROTOCOL = "nec"
const ONKYO_PROTOCOL = "onkyo"
const APPLE_PROTOCOL = "apple"
const DENON_PROTOCOL = "denon"
const SHARP_PROTOCOL = "sharp"
const PANASONIC_PROTOCOL = "panasonic"
const KASEIKYO_PROTOCOL = "kaseikyo"
const JVC_PROTOCOL = "jvc"
const LG_PROTOCOL = "lg"
const SONY_PROTOCOL = "sony"
const RC5_PROTOCOL = "rc5"
const RC6_PROTOCOL = "rc6"
const UNIVERSAL_PULSE_DISTANCE_PROTOCOL = "universal_pulse_distance"
const UNIVERSAL_PULSE_WIDTH_PROTOCOL = "universal_pulse_width"
const UNIVERSAL_PULSE_DISTANCE_WIDTH_PROTOCOL = "universal_pulse_distance_width"
const HASH_PROTOCOL = "hash"
const PRONTO_PROTOCOL = "pronto"
const BOSE_WAVE_PROTOCOL = "bose_wave"
const BANG_OLUFSEN_PROTOCOL = "bang_olufsen"
const LEGO_PROTOCOL = "lego"
const FAST_PROTOCOL = "fast"
const WHYNTER_PROTOCOL = "whynter"
const MAGIQUEST_PROTOCOL = "magiquest"
const POWER_COMMAND_TYPE = "power"
const INPUT_COMMAND_TYPE = "input"
const ONE_COMMAND_TYPE = "1"
const TWO_COMMAND_TYPE = "2"
const THREE_COMMAND_TYPE = "3"
const FOUR_COMMAND_TYPE = "4"
const FIVE_COMMAND_TYPE = "5"
const SIX_COMMAND_TYPE = "6"
const SEVEN_COMMAND_TYPE = "7"
const EIGHT_COMMAND_TYPE = "8"
const NINE_COMMAND_TYPE = "9"
const ZERO_COMMAND_TYPE = "0"
const VOLUME_UP_COMMAND_TYPE = "volume_up"
const VOLUME_DOWN_COMMAND_TYPE = "volume_down"
const MUTE_COMMAND_TYPE = "mute"
const CHANNEL_UP_COMMAND_TYPE = "channel_up"
const CHANNEL_DOWN_COMMAND_TYPE = "channel_down"
const MENU_COMMAND_TYPE = "menu"
const HOME_COMMAND_TYPE = "home"
const SETTINGS_COMMAND_TYPE = "settings"
const OPTIONS_COMMAND_TYPE = "options"
const UP_COMMAND_TYPE = "up"
const DOWN_COMMAND_TYPE = "down"
const LEFT_COMMAND_TYPE = "left"
const RIGHT_COMMAND_TYPE = "right"
const ENTER_COMMAND_TYPE = "enter"
const INFO_COMMAND_TYPE = "info"
const RETURN_COMMAND_TYPE = "return"
const EXIT_COMMAND_TYPE = "exit"
const RED_COMMAND_TYPE = "red"
const GREEN_COMMAND_TYPE = "green"
const YELLOW_COMMAND_TYPE = "yellow"
const BLUE_COMMAND_TYPE = "blue"
const REWIND_COMMAND_TYPE = "rewind"
const PLAY_COMMAND_TYPE = "play"
const PAUSE_COMMAND_TYPE = "pause"
const STOP_COMMAND_TYPE = "stop"
const FORWARD_COMMAND_TYPE = "forward"
const OTHER_COMMAND_TYPE = "other"
type Command struct {
Id string `json:"id"`
Protocol string `json:"protocol"`
CommandNumber int `json:"commandNumber"`
Device int `json:"device"`
CommandType string `json:"commandType"`
Title string `json:"title"`
}

7
data/remote.go Normal file
View File

@ -0,0 +1,7 @@
package data
type Remote struct {
Id string `json:"id"`
Title string `json:"title"`
Commands []Command `json:"commands"`
}

255
data/remote_database.go Normal file
View File

@ -0,0 +1,255 @@
package data
import (
"database/sql"
"fmt"
"path/filepath"
gonanoid "github.com/matoous/go-nanoid"
)
type RemoteDatabase struct {
Connection *sql.DB
databaseDirectory string
}
func (db *RemoteDatabase) Initialize() error {
connection, error := sql.Open("sqlite3", filepath.Join(db.databaseDirectory, "remotes.db"))
if error != nil {
return error
}
db.Connection = connection
_, error = db.Connection.Exec(`PRAGMA foreign_keys = ON;`)
if error != nil {
return fmt.Errorf("error enabling foreign keys: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS Remotes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL
);`)
if error != nil {
return fmt.Errorf("error creating remotes table: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS Commands (
id TEXT PRIMARY KEY,
protocol TEXT,
commandNumber INTEGER,
device INTEGER,
commandType TEXT,
title TEXT
);`)
if error != nil {
return fmt.Errorf("error creating commands table: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS RemoteCommands (
remote_id TEXT NOT NULL,
command_id TEXT NOT NULL,
PRIMARY KEY (remote_id, command_id),
FOREIGN KEY (remote_id) REFERENCES Remotes(id) ON DELETE CASCADE,
FOREIGN KEY (command_id) REFERENCES Commands(id) ON DELETE CASCADE
);`)
if error != nil {
return fmt.Errorf("error creating remote-commands table: %s", error)
}
return nil
}
func (db *RemoteDatabase) Close() error {
return db.Connection.Close()
}
func (db *RemoteDatabase) CreateRemote(remote Remote) (string, error) {
remoteId, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
queryString := "INSERT INTO Remotes (id, title) VALUES (?, ?)"
_, err = db.Connection.Exec(queryString, remoteId, remote.Title)
if err != nil {
return "", err
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return "", err
}
return remoteId, nil
}
func (db *RemoteDatabase) CreateRemoteCommands(remoteId string, commandIds []string) error {
for _, commandId := range commandIds {
queryString := "INSERT INTO RemoteCommands (remote_id, command_id) VALUES (?, ?)"
_, err := db.Connection.Exec(queryString, remoteId, commandId)
if err != nil {
return err
}
}
return nil
}
func (db *RemoteDatabase) UpdateRemoteCommands(remoteId string, commandIds []string) error {
queryString := "DELETE FROM RemoteCommands WHERE remote_id = ?"
_, err := db.Connection.Exec(queryString, remoteId)
if err != nil {
return err
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) GetRemote(remoteId string) (Remote, error) {
var remote Remote
queryString := "SELECT id, title FROM Remotes WHERE id = ?"
row := db.Connection.QueryRow(queryString, remoteId)
error := row.Scan(&remote.Id, &remote.Title)
if error != nil {
return Remote{}, fmt.Errorf("error scanning remote: %s", error)
}
commands, error := db.GetCommandsByRemoteId(remoteId)
if error != nil {
return Remote{}, error
}
remote.Commands = commands
return remote, nil
}
func (db *RemoteDatabase) GetRemotes() ([]Remote, error) {
rows, error := db.Connection.Query("SELECT id, title FROM Remotes")
if error != nil {
return nil, fmt.Errorf("error querying remotes: %s", error)
}
defer rows.Close()
remotes := []Remote{}
for rows.Next() {
var remote Remote
error = rows.Scan(&remote.Id, &remote.Title)
if error != nil {
return nil, fmt.Errorf("error scanning remote: %s", error)
}
remotes = append(remotes, remote)
}
return remotes, nil
}
func (db *RemoteDatabase) UpdateRemote(remote Remote) error {
queryString := "UPDATE Remotes SET title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, remote.Title, remote.Id)
if error != nil {
return fmt.Errorf("error updating remote %s: %s", remote.Id, error)
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err := db.UpdateRemoteCommands(remote.Id, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) DeleteRemote(remoteId string) error {
queryString := "DELETE FROM Remotes WHERE id = ?"
_, error := db.Connection.Exec(queryString, remoteId)
if error != nil {
return fmt.Errorf("error deleting remote %s: %s", remoteId, error)
}
return nil
}
func (db *RemoteDatabase) GetCommandsByRemoteId(remoteId string) ([]Command, error) {
rows, error := db.Connection.Query("SELECT id, protocol, commandNumber, device, commandType, title FROM Commands WHERE id IN (SELECT command_id FROM RemoteCommands WHERE remote_id = ?)", remoteId)
if error != nil {
return nil, fmt.Errorf("error querying commands for remote %s: %s", remoteId, error)
}
defer rows.Close()
commands := []Command{}
for rows.Next() {
var command Command
error = rows.Scan(&command.Id, &command.Protocol, &command.CommandNumber, &command.Device, &command.CommandType, &command.Title)
if error != nil {
return nil, fmt.Errorf("error scanning command: %s", error)
}
commands = append(commands, command)
}
return commands, nil
}
func (db *RemoteDatabase) CreateCommand(command Command) (string, error) {
commandId, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
queryString := "INSERT INTO Commands (id, protocol, commandNumber, device, commandType, title) VALUES (?, ?, ?, ?, ?, ?)"
_, err = db.Connection.Exec(queryString, commandId, command.Protocol, command.CommandNumber, command.Device, command.CommandType, command.Title)
if err != nil {
return "", err
}
return commandId, nil
}
func (db *RemoteDatabase) CreateCommands(commands []Command) error {
for _, command := range commands {
_, err := db.CreateCommand(command)
if err != nil {
return err
}
}
return nil
}
func (db *RemoteDatabase) GetCommands() ([]Command, error) {
rows, error := db.Connection.Query("SELECT id, protocol, commandNumber, device, commandType, title FROM Commands")
if error != nil {
return nil, fmt.Errorf("error querying commands: %s", error)
}
defer rows.Close()
commands := []Command{}
for rows.Next() {
var command Command
error = rows.Scan(&command.Id, &command.Protocol, &command.CommandNumber, &command.Device, &command.CommandType, &command.Title)
if error != nil {
return nil, fmt.Errorf("error scanning command: %s", error)
}
commands = append(commands, command)
}
return commands, nil
}
func (db *RemoteDatabase) UpdateCommand(command Command) error {
queryString := "UPDATE Commands SET protocol = ?, commandNumber = ?, device = ?, commandType = ?, title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, command.Protocol, command.CommandNumber, command.Device, command.CommandType, command.Title, command.Id)
if error != nil {
return fmt.Errorf("error updating command %s: %s", command.Id, error)
}
return nil
}
func (db *RemoteDatabase) DeleteCommand(commandId string) error {
queryString := "DELETE FROM Commands WHERE id = ?"
_, error := db.Connection.Exec(queryString, commandId)
if error != nil {
return fmt.Errorf("error deleting command %s: %s", commandId, error)
}
return nil
}
func (db *RemoteDatabase) SetDirectory(directory string) {
db.databaseDirectory = directory
}

View File

@ -39,6 +39,15 @@ func main() {
}
defer deviceDatabase.Close()
remoteDatabase := data.RemoteDatabase{}
remoteDatabase.SetDirectory(configuration.DatabaseDirectory)
err = remoteDatabase.Initialize()
if err != nil {
log.Error().Err(err).Msg("failed to initialize remote database")
os.Exit(1)
}
defer remoteDatabase.Close()
userManager := management.UserManager{}
err = userManager.Initialize(&userDatabase)
if err != nil {
@ -51,6 +60,12 @@ func main() {
log.Error().Err(err).Msg("failed to initialize device manager")
}
remoteManager := management.RemoteManager{}
err = remoteManager.Initialize(&remoteDatabase)
if err != nil {
log.Error().Err(err).Msg("failed to initialize remote manager")
}
webServer := server.WebServer{}
webServer.SetWebAppDirectoryPath("www")
webServer.SetPort(configuration.Port)
@ -71,6 +86,11 @@ func main() {
deviceApiHandler.SetRouter(webServer.Router())
deviceApiHandler.Initialize(&authenticator)
remoteApiHandler := server.RemoteApiHandler{}
remoteApiHandler.SetRemoteManager(&remoteManager)
remoteApiHandler.SetRouter(webServer.Router())
remoteApiHandler.Initialize(&authenticator)
webSocketServer := server.WebsocketServer{}
webSocketServer.SetRouter(webServer.Router())
webSocketServer.Initialize(&authenticator)

View File

@ -0,0 +1,66 @@
package management
import (
d "playback-device-server/data"
)
type RemoteManager struct {
remoteDatabase *d.RemoteDatabase
}
func (rm *RemoteManager) Initialize(remoteDatabase *d.RemoteDatabase) error {
rm.remoteDatabase = remoteDatabase
return nil
}
func (rm *RemoteManager) CreateRemote(remote d.Remote) (string, error) {
return rm.remoteDatabase.CreateRemote(remote)
}
func (rm *RemoteManager) GetRemote(remoteID string) (d.Remote, error) {
remote, err := rm.remoteDatabase.GetRemote(remoteID)
if err != nil {
return d.Remote{}, err
}
return remote, nil
}
func (rm *RemoteManager) GetRemotes() ([]d.Remote, error) {
remotes, err := rm.remoteDatabase.GetRemotes()
if err != nil {
return nil, err
}
return remotes, nil
}
func (rm *RemoteManager) UpdateRemote(remote d.Remote) error {
return rm.remoteDatabase.UpdateRemote(remote)
}
func (rm *RemoteManager) DeleteRemote(remoteID string) error {
return rm.remoteDatabase.DeleteRemote(remoteID)
}
func (rm *RemoteManager) CreateCommand(command d.Command) (string, error) {
return rm.remoteDatabase.CreateCommand(command)
}
func (rm *RemoteManager) CreateCommands(commands []d.Command) error {
return rm.remoteDatabase.CreateCommands(commands)
}
func (rm *RemoteManager) GetCommands() ([]d.Command, error) {
return rm.remoteDatabase.GetCommands()
}
func (rm *RemoteManager) UpdateCommand(command d.Command) error {
return rm.remoteDatabase.UpdateCommand(command)
}
func (rm *RemoteManager) SetRemoteDatabase(remoteDatabase *d.RemoteDatabase) {
rm.remoteDatabase = remoteDatabase
}
func (rm *RemoteManager) DeleteCommand(commandID string) error {
return rm.remoteDatabase.DeleteCommand(commandID)
}

View File

@ -0,0 +1,146 @@
package server
import (
d "playback-device-server/data"
m "playback-device-server/management"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
type RemoteApiHandler struct {
router *echo.Echo
remoteManager *m.RemoteManager
}
func (r *RemoteApiHandler) Initialize(authenticator *Authenticator) {
r.router.Use(authenticator.Authenticate("/api/remotes", []string{}))
remotesApi := r.router.Group("/api/remotes")
remotesApi.GET("", r.handleGetRemotes)
remotesApi.GET("/:id", r.handleGetRemote)
remotesApi.POST("", r.handleCreateRemote)
remotesApi.PUT("/:id", r.handleUpdateRemote)
remotesApi.DELETE("/:id", r.handleDeleteRemote)
r.router.Use(authenticator.Authenticate("/api/commands", []string{}))
commandsApi := r.router.Group("/api/commands")
commandsApi.GET("", r.handleGetCommands)
commandsApi.POST("", r.handleCreateCommands)
commandsApi.PUT("/:id", r.handleUpdateCommand)
commandsApi.DELETE("/:id", r.handleDeleteCommand)
}
func (r *RemoteApiHandler) handleCreateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
id, err := r.remoteManager.CreateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
remote.Id = id
return context.JSON(200, remote)
}
func (r *RemoteApiHandler) handleGetRemote(context echo.Context) error {
id := context.Param("id")
remote, err := r.remoteManager.GetRemote(id)
if err != nil {
SendError(500, context, err.Error())
log.Error().Err(err)
return err
}
return context.JSON(200, remote)
}
func (r *RemoteApiHandler) handleGetRemotes(context echo.Context) error {
remotes, err := r.remoteManager.GetRemotes()
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, remotes)
}
func (r *RemoteApiHandler) handleUpdateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteRemote(context echo.Context) error {
id := context.Param("id")
err := r.remoteManager.DeleteRemote(id)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleCreateCommands(context echo.Context) error {
commands := []d.Command{}
if err := context.Bind(&commands); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.CreateCommands(commands)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleGetCommands(context echo.Context) error {
commands, err := r.remoteManager.GetCommands()
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, commands)
}
func (r *RemoteApiHandler) handleUpdateCommand(context echo.Context) error {
command := d.Command{}
if err := context.Bind(&command); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateCommand(command)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteCommand(context echo.Context) error {
id := context.Param("id")
err := r.remoteManager.DeleteCommand(id)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) SetRouter(router *echo.Echo) {
r.router = router
}
func (r *RemoteApiHandler) SetRemoteManager(remoteManager *m.RemoteManager) {
r.remoteManager = remoteManager
}

View File

@ -14,6 +14,8 @@
href="./src/lib/bootstrap-icons-1.11.3/font/bootstrap-icons.css"
rel="stylesheet"
/>
<script src="./lib/papaparse-5.5.2.min.js"></script>
<script src="./lib/fusejs-7.1.0.min.js"></script>
<script src="./lib/popper.min.js"></script>
<script src="./lib/bootstrap-5.3.3-dist/js/bootstrap.bundle.min.js"></script>
<title>Playback Device Server</title>

View File

@ -0,0 +1,183 @@
import { createMemo, createSignal, mergeProps } from "solid-js";
function ListManager(props) {
props = mergeProps(
{
items: [],
availableItems: [],
style: "",
itemsTitle: "Selected items",
availableItemsTitle: "Available items",
itemToString: () => "",
onItemSelect: () => {},
onItemDeselect: () => {},
itemsEqual: (a, b) => a === b,
},
props
);
const itemToString = (item) => props.itemToString(item);
const byLabel = (a, b) => itemToString(a).localeCompare(itemToString(b));
const [selectedAvailableItemIndex, setSelectedAvailableItemIndex] =
createSignal(-1);
const [selectedItemIndex, setSelectedItemIndex] = createSignal(-1);
const [itemsSearchString, setItemsSearchString] = createSignal("");
const [availableItemsSearchString, setAvailableItemsSearchString] =
createSignal("");
const itemsFuse = createMemo(
() =>
new Fuse(props.items, {
keys: [{ name: "label", getFn: (item) => props.itemToString(item) }],
})
);
const availableItemsFuse = createMemo(
() =>
new Fuse(props.availableItems, {
keys: [{ name: "label", getFn: (item) => props.itemToString(item) }],
})
);
const selectableAvailableItems = createMemo(() =>
(availableItemsSearchString()
? availableItemsFuse()
.search(availableItemsSearchString())
.map((item) => item.item)
: props.availableItems
).filter(
(availableItem) =>
!props.items.find((item) => props.itemsEqual(item, availableItem))
)
);
const selectableItems = createMemo(() =>
itemsSearchString()
? itemsFuse()
.search(itemsSearchString())
.map((item) => item.item)
: props.items
);
const canSelect = createMemo(
() =>
selectedAvailableItemIndex() >= 0 &&
selectedAvailableItemIndex() < selectableAvailableItems().length
);
const canDeselect = createMemo(
() =>
selectedItemIndex() >= 0 && selectedItemIndex() < selectableItems().length
);
function handleSelectItem() {
let itemIndex = selectedAvailableItemIndex();
let item = selectableAvailableItems()[itemIndex];
if (itemIndex === selectableAvailableItems().length - 1) {
setSelectedAvailableItemIndex(itemIndex - 1);
}
props.onItemSelect(item);
}
function handleDeselectItem() {
let itemIndex = selectedItemIndex();
let item = selectableItems()[itemIndex];
if (itemIndex === selectableItems().length - 1) {
setSelectedItemIndex(itemIndex - 1);
}
props.onItemDeselect(item);
}
function ListItem(props) {
props = mergeProps(
{ children: "", selected: false, onClick: () => {} },
props
);
return (
<div
class={"px-2" + (props.selected ? " bg-secondary" : "")}
role="button"
onClick={props.onClick}
>
{props.children}
</div>
);
}
function ItemList(props) {
props = mergeProps(
{
items: [],
onItemSelected: () => {},
selectedItemIndex: -1,
onSearchStringChange: () => {},
},
props
);
return (
<>
<div class="input-group input-group-sm">
<span
class="input-group-text border-bottom-0 rounded-bottom-0"
id="basic-addon1"
>
<i class="bi bi-search"></i>
</span>
<input
type="text"
class="form-control form-control-sm border-bottom-0 rounded-bottom-0"
onInput={(event) => props.onSearchStringChange(event.target.value)}
/>
</div>
<div class="rounded rounded-top-0 border bg-body flex-fill overflow-y-scroll">
{props.items.sort(byLabel).map((item, index) => (
<ListItem
onClick={() => props.onItemSelected(index)}
selected={index === props.selectedItemIndex}
>
{itemToString(item)}
</ListItem>
))}
</div>
</>
);
}
return (
<div class={"d-flex"} style={props.style}>
<div class="flex-shrink-1 w-50 d-flex flex-column">
<div class="px-2">{props.itemsTitle}</div>
<ItemList
items={selectableItems()}
onItemSelected={(index) => setSelectedItemIndex(index)}
selectedItemIndex={selectedItemIndex()}
onSearchStringChange={(value) => setItemsSearchString(value)}
/>
</div>
<div class="p-2 d-flex flex-column justify-content-center align-items-center">
<button
class="btn btn-sm btn-outline-secondary mb-2"
onClick={handleSelectItem}
disabled={!canSelect()}
>
<i class="bi bi-caret-left-fill"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary"
onClick={handleDeselectItem}
disabled={!canDeselect()}
>
<i class="bi bi-caret-right-fill"></i>
</button>
</div>
<div class="flex-shrink-1 w-50 d-flex flex-column">
<div class="px-2">{props.availableItemsTitle}</div>
<ItemList
items={selectableAvailableItems()}
onItemSelected={(index) => setSelectedAvailableItemIndex(index)}
selectedItemIndex={selectedAvailableItemIndex()}
onSearchStringChange={(value) => setAvailableItemsSearchString(value)}
/>
</div>
</div>
);
}
export default ListManager;

View File

@ -8,7 +8,17 @@ import {
} from "solid-js";
function List(props) {
props = mergeProps({ items: [], showHeader: true, selectable: true }, props);
props = mergeProps(
{
items: [],
showHeader: true,
selectable: true,
onListItemsSelect: () => {},
onLazyLoad: () => {},
onListItemClick: () => {},
},
props
);
const [listItems, setListItems] = createSignal([]);
const selectedItems = createMemo(() =>
listItems()
@ -31,16 +41,13 @@ function List(props) {
);
});
createEffect(() => {
if (!props.onListItemsSelect) return;
props.onListItemsSelect(selectedItems());
});
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (props.onLazyLoad) {
props.onLazyLoad();
}
props.onLazyLoad();
}
});
});
@ -61,7 +68,6 @@ function List(props) {
});
function handleListItemClick(item) {
if (!props.onListItemClick) return;
props.onListItemClick(item);
}

View File

@ -0,0 +1,180 @@
import { createEffect, createMemo, mergeProps } from "solid-js";
import Remote from "../data/remote";
import Command from "../data/command";
function RemoteControl(props) {
props = mergeProps(
{
remote: new Remote(),
onCommand: () => {},
},
props
);
const BUTTON_SIZE_REGULAR = "regular";
const BUTTON_SIZE_SMALL = "small";
const BUTTON_HEIGHT = 2;
const BUTTON_WIDTH = BUTTON_HEIGHT * 1.4;
const SMALL_BUTTON_HEIGHT = 1.5;
const SMALL_BUTTON_WIDTH = SMALL_BUTTON_HEIGHT * 1.3334;
const TYPES = Command.TYPES;
const commandMap = createMemo(() =>
props.remote
.getCommands()
.reduce(
(map, command) => ({ ...map, [command.getCommandType()]: command }),
{}
)
);
let layout = [
[TYPES.POWER, null, TYPES.INPUT],
[TYPES.ONE, TYPES.TWO, TYPES.THREE],
[TYPES.FOUR, TYPES.FIVE, TYPES.SIX],
[TYPES.SEVEN, TYPES.EIGHT, TYPES.NINE],
[TYPES.VOLUME_UP, TYPES.ZERO, TYPES.CHANNEL_UP],
[TYPES.VOLUME_DOWN, TYPES.MUTE, TYPES.CHANNEL_DOWN],
[TYPES.MENU, TYPES.HOME, TYPES.SETTINGS],
[TYPES.OPTIONS, TYPES.UP, TYPES.INFO],
[TYPES.LEFT, TYPES.ENTER, TYPES.RIGHT],
[TYPES.RETURN, TYPES.DOWN, TYPES.EXIT],
[TYPES.RED, TYPES.GREEN, TYPES.YELLOW, TYPES.BLUE],
[TYPES.PLAY, TYPES.PAUSE, TYPES.STOP],
[TYPES.REWIND, null, TYPES.FORWARD],
];
function toButtonProps(type) {
let mapping = {
[TYPES.POWER]: () => ({ icon: "bi-power", text: "" }),
[TYPES.INPUT]: () => ({ icon: "bi-box-arrow-in-right", text: "" }),
[TYPES.VOLUME_UP]: () => ({ icon: "bi-volume-up", text: "" }),
[TYPES.VOLUME_DOWN]: () => ({ icon: "bi-volume-down", text: "" }),
[TYPES.MUTE]: () => ({ icon: "bi-volume-mute", text: "" }),
[TYPES.CHANNEL_UP]: () => ({ icon: "bi-chevron-up", text: "" }),
[TYPES.CHANNEL_DOWN]: () => ({ icon: "bi-chevron-down", text: "" }),
[TYPES.HOME]: () => ({ icon: "bi-house-door", text: "" }),
[TYPES.SETTINGS]: () => ({ icon: "bi-gear", text: "" }),
[TYPES.INFO]: () => ({ icon: "bi-info-circle", text: "" }),
[TYPES.UP]: () => ({ icon: "bi-arrow-up", text: "" }),
[TYPES.DOWN]: () => ({ icon: "bi-arrow-down", text: "" }),
[TYPES.LEFT]: () => ({ icon: "bi-arrow-left", text: "" }),
[TYPES.RIGHT]: () => ({ icon: "bi-arrow-right", text: "" }),
[TYPES.ENTER]: () => ({ icon: "", text: "OK" }),
[TYPES.EXIT]: () => ({ icon: "", text: "EXIT" }),
[TYPES.OPTIONS]: () => ({ icon: "bi-list-task", text: "" }),
[TYPES.RETURN]: () => ({ icon: "bi-arrow-return-left", text: "" }),
[TYPES.ONE]: () => ({ icon: "", text: "1" }),
[TYPES.TWO]: () => ({ icon: "", text: "2" }),
[TYPES.THREE]: () => ({ icon: "", text: "3" }),
[TYPES.FOUR]: () => ({ icon: "", text: "4" }),
[TYPES.FIVE]: () => ({ icon: "", text: "5" }),
[TYPES.SIX]: () => ({ icon: "", text: "6" }),
[TYPES.SEVEN]: () => ({ icon: "", text: "7" }),
[TYPES.EIGHT]: () => ({ icon: "", text: "8" }),
[TYPES.NINE]: () => ({ icon: "", text: "9" }),
[TYPES.ZERO]: () => ({ icon: "", text: "0" }),
[TYPES.MENU]: () => ({ icon: "", text: "MENU" }),
[TYPES.PLAY]: () => ({ icon: "bi-play", text: "" }),
[TYPES.PAUSE]: () => ({ icon: "bi-pause", text: "" }),
[TYPES.STOP]: () => ({ icon: "bi-stop", text: "" }),
[TYPES.REWIND]: () => ({ icon: "bi-rewind", text: "" }),
[TYPES.FORWARD]: () => ({ icon: "bi-fast-forward", text: "" }),
[TYPES.RED]: () => ({
class: "bg-danger",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.GREEN]: () => ({
class: "bg-success",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.YELLOW]: () => ({
class: "bg-warning",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.BLUE]: () => ({
class: "bg-primary",
buttonSize: BUTTON_SIZE_SMALL,
}),
};
if (!(type in mapping)) return {};
let props = mapping[type]();
props.type = type;
props.text = props.text || "";
return props;
}
function PlaceholderButton() {
return (
<div
style={`width: ${BUTTON_WIDTH}em; height: ${BUTTON_HEIGHT}em; margin: 0.2em;`}
></div>
);
}
function RemoteButton(props) {
props = mergeProps(
{
onClick: () => {},
disabled: false,
class: "",
buttonSize: BUTTON_SIZE_REGULAR,
icon: "",
text: "",
},
props
);
let buttonWidth = BUTTON_WIDTH;
let buttonHeight = BUTTON_HEIGHT;
if (props.buttonSize === BUTTON_SIZE_SMALL) {
buttonWidth = SMALL_BUTTON_WIDTH;
buttonHeight = SMALL_BUTTON_HEIGHT;
}
let buttonFontSize = buttonHeight * 0.4;
let iconSize = buttonHeight * 0.5;
return (
<button
class={
"btn btn-dark d-flex justify-content-center align-items-center " +
props.class
}
style={`width: ${buttonWidth}em; height: ${buttonHeight}em; margin: 0.2em;`}
onClick={props.onClick}
disabled={props.disabled}
>
<i style={`font-size: ${iconSize}em;`} class={"bi " + props.icon} />
<span style={`font-size: ${buttonFontSize}em;`}>{props.text}</span>
</button>
);
}
return (
<div class="d-flex flex-column justify-content-center align-items-center p-1 bg-secondary rounded">
{layout.map((row) => (
<div class="d-flex flex-row justify-content-center align-items-center">
{row
.map(toButtonProps)
.map(({ type, class: className, buttonSize, icon, text }) =>
!type ? (
<PlaceholderButton />
) : (
<RemoteButton
class={className}
buttonSize={buttonSize}
onClick={() => props.onCommand(commandMap()[type])}
disabled={!commandMap()[type]}
icon={icon}
text={text}
/>
)
)}
</div>
))}
</div>
);
}
export default RemoteControl;

View File

@ -2,7 +2,13 @@ import { createSignal, mergeProps } from "solid-js";
function ValidatedTextInput(props) {
props = mergeProps(
{ type: "text", valid: true, onInput: () => {}, errorText: "" },
{
type: "text",
valid: true,
onInput: () => {},
errorText: "",
placeholder: "",
},
props
);
let [isActive, setActive] = createSignal(false);
@ -15,6 +21,7 @@ function ValidatedTextInput(props) {
}
id={props.id}
value={props.value}
placeholder={props.placeholder}
onInput={props.onInput}
onFocusOut={() => setActive(true)}
/>

View File

@ -1,11 +1,14 @@
function Command({
id,
protocol,
command: commandNumber,
device,
commandType,
title,
id = "",
protocol = "",
commandNumber = -1,
device = -1,
commandType = "",
title = "",
} = {}) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
if (typeof device !== "number") throw new Error("Device must be a number");
let _id = id;
let _protocol = protocol;
let _commandNumber = commandNumber;
@ -34,6 +37,8 @@ function Command({
}
function setCommandNumber(commandNumber) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
_commandNumber = commandNumber;
}
@ -42,6 +47,7 @@ function Command({
}
function setDevice(device) {
if (typeof device !== "number") throw new Error("Device must be a number");
_device = device;
}
@ -54,7 +60,8 @@ function Command({
}
function getTitle() {
return _title;
if (_title) return _title;
return `${Command.getTypeString(_commandType)} (${Command.getProtocolString(_protocol)})`;
}
function setTitle(title) {
@ -77,73 +84,148 @@ function Command({
};
}
Command.Protocols = {
samsung: "Samsung",
nec: "NEC",
onkyo: "Onkyo",
apple: "Apple",
denon: "Denon",
sharp: "Sharp",
panasonic: "Panasonic",
kaseikyo: "Kaseikyo",
jvc: "JVC",
lg: "LG",
sony: "Sony",
rc5: "RC5",
rc6: "RC6",
universal_pulse_distance: "Universal Pulse Distance",
universal_pulse_width: "Universal Pulse Width",
universal_pulse_distance_width: "Universal Pulse Distance Width",
hash: "Hash",
pronto: "Pronto",
bose_wave: "BoseWave",
bang_olufsen: "Bang & Olufsen",
lego: "Lego",
fast: "FAST",
whynter: "Whynter",
magiquest: "MagiQuest",
Command.PROTOCOLS = {
SAMSUNG: "samsung",
NEC: "nec",
ONKYO: "onkyo",
APPLE: "apple",
DENON: "denon",
SHARP: "sharp",
PANASONIC: "panasonic",
KASEIKYO: "kaseikyo",
JVC: "jvc",
LG: "lg",
SONY: "sony",
RC5: "rc5",
RC6: "rc6",
UNIVERSAL_PULSE_DISTANCE: "universal_pulse_distance",
UNIVERSAL_PULSE_WIDTH: "universal_pulse_width",
UNIVERSAL_PULSE_DISTANCE_WIDTH: "universal_pulse_distance_width",
HASH: "hash",
PRONTO: "pronto",
BOSE_WAVE: "bose_wave",
BANG_OLUFSEN: "bang_olufsen",
LEGO: "lego",
FAST: "fast",
WHYNTER: "whynter",
MAGIQUEST: "magiquest",
}
Command.CommandTypes = {
power: "Power",
input: "Input",
one: "1",
two: "2",
three: "3",
four: "4",
five: "5",
six: "6",
seven: "7",
eight: "8",
nine: "9",
zero: "0",
volume_up: "Volume Up",
volume_down: "Volume Down",
mute: "Mute",
channel_up: "Channel Up",
channel_down: "Channel Down",
menu: "Menu",
home: "Home",
settings: "Settings",
options: "Options",
up_arrow: "Up Arrow",
down_arrow: "Down Arrow",
left_arrow: "Left Arrow",
right_arrow: "Right Arrow",
select: "Select",
info: "Info",
back: "Back",
exit: "Exit",
red: "Red",
green: "Green",
yellow: "Yellow",
blue: "Blue",
rewind: "Rewind",
play: "Play",
pause: "Pause",
stop: "Stop",
forward: "Forward",
other: "Other",
Command.getProtocolString = (protocol) => {
let mapping = {
[Command.PROTOCOLS.SAMSUNG]: "Samsung",
[Command.PROTOCOLS.NEC]: "NEC",
[Command.PROTOCOLS.ONKYO]: "Onkyo",
[Command.PROTOCOLS.APPLE]: "Apple",
[Command.PROTOCOLS.DENON]: "Denon",
[Command.PROTOCOLS.SHARP]: "Sharp",
[Command.PROTOCOLS.PANASONIC]: "Panasonic",
[Command.PROTOCOLS.KASEIKYO]: "Kaseikyo",
[Command.PROTOCOLS.JVC]: "JVC",
[Command.PROTOCOLS.LG]: "LG",
[Command.PROTOCOLS.SONY]: "Sony",
[Command.PROTOCOLS.RC5]: "RC5",
[Command.PROTOCOLS.RC6]: "RC6",
[Command.PROTOCOLS.UNIVERSAL_PULSE_DISTANCE]: "Universal Pulse Distance",
[Command.PROTOCOLS.UNIVERSAL_PULSE_WIDTH]: "Universal Pulse Width",
[Command.PROTOCOLS.UNIVERSAL_PULSE_DISTANCE_WIDTH]: "Universal Pulse Distance Width",
[Command.PROTOCOLS.HASH]: "Hash",
[Command.PROTOCOLS.PRONTO]: "Pronto",
[Command.PROTOCOLS.BOSE_WAVE]: "BoseWave",
[Command.PROTOCOLS.BANG_OLUFSEN]: "Bang & Olufsen",
[Command.PROTOCOLS.LEGO]: "Lego",
[Command.PROTOCOLS.FAST]: "FAST",
[Command.PROTOCOLS.WHYNTER]: "Whynter",
[Command.PROTOCOLS.MAGIQUEST]: "MagiQuest",
}
return mapping[protocol]
}
Command.TYPES = {
POWER: "power",
INPUT: "input",
ONE: "one",
TWO: "two",
THREE: "three",
FOUR: "four",
FIVE: "five",
SIX: "six",
SEVEN: "seven",
EIGHT: "eight",
NINE: "nine",
ZERO: "zero",
VOLUME_UP: "volume_up",
VOLUME_DOWN: "volume_down",
MUTE: "mute",
CHANNEL_UP: "channel_up",
CHANNEL_DOWN: "channel_down",
MENU: "menu",
HOME: "home",
SETTINGS: "settings",
OPTIONS: "options",
UP: "up",
DOWN: "down",
LEFT: "left",
RIGHT: "right",
ENTER: "enter",
INFO: "info",
RETURN: "return",
EXIT: "exit",
RED: "red",
GREEN: "green",
YELLOW: "yellow",
BLUE: "blue",
REWIND: "rewind",
PLAY: "play",
PAUSE: "pause",
STOP: "stop",
FORWARD: "forward",
OTHER: "other",
}
Command.getTypeString = (type) => {
let mapping = {
[Command.TYPES.POWER]: "Power",
[Command.TYPES.INPUT]: "Input",
[Command.TYPES.ONE]: "1",
[Command.TYPES.TWO]: "2",
[Command.TYPES.THREE]: "3",
[Command.TYPES.FOUR]: "4",
[Command.TYPES.FIVE]: "5",
[Command.TYPES.SIX]: "6",
[Command.TYPES.SEVEN]: "7",
[Command.TYPES.EIGHT]: "8",
[Command.TYPES.NINE]: "9",
[Command.TYPES.ZERO]: "0",
[Command.TYPES.VOLUME_UP]: "Volume Up",
[Command.TYPES.VOLUME_DOWN]: "Volume Down",
[Command.TYPES.MUTE]: "Mute",
[Command.TYPES.CHANNEL_UP]: "Channel Up",
[Command.TYPES.CHANNEL_DOWN]: "Channel Down",
[Command.TYPES.MENU]: "Menu",
[Command.TYPES.HOME]: "Home",
[Command.TYPES.SETTINGS]: "Settings",
[Command.TYPES.OPTIONS]: "Options",
[Command.TYPES.UP]: "Up",
[Command.TYPES.DOWN]: "Down",
[Command.TYPES.LEFT]: "Left",
[Command.TYPES.RIGHT]: "Right",
[Command.TYPES.ENTER]: "Enter",
[Command.TYPES.INFO]: "Info",
[Command.TYPES.RETURN]: "Return",
[Command.TYPES.EXIT]: "Exit",
[Command.TYPES.RED]: "Red",
[Command.TYPES.GREEN]: "Green",
[Command.TYPES.YELLOW]: "Yellow",
[Command.TYPES.BLUE]: "Blue",
[Command.TYPES.REWIND]: "Rewind",
[Command.TYPES.PLAY]: "Play",
[Command.TYPES.PAUSE]: "Pause",
[Command.TYPES.STOP]: "Stop",
[Command.TYPES.FORWARD]: "Forward",
[Command.TYPES.OTHER]: "Other",
}
return mapping[type]
}
export default Command;

View File

@ -1,4 +1,4 @@
function Remote({ id, title, commands } = {}) {
function Remote({ id = "", title = "", commands = [] } = {}) {
let _id = id;
let _title = title;
let _commands = commands;

View File

@ -32,6 +32,22 @@ const Serializer = (function () {
return objects.map((object) => deserializeIntegration(object));
}
function serializeCommand(command) {
return {
id: command.getId(),
protocol: command.getProtocol(),
commandNumber: command.getCommandNumber(),
device: command.getDevice(),
commandType: command.getCommandType(),
title: command.getTitle(),
};
}
function serializeCommands(commands) {
if (!commands) return [];
return commands.map((command) => serializeCommand(command));
}
function deserializeCommand(object) {
return new Command(object);
}
@ -41,8 +57,20 @@ const Serializer = (function () {
return objects.map((object) => deserializeCommand(object));
}
function serializeRemote(remote) {
return {
id: remote.getId(),
title: remote.getTitle(),
commands: serializeCommands(remote.getCommands()),
};
}
function deserializeRemote(object) {
return new Remote(object);
return new Remote({
id: object.id,
title: object.title,
commands: deserializeCommands(object.commands),
});
}
function deserializeRemotes(objects) {
@ -57,8 +85,11 @@ const Serializer = (function () {
deserializeDevices,
deserializeIntegration,
deserializeIntegrations,
serializeCommand,
serializeCommands,
deserializeCommand,
deserializeCommands,
serializeRemote,
deserializeRemote,
deserializeRemotes,
};

9
www/src/lib/fusejs-7.1.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
www/src/lib/papaparse-5.5.2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -24,13 +24,9 @@ function CreateCommandModal(props) {
);
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
createEffect(() => {
let commandString = commandType() ? Command.CommandTypes[commandType()] : "";
let protocolString = protocol() ? Command.Protocols[protocol()] : "";
setTitle(commandString + " " + protocolString);
});
const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
);
const isFormValid = createMemo(
() =>
@ -44,13 +40,15 @@ function CreateCommandModal(props) {
async function handleCreateCommand() {
let command;
try {
command = await RemotesService.createCommand({
protocol: protocol(),
commandNumber: commandNumber(),
device: device(),
commandType: commandType(),
title: title(),
});
command = await RemotesService.createCommand(
new Command({
protocol: protocol(),
commandNumber: parseInt(commandNumber()),
device: parseInt(device()),
commandType: commandType(),
title: title(),
})
);
} catch (e) {
setError(e.message);
return;
@ -87,31 +85,19 @@ function CreateCommandModal(props) {
Protocol
</label>
<div class="col-sm-9">
<div class="btn-group">
<button
type="button"
class="btn btn-secondary dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{protocol() ? Command.Protocols[protocol()] : "Please select"}
</button>
<ul
class="dropdown-menu"
style="max-height: 10em; overflow-y: auto;"
>
{Object.keys(Command.Protocols).map((protocol) => (
<li>
<a
class="dropdown-item pe-auto"
onClick={() => setProtocol(protocol)}
>
{Command.Protocols[protocol]}
</a>
</li>
))}
</ul>
</div>
<select
class="form-select"
onChange={(e) => setProtocol(e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.PROTOCOLS).map((protocol) => (
<option value={protocol}>
{Command.getProtocolString(protocol)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
@ -148,33 +134,19 @@ function CreateCommandModal(props) {
Command Type
</label>
<div class="col-sm-9">
<div class="btn-group">
<button
type="button"
class="btn btn-secondary dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{commandType()
? Command.CommandTypes[commandType()]
: "Please select"}
</button>
<ul
class="dropdown-menu"
style="max-height: 10em; overflow-y: auto;"
>
{Object.keys(Command.CommandTypes).map((commandType) => (
<li>
<a
class="dropdown-item pe-auto"
onClick={() => setCommandType(commandType)}
>
{Command.CommandTypes[commandType]}
</a>
</li>
))}
</ul>
</div>
<select
class="form-select"
onChange={(e) => setCommandType(e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.TYPES).map((commandType) => (
<option value={commandType}>
{Command.getTypeString(commandType)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">

View File

@ -1,32 +1,43 @@
import { createMemo, createSignal } from "solid-js";
import { createMemo, createResource, createSignal, onMount } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import ListManager from "../components/list-manager.jsx";
import Remote from "../data/remote.js";
const eventEmitter = new EventEmitter();
const REMOTE_CREATED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
function CreateRemoteModal(props) {
const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]);
const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemotesService.getCommands);
const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid());
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleCreateRemote() {
let remote;
try {
remote = await RemotesService.createRemote({
title: title(),
commands: commands(),
});
remote = await RemotesService.createRemote(
new Remote({
title: title(),
commands: commands(),
})
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();
@ -40,6 +51,16 @@ function CreateRemoteModal(props) {
setError("");
}
function handleCommandSelect(item) {
setCommands([...commands(), item]);
}
function handleCommandDeselect(item) {
setCommands(
commands().filter((command) => command.getId() !== item.getId())
);
}
return (
<Modal
ref={props.ref}
@ -47,18 +68,18 @@ function CreateRemoteModal(props) {
modalTitle="New Remote"
centered={true}
>
<div class="modal-body" style="overflow-y:inherit !important;">
<div class="modal-body bg-body-tertiary">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="new_remote_title" class="col-form-label col-sm-3">
<label for="new_remote_title" class="col-form-label col-sm-1">
Title
</label>
<ValidatedTextInput
class="col-sm-9"
class="col-sm-11"
id="new_remote_title"
valid={isTitleValid()}
value={title()}
@ -66,8 +87,19 @@ function CreateRemoteModal(props) {
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
<ListManager
style="height: 20em;"
items={commands()}
availableItems={availableCommands()}
itemToString={(command) =>
`${command.getTitle()} (${command.getProtocol()}:${command.getCommandType()})`
}
onItemSelect={handleCommandSelect}
onItemDeselect={handleCommandDeselect}
itemsTitle="Selected Commands"
availableItemsTitle="Available Commands"
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
@ -85,7 +117,7 @@ function CreateRemoteModal(props) {
);
}
CreateRemoteModal.Handler = new ModalHandler();
CreateRemoteModal.Handler = modalHandler;
CreateRemoteModal.onRemoteCreated = (callback) =>
eventEmitter.on(REMOTE_CREATED_EVENT, callback);

View File

@ -0,0 +1,66 @@
import { createSignal } from "solid-js";
import Remote from "../data/remote.js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const [remote, setRemote] = createSignal(new Remote());
const eventEmitter = new EventEmitter();
const REMOTE_DELETED_EVENT = "success";
function DeleteRemoteModal(props) {
const [error, setError] = createSignal("");
async function handleDeleteRemote() {
try {
await RemotesService.deleteRemote(remote().getId());
} catch (e) {
setError(e.message);
throw e;
}
DeleteRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_DELETED_EVENT, remote);
}
return (
<Modal
ref={props.ref}
id="deleteRemoteModal"
modalTitle="Delete remote"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<span>
Do you really want to delete the remote {remote().getTitle()} ({remote().getId()})?
</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleDeleteRemote}
class="btn btn-danger"
>
Delete
</button>
</div>
</Modal>
);
}
DeleteRemoteModal.Handler = new ModalHandler();
DeleteRemoteModal.setRemote = setRemote;
DeleteRemoteModal.onRemoteDeleted = (callback) =>
eventEmitter.on(REMOTE_DELETED_EVENT, callback);
export default DeleteRemoteModal;

View File

@ -0,0 +1,199 @@
import { createEffect, createMemo, createSignal } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import RemotesService from "../services/remotes-service.js";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMAND_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const [command, setCommand] = createSignal(new Command());
function EditCommandModal(props) {
const [protocol, setProtocol] = createSignal("");
const [commandNumber, setCommandNumber] = createSignal("");
const [device, setDevice] = createSignal("");
const [commandType, setCommandType] = createSignal("");
const [title, setTitle] = createSignal("");
const [error, setError] = createSignal("");
const isProtocolValid = createMemo(() => protocol() !== "");
const isCommandNumberValid = createMemo(
() => commandNumber() !== "" && !isNaN(commandNumber())
);
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
);
createEffect(() => {
setProtocol(command().getProtocol());
setCommandNumber(command().getCommandNumber());
setDevice(command().getDevice());
setCommandType(command().getCommandType());
setTitle(command().getTitle());
});
const isFormValid = createMemo(
() =>
isProtocolValid() &&
isCommandNumberValid() &&
isDeviceValid() &&
isCommandTypeValid() &&
isTitleValid()
);
async function handleEditCommand() {
try {
await RemotesService.updateCommand(
new Command({
id: command().getId(),
protocol: protocol(),
commandNumber: parseInt(commandNumber()),
device: parseInt(device()),
commandType: commandType(),
title: title(),
})
);
} catch (e) {
console.error(e);
setError(e.message);
return;
}
resetFields();
EditCommandModal.Handler.hide();
eventEmitter.dispatchEvent(COMMAND_EDITED_EVENT);
}
function resetFields() {
setProtocol("");
setCommandNumber("");
setDevice("");
setCommandType("");
setTitle("");
setError("");
}
return (
<Modal
ref={props.ref}
id="editCommandModal"
modalTitle="Edit Command"
centered={true}
>
<div class="modal-body" style="overflow-y:inherit !important;">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_command_protocol" class="col-form-label col-sm-3">
Protocol
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setProtocol(e.target.value)}
>
{Object.values(Command.PROTOCOLS).map((protocol) => (
<option
value={protocol}
selected={protocol === command().getProtocol()}
>
{Command.getProtocolString(protocol)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="edit_command_number" class="col-form-label col-sm-3">
Command Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_number"
valid={isCommandNumberValid()}
value={commandNumber()}
onInput={(e) => setCommandNumber(e.target.value)}
errorText={"Command number must be a number"}
/>
</div>
<div class="mb-3 row">
<label
for="edit_command_device_number"
class="col-form-label col-sm-3"
>
Device Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_device_number"
valid={isDeviceValid()}
value={device()}
onInput={(e) => setDevice(e.target.value)}
errorText={"Device number must be a number"}
/>
</div>
<div class="mb-3 row">
<label for="edit_command_protocol" class="col-form-label col-sm-3">
Command Type
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setCommandType(e.target.value)}
>
{Object.values(Command.TYPES).map((commandType) => (
<option
value={commandType}
selected={commandType === command().getCommandType()}
>
{Command.getTypeString(commandType)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="edit_command_title" class="col-form-label col-sm-3">
Title
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditCommand}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditCommandModal.Handler = new ModalHandler();
EditCommandModal.onCommandEdited = (callback) =>
eventEmitter.on(COMMAND_EDITED_EVENT, callback);
EditCommandModal.setCommand = setCommand;
export default EditCommandModal;

View File

@ -0,0 +1,145 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
} from "solid-js";
import ListManager from "../components/list-manager.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import Remote from "../data/remote.js";
import RemoteService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const eventEmitter = new EventEmitter();
const REMOTE_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
const [remoteId, setRemoteId] = createSignal(null);
function EditRemoteModal(props) {
const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]);
const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemoteService.getCommands);
const [remote, {}] = createResource(
remoteId,
() => RemoteService.getRemote(remoteId()),
{ initialValue: new Remote() }
);
const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid());
createEffect(() => {
if (!remote()) return;
setTitle(remote().getTitle());
setCommands(remote().getCommands());
});
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleEditRemote() {
try {
await RemoteService.updateRemote(
new Remote({
id: remote().getId(),
title: title(),
commands: commands(),
})
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();
EditRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_EDITED_EVENT);
}
function resetFields() {
setTitle("");
setCommands([]);
setRemoteId("");
setError("");
}
function handleCommandSelect(item) {
setCommands([...commands(), item]);
}
function handleCommandDeselect(item) {
setCommands(
commands().filter((command) => command.getId() !== item.getId())
);
}
return (
<Modal
ref={props.ref}
id="editRemoteModal"
modalTitle="New Remote"
centered={true}
>
<div class="modal-body bg-body-tertiary">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_remote_title" class="col-form-label col-sm-1">
Title
</label>
<ValidatedTextInput
class="col-sm-11"
id="edit_remote_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
<ListManager
style="height: 20em;"
items={commands()}
availableItems={availableCommands()}
itemToString={(command) =>
`${command.getTitle()} (${command.getProtocol()}:${command.getCommandType()})`
}
onItemSelect={handleCommandSelect}
onItemDeselect={handleCommandDeselect}
itemsTitle="Selected Commands"
availableItemsTitle="Available Commands"
itemsEqual={(a, b) => a.getId() === b.getId()}
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditRemote}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditRemoteModal.Handler = modalHandler;
EditRemoteModal.onRemoteEdited = (callback) =>
eventEmitter.on(REMOTE_EDITED_EVENT, callback);
EditRemoteModal.setRemoteId = setRemoteId;
export default EditRemoteModal;

View File

@ -0,0 +1,500 @@
import {
createEffect,
createMemo,
createSignal,
Match,
on,
Show,
Switch,
} from "solid-js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMANDS_IMPORTED_EVENT = "success";
const ENTER_CSV_STEP = 1;
const MAP_FIELDS_STEP = 2;
const MAP_VALUES_STEP = 3;
const CONFIRM_STEP = 4;
const TOTAL_STEPS = 4;
const PROTOCOL_FIELD = "protocol";
const COMMAND_NUMBER_FIELD = "commandNumber";
const DEVICE_FIELD = "device";
const COMMAND_TYPE_FIELD = "commandType";
const TITLE_FIELD = "title";
let FieldTitles = {};
FieldTitles[PROTOCOL_FIELD] = "Protocol";
FieldTitles[COMMAND_NUMBER_FIELD] = "Command";
FieldTitles[DEVICE_FIELD] = "Device";
FieldTitles[COMMAND_TYPE_FIELD] = "Type";
FieldTitles[TITLE_FIELD] = "Title";
const CommandFieldsRequiringValueMapping = ["protocol", "commandType"];
const commandTypeFuse = new Fuse(
Object.values(Command.TYPES).map((type) => Command.getTypeString(type))
);
const protocolsFuse = new Fuse(
Object.values(Command.PROTOCOLS).map((protocol) =>
Command.getProtocolString(protocol)
)
);
function ImportCommandsModal(props) {
const [csvString, setCsvString] = createSignal("");
const [currentStep, setCurrentStep] = createSignal(ENTER_CSV_STEP);
const [error, setError] = createSignal("");
const [csvArray, setCsvArray] = createSignal([]);
const [commands, setCommands] = createSignal([]);
const [fieldMapping, setFieldMapping] = createSignal(
{},
{ equals: () => false }
);
const [isComputingValueMapping, setComputingValueMapping] =
createSignal(false);
const [valueMapping, setValueMapping] = createSignal(
(() => {
let mapping = {};
mapping[PROTOCOL_FIELD] = {};
mapping[COMMAND_TYPE_FIELD] = {};
return mapping;
})(),
{ equals: () => false }
);
const canMakeNextStep = createMemo(() => {
switch (currentStep()) {
case ENTER_CSV_STEP:
return csvString() !== "";
default:
return true;
}
});
const fields = createMemo(() =>
Object.keys(csvArray().find(() => true) || {})
);
createEffect(() => {
switch (currentStep()) {
case MAP_VALUES_STEP:
onMapValuesStep();
break;
}
});
function handleNextStep() {
switch (currentStep()) {
case ENTER_CSV_STEP:
handleEnterCsvStep();
break;
case MAP_FIELDS_STEP:
handleMapFieldsStep();
break;
case MAP_VALUES_STEP:
handleMapValuesStep();
break;
case CONFIRM_STEP:
handleImportCommands();
break;
default:
nextStep();
}
}
async function handleImportCommands() {
let newCommands = [];
try {
newCommands = await RemotesService.createCommands(commands());
} catch (e) {
setError(e.message);
return;
}
resetFields();
ImportCommandsModal.Handler.hide();
eventEmitter.dispatchEvent(COMMANDS_IMPORTED_EVENT, newCommands);
}
function EnterCsvStep() {
return (
<div class="mb-3">
<label for="csvTextarea" class="form-label">
Enter CSV:
</label>
<textarea
class="form-control"
id="csvTextarea"
rows="10"
value={csvString()}
onInput={(e) => setCsvString(e.target.value)}
></textarea>
</div>
);
}
function handleEnterCsvStep() {
if (!csvString()) {
setError("Please enter a CSV string.");
return;
}
let result = Papa.parse(csvString(), { header: true });
if (result.errors.length > 0) {
setError(result.errors[0].message);
return;
}
let csvArray = result.data;
setCsvArray(csvArray);
nextStep();
}
function MapFieldsStep() {
function setMapping(field, commandField) {
let mapping = fieldMapping();
mapping[field] = commandField;
setFieldMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Fields:</div>
<div class="d-flex flex-column">
{fields().map((field) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{field}</div>
<div class="fw-light lh-1">(e.g. {csvArray()[0][field]})</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<select
class="form-select"
onChange={(e) => setMapping(field, e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.keys(FieldTitles).map((commandField) => (
<option
value={commandField}
selected={fieldMapping()[field] === commandField}
>
{FieldTitles[commandField]}
</option>
))}
</select>
</div>
</div>
))}
</div>
</div>
);
}
function handleMapFieldsStep() {
let usedCommandFields = [];
let mapping = fieldMapping();
for (let field in mapping) {
if (usedCommandFields.includes(mapping[field])) {
setError("Duplicate mapping found.");
return false;
}
usedCommandFields.push(mapping[field]);
}
nextStep();
}
function MapValuesStep() {
function setMapping(field, value, commandValue) {
let mapping = valueMapping();
if (!mapping[field]) mapping[field] = {};
mapping[field][value] = commandValue;
setValueMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Values:</div>
{Object.keys(fieldMapping())
.filter((field) =>
CommandFieldsRequiringValueMapping.includes(fieldMapping()[field])
)
.map((csvField) => {
let field = fieldMapping()[csvField];
return (
<div>
<div>{FieldTitles[field]}:</div>
<div class="d-flex flex-column">
{csvArray()
.map((row) => row[csvField])
.filter(
(value, index, array) => array.indexOf(value) === index
)
.map((value) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{value}</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<Switch>
<Match when={field === "protocol"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.PROTOCOLS).map(
(protocol) => (
<option
value={protocol}
selected={
valueMapping()[PROTOCOL_FIELD][
value
] === protocol
}
>
{Command.getProtocolString(protocol)}
</option>
)
)}
</select>
</Match>
<Match when={field === "commandType"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.TYPES).map(
(commandType) => (
<option
value={commandType}
selected={
valueMapping()[COMMAND_TYPE_FIELD][
value
] === commandType
}
>
{Command.getTypeString(commandType)}
</option>
)
)}
</select>
</Match>
</Switch>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function onMapValuesStep() {
setComputingValueMapping(true);
(async () => {
let valueMapping = {};
valueMapping[PROTOCOL_FIELD] = {};
valueMapping[COMMAND_TYPE_FIELD] = {};
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
csvArray().forEach((row) => {
let protocolValue = row[protocolField];
let commandTypeValue = row[commandTypeField];
if (!valueMapping[PROTOCOL_FIELD][protocolValue]) {
let result = protocolsFuse.search(protocolValue).shift();
if (result) {
let protocol = Object.values(Command.PROTOCOLS).find(
(protocol) => Command.getProtocolString(protocol) === result.item
);
valueMapping[PROTOCOL_FIELD][protocolValue] = protocol;
}
}
if (!valueMapping[COMMAND_TYPE_FIELD][commandTypeValue]) {
let result = commandTypeFuse.search(commandTypeValue).shift();
if (result) {
let commandType = Object.values(Command.TYPES).find(
(commandType) =>
Command.getTypeString(commandType) === result.item
);
valueMapping[COMMAND_TYPE_FIELD][commandTypeValue] = commandType;
}
}
});
setValueMapping(valueMapping);
setComputingValueMapping(false);
})();
}
function handleMapValuesStep() {
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandNumberField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_NUMBER_FIELD
);
let deviceField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === DEVICE_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
let titleField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === TITLE_FIELD
);
let commands = csvArray()
.map((row) => {
let protocol = valueMapping()[PROTOCOL_FIELD][row[protocolField]];
if (!protocol) return null;
let commandNumber = row[commandNumberField];
if (isNaN(commandNumber)) return null;
commandNumber = parseInt(commandNumber);
let device = row[deviceField];
if (isNaN(device)) return null;
device = parseInt(device);
let commandType =
valueMapping()[COMMAND_TYPE_FIELD][row[commandTypeField]];
if (!commandType) return null;
let title = row[titleField];
let command = new Command({
protocol,
commandNumber,
device,
commandType,
title,
});
return command;
})
.filter((command) => command !== null);
setCommands(commands);
nextStep();
}
function ConfirmStep() {
return (
<div class="mb-3">
<div>Confirm:</div>
<div class="d-flex flex-column">
{commands().map((command) => (
<div class="d-flex flex-row align-items-center justify-content-center mb-2">
<div class="col-sm-2">{command.getProtocol()}</div>
<div class="col-sm-2">{command.getCommandNumber()}</div>
<div class="col-sm-2">{command.getDevice()}</div>
<div class="col-sm-2">{command.getCommandType()}</div>
<div class="col-sm-2">{command.getTitle()}</div>
</div>
))}
</div>
</div>
);
}
function nextStep() {
if (currentStep() >= TOTAL_STEPS) return;
setError("");
setCurrentStep(currentStep() + 1);
}
function previousStep() {
if (currentStep() <= 1) return;
setError("");
setCurrentStep(currentStep() - 1);
}
function resetFields() {
setCsvString("");
setError("");
}
return (
<Modal
ref={props.ref}
id="importCommandsModal"
modalTitle="Import Commands from CSV"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<Switch>
<Match when={currentStep() === ENTER_CSV_STEP}>
<EnterCsvStep />
</Match>
<Match when={currentStep() === MAP_FIELDS_STEP}>
<MapFieldsStep />
</Match>
<Match when={currentStep() === MAP_VALUES_STEP}>
<MapValuesStep />
</Match>
<Match when={currentStep() === CONFIRM_STEP}>
<ConfirmStep />
</Match>
</Switch>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<Show when={currentStep() > 1}>
<button
type="button"
onClick={previousStep}
class="btn btn-secondary"
disabled={false}
>
Back
</button>
</Show>
<Show when={currentStep() < TOTAL_STEPS}>
<button
type="button"
onClick={handleNextStep}
class="btn btn-secondary"
disabled={!canMakeNextStep()}
>
Next
</button>
</Show>
<Show when={currentStep() === TOTAL_STEPS}>
<button
type="button"
onClick={handleImportCommands}
class="btn btn-primary"
disabled={false}
>
Import
</button>
</Show>
</div>
</Modal>
);
}
ImportCommandsModal.Handler = new ModalHandler();
ImportCommandsModal.onCommandsImported = (callback) =>
eventEmitter.on(COMMANDS_IMPORTED_EVENT, callback);
export default ImportCommandsModal;

View File

@ -1,11 +1,23 @@
import EventEmitter from "../tools/event-emitter";
const SHOW_EVENT = "show";
const HIDE_EVENT = "hide";
function ModalHandler() {
let _ref;
let _modalRef;
let _modalId;
let eventEmitter = new EventEmitter();
function setRef(ref) {
_ref = ref;
_modalRef = new bootstrap.Modal(ref);
_ref.addEventListener('hidden.bs.modal', () => {
eventEmitter.dispatchEvent(HIDE_EVENT);
});
_ref.addEventListener('show.bs.modal', () => {
eventEmitter.dispatchEvent(SHOW_EVENT);
});
}
function show() {
@ -21,15 +33,11 @@ function ModalHandler() {
}
function onHidden(callback) {
_ref.addEventListener('hidden.bs.modal', () => {
callback();
});
eventEmitter.on(HIDE_EVENT, callback);
}
function onShow(callback) {
_ref.addEventListener('show.bs.modal', () => {
callback();
});
eventEmitter.on(SHOW_EVENT, callback);
}
return {

View File

@ -9,6 +9,10 @@ import DeleteIntegrationModal from "./delete-integration-modal.jsx";
import CreateCommandModal from "./create-command-modal.jsx";
import DeleteCommandModal from "./delete-command-modal.jsx";
import CreateRemoteModal from "./create-remote-modal.jsx";
import DeleteRemoteModal from "./delete-remote-modal.jsx";
import ImportCommandsModal from "./import-commands-modal.jsx";
import EditCommandModal from "./edit-command-modal.jsx";
import EditRemoteModal from "./edit-remote-modal.jsx";
const ModalRegistry = (function () {
const modals = [
@ -57,6 +61,26 @@ const ModalRegistry = (function () {
component: CreateRemoteModal,
ref: null,
},
{
id: "deleteRemoteModal",
component: DeleteRemoteModal,
ref: null,
},
{
id: "importCommandsModal",
component: ImportCommandsModal,
ref: null,
},
{
id: "editCommandModal",
component: EditCommandModal,
ref: null,
},
{
id: "editRemoteModal",
component: EditRemoteModal,
ref: null,
}
];
function getModals(props) {

View File

@ -79,6 +79,23 @@ function DeviceService() {
return codeObject.code;
}
async function getIntegration(id) {
if (!id) return null;
let response = await Net.sendRequest({
method: "GET",
url: "/api/integrations/" + id,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let integration = JSON.parse(response.data);
integration = Serializer.deserializeIntegration(integration);
return integration;
}
async function getIntegrations() {
let response = await Net.sendRequest({
method: "GET",
@ -113,6 +130,7 @@ function DeviceService() {
updateDevice,
deleteDevice,
getRegistrationCode,
getIntegration,
getIntegrations,
deleteIntegration,
};

View File

@ -1,49 +1,163 @@
import Serializer from "../data/serializer";
import Net from "../tools/net";
import WebRTCService from "./webrtc-service";
function RemotesService() {
let commands = [];
let remotes = [];
const MESSAGE_TYPE_COMMAND = "command";
function RemoteService() {
async function getRemote(remoteId) {
if (!remoteId) return null;
let response = await Net.sendRequest({
method: "GET",
url: "/api/remotes/" + remoteId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let remoteObject = JSON.parse(response.data);
return Serializer.deserializeRemote(remoteObject);
}
async function getRemotes() {
return [].concat(remotes);
let response = await Net.sendRequest({
method: "GET",
url: "/api/remotes",
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let remoteObjects = JSON.parse(response.data);
return Serializer.deserializeRemotes(remoteObjects);
}
async function createRemote(remoteObject) {
let remote = Serializer.deserializeRemote(remoteObject);
let id = Math.random().toString(36).substr(2, 9);
remote.setId(id);
remotes.push(remote);
return remote;
}
async function createRemote(remote) {
let remoteObject = Serializer.serializeRemote(remote);
let response = await Net.sendJsonRequest({
method: "POST",
url: "/api/remotes",
data: remoteObject,
});
async function getCommands() {
return [].concat(commands);
}
async function createCommand(commandObject) {
let command = Serializer.deserializeCommand(commandObject);
let id = Math.random().toString(36).substr(2, 9);
command.setId(id);
commands.push(command);
return command;
}
async function deleteCommand(commandId) {
let index = commands.findIndex((command) => command.getId() === commandId);
if (index >= 0) {
commands.splice(index, 1);
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function updateRemote(remote) {
let remoteObject = Serializer.serializeRemote(remote);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/remotes/" + remote.getId(),
data: remoteObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteRemote(remoteId) {
let response = await Net.sendRequest({
method: "DELETE",
url: "/api/remotes/" + remoteId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function getCommands() {
let response = await Net.sendRequest({
method: "GET",
url: "/api/commands",
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let commandObjects = JSON.parse(response.data);
return Serializer.deserializeCommands(commandObjects);
}
async function createCommand(command) {
return createCommands([command]);
}
async function createCommands(commands) {
let commandObjects = Serializer.serializeCommands(commands);
let response = await Net.sendJsonRequest({
method: "POST",
url: "/api/commands",
data: commandObjects,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function updateCommand(command) {
let commandObject = Serializer.serializeCommand(command);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/commands/" + command.getId(),
data: commandObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteCommand(commandId) {
let response = await Net.sendRequest({
method: "DELETE",
url: "/api/commands/" + commandId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
function sendCommand(command) {
let commandObject = Serializer.serializeCommand(command);
WebRTCService.sendDataJson({
type: MESSAGE_TYPE_COMMAND,
data: commandObject,
});
}
return {
getRemote,
getRemotes,
createRemote,
updateRemote,
deleteRemote,
getCommands,
createCommand,
createCommands,
updateCommand,
deleteCommand,
sendCommand,
};
}
RemotesService = new RemotesService();
RemoteService = new RemoteService();
export default RemotesService;
export default RemoteService;

View File

@ -8,10 +8,12 @@ function WebRTCService() {
const STATE_CLOSED = "closed";
const STATE_FAILED = "failed";
const ICE_CONNECTION_STATE_CHANGE_EVENT = "iceconnectionstatechange";
const DATA_CHANNEL_OPEN_EVENT = "datachannelopen";
let videoElement;
let peerConnection;
let peerId;
let dataChannel;
let eventEmitter = new EventEmitter();
@ -57,12 +59,19 @@ function WebRTCService() {
console.log("Negotiation needed");
negotiate(targetId);
};
dataChannel = peerConnection.createDataChannel("data");
dataChannel.addEventListener("open", () => {
eventEmitter.dispatchEvent(DATA_CHANNEL_OPEN_EVENT);
});
negotiate(targetId);
}
function disconnect() {
peerConnection.close();
eventEmitter.dispatchEvent(ICE_CONNECTION_STATE_CHANGE_EVENT, peerConnection.iceConnectionState);
eventEmitter.dispatchEvent(
ICE_CONNECTION_STATE_CHANGE_EVENT,
peerConnection.iceConnectionState
);
}
async function negotiate(targetId) {
@ -125,12 +134,27 @@ function WebRTCService() {
peerConnection.addIceCandidate(iceCandidate);
}
function sendDataString(data) {
if (!dataChannel) return;
if (dataChannel.readyState !== "open") return;
dataChannel.send(data);
}
function sendDataJson(data) {
let dataJson = JSON.stringify(data);
sendDataString(dataJson);
}
function onStateChanged(callback) {
eventEmitter.on(ICE_CONNECTION_STATE_CHANGE_EVENT, () => {
callback(peerConnection.iceConnectionState);
});
}
function onDataChannelOpen(callback) {
eventEmitter.on(DATA_CHANNEL_OPEN_EVENT, callback);
}
function getConfiguration() {
return {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
@ -155,7 +179,10 @@ function WebRTCService() {
disconnect,
setVideoElement,
getVideoElement,
sendDataString,
sendDataJson,
onStateChanged,
onDataChannelOpen,
};
}

View File

@ -0,0 +1,30 @@
function FileUtils() {
function createJsonFile(jsonData, fileName) {
const json = JSON.stringify(jsonData, null, 2);
const file = new File([json], fileName, {
type: "text/json;charset=utf-8",
});
return file;
}
function downloadFile(file) {
console.log(file)
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", file.name);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return {
createJsonFile,
downloadFile,
};
}
FileUtils = new FileUtils();
export default FileUtils;

View File

@ -1,195 +0,0 @@
import {
createResource,
createSignal,
mergeProps,
onMount,
Show,
} from "solid-js";
import List from "../components/list";
import CreateDeviceModal from "../modals/create-device-modal";
import DeviceService from "../services/device-service";
import ShowRegistrationCodeModal from "../modals/show-registration-code-modal";
import DeleteIntegrationModal from "../modals/delete-integration-modal";
function DevicesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const DEVICES_LIST_TYPE = "devices";
const INTEGRATION_LIST_TYPE = "integrations";
const [listType, setListType] = createSignal(INTEGRATION_LIST_TYPE);
const [devices, setDevices] = createSignal([]);
const [integrations, { refetch: refetchIntegrations }] = createResource(
DeviceService.getIntegrations
);
CreateDeviceModal.onDeviceCreated(() => {
handleRefreshDevices();
});
onMount(() => {
handleRefreshDevices();
DeleteIntegrationModal.onIntegrationDeleted(() => {
refetchIntegrations();
});
});
function handleNewDevice() {
CreateDeviceModal.Handler.show();
}
async function handleRefreshDevices() {
let devices = await DeviceService.getDevices();
setDevices(devices);
}
function handleRegisterIntegration() {
ShowRegistrationCodeModal.Handler.show();
}
function handleDeleteIntegration(integration) {
DeleteIntegrationModal.setIntegration(integration);
DeleteIntegrationModal.Handler.show();
}
function handleIntegrationItemClicked(item) {
props.onIntegrationClicked(item.integration);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(listType() === INTEGRATION_LIST_TYPE
? " btn-secondary"
: " btn-dark")
}
onClick={() => setListType(INTEGRATION_LIST_TYPE)}
>
<i class="bi bi-gear me-2"></i>
Integrations
</button>
<button
class={
"btn me-2 mb-3" +
(listType() === DEVICES_LIST_TYPE
? " btn-secondary"
: " btn-dark")
}
onClick={() => setListType(DEVICES_LIST_TYPE)}
>
<i class="bi bi-tv me-2"></i>
Devices
</button>
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={listType() === DEVICES_LIST_TYPE}>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewDevice}>
<i class="bi bi-plus-square me-2"></i>
New Device
</button>
</Show>
<Show when={listType() === INTEGRATION_LIST_TYPE}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleRegisterIntegration}
>
<i class="bi bi-plus-square me-2"></i>
Register
</button>
</Show>
</div>
</div>
<Show when={listType() === DEVICES_LIST_TYPE}>
<List
onListItemClick={() => {}}
items={devices().map((device) => ({
id: {
html: <span class="font-monospace">{device.getId()}</span>,
},
name: {
text: device.getName(),
},
description: {
text: device.getDescription(),
},
device,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
width: 10,
},
{
id: "description",
name: "Description",
},
]}
></List>
</Show>
<Show when={listType() === INTEGRATION_LIST_TYPE}>
<List
onListItemClick={handleIntegrationItemClicked}
items={(integrations() || []).map((integration) => ({
id: {
html: <span class="font-monospace">{integration.getId()}</span>,
},
name: {
text: integration.getName(),
},
options: {
html: (
<>
<button
class="btn btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteIntegration(integration);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
integration: integration,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
},
{
id: "options",
name: "",
width: 4,
},
]}
></List>
</Show>
</div>
);
}
export default DevicesView;

View File

@ -0,0 +1,76 @@
import { createSignal } from "solid-js";
import List from "../../components/list";
import CreateDeviceModal from "../../modals/create-device-modal";
import DeviceService from "../../services/device-service";
function DevicesListView(props) {
const [devices, setDevices] = createSignal([]);
handleRefreshDevices();
CreateDeviceModal.onDeviceCreated(() => {
handleRefreshDevices();
});
function handleNewDevice() {
CreateDeviceModal.Handler.show();
}
async function handleRefreshDevices() {
let devices = await DeviceService.getDevices();
setDevices(devices);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
{props.navigation}
<div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleNewDevice}>
<i class="bi bi-plus-square me-2"></i>
New Device
</button>
</div>
</div>
<List
onListItemClick={() => {}}
items={devices().map((device) => ({
id: {
html: <span class="font-monospace">{device.getId()}</span>,
},
name: {
text: device.getName(),
},
description: {
text: device.getDescription(),
},
device,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
width: 10,
},
{
id: "description",
name: "Description",
},
]}
></List>
</div>
);
}
export default DevicesListView;

View File

@ -0,0 +1,105 @@
import {
createEffect,
createSignal,
Match,
mergeProps,
onCleanup,
Switch,
} from "solid-js";
import DevicesListView from "./devices-list-view";
import IntegrationListView from "./integration-list-view";
import UrlUtils from "../../tools/url-utils";
import IntegrationView from "./integration-view";
function DevicesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const DEVICES_LIST_VIEW = "devices";
const INTEGRATION_LIST_VIEW = "integrations";
const INTEGRATION_VIEW = "integration";
const VIEWS = [DEVICES_LIST_VIEW, INTEGRATION_LIST_VIEW, INTEGRATION_VIEW];
const [currentView, setCurrentView] = createSignal(INTEGRATION_LIST_VIEW);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "tab", currentView());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setViewFromUrl);
});
setViewFromUrl();
window.addEventListener("popstate", setViewFromUrl);
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("tab");
if (!view) return;
if (!VIEWS.includes(view)) return;
setCurrentView(view);
}
function handleIntegrationClicked(integration) {
IntegrationView.setIntegration(integration);
setCurrentView(INTEGRATION_VIEW);
}
function Navigation() {
return (
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(currentView() === INTEGRATION_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(INTEGRATION_LIST_VIEW)}
>
<i class="bi bi-gear me-2"></i>
Integrations
</button>
<button
class={
"btn me-2 mb-3" +
(currentView() === DEVICES_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(DEVICES_LIST_VIEW)}
>
<i class="bi bi-tv me-2"></i>
Devices
</button>
</div>
);
}
return (
<div
class={
"d-flex flex-column overflow-hidden " +
props.class
}
>
<Switch>
<Match when={currentView() === INTEGRATION_LIST_VIEW}>
<IntegrationListView
navigation={<Navigation />}
onIntegrationClicked={handleIntegrationClicked}
/>
</Match>
<Match when={currentView() === DEVICES_LIST_VIEW}>
<DevicesListView navigation={<Navigation />} />
</Match>
<Match when={currentView() === INTEGRATION_VIEW}>
<IntegrationView />
</Match>
</Switch>
</div>
);
}
export default DevicesView;

View File

@ -0,0 +1,103 @@
import { createResource, mergeProps } from "solid-js";
import List from "../../components/list";
import ShowRegistrationCodeModal from "../../modals/show-registration-code-modal";
import DeleteIntegrationModal from "../../modals/delete-integration-modal";
import DeviceService from "../../services/device-service";
function IntegrationListView(props) {
props = mergeProps(
{
onIntegrationClicked: () => {},
},
props
);
const [integrations, { refetch: refetchIntegrations }] = createResource(
DeviceService.getIntegrations,
{ initialValue: [] }
);
function handleRegisterIntegration() {
ShowRegistrationCodeModal.Handler.show();
}
DeleteIntegrationModal.onIntegrationDeleted(() => {
refetchIntegrations();
});
function handleDeleteIntegration(integration) {
DeleteIntegrationModal.setIntegration(integration);
DeleteIntegrationModal.Handler.show();
}
function handleIntegrationItemClicked(item) {
props.onIntegrationClicked(item.integration);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
{props.navigation}
<div class="d-flex flex-row justify-content-end flex-fill">
<button
class="btn btn-dark me-2 mb-3"
onClick={handleRegisterIntegration}
>
<i class="bi bi-plus-square me-2"></i>
Register
</button>
</div>
</div>
<List
onListItemClick={handleIntegrationItemClicked}
items={integrations().map((integration) => ({
id: {
html: <span class="font-monospace">{integration.getId()}</span>,
},
name: {
text: integration.getName(),
},
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteIntegration(integration);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
integration: integration,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
},
{
id: "options",
name: "",
width: 4,
},
]}
></List>
</div>
);
}
export default IntegrationListView;

View File

@ -0,0 +1,181 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
Show,
} from "solid-js";
import WebRTCService from "../../services/webrtc-service";
import Integration from "../../data/integration";
import DeviceService from "../../services/device-service";
import UrlUtils from "../../tools/url-utils";
import RemoteControl from "../../components/remote-control";
import RemoteService from "../../services/remotes-service";
const [integration, setIntegration] = createSignal(new Integration());
function IntegrationView(props) {
const title = createMemo(() =>
integration && typeof integration === "function"
? integration().getName()
: "Integration"
);
const [connectionState, setConnectionState] = createSignal(
WebRTCService.STATE_DISCONNECTED
);
const showConnectButton = createMemo(
() =>
connectionState() === WebRTCService.STATE_DISCONNECTED ||
connectionState() === WebRTCService.STATE_FAILED ||
connectionState() === WebRTCService.STATE_CLOSED
);
WebRTCService.onStateChanged(handleConnectionStateChanged);
WebRTCService.onDataChannelOpen(handleDataChannelOpen);
let videoElement = null;
const [availableRemotes] = createResource(RemoteService.getRemotes, {
initialValue: [],
});
const [selectedRemote, setSelectedRemote] = createSignal();
const [remoteControlVisible, setRemoteControlVisible] = createSignal(false);
createEffect(() =>
handleRemoteSelected(
availableRemotes()
.find(() => true)
?.getId()
)
);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "id", integration()?.getId());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setIntegrationFromUrl);
});
setIntegrationFromUrl();
window.addEventListener("popstate", setIntegrationFromUrl);
async function setIntegrationFromUrl() {
let integrationId = UrlUtils.getQueryParameter("id");
if (!integrationId) return;
let integration = await DeviceService.getIntegration(integrationId);
setIntegration(integration);
}
function handleConnectWebRTC() {
let integrationId = integration().getId();
WebRTCService.setVideoElement(videoElement);
WebRTCService.connect(integrationId);
}
function handleDisconnectWebRTC() {
WebRTCService.disconnect();
}
function handleConnectionStateChanged(state) {
setConnectionState(state);
}
function handleDataChannelOpen() {
setInterval(() => {
WebRTCService.sendDataJson({ message: "ping" });
}, 1000);
}
async function handleRemoteSelected(remoteId) {
if (!remoteId) return;
let remote = await RemoteService.getRemote(remoteId);
setSelectedRemote(remote);
}
function handleRemoteButtonPressed(command) {
RemoteService.sendCommand(command);
}
function toggleRemoteControl() {
setRemoteControlVisible(!remoteControlVisible());
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<a>
<i class="bi bi-arrow-left-short"></i>
<span>Integration</span>
</a>
<h1>{title}</h1>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleConnectWebRTC}
>
<i class="bi bi-plug-fill me-2"></i>
Connect
</button>
</Show>
<Show when={!showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleDisconnectWebRTC}
>
<i class="bi bi-x-lg me-2"></i>
Disconnect
</button>
</Show>
</div>
</div>
<div class="flex-fill d-flex flex-row overflow-hidden">
<div
class="flex-fill rounded overflow-hidden"
style="position:relative;"
>
<video
ref={videoElement}
class="w-100 h-100"
style="background-color: #000"
></video>
<button
class="btn btn-dark mt-2 me-2"
style="transform: rotate(180deg);position:absolute;top:0;right:0;z-index:1000;"
onClick={toggleRemoteControl}
>
<i class="bi bi-building"></i>
</button>
</div>
<Show when={remoteControlVisible()}>
<div class="d-flex flex-column ps-3">
<select
class="form-select mb-3"
onChange={(e) => handleRemoteSelected(e.target.value)}
>
{availableRemotes().map((remote, index) => (
<option value={remote.getId()} selected={index === 0}>
{remote.getTitle()}
</option>
))}
</select>
<RemoteControl
onCommand={handleRemoteButtonPressed}
remote={selectedRemote()}
/>
</div>
</Show>
</div>
</div>
);
}
IntegrationView.setIntegration = setIntegration;
export default IntegrationView;

View File

@ -1,82 +0,0 @@
import { createMemo, createSignal } from "solid-js";
import Integration from "../data/integration";
import WebRTCService from "../services/webrtc-service";
const [integration, setIntegration] = createSignal(null);
function IntegrationView(props) {
const title = createMemo(() =>
integration && typeof integration === "function"
? integration().getName()
: "Integration"
);
const [connectionState, setConnectionState] = createSignal(
WebRTCService.STATE_DISCONNECTED
);
const showConnectButton = createMemo(
() =>
connectionState() === WebRTCService.STATE_DISCONNECTED ||
connectionState() === WebRTCService.STATE_FAILED ||
connectionState() === WebRTCService.STATE_CLOSED
);
WebRTCService.onStateChanged(handleConnectionStateChanged);
let videoElement = null;
function handleConnectWebRTC() {
let integrationId = integration().getId();
WebRTCService.setVideoElement(videoElement);
WebRTCService.connect(integrationId);
}
function handleDisconnectWebRTC() {
WebRTCService.disconnect();
}
function handleConnectionStateChanged(state) {
setConnectionState(state);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<span>Integration</span>
<h1>{title}</h1>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleConnectWebRTC}
>
<i class="bi bi-plug-fill me-2"></i>
Connect
</button>
</Show>
<Show when={!showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleDisconnectWebRTC}
>
<i class="bi bi-x-lg me-2"></i>
Disconnect
</button>
</Show>
</div>
</div>
<div class="flex-fill d-flex flex-column justify-content-center align-items-center rounded overflow-hidden">
<video
ref={videoElement}
class="w-100 h-100"
style="background-color: #000"
></video>
</div>
</div>
);
}
IntegrationView.setIntegration = setIntegration;
export default IntegrationView;

View File

@ -11,7 +11,7 @@ import {
import ModalRegistry from "../modals/modal-registry.jsx";
import UserService from "../services/user-service.js";
import UrlUtils from "../tools/url-utils.js";
import DevicesView from "./devices-view.jsx";
import DevicesView from "./devices/devices-view.jsx";
import SettingsView from "./settings-view.jsx";
import {
@ -21,12 +21,12 @@ import {
REMOTES_VIEW,
SETTINGS_VIEW,
} from "../data/constants.js";
import IntegrationView from "./integration-view.jsx";
import IntegrationView from "./devices/integration-view.jsx";
import RemotesView from "./remotes/remotes-view.jsx";
let [activeView, setActiveView] = createSignal(DEVICES_VIEW);
const MainView = function (props) {
function MainView(props) {
props = mergeProps({ onLogout: () => {} }, props);
const [userInfo] = createResource(() => UserService.getUserInfo());
@ -55,7 +55,6 @@ const MainView = function (props) {
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("view");
console.log(view)
if (view) {
setActiveView(view);
}
@ -100,7 +99,8 @@ const MainView = function (props) {
if (activeView() === view) return;
setActiveView(view);
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "view", view);
url = UrlUtils.addQueryParameter(url, "view", activeView());
url = UrlUtils.removeQueryParameter(url, "tab");
UrlUtils.setUrl(url);
}
@ -114,7 +114,7 @@ const MainView = function (props) {
<div class="d-flex flex-column" style="height:100vh">
<Modals></Modals>
<HeaderBar></HeaderBar>
<ActiveView class={"bg-body-tertiary"}></ActiveView>
<ActiveView class={"bg-body-tertiary flex-fill"}></ActiveView>
</div>
);
}
@ -211,7 +211,10 @@ const MainView = function (props) {
<SettingsView {...props} />
</Match>
<Match when={activeView() === DEVICES_VIEW}>
<DevicesView {...props} onIntegrationClicked={handleIntegrationClicked} />
<DevicesView
{...props}
onIntegrationClicked={handleIntegrationClicked}
/>
</Match>
<Match when={activeView() === INTEGRATION_VIEW}>
<IntegrationView {...props} />
@ -225,7 +228,7 @@ const MainView = function (props) {
}
return render();
};
}
MainView.setActiveView = setActiveView;

View File

@ -3,6 +3,8 @@ import List from "../../components/list";
import RemotesService from "../../services/remotes-service";
import CreateCommandModal from "../../modals/create-command-modal";
import DeleteCommandModal from "../../modals/delete-command-modal";
import ImportCommandsModal from "../../modals/import-commands-modal";
import EditCommandModal from "../../modals/edit-command-modal";
function CommandsList(props) {
const [commands, { refetch: refetchCommands }] = createResource(
@ -17,10 +19,18 @@ function CommandsList(props) {
refetchCommands();
});
ImportCommandsModal.onCommandsImported(() => {
refetchCommands();
});
DeleteCommandModal.onCommandDeleted(() => {
refetchCommands();
});
EditCommandModal.onCommandEdited(() => {
refetchCommands();
});
function handleNewCommand() {
refetchCommands();
CreateCommandModal.Handler.show();
@ -31,6 +41,15 @@ function CommandsList(props) {
DeleteCommandModal.Handler.show();
}
function handleImportCommands() {
ImportCommandsModal.Handler.show();
}
function handleEditCommand(command) {
EditCommandModal.setCommand(command);
EditCommandModal.Handler.show();
}
return (
<>
<div class="d-flex flex-row">
@ -38,9 +57,13 @@ function CommandsList(props) {
{props.navigation ? props.navigation : null}
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleImportCommands}>
<i class="bi bi-box-arrow-in-down me-2"></i>
Import
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewCommand}>
<i class="bi bi-plus-square me-2"></i>
New Command
New
</button>
</div>
</div>
@ -59,7 +82,7 @@ function CommandsList(props) {
{
id: "protocol",
name: "Protocol",
width: 10,
width: 8,
},
{
id: "type",
@ -69,7 +92,7 @@ function CommandsList(props) {
{
id: "options",
name: "",
width: 4,
width: 6,
},
]}
items={(commands() || []).map((command) => ({
@ -88,6 +111,15 @@ function CommandsList(props) {
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditCommand(command);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {

View File

@ -1,12 +1,19 @@
import { createResource, onMount } from "solid-js";
import { createMemo, createResource, createSignal, onMount } from "solid-js";
import List from "../../components/list";
import RemotesService from "../../services/remotes-service";
import CreateRemoteModal from "../../modals/create-remote-modal";
import DeleteRemoteModal from "../../modals/delete-remote-modal";
import FileUtils from "../../tools/file-utils";
import Serializer from "../../data/serializer";
import RemoteService from "../../services/remotes-service";
import EditRemoteModal from "../../modals/edit-remote-modal";
function RemotesList(props) {
const [remotes, { refetch: refetchRemotes }] = createResource(
RemotesService.getRemotes
);
const [selectedRemotes, setSelectedRemotes] = createSignal([]);
const canExport = createMemo(() => selectedRemotes().length > 0);
onMount(() => {
refetchRemotes();
@ -16,11 +23,46 @@ function RemotesList(props) {
refetchRemotes();
});
DeleteRemoteModal.onRemoteDeleted(() => {
refetchRemotes();
});
EditRemoteModal.onRemoteEdited(() => {
refetchRemotes();
});
function handleNewRemote() {
CreateRemoteModal.Handler.show();
}
function handleDeleteRemote(remote) {}
function handleEditRemote(remote) {
EditRemoteModal.setRemoteId(remote.getId());
EditRemoteModal.Handler.show();
}
function handleDeleteRemote(remote) {
DeleteRemoteModal.setRemote(remote);
DeleteRemoteModal.Handler.show();
}
function handleListItemsSelect(items) {
setSelectedRemotes(items.map((item) => item.remote));
}
async function handleExport() {
let remotes = await Promise.all(
selectedRemotes().map((remote) => RemoteService.getRemote(remote.getId()))
);
let files = remotes.map((remote) =>
FileUtils.createJsonFile(
Serializer.serializeRemote(remote),
`${remote.getTitle()}.remote.json`
)
);
if (files.length >= 1) {
FileUtils.downloadFile(files[0]);
}
}
return (
<>
@ -29,9 +71,17 @@ function RemotesList(props) {
{props.navigation ? props.navigation : null}
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<button
class="btn btn-dark me-2 mb-3"
disabled={!canExport()}
onClick={handleExport}
>
<i class="bi bi-box-arrow-up me-2"></i>
Export
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewRemote}>
<i class="bi bi-plus-square me-2"></i>
New Remote
New
</button>
</div>
</div>
@ -50,10 +100,11 @@ function RemotesList(props) {
{
id: "options",
name: "",
width: 4,
width: 6,
},
]}
onListItemClick={() => {}}
onListItemsSelect={handleListItemsSelect}
items={(remotes() || []).map((remote) => ({
id: {
html: <span class="font-monospace">{remote.getId()}</span>,
@ -64,6 +115,15 @@ function RemotesList(props) {
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditRemote(remote);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {

View File

@ -1,10 +1,13 @@
import {
createEffect,
createSignal,
Match,
mergeProps,
Switch
onCleanup,
Switch,
} from "solid-js";
import UrlUtils from "../../tools/url-utils";
import CommandsList from "./commands-list-view";
import RemotesList from "./remotes-list-view";
@ -12,16 +15,39 @@ function RemotesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const REMOTES_LIST_VIEW = "remotes_list";
const COMMANDS_LIST_VIEW = "commands_list";
const VIEWS = [REMOTES_LIST_VIEW, COMMANDS_LIST_VIEW];
const [currentView, setCurrentView] = createSignal(REMOTES_LIST_VIEW);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "tab", currentView());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setViewFromUrl);
});
setViewFromUrl();
window.addEventListener("popstate", setViewFromUrl);
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("tab");
if (!view) return;
if (!VIEWS.includes(view)) return;
setCurrentView(view);
}
function Navigation(props) {
return (
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(currentView() === REMOTES_LIST_VIEW ? " btn-secondary" : " btn-dark")
(currentView() === REMOTES_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(REMOTES_LIST_VIEW)}
>
@ -31,7 +57,9 @@ function RemotesView(props) {
<button
class={
"btn me-2 mb-3" +
(currentView() === COMMANDS_LIST_VIEW ? " btn-secondary" : " btn-dark")
(currentView() === COMMANDS_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(COMMANDS_LIST_VIEW)}
>