Skip to content

Commit ad5993f

Browse files
committed
initial commit
0 parents  commit ad5993f

27 files changed

+10793
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
pictures/*
3+
tmp

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Picture Frame
2+
3+
This repository contains the code that supports using a
4+
[single-board computer](https://en.wikipedia.org/wiki/Single-board_computer),
5+
to drive a TV for use as a picture frame.
6+
In particular, it shows a different picture each day from the collection of photos you upload.
7+
8+
## Architecture
9+
10+
The picture frame is implemented with a single [Node.js](https://en.wikipedia.org/wiki/Node.js)
11+
application that supports two different web pages:
12+
- `/`: view and control the picture frame's behavior
13+
- `/frame`: page to view the current day's picture
14+
15+
Connections can be made to the manage page to view pictures uploaded and control the behavior.
16+
17+
New pictures can be uploading from that page, or you can issue HTTP requests PUT and DELETE in the
18+
`/pictures/` folder.
19+
20+
A web browser in kiosk mode is pointed to the `/frame` page that displays only the current picture.
21+
The web browser needs to support
22+
[server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events),
23+
which is used to update the display when the current picture changes (either because it is a new
24+
day or because the user updated the picture from the manage page).
25+
26+
## Setting Up
27+
28+
You need to use an SBC capable of running Node.js with an HDMI output. For recent TVs, you want
29+
one capable of 4k (2160p) output, any frame rate. For older TVs, HD is fine (1080p).
30+
As far as disk space, you need enough for the OS and the pictures you want to upload.
31+
(This application requires only a few hundred Mb.)
32+
33+
1. Set up your SBC running whatever flavor of Linux is supported and set up networking.
34+
If there is another web server running already, disable it.
35+
36+
2. Install Node.js then install this application and set it to start at computer boot-up.
37+
38+
3. Set the screen saver to execute the browser in kiosk mode showing `http://localhost/frame`.
39+
40+
4. Copy your pictures into the pictures folder of this app or upload them through the web server.
41+
42+
5. Connect the SBC's HDMI output to your TV.
43+
44+
6. Connect to the SBC's web interface from your computer or phone to manage it.

artwork/basic-frame.png

1.72 KB
Loading

bin/www

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
3+
if (process.env.NODE_ENV == null)
4+
process.env.NODE_ENV = 'development';
5+
6+
/**
7+
* Module dependencies.
8+
*/
9+
10+
var app = require('../index');
11+
var debug = require('debug')('site:server');
12+
var http = require('http');
13+
14+
/**
15+
* Get port from environment and store in Express.
16+
*/
17+
18+
var port = normalizePort(process.env.PORT || '3000');
19+
app.set('port', port);
20+
21+
/**
22+
* Create HTTP server.
23+
*/
24+
25+
var server = http.createServer(app);
26+
27+
/**
28+
* Listen on provided port, on all network interfaces.
29+
*/
30+
31+
server.listen(port);
32+
server.on('error', onError);
33+
server.on('listening', onListening);
34+
35+
/**
36+
* Normalize a port into a number, string, or false.
37+
*/
38+
39+
function normalizePort(val) {
40+
var port = parseInt(val, 10);
41+
42+
if (isNaN(port)) {
43+
// named pipe
44+
return val;
45+
}
46+
47+
if (port >= 0) {
48+
// port number
49+
return port;
50+
}
51+
52+
return false;
53+
}
54+
55+
/**
56+
* Event listener for HTTP server "error" event.
57+
*/
58+
59+
function onError(error) {
60+
if (error.syscall !== 'listen') {
61+
throw error;
62+
}
63+
64+
var bind = typeof port === 'string'
65+
? 'Pipe ' + port
66+
: 'Port ' + port;
67+
68+
// handle specific listen errors with friendly messages
69+
switch (error.code) {
70+
case 'EACCES':
71+
console.error(bind + ' requires elevated privileges');
72+
process.exit(1);
73+
break;
74+
case 'EADDRINUSE':
75+
console.error(bind + ' is already in use');
76+
process.exit(1);
77+
break;
78+
default:
79+
throw error;
80+
}
81+
}
82+
83+
/**
84+
* Event listener for HTTP server "listening" event.
85+
*/
86+
87+
function onListening() {
88+
var addr = server.address();
89+
var bind = typeof addr === 'string'
90+
? 'pipe ' + addr
91+
: 'port ' + addr.port;
92+
debug('Listening on ' + bind);
93+
}

