From b264dcd20ed36300cda6b3e22a69a1f2d751bfec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Valenta?= <git@imaniti.org> Date: Sun, 22 Oct 2023 23:56:34 +0200 Subject: [PATCH] semi-finish realtime updates --- static_src/timer.js | 202 ++++++++++++------ timer/consumers.py | 191 +++++++++++------ ...timer_is_running_ongoingtimer_iteration.py | 23 ++ timer/models.py | 4 + timer/routing.py | 2 +- timer/templates/timer/edit_timer.html | 9 +- timer/templates/timer/view_timer.html | 2 + 7 files changed, 307 insertions(+), 126 deletions(-) create mode 100644 timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py diff --git a/static_src/timer.js b/static_src/timer.js index 4729748..9ddf460 100644 --- a/static_src/timer.js +++ b/static_src/timer.js @@ -4,6 +4,14 @@ import Timer from "easytimer.js" import alertify from "alertifyjs"; import "alertifyjs/build/css/alertify.css"; +const disableInputs = () => { + $("#pause_play,#minutes,#seconds,#update_time").prop("disabled", true) +} + +const enableInputs = () => { + $("#pause_play,#minutes,#seconds,#update_time").prop("disabled", false) +} + const updateTimeText = (timer) => { const timeValues = timer.getTimeValues() @@ -32,10 +40,47 @@ const assignEventListeners = (timer) => { updateTimeText(timer) } +const updateTimer = (timer, data, continuePlaying) => { + console.info(`Updating timer: ${JSON.stringify(data.sync_time)}`) + + window.startingTime = { + minutes: data["sync_time"]["minutes"], + seconds: data["sync_time"]["seconds"] + } + + timer.removeAllEventListeners() + + timer = new Timer({ + countdown: true, + startValues: { + minutes: window.startingTime.minutes, + seconds: window.startingTime.seconds, + } + }) + + assignEventListeners(timer) + + if (window.timerIsRunning) { + timer.start() + } + + return timer +} + +const syncTime = (timerSocket, timer) => { + timerSocket.send(JSON.stringify({ + "sync": timer.getTimeValues() + })) +} + $(window).ready( () => { + disableInputs() + // --- BEGIN Timer --- + window.timerIsRunning = false + let timer = new Timer({ countdown: true, startValues: { @@ -55,6 +100,8 @@ $(window).ready( ) + window.location.host + "/ws/timer/" + + window.timerId + + "/" ) if (!isInitialConnect) { @@ -64,12 +111,27 @@ $(window).ready( isInitialConnect = false timerSocket.onmessage = (event) => { - console.log("Received timer message:", event.data) + enableInputs() + + console.info("Received timer message:", event.data) const data = JSON.parse(event.data) - if ("status" in data) { - if (data["status"] === "playing") { + if ("sync_time" in data) { + // Only update if there is any real difference. + + const remainingTime = timer.getTimeValues() + + if ( + data.sync_time.minutes !== remainingTime.minutes || + data.sync_time.seconds !== remainingTime.seconds + ) { + timer = updateTimer(timer, data) + } + } + + if ("is_running" in data) { + if (data["is_running"]) { // Reset if we are playing again. const remainingTime = timer.getTimeValues() @@ -86,33 +148,92 @@ $(window).ready( } timer.start() - } else if (data["status"] === "paused") { + + $("#is_counting").prop("checked", true) + $("#pause_play > .btn__body").html("⏸︎") + window.timerIsRunning = true + } else { timer.pause() + + $("#is_counting").prop("checked", false) + $("#pause_play > .btn__body").html("⏵︎") + window.timerIsRunning = false } - } else if ("time" in data) { - timer.pause() + } + } + + let interval = null + + timerSocket.onopen = () => { + syncTime(timerSocket, timer) + + interval = setInterval(syncTime, 5000, timerSocket, timer) - window.startingTime = { - minutes: data["time"]["minutes"], - seconds: data["time"]["seconds"] + // --- BEGIN Controls --- + + $("#pause_play").on( + "click", + (event) => { + $("#is_counting").click() } + ) - timer = new Timer({ - countdown: true, - startValues: { - minutes: window.startingTime.minutes, - seconds: window.startingTime.seconds, + $("#is_counting").on( + "change", + (event) => { + disableInputs() + + if (event.target.checked) { + console.info("Starting timer") + + $("#pause_play > .btn__body").html("⏸︎") + timerSocket.send(JSON.stringify({ + "is_running": true + })) + } else { + console.info("Stopping timer") + + $("#pause_play > .btn__body").html("⏵︎") + timerSocket.send(JSON.stringify({ + "is_running": false + })) } - }) + } + ) - assignEventListeners(timer) - } + $("#update_time").on( + "click", + (event) => { + disableInputs() + + timer.pause() + + let minutes = Number($("#minutes").val()) + let seconds = Number($("#seconds").val()) + + timerSocket.send(JSON.stringify({ + "time": { + "minutes": minutes, + "seconds": seconds + }, + "is_running": false + })) + + $("#is_counting").prop("checked", false) + } + ) + + // --- END Controls --- } timerSocket.onclose = (event) => { + disableInputs() + alertify.error("Ztráta spojenĂ, pokoušĂme se o zpÄ›tnĂ© pĹ™ipojenĂ.") setTimeout(connectToSocket, 5000) + clearInterval(interval) + $("#is_counting,#pause_play,#update_time").unbind("click") } } @@ -120,52 +241,5 @@ $(window).ready( assignEventListeners(timer) // --- END Timer --- - - // --- BEGIN Controls --- - - $("#pause_play").on( - "click", - (event) => { - $("#is_counting").click() - } - ) - - $("#is_counting").on( - "change", - (event) => { - if (event.target.checked) { - $("#pause_play > .btn__body").html("⏸︎") - - timerSocket.send(JSON.stringify({ - "status": "playing" - })) - } else { - $("#pause_play > .btn__body").html("⏵︎") - - timerSocket.send(JSON.stringify({ - "status": "paused" - })) - } - } - ) - - $("#update-time").on( - "click", - (event) => { - let minutes = Number($("#minutes").val()) - let seconds = Number($("#seconds").val()) - - timerSocket.send(JSON.stringify({ - "time": { - "minutes": minutes, - "seconds": seconds - } - })) - - $("#is_counting").prop("checked", false) - } - ) - - // --- END Controls --- } ) diff --git a/timer/consumers.py b/timer/consumers.py index e0a9be6..059dd27 100644 --- a/timer/consumers.py +++ b/timer/consumers.py @@ -1,90 +1,161 @@ +import copy import datetime import json +import threading +import time +from timeit import default_timer -from asgiref.sync import async_to_sync +from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncWebsocketConsumer -import time +from .models import OngoingTimer -class TimerConsumer(AsyncWebsocketConsumer): - timer_is_running = False +running_timer_threads = [] + + +def tick_timer(timer, iteration: int, total_seconds: int) -> None: + second_compensation = 0 + + start_time = default_timer() + + timer.refresh_from_db() + + end_time = default_timer() + second_compensation = end_time - start_time + + while not timer.is_running: + time.sleep(0.05) + + timer.refresh_from_db() + + if iteration != timer.iteration: + # Stop the thread if there is a new timer + + running_timer_threads.remove(timer.id) + return + + start_time = default_timer() + + if iteration != timer.iteration: + # Stop the thread if there is a new timer + + running_timer_threads.remove(timer.id) + return + + if total_seconds < 1: + return + + total_seconds -= 1 + seconds = total_seconds % 60 + minutes = round((total_seconds - seconds) / 60) + + timer.minutes = minutes + timer.seconds = seconds + + timer.save() + + end_time = default_timer() + second_compensation += end_time - start_time + + threading.Timer( + interval=1 - second_compensation, + function=tick_timer, + args=( + timer, + iteration, + total_seconds, + ), + ).start() + + +class TimerConsumer(AsyncWebsocketConsumer): async def connect(self) -> None: - await self.channel_layer.group_add("timer", self.channel_name) - await self.accept() + timer_id = self.scope["url_route"]["kwargs"]["id"] + timer = await sync_to_async(OngoingTimer.objects.filter(id=timer_id).first)() - async def disconnect(self, close_code) -> None: - await self.channel_layer.group_discard("timer", self.channel_name) + if timer is None: + # Not found - async def run_timer(self, minutes: int, seconds: int) -> None: - total_seconds = (minutes * 60) + seconds - first_run = True + await self.close() + return - print("running timer") + self.timer = timer - for second in range(total_seconds + 1): - if not first_run: - while not self.timer_is_running: - time.sleep(0.05) + await self.channel_layer.group_add(str(self.timer.id), self.channel_name) + await self.accept() + + if self.timer.is_running and self.timer.id not in running_timer_threads: + await self.run_timer() + + async def disconnect(self, close_code) -> None: + if hasattr(self, "timer"): + await self.channel_layer.group_discard( + str(self.timer.id), self.channel_name + ) - # Do on the first run, then every 5 seconds - if first_run or total_seconds % 5 == 0: - new_seconds = total_seconds % 60 - new_minutes = round((total_seconds - new_seconds) / 60) + async def run_timer(self, minutes: int = None, seconds: int = None) -> None: + self.timer.iteration += 1 + await sync_to_async(self.timer.save)() - print("sending") + while self.timer.id in running_timer_threads: + # Wait until the thread is freed + time.sleep(0.05) - await self.channel_layer.group_send( - "timer", - { - "type": "timer_message", - "text": json.dumps({ - "time": { - "minutes": new_minutes, - "seconds": new_seconds - } - }) - } - ) + running_timer_threads.append(self.timer.id) - if not first_run: - total_seconds -= 1 - time.sleep(1) + total_seconds = (self.timer.minutes * 60) + self.timer.seconds - first_run = False + time.sleep(1) + + threading.Thread( + target=tick_timer, + args=( + self.timer, + self.timer.iteration, + total_seconds, + ), + ).start() async def receive(self, text_data: str) -> None: + await sync_to_async(self.timer.refresh_from_db)() + json_data = json.loads(text_data) - response = None - time_updated = False + response = {} - if "status" in json_data: - if json_data["status"] == "playing": - response = {"status": "playing"} - self.timer_is_running = True - elif json_data["status"] == "paused": - response = {"status": "paused"} - self.timer_is_running = False + reset_timer = False + + if "is_running" in json_data: + if json_data["is_running"]: + self.timer.is_running = True + await sync_to_async(self.timer.save)() + else: + self.timer.is_running = False + await sync_to_async(self.timer.save)() if "time" in json_data: - minutes = json_data["time"]["minutes"] - seconds = json_data["time"]["seconds"] - - response = { - "time": { - "minutes": minutes, - "seconds": seconds, - } + self.timer.minutes = json_data["time"]["minutes"] + self.timer.seconds = json_data["time"]["seconds"] + + reset_timer = True + + response.update( + { + "sync_time": { + "minutes": self.timer.minutes, + "seconds": self.timer.seconds, + }, + "is_running": self.timer.is_running, } + ) - time_updated = True - - if response is not None: - if time_updated: - await self.run_timer(minutes, seconds) + if reset_timer: + await self.run_timer() - await self.send(text_data=json.dumps({})) + await self.channel_layer.group_send( + str(self.timer.id), {"type": "timer_message", "text": json.dumps(response)} + ) async def timer_message(self, event: dict) -> None: await self.send(text_data=event["text"]) diff --git a/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py b/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py new file mode 100644 index 0000000..12bb46d --- /dev/null +++ b/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.5 on 2023-10-22 09:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('timer', '0003_remove_ongoingtimer_hours'), + ] + + operations = [ + migrations.AddField( + model_name='ongoingtimer', + name='is_running', + field=models.BooleanField(default=False, verbose_name='AktuálnÄ› běžĂ'), + ), + migrations.AddField( + model_name='ongoingtimer', + name='iteration', + field=models.IntegerField(default=0, verbose_name='Iterace'), + ), + ] diff --git a/timer/models.py b/timer/models.py index 545cb28..a715043 100644 --- a/timer/models.py +++ b/timer/models.py @@ -30,3 +30,7 @@ class OngoingTimer(models.Model): MaxValueValidator(limit_value=60), ], ) + + is_running = models.BooleanField(verbose_name="AktuálnÄ› běžĂ", default=False) + + iteration = models.IntegerField(verbose_name="Iterace", default=0) diff --git a/timer/routing.py b/timer/routing.py index 98d01b5..5efd46f 100644 --- a/timer/routing.py +++ b/timer/routing.py @@ -3,5 +3,5 @@ from django.urls import path from . import consumers websocket_urlpatterns = [ - path("ws/timer/", consumers.TimerConsumer.as_asgi()), + path("ws/timer/<int:id>/", consumers.TimerConsumer.as_asgi()), ] diff --git a/timer/templates/timer/edit_timer.html b/timer/templates/timer/edit_timer.html index 370a720..d2f0add 100644 --- a/timer/templates/timer/edit_timer.html +++ b/timer/templates/timer/edit_timer.html @@ -20,11 +20,18 @@ minutes: {{ timer.minutes }}, seconds: {{ timer.seconds }}, } + + window.timerId = "{{ timer.id }}" </script> <main> <h1 class="text-6xl font-bebas">Ăšprava ÄŤasovaÄŤe {{ timer.name }}</h1> + <a class="hover:no-underline" href="{% url 'timer:view_timer' timer.id %}"> + <i class="ico--chevron-left"></i> + <span class="underline">ZpÄ›t na zobrazenĂ</span> + </a> + <hr> <div class="text-xl"> @@ -75,7 +82,7 @@ autocomplete="off" > <button - id="update-time" + id="update_time" class="btn w-64" > <div class="btn__body">Aktualizovat ÄŤas</div> diff --git a/timer/templates/timer/view_timer.html b/timer/templates/timer/view_timer.html index 88feba5..c20b679 100644 --- a/timer/templates/timer/view_timer.html +++ b/timer/templates/timer/view_timer.html @@ -22,6 +22,8 @@ minutes: {{ timer.minutes }}, seconds: {{ timer.seconds }}, } + + window.timerId = "{{ timer.id }}" </script> <main class="text-center bg-black text-white h-screen flex flex-col justify-between py-5"> -- GitLab