commit 58720611f793d11e15ed3683e4aa490e306716c3 Author: lol Date: Sat Jun 13 20:42:03 2026 +0200 v1 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..df819cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lol/liquid-shape-distortions"] + path = lol/liquid-shape-distortions + url = https://github.com/collidingScopes/liquid-shape-distortions diff --git a/lol/bianzhidai_noBG-Pearl.otf b/lol/bianzhidai_noBG-Pearl.otf new file mode 100644 index 0000000..5e92fde Binary files /dev/null and b/lol/bianzhidai_noBG-Pearl.otf differ diff --git a/lol/components/clock.js b/lol/components/clock.js new file mode 100644 index 0000000..f069fa9 --- /dev/null +++ b/lol/components/clock.js @@ -0,0 +1,26 @@ +const TEMPLATE = document.createElement("template"); +TEMPLATE.innerHTML = `--:--` + +class ClockElement extends HTMLElement { + constructor(){ + super() + setInterval(this.updateContent.bind(this), 1000); + } + + connectedCallback(){ + this.replaceChildren(TEMPLATE.content.cloneNode(true)) + this.updateContent() + } + + updateContent(){ + if(!this.querySelector(".hours")) + return + + let now = new Date(); + this.querySelector(".hours").textContent = now.getHours().toString().padStart(2, "0") + this.querySelector(".minutes").textContent = now.getMinutes().toString().padStart(2, "0") + this.setAttribute("datetime", now.toISOString()) + } +} + +customElements.define("gavle-clock", ClockElement) \ No newline at end of file diff --git a/lol/components/next-bus.js b/lol/components/next-bus.js new file mode 100644 index 0000000..d7353af --- /dev/null +++ b/lol/components/next-bus.js @@ -0,0 +1,34 @@ +const TEMPLATE = document.createElement("template"); + +class NextBusElement extends HTMLElement { + #stop + + get stopId(){ + return this.#stop + } + + set stopId(value){ + this.#stop = value + this.updateContent() + } + + connectedCallback(){ + this.replaceChildren(TEMPLATE.content.cloneNode(true)) + } + + updateContent(){ + + } + + static observedAttributes = ["stop-id"] + + attributeChangedCallback(name, oldVal, newVal){ + switch(name){ + case "stop-id": + this.stopId = newVal + break; + } + } +} + +customElements.define("camp-next-bus", NextBusElement); \ No newline at end of file diff --git a/lol/index.html b/lol/index.html new file mode 100644 index 0000000..647209f --- /dev/null +++ b/lol/index.html @@ -0,0 +1,263 @@ + + + + + + GavleTv × Dernier métro + + + + + + +
+
+ Quelques petites choses a faire avant de partir : +
    +
  • Ranger les établis et les machines
  • +
  • Faire un petit coup de vaisselle
  • +
