8 Commits

16 changed files with 286 additions and 218 deletions

4
.gitignore vendored
View File

@@ -1,4 +1,4 @@
### Above combination will ignore all files without extension ###
src/resources/pixctrl.js
bin/
.vscode/
.vscode/
pixctrl.js

View File

@@ -1,6 +1,6 @@
# Package
version = "1.0.0"
version = "1.1.0"
author = "luxick"
description = "Play an image slide show from different sources"
license = "GPL-2.0"
@@ -10,10 +10,12 @@ bin = @["randopix", "pixctrl"]
# Dependencies
requires "nim >= 1.0.0", "gintro", "argparse", "jester", "ajax"
# Not on nimble yet
requires "https://github.com/luxick/op.git >= 1.0.0"
proc genJS =
echo "Generating JS Client"
exec("nim js -o:src/resources/pixctrl.js src/pixctrl.nim")
exec("nim js -o:src/resources/www/pixctrl.js src/pixctrl.nim")
task genJS, "Generate the Javascript client":
genJS()

View File

@@ -1,16 +1,15 @@
import os, sets, random, httpClient, json, strutils, strformat, options, deques, times
import gintro/[gdkpixbuf, gobject]
from lenientops import `*`
import op, gintro/[gdkpixbuf, gobject]
import common
const
supportedExts = @[".png", ".jpg", ".jpeg"]
placeholderImg = slurp("resources/blank.png")
foxesUrl = "https://randomfox.ca/floof/"
inspiroUrl = "http://inspirobot.me/api?generate=true"
type
FileOpResult* = object of OpResult
file*: string
ImageProvider* = ref object of RootObj
## Manages images that should be displayed
verbose: bool ## Additional logging for the image provider
@@ -43,12 +42,6 @@ proc newImageProvider*(verbose: bool, mode: Mode): ImageProvider =
proc newImageProvider*(verbose: bool, mode: Mode, path: string): ImageProvider =
newImageProvider(verbose, mode, some(path))
proc newFileOpResultError(msg: string): FileOpResult =
FileOpResult(success: false, errorMsg: msg)
proc newFileOpResult(file: string): FileOpResult =
FileOpResult(success: true, file: file)
########################
# Utilities
########################
@@ -57,11 +50,31 @@ proc log(ip: ImageProvider, things: varargs[string, `$`]) =
if ip.verbose:
echo things.join()
func calcImageSize(maxWidth, maxHeight, imgWidth, imgHeight: int): tuple[width: int, height: int] =
## Calculate the best fit for an image on the give screen size.
## This should keep the image aspect ratio
let
ratioMax = maxWidth / maxHeight
ratioImg = imgWidth / imgHeight
if (ratioMax > ratioImg):
result.width = (imgWidth * (maxHeight / imgHeight)).toInt
result.height = maxHeight
else:
result.width = maxWidth
result.height = (imgHeight * (maxWidth / imgWidth)).toInt
########################
# Image Provider procs
########################
proc getFox(ip: ImageProvider): FileOpResult =
proc getPlaceHolder(ip: ImageProvider): OP[string] =
## Provide the placeholder image.
## This is used when no mode is active
let f = fmt"{tmpFile}.blank"
writeFile(f, placeholderImg)
ok f
proc getFox(ip: ImageProvider): OP[string] =
## Download image from the fox API
try:
let urlData = client.getContent(foxesUrl)
@@ -69,15 +82,15 @@ proc getFox(ip: ImageProvider): FileOpResult =
let imageData = client.getContent(info["image"].getStr)
let dlFile = fmt"{tmpFile}.download"
writeFile(dlFile, imageData)
return newFileOpResult(dlFile)
ok dlFile
except JsonParsingError:
ip.log fmt"Error while fetching from fox API: {getCurrentExceptionMsg()}"
return newFileOpResultError("Json parsing error")
fail[string] "Json parsing error"
except KeyError:
ip.log fmt"No image in downloaded data: {getCurrentExceptionMsg()}"
return newFileOpResultError("No image from API")
fail[string] "No image from API"
proc getInspiro(ip: ImageProvider): FileOpResult =
proc getInspiro(ip: ImageProvider): OP[string] =
## Download and save image from the inspiro API
try:
let imageUrl = client.getContent(inspiroUrl)
@@ -85,12 +98,12 @@ proc getInspiro(ip: ImageProvider): FileOpResult =
let imageData = client.getContent(imageUrl)
let dlFile = fmt"{tmpFile}.download"
writeFile(dlFile,imageData)
return newFileOpResult(dlFile)
ok dlFile
except:
ip.log fmt"Unexpected error while downloading: {getCurrentExceptionMsg()}"
return newFileOpResultError(getCurrentExceptionMsg())
fail[string] getCurrentExceptionMsg()
proc getLocalFile(ip: var ImageProvider): FileOpResult =
proc getLocalFile(ip: var ImageProvider): OP[string] =
## Provide an image from a local folder
# First, check if there are still images left to be loaded.
@@ -108,61 +121,54 @@ proc getLocalFile(ip: var ImageProvider): FileOpResult =
for file in tmp:
fileList.addLast(file)
if fileList.len == 0:
return newFileOpResultError("No files found")
return fail[string] "No files found"
let next = fileList.popFirst()
# Remove the current file after
result = newFileOpResult(next)
ok next
proc getFileName(ip: var ImageProvider): FileOpResult =
proc getFileName(ip: var ImageProvider): OP[string] =
## Get the temporary file name of the next file to display
case ip.mode
of Mode.None:
return ip.getPlaceHolder()
of Mode.File:
return ip.getLocalFile()
of Mode.Foxes:
return ip.getFox()
of Mode.Inspiro:
return ip.getInspiro()
else:
return newFileOpResultError("Not implemented")
########################
# Exported procs
########################
proc next*(ip: var ImageProvider, width, height: int): FileOpResult =
proc next*(ip: var ImageProvider, maxWidth, maxHeight: int): OP[string] =
## Uses the image provider to get a new image ready to display.
## `width` and `height` should be the size of the window.
if ip.mode == Mode.None:
return newFileOpResultError("No mode active")
let op = ip.getFileName()
if not op.success: return op
let r = ip.getFileName()
if not r.isOk: return r
var rawPixbuf = newPixbufFromFile(op.file)
# resize the pixbuf to best fit on screen
var w, h: int
if (width > height):
h = height
w = ((rawPixbuf.width * h) / rawPixbuf.height).toInt
else:
w = width
h = ((rawPixbuf.height * w) / rawPixbuf.width).toInt
var rawPixbuf = newPixbufFromFile(r.val)
# Resize the pixbuf to best fit on screen
let size = calcImageSize(maxWidth, maxHeight, rawPixbuf.width, rawPixbuf.height)
ip.log "Scale image to: ", size
let then = now()
var pixbuf = rawPixbuf.scaleSimple(w, h, InterpType.nearest)
var pixbuf = rawPixbuf.scaleSimple(size.width, size.height, InterpType.nearest)
let now = now()
ip.log "Image scaled. Time: ", (now - then).inMilliseconds, "ms"
# The pixbuf is written to disk and loaded again once because
# directly setting the image from a pixbuf will leak memory
let saved = pixbuf.savev(tmpFile, "png", @[])
if not saved:
return newFileOpResultError("Error while saving temporary image")
return result.fail "Error while saving temporary image"
# GTK pixbuf leaks memory when not manually decreasing reference count
pixbuf.unref()
rawPixbuf.unref()
newFileOpResult(tmpFile)
ok tmpFile
createDir(tmpDir)
randomize()

