first draft, somewhat working

time transfer does not work
This commit is contained in:
Franzi 2023-09-29 12:08:43 +02:00
commit 9e2c26e5d9
13 changed files with 2258 additions and 0 deletions

235
anims.lua Normal file
View file

@ -0,0 +1,235 @@
local M = {}
local function make_smooth(timeline)
assert(#timeline >= 1)
local function find_span(t)
local lo, hi = 1, #timeline
while lo <= hi do
local mid = math.floor((lo+hi)/2)
if timeline[mid].t > t then
hi = mid - 1
else
lo = mid + 1
end
end
return math.max(1, lo-1)
end
local function get_value(t)
local t1 = find_span(t)
local t0 = math.max(1, t1-1)
local t2 = math.min(#timeline, t1+1)
local t3 = math.min(#timeline, t1+2)
local p0 = timeline[t0]
local p1 = timeline[t1]
local p2 = timeline[t2]
local p3 = timeline[t3]
local v0 = p0.val
local v1 = p1.val
local v2 = p2.val
local v3 = p3.val
local progress = 0.0
if p1.t ~= p2.t then
progress = math.min(1, math.max(0, 1.0 / (p2.t - p1.t) * (t - p1.t)))
end
if p1.ease == "linear" then
return (v1 * (1-progress) + (v2 * progress))
elseif p1.ease == "step" then
return v1
elseif p1.ease == "inout" then
return -(v2-v1) * progress*(progress-2) + v1
else
local d1 = p2.t - p1.t
local d0 = p1.t - p0.t
local bias = 0.5
local tension = 0.8
local mu = progress
local mu2 = mu * mu
local mu3 = mu2 * mu
local m0 = (v1-v0)*(1+bias)*(1-tension)/2 + (v2-v1)*(1-bias)*(1-tension)/2
local m1 = (v2-v1)*(1+bias)*(1-tension)/2 + (v3-v2)*(1-bias)*(1-tension)/2
m0 = m0 * (2*d1)/(d0+d1)
m1 = m1 * (2*d0)/(d0+d1)
local a0 = 2*mu3 - 3*mu2 + 1
local a1 = mu3 - 2*mu2 + mu
local a2 = mu3 - mu2
local a3 = -2*mu3 + 3*mu2
return a0*v1+a1*m0+a2*m1+a3*v2
end
end
return get_value
end
local function rotating_entry_exit(S, E, obj)
local rotate = make_smooth{
{t = S , val = -5},
{t = S+.3,val = 0, ease='step'},
{t = E-.3,val = 0},
{t = E, val = 5},
}
return function(t)
gl.rotate(rotate(t), 0, 1, 0)
return obj(t)
end
end
local function move_in_move_out(S, E, x, y, obj)
local x = make_smooth{
{t = S, val = x+50},
{t = S+.3,val = x, ease='step'},
{t = E-.3,val = x},
{t = E, val = x-20},
}
local y = make_smooth{
{t = S, val = y*1.05},
{t = S+.3,val = y, ease='step'},
{t = E-.3,val = y},
{t = E, val = y*0.95},
}
return function(t)
gl.translate(x(t), y(t))
return obj(t)
end
end
local M = {
make_smooth = make_smooth;
rotating_entry_exit = rotating_entry_exit;
move_in_move_out = move_in_move_out;
}
function M.Area(width, height)
local anims = {}
local function add(anim, pos)
pos = pos or #anims+1
table.insert(anims, pos, anim)
end
local function draw(t, x1, y1, x2, y2)
local w = x2 - x1
local h = y2 - y1
local ax1, ay1, ax2, ay2 = util.scale_into(w, h, width, height)
local s = (ax2 - ax1) / width
gl.pushMatrix()
gl.translate(x1+ax1, y1+ay1)
gl.scale(s, s)
for idx = 1, #anims do
gl.pushMatrix()
anims[idx](t)
gl.popMatrix()
end
gl.popMatrix()
end
return {
add = add;
draw = draw;
width = width;
height = height;
}
end
function M.moving_font(S, E, font, x, y, text, size, r, g, b, a)
local alpha = make_smooth{
{t = S, val = 0},
{t = S+.5,val = 1, ease='step'},
{t = E-.5,val = 1},
{t = E, val = 0},
}
return move_in_move_out(S, E, x, y,
rotating_entry_exit(S, E, function(t)
return font:write(0, 0, text, size, r, g, b, a * alpha(t))
end)
)
end
function M.moving_image_raw(S, E, img, x1, y1, x2, y2, a)
a = a or 1
local alpha = make_smooth{
{t = S, val = 0},
{t = S+.5,val = 1, ease='step'},
{t = E-.5,val = 1},
{t = E, val = 0},
}
return move_in_move_out(S, E, x1, y1,
rotating_entry_exit(S, E, function(t)
return img:draw(0, 0, x2-x1, y2-y1, a * alpha(t))
end)
)
end
function M.moving_image(S, E, img, x1, y1, x2, y2, a)
local alpha = make_smooth{
{t = S, val = 0},
{t = S+.5,val = 1, ease='step'},
{t = E-.5,val = 1},
{t = E, val = 0},
}
return move_in_move_out(S, E, x1, y1,
rotating_entry_exit(S, E, function(t)
return util.draw_correct(img, 0, 0, x2-x1, y2-y1, a * alpha(t))
end)
)
end
function M.logo(S, E, x, y, img, size)
local alpha = make_smooth{
{t = S+0, val = 0},
{t = S+.5, val = 1, ease='step'},
{t = E-.5, val = 1},
{t = E, val = 0},
}
return function(t)
return util.draw_correct(img, x, y, x+size, y+size, alpha(t))
end
end
function M.tweet_profile(S, E, x, y, img, size)
local x = make_smooth{
{t = S+0, val = 1800},
{t = S+1, val = 500},
{t = S+2, val = x, ease='step'},
{t = E-1, val = x},
{t = E, val = -20},
}
local y = make_smooth{
{t = S+0, val = 500},
{t = S+1, val = 200},
{t = S+2, val = y, ease='step'},
{t = E-1, val = y},
{t = E, val = 0},
}
local scale = make_smooth{
{t = S , val = 0},
{t = S+1, val = 8},
{t = S+2, val = 1, ease='step'},
{t = E-1, val = 1},
{t = E, val = 0},
}
local alpha = make_smooth{
{t = S, val = 0},
{t = S+1, val = 1, ease='step'},
{t = E-1, val = 1},
{t = E, val = 0},
}
return function(t)
local size = scale(t) * size
gl.translate(x(t), y(t))
return util.draw_correct(img, 0, 0, size, size, alpha(t))
end
end
return M

BIN
empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

BIN
font.ttf Normal file

Binary file not shown.

1374
hosted.py Normal file

File diff suppressed because it is too large Load diff

58
node.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "Plugin: pretalx-broadcast-tools",
"permissions": {
"network": "Has to fetch schedule from remote site"
},
"options": [{
"title": "Design",
"type": "section"
}, {
"title": "Font",
"ui_width": 6,
"name": "font",
"type": "font",
"default": "silkscreen.ttf"
}, {
"title": "Show Language",
"ui_width": 4,
"name": "show_language",
"type": "boolean",
"default": true
}, {
"title": "Show Track",
"ui_width": 4,
"name": "show_track",
"type": "boolean",
"hint": "Show colored bars in 'all talks', track name in 'next talk'",
"default": true
}, {
"title": "Schedule",
"type": "section"
}, {
"title": "Event URL",
"name": "event_url",
"type": "string",
"default": "https://pretalx.example.com/my-super-cool-event/"
}, {
"title": "Rooms",
"name": "rooms",
"doc_link": true,
"type": "list",
"itemname": "Room",
"hint": "Define all rooms in your schedule and assign them to devices",
"items": [{
"title": "Room Name",
"ui_width": 3,
"name": "name",
"type": "string",
"hint": "Name of this room in your events native language",
"default": ""
}, {
"title": "Serial",
"ui_width": 3,
"name": "serial",
"type": "device",
"default": ""
}]
}]
}

7
node.lua Normal file
View file

@ -0,0 +1,7 @@
util.init_hosted()
gl.setup(NATIVE_WIDTH, NATIVE_HEIGHT)
function node.render()
CONFIG.font:write(10, 10, "This is a plugin for scheduled player, don't use it individually", 30, 1,1,1,1)
end

BIN
node.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "pretalx-broadcast-tools",
"author": "git@kunsmann.eu",
"desc": "Shows information provided by pretalx-broadcast-tools",
"nesting": {
"parents": ["scheduled-plugin"],
"childs": []
},
"platforms": ["pi/epoch-1", "pi/epoch-2"],
"offline": {
"support": "no",
"info": "Needs to fetch remote content"
}
}

BIN
package.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

118
service Executable file
View file

@ -0,0 +1,118 @@
#!/usr/bin/python
import sys
import time
import traceback
from datetime import datetime, timedelta
import pytz
from requests import get
from hosted import config, node
config.restart_on_update()
def log(msg):
sys.stderr.write("[pretalx] {}\n".format(msg))
def idle(seconds, event_start, event_tz):
end = time.time() + seconds
log("sleeping for {} seconds".format(seconds))
while time.time() < end:
send_data = {
'day': '??',
}
if event_start is not None:
timezone = pytz.timezone(event_tz)
event_now = datetime.now(timezone)
utc_now = datetime.now(pytz.utc)
utc_offset = (event_now - utc_now).total_seconds()
day_zero = event_start.replace(
hour=0,
minute=0,
second=0,
tzinfo=timezone
) - timedelta(days=1)
day_info = event_now - day_zero
log("Day0: {}".format(day_zero.isoformat()))
log("NOW: {}".format(event_now.isoformat()))
send_data['day'] = day_info.days
send_data['time'] = int(time.time() + utc_offset)
else:
send_data['time'] = time.time()
for k,v in send_data.items():
node.send_raw('root/plugin/pretalx/{}:{}'.format(k, v))
node.send_raw('root/plugin/pretalx-broadcast-tools/{}:{}'.format(k, v))
time.sleep(1)
def main():
event_info = None
event_start = None
schedule = None
event_tz = 'UTC'
while True:
event_url = config["event_url"]
if not event_url.endswith("/"):
event_url = event_url + "/"
if "example.com" in event_url:
log("default event url, waiting for config update")
# sleep forever, service will get restarted if the config
# changes.
time.sleep(99999999)
log("event url: {}".format(event_url))
try:
r = get(
event_url + "p/broadcast-tools/event.json",
)
r.raise_for_status()
except Exception as e:
log("updating event info failed: {}".format(repr(e)))
# Only print the error message. If we have fetched the event
# info json blob atleast once, we have all the information
# we need.
else:
event_info = r.json()
node.write_json("event.json", event_info)
log("updated event info json")
try:
r = get(
event_url + "p/broadcast-tools/schedule.json",
)
r.raise_for_status()
except Exception as e:
log("updating schedule failed: {}".format(repr(e)))
# Only print the error message. If we have fetched the schedule
# info json blob atleast once, we have all the information
# we need.
else:
schedule = r.json()
for talk in schedule['talks']:
talk['start_str'] = datetime.fromtimestamp(talk['start_ts']).strftime('%H:%M')
node.write_json("schedule.json", schedule)
log("updated schedule json")
if event_info is not None:
event_start = datetime.strptime(event_info["start"], "%Y-%m-%d")
event_tz = event_info['timezone']
idle(30, event_start, event_tz)
if __name__ == "__main__":
try:
main()
except:
traceback.print_exc()
time.sleep(30)

BIN
silkscreen.ttf Normal file

Binary file not shown.

94
tile.js Normal file
View file

@ -0,0 +1,94 @@
var config = {
props: ['config'],
template: `
<div>
<h4>Frab Plugin</h4>
<div class='row'>
<div class='col-xs-3'>
<select class='btn btn-default' v-model="mode">
<option value="all_talks">All Talks</option>
<option value="next_talk">Next Talk</option>
<option value="room">Room Name</option>
<option value="day">Day</option>
</select>
</div>
<div class='col-xs-3'>
<input
type="color"
v-model="color"
class='form-control'/>
</div>
<div class='col-xs-3'>
<select class='btn btn-default' v-model="font_size">
<option value="40">40px</option>
<option value="50">50px</option>
<option value="60">60px</option>
<option value="70">70px</option>
<option value="80">80px</option>
<option value="90">90px</option>
<option value="100">100px</option>
<option value="110">110px</option>
<option value="150">150px</option>
<option value="200">200px</option>
</select>
</div>
</div>
<template v-if='mode == "all_talks"'>
<h4>All Talks options</h4>
<div class='row'>
<div class='col-xs-3'>
<input
type="checkbox"
v-model="all_speakers"
class='form-check-input'/>
Show speaker names
</div>
</div>
</template>
<template v-if='mode == "next_talk"'>
<h4>Next Talk options</h4>
<div class='row'>
<div class='col-xs-3'>
<input
type="checkbox"
v-model="next_abstract"
class='form-check-input'/>
Show abstract
</div>
</div>
</template>
<template v-if='mode == "day"'>
<h4>Clock options</h4>
<div class='row'>
<div class='col-xs-3'>
<select class='btn btn-default' v-model="day_align">
<option value="left">Align left</option>
<option value="center">Align centered</option>
<option value="right">Align right</option>
</select>
</div>
<div class='col-xs-3'>
<input
type="text"
v-model="day_template"
placeholder="Template: 'Day %s'"
class='form-control'/>
</div>
</div>
</template>
</div>
`,
computed: {
mode: ChildTile.config_value('mode', 'all_talks'),
color: ChildTile.config_value('color', '#ffffff'),
font_size: ChildTile.config_value('font_size', 70, parseInt),
all_names: ChildTile.config_value('all_names', true),
next_abstract: ChildTile.config_value('next_abstract', false),
day_align: ChildTile.config_value('day_align', 'left'),
day_template: ChildTile.config_value('day_template', 'Day %d'),
}
}
ChildTile.register({
config: config,
});

358
tile.lua Normal file
View file

@ -0,0 +1,358 @@
local api, CHILDS, CONTENTS = ...
local json = require "json"
local helper = require "helper"
local anims = require(api.localized "anims")
local font
local white = resource.create_colored_texture(1,1,1)
local fallback_track_background = resource.create_colored_texture(.5,.5,.5,1)
local schedule = {}
local event = {}
local rooms = {}
local all_next_talks = {}
local room_next_talks = {}
local current_room
local day = 0
local time = 1695989869
local show_language = true
local show_track = true
local M = {}
local function rgba(base, a)
return base[1], base[2], base[3], a
end
local function log(what)
return print("[pretalx] " .. what)
end
function M.data_trigger(path, data)
log("received data '" .. data .. "' on " .. path)
end
function M.updated_config_json(config)
log("running on device ".. tostring(sys.get_env "SERIAL"))
font = resource.load_font(api.localized(config.font.asset_name))
show_language = config.show_language
show_track = config.show_track
current_room = nil
for idx, room in ipairs(config.rooms) do
log(tostring(room.serial) .. " room '" .. room.name .. "'")
if room.serial == sys.get_env "SERIAL" then
log("found my room: ", room.name)
current_room = room.name
end
end
end
function M.updated_schedule_json(new_schedule)
log("new schedule")
schedule = new_schedule.talks
end
function M.updated_event_json(new_info)
log("new event info")
event = new_info
end
local function wrap(str, font, size, max_w)
local lines = {}
local space_w = font:width(" ", size)
local remaining = max_w
local line = {}
for non_space in str:gmatch("%S+") do
local w = font:width(non_space, size)
if remaining - w < 0 then
lines[#lines+1] = table.concat(line, "")
line = {}
remaining = max_w
end
line[#line+1] = non_space
line[#line+1] = " "
remaining = remaining - w - space_w
end
if #line > 0 then
lines[#lines+1] = table.concat(line, "")
end
return lines
end
local function check_next_talks()
log("time is now " .. time)
if time == 0 then
return
end
room_next_talks = {}
all_next_talks = {}
local min_start = time - 25 * 60
log("my room is '" .. current_room .. "'")
for idx = 1, #schedule do
local talk = schedule[idx]
-- Ignore all talks that started long before now or have already
-- ended here. We don't want to announce these.
if talk.start_ts > min_start and talk.end_ts > time then
-- is this in *this* room, or somewhere else?
if current_room and talk.room == current_room then
room_next_talks[#room_next_talks+1] = talk
end
all_next_talks[#all_next_talks+1] = talk
end
end
local function sort_talks(a, b)
return a.start_ts < b.start_ts or (a.start_ts == b.start_ts and a.room < b.room)
end
table.sort(room_next_talks, sort_talks)
table.sort(all_next_talks, sort_talks)
log(tostring(#all_next_talks) .. " talks to come")
log(tostring(#room_next_talks) .. " in this room")
end
local function view_next_talk(starts, ends, config, x1, y1, x2, y2)
local font_size = config.font_size or 70
local show_abstract = config.next_abstract
local default_color = {helper.parse_rgb(config.color or "#ffffff")}
local a = anims.Area(x2 - x1, y2 - y1)
local S = starts
local E = ends
local function text(...)
return a.add(anims.moving_font(S, E, font, ...))
end
local x, y = 0, 0
local time_size = font_size
local title_size = font_size
local abstract_size = math.floor(font_size * 0.8)
local speaker_size = math.floor(font_size * 0.8)
local current_talk = room_next_talks[1]
local col1 = 0
local col2 = 0 + font:width("in XXX min", time_size)
if #schedule == 0 then
text(col2, y, "Fetching talks...", time_size, rgba(default_color,1))
elseif not current_talk then
text(col2, y, "Nope. That's it.", time_size, rgba(default_color,1))
else
-- Time
text(col1, y, current_talk.start_str, time_size, rgba(default_color,1))
-- Delta
local delta = current_talk.start_ts - time
local talk_time
if delta > 180*60 then
talk_time = string.format("in %d h", math.floor(delta/3600))
elseif delta > 0 then
talk_time = string.format("in %d min", math.floor(delta/60)+1)
else
talk_time = "Now"
end
local y_time = y+time_size
text(col1, y_time, talk_time, time_size, rgba(default_color,1))
-- Title
local y_start = y
local lines = wrap(current_talk.title, font, title_size, a.width - col2)
for idx = 1, math.min(5, #lines) do
text(col2, y, lines[idx], title_size, rgba(default_color,1))
y = y + title_size
end
if current_talk.track then
local r,g,b = helper.parse_rgb(current_talk.track.color)
text(col2, y, current_talk.track.name, title_size, r,g,b,1)
y = y + title_size
end
y = y + 20
-- Show abstract only if it fits into the drawing area completely
if show_abstract and a.height > (y + #lines*abstract_size + 20) then
local lines = wrap(current_talk.abstract, font, abstract_size, a.width - col2)
for idx = 1, #lines do
text(col2, y, lines[idx], abstract_size, rgba(default_color,1))
y = y + abstract_size
end
y = y + 20
end
-- Show speakers only if all of them do fit into the drawing area
if a.height > (y + #current_talk.persons*speaker_size + 20) then
for idx = 1, #current_talk.persons do
text(col2, y, current_talk.persons[idx], speaker_size, rgba(default_color,.8))
y = y + speaker_size
end
end
end
for now in api.frame_between(starts, ends) do
a.draw(now, x1, y1, x2, y2)
end
end
local function view_all_talks(starts, ends, config, x1, y1, x2, y2)
local title_size = config.font_size or 70
local default_color = {helper.parse_rgb(config.color or "#ffffff")}
local show_speakers = config.all_speakers
local a = anims.Area(x2 - x1, y2 - y1)
local S = starts
local E = ends
local time_size = title_size
local info_size = math.floor(title_size * 0.8)
-- always leave room for 15px of track bar
local col1 = 0
local col2 = 25 + font:width("XXX min ago", time_size)
local x, y = 0, 0
local function text(...)
return a.add(anims.moving_font(S, E, font, ...))
end
if #schedule == 0 then
text(col2, y, "Fetching talks...", title_size, rgba(default_color,1))
elseif #all_next_talks == 0 and #schedule > 0 and sys.now() > 30 then
text(col2, y, "Nope. That's it.", title_size, rgba(default_color,1))
end
for idx = 1, #all_next_talks do
local talk = all_next_talks[idx]
local title_lines = wrap(
talk.title,
font, title_size, a.width - col2
)
local info_line = talk.room
if show_speakers and #talk.persons then
info_line = info_line .. table.concat(talk.persons, ", ")
end
local info_lines = wrap(
info_line,
font, info_size, a.width - col2
)
if y + #title_lines * title_size + 3 + #info_lines * info_size > a.height then
break
end
-- time
local talk_time
local delta = talk.start_ts - time
if delta > -60 and delta < 60 then
talk_time = "Now"
elseif delta > 180*60 then
talk_time = talk.start_str
elseif delta > 0 then
talk_time = string.format("in %d min", math.floor(delta/60)+1)
else
talk_time = string.format("%d min ago", math.ceil(-delta/60))
end
text(col1, y, talk_time, time_size, rgba(default_color, 1))
if show_track and talk.track then
local r,g,b = helper.parse_rgb(talk.track.color)
a.add(anims.moving_image_raw(
S, E, resource.create_colored_texture(r,g,b,1),
col2 - 25, y,
col2 - 10, y + #title_lines*title_size + 3 + #info_lines*info_size
))
end
-- title
for idx = 1, #title_lines do
text(col2, y, title_lines[idx], title_size, rgba(default_color,1))
y = y + title_size
end
y = y + 3
-- info
for idx = 1, #info_lines do
text(col2, y, info_lines[idx], info_size, rgba(default_color,.8))
y = y + info_size
end
y = y + 20
end
for now in api.frame_between(starts, ends) do
a.draw(now, x1, y1, x2, y2)
end
end
local function view_room(starts, ends, config, x1, y1, x2, y2)
local font_size = config.font_size or 70
local r,g,b = helper.parse_rgb(config.color or "#ffffff")
for now in api.frame_between(starts, ends) do
local line = current_room
font:write(x1, y1, line, font_size, r,g,b)
end
end
local function view_day(starts, ends, config, x1, y1, x2, y2)
local font_size = config.font_size or 70
local r,g,b = helper.parse_rgb(config.color or "#ffffff")
local align = config.day_align or "left"
local template = config.day_template or "Day %d"
for now in api.frame_between(starts, ends) do
local line = string.format(template, day)
if align == "left" then
font:write(x1, y1, line, font_size, r,g,b)
elseif align == "center" then
local w = font:width(line, font_size)
font:write((x1+x2-w) / 2, y1, line, font_size, r,g,b)
else
local w = font:width(line, font_size)
font:write(x2-w, y1, line, font_size, r,g,b)
end
end
end
function M.task(starts, ends, config, x1, y1, x2, y2)
check_next_talks()
return ({
next_talk = view_next_talk,
all_talks = view_all_talks,
room = view_room,
day = view_day,
})[config.mode or 'all_talks'](starts, ends, config, x1, y1, x2, y2)
end
function M.can_show(config)
local mode = config.mode or 'all_talks'
-- these can always play
if mode == "day" or
mode == "all_talks"
then
return true
end
return not not current_room
end
return M