案例一步步带你快速复刻舞台计时器【stagetimer】,远程控制演讲者手中手机、舞台屏幕、提词器中的倒计时、议题、即时消息。

zcimg1 年前
00

stagetimer】是可远程控制演讲者手中手机、舞台屏幕、提词器等设备中的倒计时、议题、即时消息,适用于大型会议、舞台活动、多讲者论坛的计时器。

整个过程在众触低代码平台进行,使用表达式快速复刻逻辑(高度简化版JS)。
本课程重点学习websocket实时消息的发送与接收处理。

多设备同步演示

先动手创建一个计时器玩一玩:https://stagetimer.zc-app.cn,以后举办的会议演讲的计时工作就靠你了。
URL后面加/z就进入开发模式:https://stagetimer.zc-app.cn/z

详尽的的教学请移步B站视频:https://www.bilibili.com/video/BV1RX4y1f7Nb

创建舞台

首页点击【我的定时器】,就可以【创建舞台】了(会让你先注册或登录)。

在product表中创建一个舞台stage,包含一个默认议题agenda和一个空白消息msg

$product.create("stage", {agendas: [Object.assign({ d: date().getTime() }, $c.obj.timer)], msgs: [{d: date().getTime(), txt: "", color: "white"}]})
go("r/" + $r._id + "/ctrl")

默认议题agenda在控制器页面【添加议题】还会用到,所有放到公共的地方($c.常量)方便不同的页面调用:$c.obj.timer。当然我们还给议题和消息添加一个时间戳d作为识别。

{
	"appearance": "countDown",
	"trigger": "link",
	"hour": 0,
	"minute": 10,
	"second": 0,
	"showName": true,
	"showSpeaker": true,
	"yellow": 60,
	"red": 15
}

默认是倒计时countDown,通过链接等上一个结束后自动开始触发,演讲时长10分钟,显示议题名称和演讲者名字,演讲结束前60秒黄色提醒总结会议,15秒前红色提醒时间到。

数据变更及通知广播

接下来的很多远程控制其实都是直接修改上面创建的这条数据:

$product.modify(_id, {x: $f.modal})
stopIf(!$r._id, 'warn("保存失败")')
info("已保存")
render()

当然,因为被多处调用,把它执行表达式转换成函数$v.save使用更简洁变量。
当$product.modify这个api发送后端服务器执行后会群发数据变更的通知(类型为"onModify")给所有订阅了此条数据ID设备:

$socket.send($x._id, "onModify", $x.x)

各个终端收到数据更新通知后执行$v.setData(x)更新页面状态。这就是多设备同步的核心所在。
当然,此api执行前还有需要检查发出变更请求的用户(user._id)是否是此条数据的创建者(auth):

ngIf($x.auth != user._id)

如果不是同一个人就拒绝执行(ng,即Not Good)。

打开连接

要想收到通知得先打开连接socket。连接相关的逻辑我们就不掺和到ready了,统一放到一个挂载组件里:

$socket.open($id, $exp, { login: 0, offline: true })

$id是当前页面的数据ID,作为订阅的频道名。login: 0表示无需登录即可收发消息(作为控制器编辑数据还是要登录的,但观众大屏幕,议题列表,提词器等设备就只管接受数据);offline: true表示接受设备消息的通知。具体请查看文档
$exp是当前组件的局部表达式,配置有onConn,onData:

$exp.onConn

连接打开后执行。上面我们没使用online: true来订阅上线消息,因为我们只想订阅页面还停留在同一个舞台的设备,所以连接打开后就询问一下谁在同一舞台,并带上页面数据ID作为频道名,页面KEY作为链接角色:

$socket.send($id, "online", {channel: $id, role: $key})

$exp.onData

连接打开后就能收到消息了,那我们就要告诉它如何处理收到的消息。

exc($v.socketExp[type] || "")
render()

但消息又有几种,处理逻辑也不同,所以有根据消息类型type分开写表达式。值得注意的是虽然表达式是在某个组件上写的,但它的执行时机是收到消息的时候,跟组件是没关系的,所以执行的时候并没有组件的运行时上下文$exp,所以我们在组件挂载的时候就把$exp放到页面变量中以脱离组件:$v.socketExp = $exp

$exp.online

上面onConn的时候就询问谁在线并带上了自己的消息了吗,那收到此消息后onData就会转到这里来。
先是把来者的会话session放进$v.onlines里,也把角色(控制,观众,议题)放到$v.role。

$v.onlines.push(session)
$v.onlines = $v.onlines.unique()
$v.role[session] = $v.linkType[x.role]
x.role == "ctrl" && to == $id && session != $session ? $socket.send(session, "online", {channel: $id, role: $key}) : ""