View File

@@ -1,4 +1,5 @@
import os, options, strformat
import op
import gintro/[glib, gobject, gtk, gio]
import gintro/gdk except Window
import argparse except run
@@ -22,9 +23,9 @@ type
port: int ## Port to host the control server
var
imageProvider: ImageProvider ## Gets images from the chosen source
args: Args ## The parsed command line args
updateTimeout: int ## ID of the timeout that updates the images
imageProvider: ImageProvider ## Gets images from the chosen source
args: Args ## The parsed command line args
updateTimeout, serverTimeout: int ## ID of the timeouts for image updating and server checking
# Widgets
window: ApplicationWindow
label: Label
@@ -98,19 +99,19 @@ proc updateImage(image: Image): bool =
if imageProvider.mode == Mode.None:
log "No display mode"
label.notify "No mode selected"
return true
var wWidth, wHeight: int
window.getSize(wWidth, wHeight)
let op = imageProvider.next(wWidth, wHeight)
result = op.success
if not op.success:
label.notify op.errorMsg
let r = imageProvider.next(wWidth, wHeight)
result = r.isOk
if not r.isOk:
label.notify r.error
return
image.setFromFile(op.file)
label.notify
image.setFromFile(r.val)
if imageProvider.mode != Mode.None:
label.notify
except:
let
e = getCurrentException()
@@ -165,8 +166,6 @@ proc checkServerChannel(image: Image): bool =
else:
log "Command ignored: ", msg.command
sleep(100)
result = true
proc toggleFullscreen(action: SimpleAction; parameter: Variant; window: ApplicationWindow) =
@@ -193,12 +192,18 @@ proc quit(action: SimpleAction; parameter: Variant; app: Application) =
## Application quit event
cleanUp(window, app)
proc hidePointer(window: ApplicationWindow): void =
proc realizeWindow(window: ApplicationWindow, image: Image): void =
## Hides the mouse pointer for the application.
if args.fullscreen:
window.fullscreen
let cur = window.getDisplay().newCursorForDisplay(CursorType.blankCursor)
let win = window.getWindow()
win.setCursor(cur)
# Setting the inital image
# Fix 1 second timeout to make sure all other initialization has finished
updateTimeout = int(timeoutAdd(1000, timedUpdate, image))
proc appActivate(app: Application) =
# Parse arguments from the command line
let parsed = newArgs()
@@ -243,9 +248,6 @@ proc appActivate(app: Application) =
let image = newImage()
container.add(image)
if args.fullscreen:
window.fullscreen
## Connect the GTK signals to the procs
var action: SimpleAction
@@ -270,23 +272,19 @@ proc appActivate(app: Application) =
window.actionMap.addAction(action)
window.connect("destroy", cleanUp, app)
window.connect("realize", hidePointer)
window.connect("realize", realizeWindow, image)
window.showAll
# Help is only shown on demand
helpBox.hide
# Setting the inital image
# Fix 1 second timeout to make sure all other initialization has finished
updateTimeout = int(timeoutAdd(1000, timedUpdate, image))
## open communication channel from the control server
chan.open()
## Start the server for handling incoming commands
let serverArgs = ServerArgs(verbose: args.verbose, port: args.port)
createThread(serverWorker, runServer, serverArgs)
discard idleAdd(checkServerChannel, image)
serverTimeout = int(timeoutAdd(100, checkServerChannel, image))
when isMainModule:
let app = newApplication("org.luxick.randopix")