+ Bonne nuit :) +
+ +
+ + + + + \ No newline at end of file diff --git a/lol/index.js b/lol/index.js new file mode 100644 index 0000000..39b1262 --- /dev/null +++ b/lol/index.js @@ -0,0 +1,185 @@ +import { getNextPassage } from "./tcl.js"; + +const MIN_TIME_BETWEEN_DAYS = 2 * 60 * 60000 // 2 hours +const MAX_LAST_PASSAGE_TIME = 30 * 60000 // 30 min +const MAX_DERNIER_METRO_MESSAGE = 3 + +let tracked_stops = [ + "line:SYTNEX:A/forward/stop_point:SYTNEX:46052", // Metro A dir Vaulx, Hotel de ville + "line:SYTNEX:A/backward/stop_point:SYTNEX:42743", // Metro A dir Perrache, Hotel de ville + "line:SYTNEX:9/forward/stop_point:SYTNEX:2494", // Bus 9 dir Satoney, Pont de lattre RD + "line:SYTNEX:9/backward/stop_point:SYTNEX:2494", // Bus 9 dir cordeliers, Pont de lattre RD + "line:SYTNEX:C/forward/stop_point:SYTNEX:10787", // Métro C dir Cuire, Croix-Paquet + "line:SYTNEX:C6/forward/stop_point:SYTNEX:2496", // Bus C6 arret Pont de lattre RD campus lyon ouest + "line:SYTNEX:C6/backward/stop_point:SYTNEX:2495", // Bus C6 arret Pont de lattre RD Gare part dieu v. merle + "line:SYTNEX:C13/forward/stop_point:SYTNEX:10169", // Bus C13 arret Austerlitz montessuy gutemberg + "line:SYTNEX:C13/backward/stop_point:SYTNEX:10169", // Bus C13 arret Austerlitz vers grange blanche + "line:SYTNEX:PL2/backward/stop_point:SYTNEX:10889", // Bus PL2 arret Pont de lattre RD vers Musée des confluences + "line:SYTNEX:PL2/forward/stop_point:SYTNEX:10169", // BUS PL2 arret Austerlitz vers Cuire + "line:SYTNEX:C23/backward/stop_point:SYTNEX:10889", // Bus C13 arret Pont de lattre RD vers Flachet - Alain Gilles + "line:SYTNEX:C23/forward/stop_point:SYTNEX:10889", // Bus C13 arret Pont de lattre RD vers Cite internationale +] + +let stops_cache = {} + +async function shuffleStops(){ + tracked_stops = tracked_stops + .map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) +} + +async function getNextStop(){ + let stop = tracked_stops.shift() + tracked_stops.push(stop) + return stop +} + +async function showNextLine(){ + let stop = await getNextStop() + + let nextPassage = await getNextPassage(stop) + stops_cache[stop] = nextPassage + + let time = nextPassage.time + + if(!time){ + time = nextPassage.line.timetable.find(it => it.time.getTime() > Date.now())?.time + } + + if(!time){ + return + } + + let modal = document.getElementById("next-line") + modal.hidden = false + + modal.querySelector(".line-picto").src = nextPassage.line.picto + modal.querySelector(".line-name").textContent = nextPassage.line.displayName + modal.querySelector(".stop-name").textContent = nextPassage.displayName + + let timeFormatter = new Intl.DateTimeFormat("fr-FR", { + timeStyle: "short" + }); + let timeEl = modal.querySelector(".stop-passage-time") + timeEl.textContent = timeFormatter.format(time) + + let relativeTimeFOrmatter = new Intl.RelativeTimeFormat("fr-FR", { + style: "short", + numeric: "auto" + }); + + let time_minutes = Math.floor((time.getTime() - Date.now()) / 60000) + + let relative_time_str; + if(time_minutes > 60) { + let time_hours = Math.floor(time_minutes / 60) + time_minutes -= time_hours * 60 + + relative_time_str = relativeTimeFOrmatter.format( + time_hours, + "hours" + ) + if(time_minutes > 0){ + relative_time_str += ` et ${Math.abs(time_minutes)} min` + } + } else { + relative_time_str = relativeTimeFOrmatter.format( + time_minutes, + "minutes" + ) + } + modal.querySelector(".stop-passage-time-relative").textContent = relative_time_str + + setTimeout(() => modal.hidden = true, 15000) +} + +function getLastPassageTime(stopOrLine){ + + let timetable = stopOrLine.timetable || stopOrLine.line.timetable + + if(!timetable){ + return + } + + let now = new Date() + + let last_passage = timetable + .find((it, i) => { + if((timetable[i+1]) && it.time.getTime() > now && (it.time.getTime() + MIN_TIME_BETWEEN_DAYS) < timetable[i+1].time.getTime()){ + return true + } else { + return false + } + }) + + return last_passage +} + +async function updateLastMetro(){ + let last_passage_list = Object.values(stops_cache) + .map(it => [it, getLastPassageTime(it)]) + .filter(([_, lastPassage]) => lastPassage && (lastPassage.time.getTime() - Date.now()) < MAX_LAST_PASSAGE_TIME) + + let hidden_offset = 0 + let last_hidden = true + for(let el of document.querySelectorAll("#last-passage > .last-passage")){ + if(last_hidden && el.hidden){ + hidden_offset++ + } else { + last_hidden = false + } + el.remove() + } + + let container = document.getElementById("last-passage") + let template = document.querySelector("#last-passage template.last-passage-template"); + + if(hidden_offset >= last_passage_list.length){ + hidden_offset = 0 + } + + if(last_passage_list.length > 0){ + let i = 0 + for(let [stop, last_passage] of last_passage_list){ + let el = template.content.cloneNode(true) + + el.children[0].hidden = i < hidden_offset || i >= hidden_offset + MAX_DERNIER_METRO_MESSAGE; + + el.querySelector(".line-picto").src = stop.line.picto + el.querySelector(".line-name").textContent = stop.line.displayName + el.querySelector(".stop-name").textContent = stop.displayName + + let timeFormatter = new Intl.DateTimeFormat("fr-FR", { + timeStyle: "short" + }); + let timeEl = el.querySelector(".stop-passage-time") + timeEl.textContent = timeFormatter.format(last_passage.time) + timeEl.datetime = last_passage.time.toISOString() + + let relativeTimeFormatter = new Intl.RelativeTimeFormat("fr-FR", { + style: "short", + numeric: "auto" + }); + + el.querySelector(".stop-passage-time-relative").textContent = relativeTimeFormatter.format( + Math.floor((last_passage.time.getTime() - Date.now()) / 60000), + "minutes" + ) + + container.prepend(el) + i++; + } + container.hidden = false + } else { + container.hidden = true + } + +} + +shuffleStops() + .then(() => showNextLine()) + .then(() => updateLastMetro()) + +setInterval(() => showNextLine(), 17000) +setInterval(() => updateLastMetro(), 60000) diff --git a/lol/liquid-shape-distortions b/lol/liquid-shape-distortions new file mode 160000 index 0000000..a30cc65 --- /dev/null +++ b/lol/liquid-shape-distortions @@ -0,0 +1 @@ +Subproject commit a30cc65745d81a89b40cde01c85a2e59d3fb5cf6 diff --git a/lol/math.gif b/lol/math.gif new file mode 100644 index 0000000..4713be8 Binary files /dev/null and b/lol/math.gif differ diff --git a/lol/tcl-iframe.js b/lol/tcl-iframe.js new file mode 100644 index 0000000..d3aeb8d --- /dev/null +++ b/lol/tcl-iframe.js @@ -0,0 +1,189 @@ +const BASE_MAP_URL = new URL("/public-transport/lines/", document.baseURI) + +const DAY_IN_MS = 8.64e+7; + +async function waitForElement(iframe, selector, options = { + timeout: 15000, + multiple: false +}){ + let timeout = options?.timeout || 15000 + let multiple = options?.multiple || false + + let el = null; + let startDate = Date.now() + + if(multiple){ + while(!( + el = iframe.contentWindow.document.querySelectorAll(selector) + )?.length){ + await new Promise(res => setTimeout(res, 500)) + if(Date.now() > startDate+timeout) { + throw new Error("Element search timeout") + } + } + } else { + while(( + el = iframe.contentWindow.document.querySelector(selector) + ) == null){ + await new Promise(res => setTimeout(res, 500)) + if(Date.now() > startDate+timeout) { + throw new Error("Element search timeout") + } + } + } + + return el +} + +async function spawnIframe(url){ + /** @type {HTMLIFrameElement} */ + let iframe = document.createElement("iframe") + + let container = document.createElement("div") + container.classList.add("thinking") + + container.style.left = Math.round(Math.random()*(window.innerWidth-400))+"px" + container.style.top = Math.round(Math.random()*(window.innerHeight-350))+"px" + + let img = document.createElement("img") + img.src = "/lol/math.gif" + container.append(img) + + container.append(iframe) + + document.body.append(container) + + await navigateIframe(iframe, url) + + return iframe +} + +async function navigateIframe(iframe, url){ + let prom = new Promise(res => iframe.addEventListener("load", res(), {once: true})) + iframe.src = new URL(url, BASE_MAP_URL).toString() + await prom + + return iframe +} + +async function getLineDetails(iframe){ + let lineElement = await waitForElement(iframe, `[class|="content"] [class|="pictoAndDirection"]`) + let linePictoElement = await waitForElement(iframe, `[class|="content"] [class|="linePictoSvg"]`) + let directionTextElement = await waitForElement(iframe, `[class|="content"] [class|="directionText"]`) + + let line = { + displayName: lineElement.getAttribute("aria-label"), + picto: linePictoElement.src, + name: linePictoElement.getAttribute("aria-label"), + direction: directionTextElement.innerText, + timetable: null + } + + return line +} + +async function getStopTimeTable(iframe, stop_description, amount=1){ + let timetable = [] + for(let dayOffset = 0; dayOffset { + iframe.parentElement.remove() + }, Math.random()*1000) + + return nextPassage +} diff --git a/lol/tcl.js b/lol/tcl.js new file mode 100644 index 0000000..a1fcc01 --- /dev/null +++ b/lol/tcl.js @@ -0,0 +1,164 @@ +const DAY_IN_MS = 8.64e+7; + +class StopId { + constructor(stopIdString){ + let [lineId, direction, stopId] = stopIdString.split(/\//) + this.lineId = lineId + this.stopId = stopId + this.direction = direction + } +} + +async function getNextTripJSON(stop_id){ + let res = await fetch(`/api/interface/tcl/next-trips/stops/${encodeURIComponent(stop_id.stopId)}/${encodeURIComponent(stop_id.lineId)}/${stop_id.direction}`); + if(!res.ok){ + throw new Error(`Server responded with ${res.status} ${res.statusText}`) + } + + let result = await res.json(); + + if(!result.data){ + throw new Error(`Stop ${stop_id.stopId} not found`) + } + + return result.data[0] +} + +async function getStopsJSON(stop_id){ + let res = await fetch(`/api/interface/tcl/lines/${encodeURIComponent(stop_id.lineId)}/stops`); + if(!res.ok){ + throw new Error(`Server responded with ${res.status} ${res.statusText}`) + } + + let result = await res.json(); + + if(!result.data){ + throw new Error(`Line ${stop_id.lineId} not found`) + } + + return result.data +} + +async function getTimetableJSON(stop_id, date){ + let res = await fetch(`/api/interface/tcl/timetables/${encodeURIComponent(stop_id.stopId)}/${encodeURIComponent(stop_id.lineId)}/${stop_id.direction}?date=${date.getFullYear()}-${(date.getMonth()+1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`); + if(!res.ok){ + throw new Error(`Server responded with ${res.status} ${res.statusText}`) + } + + let result = await res.json(); + + if(!result.data){ + throw new Error(`Timetable for ${stop_id.lineId} not found`) + } + + return result.data +} + +async function getLineDetailsJSON(stop_id){ + let res = await fetch(`/api/interface/tcl/lines/${encodeURIComponent(stop_id.lineId)}`); + if(!res.ok){ + throw new Error(`Server responded with ${res.status} ${res.statusText}`) + } + + let result = await res.json(); + + if(!result.data){ + throw new Error(`Line ${stop_id.lineId} not found`) + } + + return result.data +} + +function getIconURL(stop_id){ + return new URL(`/api/valkyrie/assets/lines/${stop_id.lineId}.svg?type=image%2Fsvg%2Bxml`, window.location).toString() +} + +async function getLineDetails(stop_id){ + let line_json = await getLineDetailsJSON(stop_id) + + let route = line_json.routes.find(it => it.direction == stop_id.direction) + + let line = { + id: stop_id.lineId, + displayName: route?.name, + picto: getIconURL(stop_id), + name: line_json.code, + direction: route?.name, + timetable: null + } + + return line +} + +async function getTimetable(stop_id, date){ + let timetable_json = await getTimetableJSON(stop_id, date) + + let timetable = [] + for(let scheduled_time of timetable_json.scheduleTimes){ + let time = new Date(scheduled_time.dateTime) + timetable.push({ + displayHours: time.getHours().toString().padStart(2, "0"), + displayMinutes: time.getMinutes().toString().padStart(2, "0"), + displayTime: `${time.getHours().toString().padStart(2, "0")}h${time.getMinutes().toString().padStart(2, "0")}`, + time + }) + } + + return timetable +} + +/** + * Get next Bus/Metro/Tram passage of given stop description + * @param {String} stop_description Stop description string + * + * To get stop description go to https://carte-interactive.tcl.fr/public-transport/lines/ + * then find your line and your stop in this line. + * Your URL must look like something like this + * https://carte-interactive.tcl.fr/public-transport/lines/line:SYTNEX:C/forward/stop_point:SYTNEX:10787 + * Stop description is everything after "lines/" ("line:SYTNEX:C/forward/stop_point:SYTNEX:10787" in example above) + */ +export async function getNextPassage(stop_description, options = { + timetable: +2 // Today and tomorrow +}){ + let stopId = new StopId(stop_description); + + let proms = [ + getLineDetails(stopId), + getNextTripJSON(stopId), + getStopsJSON(stopId), + Promise.resolve([]) + ] + + let timeTableAmount = options?.timetable || +2; + if(timeTableAmount){ + for(let dayOffset = 0; dayOffset it.id == stopId.stopId) + + let nextPassage = { + line, + displayName: stop?.name, + displayTime: "", + time: next_trip.schedules?.[0] ? new Date(next_trip.schedules?.[0].dateTime) : null + } + + return nextPassage +} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..dc42528 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,44 @@ +daemon off; +#error_log /tmp/gavle-error.log; +pid /dev/null; + + +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; +} + +http { + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + + resolver 10.0.0.2 [fd45:ae2e:e260:101::] valid=30s; + + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /dev/stdout; + + server { + listen 8000 ssl default_server; + ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root /home/lol/dernier-metro; + + location / { + proxy_ssl_server_name on; + proxy_buffering off; + proxy_pass https://carte-interactive.tcl.fr/; + } + + location /lol { + root /home/lol/dernier-metro; + } + } +} diff --git a/restart-kiosk.sh b/restart-kiosk.sh new file mode 100755 index 0000000..af8fd76 --- /dev/null +++ b/restart-kiosk.sh @@ -0,0 +1,2 @@ +#!/bin/bash +sudo systemctl restart getty@tty1.service \ No newline at end of file