如果来者是同一舞台的控制器还得回复它自己的信息。需要注意的要是自己给自己发online消息并回复online消息会导致死循环停不了,所以我们这里要做个判断:当来者的会话跟自己会话不同session != $session时才回复。

$exp.offline

上面打开连接时我们定义了offline: true,表示接受设备消息的通知,所以当某设备断线时系统会自动发送offline消息:把下线的设备从已连设备$v.onlines中移除即可。

$v.onlines.includes(session) ? $v.onlines.splice($v.onlines.indexOf(session), 1) : ""

destroy

当某个设备离开同一个舞台点到其它舞台去时我们也想把它从【已连设备】中移除,所以在页面退出(destroy)时也手动发送offline消息,虽然其实此设备还是与服务器保持连接的,只是此舞台不再关心它了而已。

$socket.send($id, "offline", {})

闪屏

当控制器点击【闪屏】按钮时要通知其它设备也闪一闪:

$socket.send($id, "flash", "timer")

有两个【闪屏】按钮,所以带上按钮位置信息“timer”或“msg”。

$exp.flash

收到flash消息后放到$v.flash变量中并重新渲染:

$v.flash = x
render()
timeout(5000)
$v.flash = ""

给对应的类名添加flash类名:

"time" + ($v.flash == "timer" ? " flash" : "")

flash类名会让字体闪一闪。过了5秒后把变量置空取消闪烁。

黑屏

类似的是【黑屏】按钮,关闭屏幕让大家更加专注,它是不会自己恢复的,要持久化到状态中的,所以只管数据更新,后台会负责推送消息:

$v.state.blackout = !$v.state.blackout
$product.modify($id, {state: $v.state})

$exp.onModify

上面提到当数据变更服务器会推送onModify通知广播,这里收到此消息时逻辑处理了:

$v.setData(x)

它仅仅是把消息体转发给$v.setData函数。而它是在页面初始化init的时候用根节点的表达式生成的:

$v.setData = func($exp.setData)

这样做是因为这部分逻辑不止一个地方要调用,而且还有点啰嗦,放到页面变量中以超越当前组件环境。

$exp.setData

$f.x = $arg
$v.state = $arg.state || {d: $v.agendas[$v.timerIdx + 1].d, running: false, kickoff: date().getTime(), rest: 0, progress: 0}
$v.agendas = $arg.agendas
$v.running = $v.state.running
$v.timerIdx = $v.state.d ? $arg.agendas.findIndex('$x.d == $v.state.d') : 0
$v.timer = $arg.agendas[$v.timerIdx]
$v.duration = $v.timer.hour * 3600 + $v.timer.minute * 60 + $v.timer.second
$exp.run.exc()
$exp.resize.exc()

先是把参数(即onModify的消息体,另一种情况是ready调用时传的页面数据源product中的x)放到表单$f.x中,然后初始化几个变量方便后面使用,从议题列表agendas中找到和当前状态里的时间戳标识d相同的议题作为当前议题,再运行run和resize表达式。

至此如何在多设备中同步的逻辑就讲完了。

开始/暂停议题

当点击大屏时钟下方的开始/暂停按钮时,按钮会在开始和暂停两个状态来回切换。状态running放在页面变量中,也更新到数据库中,然后通过最上面提到的服务器端数据变更广播通知其它设备:

$v.state.running = !$v.running
$exp[$v.state.running ? "resume" : "stop"].exc()
$product.modify($id, {state: $v.state})

$exp.resume

开始按钮也可能是【继续】的概念,即开始后暂停了一段,现在再点就是继续了,此时还得计算暂停/休息了多长时间:

$v.state.rest = date().getTime() - $v.state.kickoff  - $v.duration * 1000 * $v.progress

$exp.stop

停止只需要把当前进度更新到数据库即可:

$v.state.progress = $v.progress

如果按的是议题列表里的开始按钮,可能选的不是当前大屏上显示的议题($v.timerIdx != $i),那就是开始一个新议题了:

$exp[$v.running ? ($v.timerIdx == $i ? "resume" : "start") : "stop"].exc()

$exp.start

新议题的进度为0,暂停了0秒,kickoff触发时间为当前时间戳,其中d是列表中当前被点议题的时间戳,是创建时赋的作为标识的。

$v.state = {d, running: true, kickoff: date().getTime(), rest: 0, progress: 0}

选择议题/回到起点

议题列表开始按钮左边的按钮有个定时器,点击它是【选择此议题】,给一个初始状态:当前作为标识的时间戳,只是选中而没开始运行,未暂停,无进度:

$product.modify($id, {state: {d, running: false, kickoff: date().getTime(), rest: 0, progress: 0}})

点击后当前议题就投射到大屏了,图标变成【回到起点】,逻辑相同。

添加议题