BIN
src/resources/blank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -17,6 +17,8 @@
<main>
<h1 class="center">Randopix Remote</h1>
<p>Control the image that is shown on this randopix.</p>
<div class="control">
<div class="control-info">Load the next image</div>
<div class="control-body">
@@ -40,6 +42,12 @@
</div>
</div>
<div class="social">
<a class="btn btn-mini" href="https://github.com/luxick/randopix">
<img src="/github.png"></img>
Source code and usage
</a>
</div>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

157
src/resources/www/site.css Normal file
View File

@@ -0,0 +1,157 @@
@charset "utf-8";
/* CSS Document */
@font-face {
font-family: notcomicsans;
src: url("/not-comic-sans.woff") format('truetype');
font-weight: normal;
font-style: normal;
}
.star-bg {
background-color: #1c1d1b;
background-image: url("/stars.gif")
}
/* Chrome, Safari, Opera */
@-webkit-keyframes rainbow {
0%{color: orange;}
10%{color: purple;}
20%{color: red;}
30%{color: CadetBlue;}
40%{color: yellow;}
50%{color: coral;}
60%{color: green;}
70%{color: cyan;}
80%{color: DeepPink;}
90%{color: DodgerBlue;}
100%{color: orange;}
}
/* Internet Explorer */
@-ms-keyframes rainbow {
0%{color: orange;}
10%{color: purple;}
20%{color: red;}
30%{color: CadetBlue;}
40%{color: yellow;}
50%{color: coral;}
60%{color: green;}
70%{color: cyan;}
80%{color: DeepPink;}
90%{color: DodgerBlue;}
100%{color: orange;}
}
/* Standard Syntax */
@keyframes rainbow {
0%{color: orange;}
10%{color: purple;}
20%{color: red;}
30%{color: CadetBlue;}
40%{color: yellow;}
50%{color: coral;}
60%{color: green;}
70%{color: cyan;}
80%{color: DeepPink;}
90%{color: DodgerBlue;}
100%{color: orange;}
}
body {
font-family: notcomicsans;
color:#FFFF00;
display: flex;
flex-direction: column;
align-items: stretch;
font-size: 1.2rem;
}
main {
max-width: 900px;
align-self: center;
border: 1px solid gray;
border-radius: 5px;
background-image: url("/microfab.gif");
padding: 15px;
}
.control {
padding-bottom: 20px;
}
.control-info {
padding-bottom: 5px;
}
.control-body {
display: flex;
justify-content: space-around;
align-items: center;
}
.control-body > * {
margin-left: 5px;
margin-right: 5px;
}
h1 {
/* Chrome, Safari, Opera */
-webkit-animation: rainbow 5s infinite;
/* Internet Explorer */
-ms-animation: rainbow 5s infinite;
/* Standar Syntax */
animation: rainbow 5s infinite;
margin-top: 0;
}
button {
padding: 0;
border: none;
font: inherit;
color: inherit;
background-color: transparent;
/* show a hand cursor on hover; some argue that we
should keep the default arrow cursor for buttons */
cursor: pointer;
}
a:visited {
color: #ff0;
}
.btn {
text-align: center;
font-size: 1.3rem;
text-decoration: none;
display: block;
border: 2px solid darkgray;
border-radius: 5px;
padding: 0px 10px;
background-color: #1c1d1b;
}
.btn:hover {
color: #ff0;
fill: #ff0;
background-color: #0b0c0b;
}
.btn-mini {
font-size: 0.75rem;
border: 1px solid darkgray;
padding: 3px;
display: flex;
align-items: center;
}
.btn-mini > img {
width: 0.75rem;
height: 0.75rem;
margin-right: 5px;
}
.social {
margin-top: 20px;
display: inline-block;
}
.center {
text-align: center;
}