config/server.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const path = require('path');
2+
3+
module.exports = {
4+
/**
5+
* Directory in which picture files are located.
6+
*/
7+
pictures: path.join(__dirname, '../pictures'),
8+
9+
/**
10+
* If true, PUT and DELETE is supported to upload and remove images.
11+
*/
12+
uploads: true,
13+
14+
/**
15+
* Allowed picture image extensions.
16+
*/
17+
extensions: ['jpg', 'jpeg', 'png'],
18+
};

index.js

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright 2016 John Coker for ThrustCurve.org
3+
* Licensed under the ISC License, https://opensource.org/licenses/ISC
4+
*/
5+
'use strict';
6+
7+
const path = require('path'),
8+
fs = require('fs'),
9+
express = require('express'),
10+
favicon = require('serve-favicon'),
11+
logger = require('morgan'),
12+
cookieParser = require('cookie-parser'),
13+
config = require('./config/server.js'),
14+
Pictures = require('./pictures.js');
15+
16+
const app = express(),
17+
router = express.Router();
18+
19+
app.use(logger('dev'));
20+
app.use(favicon(__dirname + '/public/favicon.ico'));
21+
app.use(express.static(path.join(__dirname, '/public')));
22+
app.use(cookieParser());
23+
24+
// set up picture state
25+
const pictures = new Pictures(config);
26+
pictures.reload();
27+
28+
// main page is dynamic
29+
const manageRoot = path.join(__dirname + '/public/manage/'),
30+
manageHeader = fs.readFileSync(path.join(manageRoot, 'header.incl'), 'utf8'),
31+
manageFooter = fs.readFileSync(path.join(manageRoot, 'footer.incl'), 'utf8');
32+
33+
function formatCaption(picture) {
34+
let s = encodeHTML(picture.file) + ', ';
35+
36+
if (picture == pictures.current)
37+
s += 'showing now';
38+
else if (!picture.lastShown)
39+
s += 'never shown';
40+
else {
41+
let midnight = new Date();
42+
midnight.setHours(0, 0, 0, 0);
43+
let ago = 0;
44+
while (picture.lastShown < midnight) {
45+
midnight.setDate(midnight.getDate(-1));
46+
ago++;
47+
}
48+
49+
if (ago < 1)
50+
s += 'shown today';
51+
else if (ago == 1)
52+
s += 'shown yesterday';
53+
else
54+
s += `shown ${ago}d ago`;
55+
}
56+
57+
return s;
58+
}
59+
60+
router.get(['/', '/index.html', '/manage', '/manage.html'], function(req, res, next) {
61+
62+
var feedback = [];
63+
64+
// change the current picture
65+
if (req.query.hasOwnProperty('switch') && pictures.switch(req.query.switch)) {
66+
let encoded = encodeHTML(pictures.current.file);
67+
feedback.push(`Now showing ${encoded}.`);
68+
}
69+
70+
// reset the sequence
71+
if (req.query.hasOwnProperty('reset')) {
72+
feedback.push('Picture sequence reset.');
73+
}
74+
75+
// resume normal sequence
76+
if (req.query.hasOwnProperty('resume')) {
77+
78+
if (pictures.current) {
79+
let encoded = encodeHTML(pictures.current.file);
80+
feedback.push('Resumed sequence with ${encoded}.');
81+
}
82+
}
83+
84+
// collect the sort order
85+
let sort, setSort = false;
86+
if (req.query.hasOwnProperty('sort') &&
87+
/^(name|shown|updated)$/.test(req.query.sort.toLowerCase())) {
88+
sort = req.query.sort;
89+
res.append('Set-Cookie', 'sort=' + sort);
90+
} else {
91+
sort = req.cookies.sort;
92+
}
93+
94+
// generate the page
95+
var parts = [];
96+
parts.push(manageHeader);
97+
98+
if (feedback.length > 0) {
99+
parts.push(' <div class="container">\n');
100+
for (let i = 0; i < feedback.length; i++) {
101+
parts.push(` <div class="alert alert-info" role="alert">
102+
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
103+
${feedback[i]}
104+
</div>
105+
`);
106+
}
107+
parts.push('</div>\n');
108+
}
109+
110+
parts.push(' <div class="container">\n');
111+
112+
if (pictures.current) {
113+
let caption = formatCaption(pictures.current);
114+
parts.push(` <div class="row">
115+
<div class="col-sm-12 picture current">
116+
<img src="/picture/current" />
117+
<p class="caption">${caption}</p>
118+
</div>
119+
</div>\n`);
120+
}
121+
122+
if (pictures.length > 1) {
123+
parts.push(' <div class="row">\n');
124+
let col = 0;
125+
let sorted = pictures.sorted(sort);
126+
for (let i = 0; i < sorted.length; i++) {
127+
if (sorted[i] == pictures.current)
128+
continue;
129+
130+
if (col >= 12) {
131+
parts.push(' </div>\n');
132+
parts.push(' <div class="row">\n');
133+
col = 0;
134+
}
135+
parts.push(' <div class="col-sm-3 picture">\n');
136+
let encoded = encodeURIComponent(sorted[i].file);
137+
parts.push(` <a href="?switch=${encoded}"><img src="/thumbnail/${encoded}" /></a>\n`);
138+
let caption = formatCaption(sorted[i]);
139+
parts.push(` <p class="caption">${caption}</p>\n`);
140+
parts.push(' </div>\n');
141+
col += 3;
142+
}
143+
if (col < 12)
144+
parts.push(` <div class="col-sm-${12 - col}">&nbsp;</div>\n`);
145+
parts.push(' </div>\n');
146+
}
147+
148+
parts.push(' </div>\n');
149+
parts.push(manageFooter);
150+
151+
res.status(200)
152+
.type('html');
153+
for (let i = 0; i < parts.length; i++)
154+
res.write(parts[i]);
155+
res.end();
156+
});
157+
158+
router.get(['/picture/current', '/thumbnail/current'], function(req, res, next) {
159+
if (pictures.current) {
160+
res.sendFile(pictures.current.path, {
161+
lastModified: false,
162+
cacheControl: false,
163+
headers: {
164+
'Last-Modified': pictures.current.lastShown.toUTCString(),
165+
'Cache-Control': 'must-revalidate',
166+
'X-Current-Picture': pictures.current.file
167+
}
168+
});
169+
} else
170+
res.status(404).send();
171+
});
172+
173+
router.get(['/picture/:file', '/thumbnail/:file'], function(req, res, next) {
174+
let found;
175+
if (req.params.file != null && req.params.file !== '')
176+
found = pictures.byFile(req.params.file);
177+
if (found)
178+
res.sendFile(found.path);
179+
else
180+
res.status(404).send();
181+
});
182+
183+
app.use('/', router);
184+
185+
// handle other routes as 404
186+
app.use(function(req, res, next) {
187+
res.status(404).send();
188+
});
189+
190+
// handle internal errors
191+
app.use(function(err, req, res, next) {
192+
let status = err.status || 500;
193+
let message = encodeHTML(err.message) || 'Unknown error';
194+
let body = `<!DOCTYPE html>
195+
<html>
196+
<head>
197+
<title>Server Error</title>
198+
</head>
199+
<body>
200+
<h1>Server Error</h1>
201+
<ul>
202+
<li>URL: ${req.url}</li>
203+
<li>status: ${status}</li>
204+
<li>message: ${message}</li>
205+
</ul>
206+
`;
207+
208+
if (err && err.stack)
209+
body += ' <pre>\n' + encodeHTML(err.stack) + '\n</pre>\n';
210+
211+
body += `
212+
</body>
213+
</html>`;
214+
215+
res.status(status).send(body);
216+
});
217+
218+
function encodeHTML(s) {
219+
if (s == null || s === '')
220+
return '';
221+
return s.replace(/&/g, '&amp;')
222+
.replace(/</g, '&lt;')
223+
.replace(/>/g, '&gt;');
224+
}
225+
226+
module.exports = app;

0 commit comments

Comments
 (0)