给当前舞台的议题列表最后追加($push)默认议题,弹窗让用户编辑议题的所有(all)属性(在控制器点击某一属性的处理逻辑也类似,但key带的是所点击的属性从而隐藏其它属性)。

$product.modify(_id, { $push: { agendas: Object.assign({ d: date().getTime() }, $c.obj.timer) } })
$v.modal = { key: "all",  idx: $r.x.agendas.length - 1}

添加消息

$product.modify(_id, { $push: { msgs: { d: date().getTime(), txt: "", color: "white" } } })

提交问题

上面这些$product.modify都会触发后端服务器的数据变更通知。值得注意的是有个【问题提交】页面q,让观众提交想问演讲者的问题给控制台,页面很简单,就一个表单x,填写问题内容,可填写自己昵称或干脆匿名,提交按钮也就只是把内容追加到消息列表中:

$product.modify(_id, { $push: { msgs: $f.x } })

然后控制台就看到新消息了,管理员可以进一步编辑,投射到大屏。
值得注意的是此页面是不需要登录的,也不需要打开连接。

循环渲染

页面要显示当前时间,如果是计时器正在进行的话还要显示已用时间和剩余时间,四个进度条的进度,颜色变换,自动触发下一个议题等,这些要求不停地刷新页面内容。
刷新页面有多种方式。第一种是把状态放在变量中,页面组件动态拿到这些变量重新渲染,这种方式简便,但重新渲染的频率不能太高以免消耗性能影响手机电池;第二种是使用元素选择器选中需要更新内容的元素进行局部操作,这个由于是局部的,可以进行高频度的操作,但微信小程序不支持操作元素。还一种是全页面刷新,像按浏览器的刷新按钮一样,这个是落后的方式。
这里选的是第一种,延时一秒钟循环渲染一次,即一秒后再次调用自己:

$exp.loop

$v.date = date()
$v.running && $page == "r" ? $exp.run.exc() : ""
render()
timeout(1000)
$exp.loop.exc()

注意上面的措辞是【延时一秒】而非【每一秒】,因为timeout不是精准的时间间隔,它是等待1秒后回来继续执行下面的表达式,但可能计算机/手机精准的1秒后刚好忙着执行其它任务,在1.01秒后才回来接着执行,随着误差的积累存在偶尔跳过某一秒直接跳到下一秒的可能。这个误差在舞台计时器中是没问题的。另外各设备间的同步消息是要通过网络传输的,所以也不是完全的即时同步。

$exp.run

上面循环渲染中除了获取最新时钟外如果计时器执行运行还要计算进度,倒计时,或者顺计时:

$v.progress = $v.state.running ? (date().getTime() - $v.state.kickoff - $v.state.rest) / 1000 / $v.duration : $v.state.progress
$exp.countDown.exc()
$v.timer.appearance == "countUp" ? $exp.countUp.exc() : ""

$exp.countDown

计算倒计时先要算出还剩多少时间:剩余进度1 - $v.progress乘以演讲预定时长$v.duration。
如果时间用完了并且下一个议题是【链接触发】的,那就触发下一个议题nextTimer。
否则只能继续倒计时,就成负值了。剩余时间去绝对值abs后把$v.cntDn.sign符号变成-
然后把剩余时间转换成时分秒:

$l.leftTime = Math.round((1 - $v.progress) * $v.duration)
$l.leftTime < 1 && $l.leftTime > -2 && $v.agendas[$v.timerIdx + 1].trigger == "link" ? $exp.nextTimer.exc() : ""
$l.leftTime < 0 ? nop($l.leftTime = Math.abs($l.leftTime), $v.cntDn.sign = "-") : $v.cntDn.sign = ""
$v.cntDn.H = parseInt($l.leftTime / 3600)
$v.cntDn.M = $v.pad0(parseInt($l.leftTime % 3600 / 60) || 0)
$v.cntDn.S = $v.pad0($l.leftTime % 60 || 0)

$exp.nextTimer

跟上面选中新议题的逻辑类似。

$v.progress = 0
$v.state = {d: $v.agendas[$v.timerIdx + 1].d, running: true, kickoff: date().getTime(), rest: 0, progress: 0}
$product.modify($id, {state: $v.state})

多种模式/角色

页面URL最后一块定义了当前的角色,众触平台中用$key表示。
观众模式就是把除了大屏的其它部分都不渲染,议题模式就是不渲染除了议题列表的其它部分。
鼠标移到大屏右上角有个全屏图标,点击即可全屏显示。理想情况下是观众模式一上来就自动全屏的,但由于浏览器限制做不了,浏览器用户必须跟页面有交互后才能全屏。另外微信小程序没有全屏的功能。

至此核心都讲完了,剩下的只是啰嗦的小细节,略过。

投诉
收藏
点赞 0
评论 0
由众触低代码平台生成和驱动