BIN
src/resources/www/stars.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,12 +1,22 @@
import asyncdispatch, strutils, json, logging
import asyncdispatch, strutils, json, logging, os
import jester
import common
when defined(release):
proc slurpResources(): Table[string, string] {.compileTime.} =
## Include everything from the www dir into the binary.
## This way the final executable will not need an static web folder.
for item in walkDir("src/resources/www/", true):
if item.kind == pcFile:
result[item.path] = slurp("resources/www/" & item.path)
const resources = slurpResources()
const
index = slurp("resources/index.html")
style = slurp("resources/site.css")
pixctrlJs = slurp("resources/pixctrl.js")
script = slurp("resources/script.js")
contentTypes = {
".js": "text/javascript",
".css": "text/css"
}.toTable
type
ServerArgs* = object of RootObj
@@ -21,19 +31,37 @@ proc log(things: varargs[string, `$`]) =
if verbose:
echo things.join()
router randopixRouter:
get "/":
resp index
when defined(release):
## When in release mode, use resources includes in the binary.
## When developing use the files directly.
router getRouter:
get "/":
resp resources["index.html"]
get "/site.css":
resp(style, contentType="text/css")
get "/pixctrl.js":
resp(pixctrlJs, contentType="text/javascript")
get "/script.js":
resp(script, contentType="text/javascript")
get "/@resource":
try:
var cType: string
if contentTypes.hasKey(@"resource".splitFile.ext):
cType = contentTypes[@"resource".splitFile.ext]
resp resources[@"resource"], contentType=cType
except KeyError:
log "Resource not found: ", @"resource"
resp Http404
else:
router getRouter:
get "/":
resp readFile("src/resources/www/index.html")
get "/@resource":
try:
var cType: string
if contentTypes.hasKey(@"resource".splitFile.ext):
cType = contentTypes[@"resource".splitFile.ext]
resp readFile("src/resources/www/" & @"resource"), contentType=cType
except KeyError:
log "Resource not found: ", @"resource"
resp Http404
router postRouter:
post "/":
try:
log "Command from ", request.ip
@@ -46,6 +74,10 @@ router randopixRouter:
except:
log "Error: ", getCurrentExceptionMsg()
router randopixRouter:
extend postRouter, ""
extend getRouter, ""
proc runServer*[ServerArgs](arg: ServerArgs) {.thread, nimcall.} =
verbose = arg.verbose
logging.setLogFilter(lvlInfo)

View File

@@ -1 +1 @@
1.0.0
1.1.0