Include UI (moira)
This commit is contained in:
parent
02a03731ea
commit
d1d326cfa8
42 changed files with 3666 additions and 0 deletions
|
@ -12,6 +12,7 @@ import de.moritzruth.theaterdsl.value.Color
|
|||
import de.moritzruth.theaterdsl.value.degrees
|
||||
import de.moritzruth.theaterdsl.value.percent
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
val bar = StairvilleTriLedBar(DmxAddress(400u))
|
||||
|
@ -315,10 +316,291 @@ val show = createShow {
|
|||
trigger = StepCue.Custom("Vorhang auf, Musik Ende")
|
||||
|
||||
onRun {
|
||||
// irgendwann Musik
|
||||
spotRight.brightness.static(100.percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("III.7") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
// Musik wird hektischer
|
||||
FrontLights.all.forEach { it.brightness.fade(100.percent, 30.seconds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("III.8") {
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
FrontLights.all.forEach { it.brightness.static(50.percent) }
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.Custom("Auftritt Paula")
|
||||
|
||||
onRun {
|
||||
(FrontLights.right + FrontLights.left).forEach { it.brightness.fade(0.percent, 5.seconds) }
|
||||
// kühle Farben
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.Custom("Auftritt weitere Schüler")
|
||||
|
||||
onRun {
|
||||
(FrontLights.right + FrontLights.left).forEach { it.brightness.fade(50.percent, 40.seconds) }
|
||||
// buntere Farben
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
FrontLights.all.forEach { it.brightness.off() }
|
||||
// Vorhang zu, Pause
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RnR entfällt
|
||||
|
||||
scene("IV.1") {
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Pause", 69.seconds)
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
// Badezimmer-Licht, zweigeteilt
|
||||
FrontLights.right.forEach { it.brightness.fade(75.percent, 8.seconds) }
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
// Badezimmer-Licht, zweigeteilt
|
||||
FrontLights.right.forEach { it.brightness.fade(0.percent, 3.seconds) }
|
||||
FrontLights.left.forEach { it.brightness.fade(75.percent, 3.seconds) }
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Schau mich mit anderen Augen an", 69.seconds)
|
||||
|
||||
onRun {
|
||||
// beide beleuchten
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
// Umbau
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.2") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
// vor dem Vorhang
|
||||
FrontLights.center.forEach { it.brightness.fade(75.percent, 5.seconds) }
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Und jetzt", 69.seconds)
|
||||
|
||||
onRun {
|
||||
// wütend, entgeistert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.3") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
FrontLights.all.forEach { it.brightness.fade(75.percent, 8.seconds) }
|
||||
// warmes Licht
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.4") {
|
||||
step {
|
||||
trigger = StepCue.Custom("Auftritt David")
|
||||
|
||||
onRun {
|
||||
// Vorhang bleibt auf
|
||||
FrontLights.all.forEach { it.brightness.fade(50.percent, 8.seconds) }
|
||||
spotRight.brightness.fade(100.percent, 4.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Wenn Du da bist (Duett)", 69.seconds)
|
||||
|
||||
onRun {
|
||||
(FrontLights.right + FrontLights.center).forEach { it.brightness.fade(0.percent, 8.seconds) }
|
||||
spotRight.brightness.fade(100.percent, 10.seconds)
|
||||
// romantisch
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
FrontLights.left.forEach { it.brightness.fade(0.percent, 2.seconds) }
|
||||
spotRight.brightness.fade(0.percent, 2.seconds)
|
||||
// romantisch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.5") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
(FrontLights.left + FrontLights.center).forEach { it.brightness.fade(75.percent, 8.seconds) }
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Richy", 69.seconds)
|
||||
|
||||
onRun {
|
||||
// bunt, verspielt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.6") {
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
// Vorhang zu, rechts davor
|
||||
// rotes Licht
|
||||
|
||||
spotRight.brightness.fade(100.percent, 10.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.Custom("Störung, Ruf")
|
||||
|
||||
onRun {
|
||||
FrontLights.left.forEach { it.brightness.static(100.percent) }
|
||||
spotLeft.brightness.static(100.percent)
|
||||
// romantisches Licht schlagartig aus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.7") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
// Vorhang auf, Spot immer noch auf Paula
|
||||
spotLeft.brightness.fade(0.percent, 8.seconds)
|
||||
FrontLights.all.forEach { it.brightness.fade(75.percent, 5.seconds) }
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Paulas Monolog", 69.seconds)
|
||||
|
||||
onRun {
|
||||
FrontLights.all.forEach { it.brightness.fade(20.percent, 15.seconds) }
|
||||
// blaues Licht
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
|
||||
onRun {
|
||||
// Vorhang zu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.8") {
|
||||
step {
|
||||
trigger = StepCue.MusicStart("Angstballet", 69.seconds)
|
||||
|
||||
onRun {
|
||||
// passend zur Musik
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.MusicEnd
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.9") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
spotRight.brightness.fade(100.percent, 5.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.Custom("Bühne erreicht")
|
||||
|
||||
onRun {
|
||||
// Vorhang auf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene("IV.10") {
|
||||
step {
|
||||
trigger = StepCue.Stub
|
||||
|
||||
onRun {
|
||||
// Paula steht mit dem Rücken zum Publikum in der Mitte der Bühne, relativ weit hinten
|
||||
// Sie wird von oben weiß beleuchtet
|
||||
}
|
||||
}
|
||||
|
||||
step {
|
||||
trigger = StepCue.Custom("Bühne erreicht")
|
||||
|
||||
onRun {
|
||||
// Vorhang auf
|
||||
bar.color.static(Color(40.degrees, 50.percent))
|
||||
|
||||
val steps = mutableListOf(75.percent)
|
||||
steps.addAll((0..30).map { 0.percent })
|
||||
|
||||
bar.brightness.steps(steps, 100.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,4 +23,5 @@ suspend fun main() {
|
|||
}
|
||||
|
||||
fun test() {
|
||||
|
||||
}
|
|
@ -13,6 +13,7 @@ import io.ktor.serialization.kotlinx.json.*
|
|||
import io.ktor.server.application.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
|
@ -225,6 +226,10 @@ private fun CoroutineScope.startWebsocketServer(context: ShowContext) = launch(D
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
staticResources("/", "ui") {
|
||||
enableAutoHeadResponse()
|
||||
}
|
||||
}
|
||||
}.start(wait = true)
|
||||
}
|
4
ui/.gitignore
vendored
Executable file
4
ui/.gitignore
vendored
Executable file
|
@ -0,0 +1,4 @@
|
|||
.idea/
|
||||
node_modules/
|
||||
*.env
|
||||
dist/
|
1
ui/.nvmrc
Normal file
1
ui/.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
18
|
201
ui/LICENSE
Executable file
201
ui/LICENSE
Executable file
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
13
ui/index.html
Executable file
13
ui/index.html
Executable file
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Moira</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link href="./node_modules/@fontsource-variable/manrope/index.css" rel="stylesheet"/>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
31
ui/package.json
Executable file
31
ui/package.json
Executable file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "moira",
|
||||
"version": "1.0.0",
|
||||
"author": "Moritz Ruth <dev@moritzruth.de>",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000 --host",
|
||||
"build": "vite build",
|
||||
"start": "vite preview --port 3000 --host"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/ph": "^1.1.5",
|
||||
"@types/lodash-es": "^4.17.7",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"typescript": "^5.0.4",
|
||||
"unplugin-icons": "^0.16.1",
|
||||
"vite": "^4.3.8",
|
||||
"vite-plugin-pages": "^0.30.1",
|
||||
"vite-plugin-windicss": "^1.9.0",
|
||||
"windicss": "^3.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/manrope": "^5.0.0",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"sass": "^1.62.1",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.1"
|
||||
}
|
||||
}
|
1430
ui/pnpm-lock.yaml
generated
Executable file
1430
ui/pnpm-lock.yaml
generated
Executable file
File diff suppressed because it is too large
Load diff
17
ui/server/package.json
Executable file
17
ui/server/package.json
Executable file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.0.1",
|
||||
"@types/ws": "^8.5.3",
|
||||
"tsx": "^3.7.1",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"bufferutil": "^4.0.6",
|
||||
"utf-8-validate": "^5.0.9",
|
||||
"ws": "^8.8.0"
|
||||
}
|
||||
}
|
344
ui/server/pnpm-lock.yaml
generated
Executable file
344
ui/server/pnpm-lock.yaml
generated
Executable file
|
@ -0,0 +1,344 @@
|
|||
lockfileVersion: 5.4
|
||||
|
||||
specifiers:
|
||||
'@types/node': ^18.0.1
|
||||
'@types/ws': ^8.5.3
|
||||
bufferutil: ^4.0.6
|
||||
tsx: ^3.7.1
|
||||
typescript: ^4.7.4
|
||||
utf-8-validate: ^5.0.9
|
||||
ws: ^8.8.0
|
||||
|
||||
dependencies:
|
||||
bufferutil: 4.0.6
|
||||
utf-8-validate: 5.0.9
|
||||
ws: 8.8.0_22kvxa7zeyivx4jp72v2w3pkvy
|
||||
|
||||
devDependencies:
|
||||
'@types/node': 18.0.1
|
||||
'@types/ws': 8.5.3
|
||||
tsx: 3.7.1
|
||||
typescript: 4.7.4
|
||||
|
||||
packages:
|
||||
|
||||
/@esbuild-kit/cjs-loader/2.3.0:
|
||||
resolution: {integrity: sha512-KInrVt8wlKLhWy7+y4a+E+0uBJoWgdx6Xupy+rrF4MFHA/dEt22ACvvChOZSyiqtQieYPtbPkVYSjbC7mOrFVw==}
|
||||
dependencies:
|
||||
'@esbuild-kit/core-utils': 2.0.2
|
||||
get-tsconfig: 4.1.0
|
||||
dev: true
|
||||
|
||||
/@esbuild-kit/core-utils/2.0.2:
|
||||
resolution: {integrity: sha512-clNYQUsqtc36pzW5EufMsahcbLG45EaW3YDyf0DlaS0eCMkDXpxIlHwPC0rndUwG6Ytk9sMSD5k1qHbwYEC/OQ==}
|
||||
dependencies:
|
||||
esbuild: 0.14.48
|
||||
source-map-support: 0.5.21
|
||||
dev: true
|
||||
|
||||
/@esbuild-kit/esm-loader/2.4.0:
|
||||
resolution: {integrity: sha512-zS720jXh06nfg5yAzm6oob4sWN9VTP2E1SonhFgEb6zCBswa4S8fOQ/4Bksz1flDgn56NPqoTTDn2XmWRyMG9Q==}
|
||||
dependencies:
|
||||
'@esbuild-kit/core-utils': 2.0.2
|
||||
get-tsconfig: 4.1.0
|
||||
dev: true
|
||||
|
||||
/@types/node/18.0.1:
|
||||
resolution: {integrity: sha512-CmR8+Tsy95hhwtZBKJBs0/FFq4XX7sDZHlGGf+0q+BRZfMbOTkzkj0AFAuTyXbObDIoanaBBW0+KEW+m3N16Wg==}
|
||||
dev: true
|
||||
|
||||
/@types/ws/8.5.3:
|
||||
resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==}
|
||||
dependencies:
|
||||
'@types/node': 18.0.1
|
||||
dev: true
|
||||
|
||||
/buffer-from/1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
dev: true
|
||||
|
||||
/bufferutil/4.0.6:
|
||||
resolution: {integrity: sha512-jduaYOYtnio4aIAyc6UbvPCVcgq7nYpVnucyxr6eCYg/Woad9Hf/oxxBRDnGGjPfjUm6j5O/uBWhIu4iLebFaw==}
|
||||
engines: {node: '>=6.14.2'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
node-gyp-build: 4.5.0
|
||||
dev: false
|
||||
|
||||
/esbuild-android-64/0.14.48:
|
||||
resolution: {integrity: sha512-3aMjboap/kqwCUpGWIjsk20TtxVoKck8/4Tu19rubh7t5Ra0Yrpg30Mt1QXXlipOazrEceGeWurXKeFJgkPOUg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-android-arm64/0.14.48:
|
||||
resolution: {integrity: sha512-vptI3K0wGALiDq+EvRuZotZrJqkYkN5282iAfcffjI5lmGG9G1ta/CIVauhY42MBXwEgDJkweiDcDMRLzBZC4g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-darwin-64/0.14.48:
|
||||
resolution: {integrity: sha512-gGQZa4+hab2Va/Zww94YbshLuWteyKGD3+EsVon8EWTWhnHFRm5N9NbALNbwi/7hQ/hM1Zm4FuHg+k6BLsl5UA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-darwin-arm64/0.14.48:
|
||||
resolution: {integrity: sha512-bFjnNEXjhZT+IZ8RvRGNJthLWNHV5JkCtuOFOnjvo5pC0sk2/QVk0Qc06g2PV3J0TcU6kaPC3RN9yy9w2PSLEA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-freebsd-64/0.14.48:
|
||||
resolution: {integrity: sha512-1NOlwRxmOsnPcWOGTB10JKAkYSb2nue0oM1AfHWunW/mv3wERfJmnYlGzL3UAOIUXZqW8GeA2mv+QGwq7DToqA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-freebsd-arm64/0.14.48:
|
||||
resolution: {integrity: sha512-gXqKdO8wabVcYtluAbikDH2jhXp+Klq5oCD5qbVyUG6tFiGhrC9oczKq3vIrrtwcxDQqK6+HDYK8Zrd4bCA9Gw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-32/0.14.48:
|
||||
resolution: {integrity: sha512-ghGyDfS289z/LReZQUuuKq9KlTiTspxL8SITBFQFAFRA/IkIvDpnZnCAKTCjGXAmUqroMQfKJXMxyjJA69c/nQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-64/0.14.48:
|
||||
resolution: {integrity: sha512-vni3p/gppLMVZLghI7oMqbOZdGmLbbKR23XFARKnszCIBpEMEDxOMNIKPmMItQrmH/iJrL1z8Jt2nynY0bE1ug==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-arm/0.14.48:
|
||||
resolution: {integrity: sha512-+VfSV7Akh1XUiDNXgqgY1cUP1i2vjI+BmlyXRfVz5AfV3jbpde8JTs5Q9sYgaoq5cWfuKfoZB/QkGOI+QcL1Tw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-arm64/0.14.48:
|
||||
resolution: {integrity: sha512-3CFsOlpoxlKPRevEHq8aAntgYGYkE1N9yRYAcPyng/p4Wyx0tPR5SBYsxLKcgPB9mR8chHEhtWYz6EZ+H199Zw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-mips64le/0.14.48:
|
||||
resolution: {integrity: sha512-cs0uOiRlPp6ymknDnjajCgvDMSsLw5mST2UXh+ZIrXTj2Ifyf2aAP3Iw4DiqgnyYLV2O/v/yWBJx+WfmKEpNLA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-ppc64le/0.14.48:
|
||||
resolution: {integrity: sha512-+2F0vJMkuI0Wie/wcSPDCqXvSFEELH7Jubxb7mpWrA/4NpT+/byjxDz0gG6R1WJoeDefcrMfpBx4GFNN1JQorQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-riscv64/0.14.48:
|
||||
resolution: {integrity: sha512-BmaK/GfEE+5F2/QDrIXteFGKnVHGxlnK9MjdVKMTfvtmudjY3k2t8NtlY4qemKSizc+QwyombGWTBDc76rxePA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-linux-s390x/0.14.48:
|
||||
resolution: {integrity: sha512-tndw/0B9jiCL+KWKo0TSMaUm5UWBLsfCKVdbfMlb3d5LeV9WbijZ8Ordia8SAYv38VSJWOEt6eDCdOx8LqkC4g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-netbsd-64/0.14.48:
|
||||
resolution: {integrity: sha512-V9hgXfwf/T901Lr1wkOfoevtyNkrxmMcRHyticybBUHookznipMOHoF41Al68QBsqBxnITCEpjjd4yAos7z9Tw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-openbsd-64/0.14.48:
|
||||
resolution: {integrity: sha512-+IHf4JcbnnBl4T52egorXMatil/za0awqzg2Vy6FBgPcBpisDWT2sVz/tNdrK9kAqj+GZG/jZdrOkj7wsrNTKA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-sunos-64/0.14.48:
|
||||
resolution: {integrity: sha512-77m8bsr5wOpOWbGi9KSqDphcq6dFeJyun8TA+12JW/GAjyfTwVtOnN8DOt6DSPUfEV+ltVMNqtXUeTeMAxl5KA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-windows-32/0.14.48:
|
||||
resolution: {integrity: sha512-EPgRuTPP8vK9maxpTGDe5lSoIBHGKO/AuxDncg5O3NkrPeLNdvvK8oywB0zGaAZXxYWfNNSHskvvDgmfVTguhg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-windows-64/0.14.48:
|
||||
resolution: {integrity: sha512-YmpXjdT1q0b8ictSdGwH3M8VCoqPpK1/UArze3X199w6u8hUx3V8BhAi1WjbsfDYRBanVVtduAhh2sirImtAvA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild-windows-arm64/0.14.48:
|
||||
resolution: {integrity: sha512-HHaOMCsCXp0rz5BT2crTka6MPWVno121NKApsGs/OIW5QC0ggC69YMGs1aJct9/9FSUF4A1xNE/cLvgB5svR4g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/esbuild/0.14.48:
|
||||
resolution: {integrity: sha512-w6N1Yn5MtqK2U1/WZTX9ZqUVb8IOLZkZ5AdHkT6x3cHDMVsYWC7WPdiLmx19w3i4Rwzy5LqsEMtVihG3e4rFzA==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
esbuild-android-64: 0.14.48
|
||||
esbuild-android-arm64: 0.14.48
|
||||
esbuild-darwin-64: 0.14.48
|
||||
esbuild-darwin-arm64: 0.14.48
|
||||
esbuild-freebsd-64: 0.14.48
|
||||
esbuild-freebsd-arm64: 0.14.48
|
||||
esbuild-linux-32: 0.14.48
|
||||
esbuild-linux-64: 0.14.48
|
||||
esbuild-linux-arm: 0.14.48
|
||||
esbuild-linux-arm64: 0.14.48
|
||||
esbuild-linux-mips64le: 0.14.48
|
||||
esbuild-linux-ppc64le: 0.14.48
|
||||
esbuild-linux-riscv64: 0.14.48
|
||||
esbuild-linux-s390x: 0.14.48
|
||||
esbuild-netbsd-64: 0.14.48
|
||||
esbuild-openbsd-64: 0.14.48
|
||||
esbuild-sunos-64: 0.14.48
|
||||
esbuild-windows-32: 0.14.48
|
||||
esbuild-windows-64: 0.14.48
|
||||
esbuild-windows-arm64: 0.14.48
|
||||
dev: true
|
||||
|
||||
/fsevents/2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/get-tsconfig/4.1.0:
|
||||
resolution: {integrity: sha512-bhshxJhpfmeQ8x4fAvDqJV2VfGp5TfHdLpmBpNZZhMoVyfIrOippBW4mayC3DT9Sxuhcyl56Efw61qL28hG4EQ==}
|
||||
dev: true
|
||||
|
||||
/node-gyp-build/4.5.0:
|
||||
resolution: {integrity: sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/source-map-support/0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
dev: true
|
||||
|
||||
/source-map/0.6.1:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/tsx/3.7.1:
|
||||
resolution: {integrity: sha512-dwl1GBdkwVQ9zRxTmETGi+ck8pewNm2QXh+HK6jHxdHmeCjfCL+Db3b4VX/dOMDSS2hle1j5LzQoo8OpVXu6XQ==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@esbuild-kit/cjs-loader': 2.3.0
|
||||
'@esbuild-kit/core-utils': 2.0.2
|
||||
'@esbuild-kit/esm-loader': 2.4.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
||||
/typescript/4.7.4:
|
||||
resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==}
|
||||
engines: {node: '>=4.2.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/utf-8-validate/5.0.9:
|
||||
resolution: {integrity: sha512-Yek7dAy0v3Kl0orwMlvi7TPtiCNrdfHNd7Gcc/pLq4BLXqfAmd0J7OWMizUQnTTJsyjKn02mU7anqwfmUP4J8Q==}
|
||||
engines: {node: '>=6.14.2'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
node-gyp-build: 4.5.0
|
||||
dev: false
|
||||
|
||||
/ws/8.8.0_22kvxa7zeyivx4jp72v2w3pkvy:
|
||||
resolution: {integrity: sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
dependencies:
|
||||
bufferutil: 4.0.6
|
||||
utf-8-validate: 5.0.9
|
||||
dev: false
|
46
ui/server/src/main.ts
Executable file
46
ui/server/src/main.ts
Executable file
|
@ -0,0 +1,46 @@
|
|||
import { WebSocketServer } from "ws"
|
||||
|
||||
const server = new WebSocketServer({
|
||||
clientTracking: true,
|
||||
host: "0.0.0.0",
|
||||
port: 8000
|
||||
})
|
||||
|
||||
let state = {
|
||||
music: null,
|
||||
message: "",
|
||||
position: {
|
||||
scene: 0,
|
||||
step: 0
|
||||
}
|
||||
}
|
||||
|
||||
server.on("connection", (client, request) => {
|
||||
const address = request.connection.remoteAddress
|
||||
console.log(`Connected: ${address}`)
|
||||
client.send(JSON.stringify({
|
||||
state,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
client.on("message", rawData => {
|
||||
state = JSON.parse(rawData.toString())
|
||||
|
||||
console.log("Update: ", state)
|
||||
|
||||
server.clients.forEach(c => {
|
||||
if (c !== client) {
|
||||
c.send(JSON.stringify({
|
||||
state,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
client.on("close", () => {
|
||||
console.log(`Disconnected: ${address}`)
|
||||
})
|
||||
})
|
||||
|
||||
console.log("Listening on ws://0.0.0.0:8000")
|
52
ui/src/App.vue
Executable file
52
ui/src/App.vue
Executable file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div id="app" class="bg-black text-white">
|
||||
<div class="flex flex-col justify-center items-center h-full space-y-4" v-if="isConnecting">
|
||||
<div class="font-bold text-10">Connecting...</div>
|
||||
<div class="text-s1">Created by Moritz Ruth</div>
|
||||
</div>
|
||||
<router-view v-else/>
|
||||
<TimeDisplay/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
html, body, #app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: "Manrope Variable", sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(255 255 255 / 10%);
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { connect } from "./syncing"
|
||||
import { ref } from "vue"
|
||||
import TimeDisplay from "./components/TimeDisplay.vue"
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: { TimeDisplay },
|
||||
setup() {
|
||||
const isConnecting = ref(true)
|
||||
connect().then(() => {
|
||||
isConnecting.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
isConnecting
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
23
ui/src/components/ActorsOnStageBox.vue
Executable file
23
ui/src/components/ActorsOnStageBox.vue
Executable file
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="p-2 space-y-8">
|
||||
<div>
|
||||
<div class="pb-2 font-bold text-gray-400 text-2 tracking-wider uppercase">
|
||||
auf der Bühne
|
||||
</div>
|
||||
<div>
|
||||
<EntrancesList :entrances="current.step.actorsOnStage"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import EntrancesList from "./EntrancesList.vue"
|
||||
import { current, getNextValidPosition, getStep, parseStringWithDetails, ShowPosition, state, Step, show } from "../state"
|
||||
import { computed } from "vue"
|
||||
import { intersection } from "lodash-es"
|
||||
</script>
|
81
ui/src/components/CueBox.vue
Executable file
81
ui/src/components/CueBox.vue
Executable file
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
<div class="flex items-start space-x-2">
|
||||
<component :is="icon" class="text-4 mt-0.5 flex-shrink-0"/>
|
||||
<div class="text-4" :class="singleLine && 'truncate'">
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MusicNoteIcon from "virtual:icons/ph/music-note"
|
||||
import ChatCircleTextIcon from "virtual:icons/ph/chat-circle-TEXT"
|
||||
import StopIcon from "virtual:icons/ph/stop"
|
||||
import HeadlightsIcon from "virtual:icons/ph/headlights"
|
||||
import WarningIcon from "virtual:icons/ph/warning"
|
||||
import ArrowsOutLineHorizontalIcon from "virtual:icons/ph/arrows-out-line-horizontal"
|
||||
import ArrowsInLineHorizontalIcon from "virtual:icons/ph/arrows-in-line-horizontal"
|
||||
import DotFillIcon from "virtual:icons/ph/dot-fill"
|
||||
import { computed } from "vue"
|
||||
import { START_STEP, Step } from "../state"
|
||||
import { formatSeconds } from "../helpers"
|
||||
import { isEqual } from "lodash-es"
|
||||
|
||||
const props = defineProps<{
|
||||
step: Step,
|
||||
singleLine?: boolean
|
||||
}>()
|
||||
|
||||
const icon = computed(() => {
|
||||
const cue = props.step.cue
|
||||
|
||||
if (isEqual(props.step.position, START_STEP.position)) {
|
||||
return DotFillIcon
|
||||
}
|
||||
|
||||
switch (cue.type) {
|
||||
case "CURTAIN":
|
||||
return cue.state === "closed"
|
||||
? ArrowsInLineHorizontalIcon
|
||||
: ArrowsOutLineHorizontalIcon
|
||||
|
||||
case "LIGHTS": return HeadlightsIcon
|
||||
case "TEXT": return ChatCircleTextIcon
|
||||
case "MUSIC_START": return MusicNoteIcon
|
||||
case "MUSIC_END": return StopIcon
|
||||
case "CUSTOM": return WarningIcon
|
||||
}
|
||||
})
|
||||
|
||||
const text = computed(() => {
|
||||
const cue = props.step.cue
|
||||
|
||||
switch (cue.type) {
|
||||
case "CURTAIN":
|
||||
if (cue.state === "open") {
|
||||
return cue.whileMoving ? "Der Vorhang öffnet sich" : "Der Vorhang ist geöffnet"
|
||||
} else {
|
||||
return cue.whileMoving ? "Der Vorhang schließt sich" : "Der Vorhang ist geschlossen"
|
||||
}
|
||||
|
||||
case "LIGHTS":
|
||||
if (cue.state === "on") {
|
||||
return cue.whileFading ? "Das Licht geht an" : "Das Licht ist an"
|
||||
} else {
|
||||
return cue.whileFading ? "Das Licht geht aus" : "Das Licht ist aus"
|
||||
}
|
||||
|
||||
case "TEXT":
|
||||
let suffix = ""
|
||||
if (cue.clarification !== undefined) {
|
||||
suffix = ` (${cue.clarification})`
|
||||
}
|
||||
|
||||
return `${cue.speaker}: »${cue.text}«${suffix}`
|
||||
|
||||
case "MUSIC_START": return `${cue.title} [${formatSeconds(cue.duration / 1000)}]`
|
||||
case "MUSIC_END": return "Ende der Musik"
|
||||
case "CUSTOM": return cue.text
|
||||
}
|
||||
})
|
||||
</script>
|
54
ui/src/components/EntrancesList.vue
Executable file
54
ui/src/components/EntrancesList.vue
Executable file
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<transition-group tag="div" name="list">
|
||||
<div
|
||||
v-for="actor in entrances"
|
||||
:key="parseStringWithDetails(actor).main"
|
||||
class="truncate"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ parseStringWithDetails(actor).main }}
|
||||
</span>
|
||||
<span class="text-gray-400 pl-2">
|
||||
{{ parseStringWithDetails(actor).details }}
|
||||
</span>
|
||||
</div>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { PropType } from "vue"
|
||||
import { parseStringWithDetails } from "../state"
|
||||
|
||||
export default {
|
||||
name: "EntrancesList",
|
||||
methods: { parseStringWithDetails },
|
||||
props: {
|
||||
entrances: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
46
ui/src/components/MessageDisplay.vue
Executable file
46
ui/src/components/MessageDisplay.vue
Executable file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="overflow-hidden text-6 box p-4" :data-blinking="isBlinking">
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.box {
|
||||
&[data-blinking="true"] {
|
||||
animation: alternate infinite 500ms ease-in-out pulse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
@apply bg-red-900;
|
||||
}
|
||||
|
||||
to {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { state } from "../state"
|
||||
import { toRef, watch } from "vue"
|
||||
import { autoResetRef } from "@vueuse/core"
|
||||
|
||||
export default {
|
||||
name: "MessageDisplay",
|
||||
setup() {
|
||||
const message = toRef(state, "message")
|
||||
const isBlinking = autoResetRef(false, 10 * 1000)
|
||||
|
||||
watch(message, () => {
|
||||
isBlinking.value = message.value !== ""
|
||||
})
|
||||
|
||||
return {
|
||||
message,
|
||||
isBlinking
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
23
ui/src/components/MessageEdit.vue
Executable file
23
ui/src/components/MessageEdit.vue
Executable file
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="h-full max-h-60">
|
||||
<textarea
|
||||
:value="state.message"
|
||||
id="message-box"
|
||||
:class="$style.area"
|
||||
class="h-full w-full border border-dark-200 focus:border-green-500 focus:outline-none transition rounded-lg bg-dark-800 p-4 text-3"
|
||||
placeholder="Nachricht an alle"
|
||||
@input="e => setMessage(e.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.area {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state } from "../state"
|
||||
import { setMessage } from "../syncing"
|
||||
</script>
|
35
ui/src/components/MotionsList.vue
Executable file
35
ui/src/components/MotionsList.vue
Executable file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="flex flex-col space-y-5" :class="scrollable ? 'overflow-y-auto' : 'overflow-hidden'">
|
||||
<div
|
||||
v-for="(scene, sceneIndex) in scenes"
|
||||
:key="sceneIndex"
|
||||
>
|
||||
<div class="text-gray-400 pl-3">
|
||||
{{ scene.name }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<template v-for="step in scene.steps" :key="step.position">
|
||||
<MotionsListStep
|
||||
v-if="step.actorEntrances.length > 0 || step.actorExits.length > 0"
|
||||
:step="step"
|
||||
:center-current="Boolean(centerCurrent)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[50%] flex-shrink-0"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { show } from "../state"
|
||||
import { computed } from "vue"
|
||||
import MotionsListStep from "./MotionsListStep.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
centerCurrent?: boolean
|
||||
scrollable?: boolean
|
||||
}>()
|
||||
|
||||
const scenes = computed(() => show.value.acts.flatMap(a => a.scenes))
|
||||
</script>
|
7
ui/src/components/MotionsListScene.vue
Normal file
7
ui/src/components/MotionsListScene.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
78
ui/src/components/MotionsListStep.vue
Normal file
78
ui/src/components/MotionsListStep.vue
Normal file
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div class="transition p-3" :class="isActive && 'bg-green-800'">
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex-grow">
|
||||
<CueBox :step="step"/>
|
||||
<div class="py-2 pl-8 space-y-2 text-6">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<div
|
||||
v-for="motion in step.actorEntrances"
|
||||
:key="motion"
|
||||
class="flex items-center"
|
||||
>
|
||||
<CaretDoubleRightIcon/>
|
||||
<div class="pl-2">
|
||||
<span class="font-bold">
|
||||
{{ parseStringWithDetails(motion).main }}
|
||||
</span>
|
||||
<span class="pl-1.5">
|
||||
{{ parseStringWithDetails(motion).details }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="motion in step.actorExits"
|
||||
:key="motion"
|
||||
class="flex items-center"
|
||||
>
|
||||
<CaretDoubleLeftIcon/>
|
||||
<div class="pl-2">
|
||||
<span class="font-bold">
|
||||
{{ parseStringWithDetails(motion).main }}
|
||||
</span>
|
||||
<span class="pl-1.5">
|
||||
{{ parseStringWithDetails(motion).details }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="step.hasChangedProps" class="flex space-x-2 pt-0.5">
|
||||
<BarricadeIcon class="mt-0.5"/>
|
||||
<span>Umbau</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseStringWithDetails, START_STEP, state, Step } from "../state"
|
||||
import CueBox from "./CueBox.vue"
|
||||
import CaretDoubleRightIcon from "virtual:icons/ph/caret-double-right"
|
||||
import CaretDoubleLeftIcon from "virtual:icons/ph/caret-double-left"
|
||||
import BarricadeIcon from "virtual:icons/ph/barricade"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { toRef, computed, watchEffect } from "vue"
|
||||
import { useCurrentElement } from "@vueuse/core"
|
||||
|
||||
const props = defineProps<{
|
||||
step: Step,
|
||||
centerCurrent: boolean
|
||||
}>()
|
||||
|
||||
const position = toRef(state, "position")
|
||||
const element = useCurrentElement()
|
||||
const isActive = computed(() => isEqual(props.step.position, position.value))
|
||||
|
||||
watchEffect(() => {
|
||||
const p = props.step.position
|
||||
if (isActive.value || (p.act + p.scene + p.step === 0 && isEqual(START_STEP.position, position.value))) {
|
||||
element.value?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: props.centerCurrent ? "center" : "start",
|
||||
inline: "nearest"
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
73
ui/src/components/MusicProgressBar.vue
Executable file
73
ui/src/components/MusicProgressBar.vue
Executable file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="bg-green-700 h-full transition-all"
|
||||
:style="{ width: (progress * 100) + '%' }"
|
||||
:class="barClass"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-0 top-0 w-full h-full px-4 text-4 text-white font-bold flex items-center transition flex justify-between"
|
||||
:class="music === null ? 'opacity-0' : 'opacity-100'"
|
||||
>
|
||||
<div>
|
||||
{{ lastMusic?.title ?? "" }}
|
||||
</div>
|
||||
<div class="tabular-nums">
|
||||
{{ formatSeconds(remainingSeconds) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.pulse {
|
||||
animation: alternate infinite 500ms ease-in-out pulse;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state, current } from "../state"
|
||||
import { computed, useCssModule } from "vue"
|
||||
import { avoidNull, formatSeconds } from "../helpers"
|
||||
import { syncedTime } from "../syncing"
|
||||
|
||||
const music = computed(() => current.activeMusic)
|
||||
const lastMusic = avoidNull(music)
|
||||
|
||||
const styles = useCssModule()
|
||||
|
||||
const deltaInSeconds = computed(() => {
|
||||
if (music.value === null) return 0
|
||||
return (syncedTime.value - state.musicStartTime) / 1000
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
if (music.value === null) return 0
|
||||
return Math.min(1, deltaInSeconds.value / (music.value.duration / 1000))
|
||||
})
|
||||
|
||||
const remainingSeconds = computed(() => {
|
||||
if (lastMusic.value === null) return 0
|
||||
return Math.max(0, (lastMusic.value.duration / 1000) - deltaInSeconds.value)
|
||||
})
|
||||
|
||||
const barClass = computed(() => {
|
||||
if (lastMusic.value === null) return "bg-gray-700"
|
||||
|
||||
if (remainingSeconds.value < 5) return styles.pulse + " bg-red-600"
|
||||
if (remainingSeconds.value < 10) return "bg-red-600"
|
||||
if (remainingSeconds.value < 20) return "bg-orange-600"
|
||||
|
||||
return "bg-blue-700"
|
||||
})
|
||||
</script>
|
73
ui/src/components/PropBox.vue
Executable file
73
ui/src/components/PropBox.vue
Executable file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-4"
|
||||
:class="$style.box"
|
||||
:data-blinking="isBlinking"
|
||||
>
|
||||
<div class="text-s1 tracking-wide text-gray-500">
|
||||
{{ positionName }}
|
||||
</div>
|
||||
<div class="flex-grow w-full">
|
||||
<transition name="fade" mode="out-in">
|
||||
<div :key="prop" class="flex flex-col items-center justify-center">
|
||||
<template v-if="prop !== null">
|
||||
<div class="font-bold text-3 text-center">
|
||||
{{ parseStringWithDetails(prop).main }}
|
||||
</div>
|
||||
<div v-if="parseStringWithDetails(prop).details" class="text-center px-2">
|
||||
{{ parseStringWithDetails(prop).details }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.box {
|
||||
&[data-blinking="true"] {
|
||||
animation: alternate infinite 1000ms ease-in-out pulse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
@apply bg-red-900;
|
||||
}
|
||||
|
||||
to {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef, watch } from "vue"
|
||||
import { autoResetRef } from "@vueuse/core"
|
||||
import { parseStringWithDetails } from "../state"
|
||||
|
||||
const props = defineProps<{
|
||||
prop: string | null
|
||||
positionName: string
|
||||
}>()
|
||||
|
||||
const prop = toRef(props, "prop")
|
||||
const isBlinking = autoResetRef(false, 20 * 1000)
|
||||
|
||||
watch(prop, () => {
|
||||
isBlinking.value = true
|
||||
})
|
||||
</script>
|
39
ui/src/components/StageTopDownView.vue
Executable file
39
ui/src/components/StageTopDownView.vue
Executable file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="bg-dark-800 p-5 flex flex-col">
|
||||
<div :class="$style.row">
|
||||
<div class="text-s1 text-center tracking-wide text-gray-500 pb-3">
|
||||
Publikum
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.row" class="border-t border-b border-dark-300 h-30">
|
||||
<PropBox position-name="Rechte Vorbühne" :prop="current.step.props.PROSCENIUM_RIGHT"/>
|
||||
<PropBox position-name="Mitte der Vorbühne" :prop="current.step.props.PROSCENIUM_CENTER"/>
|
||||
<PropBox position-name="Linke der Vorbühne" :prop="current.step.props.PROSCENIUM_LEFT"/>
|
||||
</div>
|
||||
<div :class="$style.row" class="flex-grow h-0">
|
||||
<PropBox position-name="Rechts" :prop="current.step.props.PROSCENIUM_RIGHT"/>
|
||||
<PropBox position-name="Mitte" :prop="current.step.props.PROSCENIUM_CENTER"/>
|
||||
<PropBox position-name="Links" :prop="current.step.props.PROSCENIUM_LEFT"/>
|
||||
</div>
|
||||
<div :class="$style.row" class="border-t border-dark-300 py-3 h-20">
|
||||
<div/>
|
||||
<PropBox position-name="Rückwand" :prop="current.step.props.BACKDROP"/>
|
||||
<div/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.row {
|
||||
@apply flex justify-between items-center gap-x-10;
|
||||
|
||||
& > div {
|
||||
@apply flex-shrink-0 flex-grow w-1 h-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { current } from "../state"
|
||||
import PropBox from "./PropBox.vue"
|
||||
</script>
|
16
ui/src/components/StepSelection.vue
Executable file
16
ui/src/components/StepSelection.vue
Executable file
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<div class="flex-grow overflow-y-auto bg-dark-800 flex flex-col pt-2">
|
||||
<StepSelectionStep :step="START_STEP"/>
|
||||
<StepSelectionAct v-for="act in show.acts" :key="act.name" :act="act"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { show, START_STEP } from "../state"
|
||||
import StepSelectionAct from "./StepSelectionAct.vue"
|
||||
import StepSelectionStep from "./StepSelectionStep.vue"
|
||||
</script>
|
18
ui/src/components/StepSelectionAct.vue
Normal file
18
ui/src/components/StepSelectionAct.vue
Normal file
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<div>
|
||||
<StepSelectionScene v-for="scene in act.scenes" :key="scene" :scene="scene"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StepSelectionScene from "./StepSelectionScene.vue"
|
||||
import { Act } from "../state"
|
||||
|
||||
const props = defineProps<{
|
||||
act: Act
|
||||
}>()
|
||||
</script>
|
26
ui/src/components/StepSelectionScene.vue
Normal file
26
ui/src/components/StepSelectionScene.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<div
|
||||
class="border-b border-dark-300 transition"
|
||||
:class="scene === current.scene ? 'bg-green-900' : ''"
|
||||
>
|
||||
<div class="pb-1 pt-4 px-4 text-4 font-bold">
|
||||
{{ scene.name }}
|
||||
</div>
|
||||
<div class="flex flex-col pb-2">
|
||||
<StepSelectionStep v-for="step in scene.steps" :key="step.position" :step="step"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { current, Scene } from "../state"
|
||||
import StepSelectionStep from "./StepSelectionStep.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
scene: Scene
|
||||
}>()
|
||||
</script>
|
46
ui/src/components/StepSelectionStep.vue
Normal file
46
ui/src/components/StepSelectionStep.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div
|
||||
class="px-4 py-1 flex items-center justify-between space-x-2 transition "
|
||||
:class="isActive ? 'bg-green-700' : ''"
|
||||
>
|
||||
<CueBox :step="step"/>
|
||||
<button
|
||||
class="flex items-center text-4"
|
||||
@click="goToPosition(step.position)"
|
||||
>
|
||||
<KeyReturnIcon/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state, Step } from "../state"
|
||||
import { goToPosition } from "../syncing"
|
||||
import KeyReturnIcon from "virtual:icons/ph/key-return"
|
||||
import CueBox from "./CueBox.vue"
|
||||
import { useCurrentElement } from "@vueuse/core"
|
||||
import { computed, toRef, watchEffect } from "vue"
|
||||
import { isEqual } from "lodash-es"
|
||||
|
||||
const props = defineProps<{
|
||||
step: Step
|
||||
}>()
|
||||
|
||||
const position = toRef(state, "position")
|
||||
const element = useCurrentElement()
|
||||
const isActive = computed(() => isEqual(props.step.position, position.value))
|
||||
|
||||
watchEffect(() => {
|
||||
if (isActive.value) {
|
||||
element.value?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "nearest"
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
21
ui/src/components/TimeDisplay.vue
Executable file
21
ui/src/components/TimeDisplay.vue
Executable file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="absolute right-3 top-2 text-5 pointer-events-none flex items-center gap-1">
|
||||
<ClockIcon/>
|
||||
<span class="font-bold tabular-nums tracking-tighter">{{ format.format(syncedTime) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { syncedTime } from "../syncing"
|
||||
import ClockIcon from "virtual:icons/ph/clock-bold"
|
||||
|
||||
const format = new Intl.DateTimeFormat("de", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
})
|
||||
</script>
|
28
ui/src/helpers.ts
Executable file
28
ui/src/helpers.ts
Executable file
|
@ -0,0 +1,28 @@
|
|||
import { Ref, ComputedRef, computed, UnwrapRef, ref, watchEffect } from "vue"
|
||||
|
||||
export const computedIfPresent = <T, V>(object: Ref<T>, access: (value: Exclude<T, undefined | null>) => V): ComputedRef<V | undefined | null> => computed(() => {
|
||||
if (object.value === null) return null
|
||||
else if (object.value === undefined) return undefined
|
||||
|
||||
return access(object.value as Exclude<T, undefined | null>)
|
||||
})
|
||||
|
||||
export const computedOrNull = <T, V>(object: Ref<T | null>, access: (value: T) => V): ComputedRef<V | null> => computed(() => {
|
||||
if (object.value === null) return null
|
||||
return access(object.value)
|
||||
})
|
||||
|
||||
export function avoidNull<T>(originRef: Ref<UnwrapRef<T> | null>): ComputedRef<UnwrapRef<T> | null> {
|
||||
const nullAvoidingRef = ref<T | null>(null)
|
||||
|
||||
watchEffect(() => {
|
||||
if (originRef.value !== null) nullAvoidingRef.value = originRef.value
|
||||
})
|
||||
|
||||
return computed(() => nullAvoidingRef.value)
|
||||
}
|
||||
|
||||
export function formatSeconds(seconds: number) {
|
||||
const duration = new Date(seconds * 1000)
|
||||
return `${duration.getMinutes().toFixed()}:${duration.getSeconds().toFixed().padStart(2, '0')}`
|
||||
}
|
22
ui/src/main.ts
Executable file
22
ui/src/main.ts
Executable file
|
@ -0,0 +1,22 @@
|
|||
import "virtual:windi.css"
|
||||
import { createApp } from "vue"
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"
|
||||
import App from "./App.vue"
|
||||
import originalRoutes from "~pages"
|
||||
|
||||
const routes = originalRoutes.map(route => {
|
||||
if (typeof route.component !== "function") return route
|
||||
return {
|
||||
...route,
|
||||
props: false
|
||||
}
|
||||
}) as RouteRecordRaw[]
|
||||
|
||||
const router = createRouter({
|
||||
routes,
|
||||
history: createWebHistory()
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount("#app")
|
35
ui/src/pages/control-m.vue
Executable file
35
ui/src/pages/control-m.vue
Executable file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<h1 class="font-800 text-9 px-4 pt-10 pb-0">
|
||||
{{ current.scene.name }}
|
||||
</h1>
|
||||
<div class="flex flex-col space-y-4 p-4 pt-8 flex-grow h-full overflow-hidden">
|
||||
<div class="flex justify-end">
|
||||
<button class="px-5 py-3 bg-green-600 font-bold text-5" @click="goNext()">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<StepSelection class="h-110"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { current, getNextValidPosition, getPreviousValidPosition, state } from "../state"
|
||||
import { goToPosition } from "../syncing"
|
||||
import { onKeyStroke } from "@vueuse/core"
|
||||
import StepSelection from "../components/StepSelection.vue"
|
||||
|
||||
function goNext() {
|
||||
const position = getNextValidPosition(state.position)
|
||||
if (position !== null) goToPosition(position)
|
||||
}
|
||||
|
||||
function goPrevious() {
|
||||
const position = getPreviousValidPosition(state.position)
|
||||
if (position !== null) goToPosition(position)
|
||||
}
|
||||
|
||||
onKeyStroke("ArrowLeft", goPrevious)
|
||||
onKeyStroke("ArrowRight", goNext)
|
||||
</script>
|
35
ui/src/pages/control.vue
Executable file
35
ui/src/pages/control.vue
Executable file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<h1 class="font-800 text-9 p-10 pb-0">
|
||||
{{ current.scene.name }}
|
||||
</h1>
|
||||
<div class="h-full flex space-x-4 p-10 pt-8 flex-grow overflow-hidden">
|
||||
<StepSelection class="w-1/2"/>
|
||||
<div class="w-1/2 flex flex-col space-y-4">
|
||||
<MessageEdit class="h-1/2"/>
|
||||
<ActorsOnStageBox class="h-1/2"/>
|
||||
</div>
|
||||
</div>
|
||||
<MusicProgressBar class="h-10"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MusicProgressBar from "../components/MusicProgressBar.vue"
|
||||
import { onKeyStroke } from "@vueuse/core"
|
||||
import StepSelection from "../components/StepSelection.vue"
|
||||
import MessageEdit from "../components/MessageEdit.vue"
|
||||
import ActorsOnStageBox from "../components/ActorsOnStageBox.vue"
|
||||
import { goToPosition } from "../syncing"
|
||||
import { current, getNextValidPosition, getPreviousValidPosition, state } from "../state"
|
||||
|
||||
onKeyStroke("ArrowRight", () => {
|
||||
const position = getNextValidPosition(state.position)
|
||||
if (position !== null) goToPosition(position)
|
||||
})
|
||||
|
||||
onKeyStroke("ArrowLeft", () => {
|
||||
const position = getPreviousValidPosition(state.position)
|
||||
if (position !== null) goToPosition(position)
|
||||
})
|
||||
</script>
|
29
ui/src/pages/kiosk/stage.vue
Executable file
29
ui/src/pages/kiosk/stage.vue
Executable file
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<h1 class="font-800 text-9 p-4 pb-0">
|
||||
{{ current.scene.name }}
|
||||
</h1>
|
||||
<div class="h-full flex space-x-4 p-4 pt-8 flex-grow overflow-hidden">
|
||||
<MotionsList center-current class="w-3/7"/>
|
||||
<div class="w-4/7 flex flex-col space-y-4">
|
||||
<StageTopDownView class="h-full"/>
|
||||
<MessageDisplay class="h-30"/>
|
||||
</div>
|
||||
</div>
|
||||
<MusicProgressBar class="h-15"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import MusicProgressBar from "../../components/MusicProgressBar.vue"
|
||||
import MessageBox from "../../components/MessageEdit.vue"
|
||||
import ActorsOnStageBox from "../../components/ActorsOnStageBox.vue"
|
||||
import { current } from "../../state"
|
||||
import MotionsList from "../../components/MotionsList.vue"
|
||||
import StageTopDownView from "../../components/StageTopDownView.vue"
|
||||
import MessageDisplay from "../../components/MessageDisplay.vue"
|
||||
</script>
|
23
ui/src/pages/microphones.vue
Executable file
23
ui/src/pages/microphones.vue
Executable file
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<h1 class="font-800 text-9 p-4 pb-0">
|
||||
{{ current.scene.name }}
|
||||
</h1>
|
||||
<div class="h-full flex space-x-4 p-4 pt-8 flex-grow overflow-hidden">
|
||||
<MotionsList scrollable center-current class="w-3/7"/>
|
||||
<div class="w-4/7 flex flex-col space-y-4">
|
||||
<ActorsOnStageBox class="h-full text-6"/>
|
||||
<MessageEdit class="h-40"/>
|
||||
</div>
|
||||
</div>
|
||||
<MusicProgressBar class="h-10"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MusicProgressBar from "../components/MusicProgressBar.vue"
|
||||
import MotionsList from "../components/MotionsList.vue"
|
||||
import { current } from "../state"
|
||||
import ActorsOnStageBox from "../components/ActorsOnStageBox.vue"
|
||||
import MessageEdit from "../components/MessageEdit.vue"
|
||||
</script>
|
40
ui/src/pages/rein.vue
Executable file
40
ui/src/pages/rein.vue
Executable file
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<h1 class="font-800 text-9 p-4 pb-0">
|
||||
{{ current.scene.name }}
|
||||
</h1>
|
||||
<div class="h-full flex space-x-4 p-4 pt-8 flex-grow overflow-hidden">
|
||||
<MotionsList center-current class="w-3/7" scrollable/>
|
||||
<div class="w-4/7 flex flex-col space-y-4">
|
||||
<StageTopDownView class="h-full"/>
|
||||
<MessageEdit class="h-30"/>
|
||||
</div>
|
||||
</div>
|
||||
<MusicProgressBar class="h-10"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import MusicProgressBar from "../components/MusicProgressBar.vue"
|
||||
import MessageBox from "../components/MessageEdit.vue"
|
||||
import ActorsOnStageBox from "../components/ActorsOnStageBox.vue"
|
||||
import { current } from "../state"
|
||||
import MotionsList from "../components/MotionsList.vue"
|
||||
import StageTopDownView from "../components/StageTopDownView.vue"
|
||||
import MessageDisplay from "../components/MessageDisplay.vue"
|
||||
import MessageEdit from "../components/MessageEdit.vue"
|
||||
|
||||
export default {
|
||||
name: "ReinPage",
|
||||
components: { MessageEdit, MessageDisplay, StageTopDownView, MotionsList, ActorsOnStageBox, MessageBox, MusicProgressBar },
|
||||
setup() {
|
||||
return {
|
||||
current: current
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
218
ui/src/state.ts
Executable file
218
ui/src/state.ts
Executable file
|
@ -0,0 +1,218 @@
|
|||
import { reactive, shallowRef } from "vue"
|
||||
import { reactiveComputed } from "@vueuse/core"
|
||||
|
||||
export interface Act {
|
||||
name: string
|
||||
scenes: Scene[]
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
name: string
|
||||
steps: Step[]
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
position: ShowPosition
|
||||
cue: StepCue
|
||||
actorEntrances: string[]
|
||||
actorExits: string[]
|
||||
actorsOnStage: string[]
|
||||
props: PropMap
|
||||
hasChangedProps: boolean
|
||||
}
|
||||
|
||||
export type StepCue = {
|
||||
type: "TEXT",
|
||||
speaker: string
|
||||
text: string
|
||||
clarification?: string
|
||||
} | {
|
||||
type: "MUSIC_START",
|
||||
title: string
|
||||
duration: number
|
||||
} | {
|
||||
type: "MUSIC_END"
|
||||
} | {
|
||||
type: "CURTAIN",
|
||||
state: "open" | "closed"
|
||||
whileMoving: boolean
|
||||
} | {
|
||||
type: "LIGHTS"
|
||||
state: "on" | "off"
|
||||
whileFading: boolean
|
||||
} | {
|
||||
type: "CUSTOM"
|
||||
text: string
|
||||
}
|
||||
|
||||
export type PropMap = Record<PropPosition, string | null>
|
||||
export type PropPosition =
|
||||
| "PROSCENIUM_LEFT"
|
||||
| "PROSCENIUM_CENTER"
|
||||
| "PROSCENIUM_RIGHT"
|
||||
| "CENTER"
|
||||
| "LEFT"
|
||||
| "RIGHT"
|
||||
| "BACKDROP"
|
||||
|
||||
export interface ShowState {
|
||||
position: ShowPosition
|
||||
message: string
|
||||
activeMusic: ShowMusic | null
|
||||
musicStartTime: number
|
||||
}
|
||||
|
||||
export interface ShowMusic {
|
||||
title: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface ShowPosition {
|
||||
act: number
|
||||
scene: number
|
||||
step: number
|
||||
}
|
||||
|
||||
export interface Show {
|
||||
acts: Act[]
|
||||
}
|
||||
|
||||
export const START_STEP: Step = {
|
||||
position: { act: -1, scene: 0, step: 0 },
|
||||
actorsOnStage: [],
|
||||
cue: {
|
||||
type: "CUSTOM",
|
||||
text: "Start"
|
||||
},
|
||||
props: {
|
||||
BACKDROP: null,
|
||||
LEFT: null,
|
||||
CENTER: null,
|
||||
RIGHT: null,
|
||||
PROSCENIUM_LEFT: null,
|
||||
PROSCENIUM_CENTER: null,
|
||||
PROSCENIUM_RIGHT: null
|
||||
},
|
||||
hasChangedProps: false,
|
||||
actorEntrances: [],
|
||||
actorExits: []
|
||||
}
|
||||
|
||||
const START_SCENE: Scene = {
|
||||
name: "Start",
|
||||
steps: [START_STEP]
|
||||
}
|
||||
|
||||
export const show = shallowRef<Show>({
|
||||
acts: []
|
||||
})
|
||||
|
||||
export const state = reactive<ShowState>({
|
||||
position: START_STEP.position,
|
||||
message: "",
|
||||
activeMusic: null,
|
||||
musicStartTime: 0
|
||||
})
|
||||
|
||||
export function getStep(position: ShowPosition) {
|
||||
if (position.act === -1) return START_STEP
|
||||
return getScene(position).steps[position.step]
|
||||
}
|
||||
|
||||
export function getScene(position: ShowPosition) {
|
||||
if (position.act === -1) return START_SCENE
|
||||
return show.value.acts[position.act].scenes[position.scene]
|
||||
}
|
||||
|
||||
export function getActiveMusicAt(position: ShowPosition): ShowMusic | null {
|
||||
let activeMusic: ShowMusic | null = null
|
||||
|
||||
for (let actIndex = 0; actIndex < show.value.acts.length; actIndex++) {
|
||||
const scenes = show.value.acts[actIndex].scenes
|
||||
|
||||
for (let sceneIndex = 0; sceneIndex < scenes.length; sceneIndex++) {
|
||||
const scene = scenes[sceneIndex]
|
||||
|
||||
for (let stepIndex = 0; stepIndex < scene.steps.length; stepIndex++) {
|
||||
const step = scene.steps[stepIndex]
|
||||
|
||||
// SONG
|
||||
if (step.cue.type === "MUSIC_START") {
|
||||
activeMusic = {
|
||||
title: step.cue.title,
|
||||
duration: step.cue.duration
|
||||
}
|
||||
} else if (step.cue.type === "MUSIC_END") {
|
||||
activeMusic = null
|
||||
}
|
||||
|
||||
if (sceneIndex == position.scene && stepIndex == position.step) return activeMusic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const current = reactiveComputed<{
|
||||
scene: Scene
|
||||
step: Step
|
||||
activeMusic: ShowMusic | null
|
||||
}>(() => ({
|
||||
scene: getScene(state.position),
|
||||
step: getStep(state.position),
|
||||
activeMusic: state.activeMusic
|
||||
}))
|
||||
|
||||
export function parseStringWithDetails(string: string) {
|
||||
const parts = string.split(" / ")
|
||||
if (parts.length === 1) return { main: parts[0], details: "" }
|
||||
return { main: parts[0], details: parts.slice(1).join(" / ") }
|
||||
}
|
||||
|
||||
export function getNextValidPosition(start: ShowPosition): ShowPosition | null {
|
||||
if (start.act === START_STEP.position.act) return { act: 0, scene: 0, step: 0 }
|
||||
const acts = show.value.acts
|
||||
let { act, scene, step } = start
|
||||
|
||||
step++
|
||||
|
||||
if (step >= acts[start.act].scenes[start.scene].steps.length) {
|
||||
step = 0
|
||||
scene++
|
||||
|
||||
if (scene >= acts[start.act].scenes.length) {
|
||||
scene = 0
|
||||
act++
|
||||
|
||||
if (act >= acts.length) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { act, scene, step }
|
||||
}
|
||||
|
||||
export function getPreviousValidPosition(start: ShowPosition): ShowPosition | null {
|
||||
let { act, scene, step } = start
|
||||
step--
|
||||
|
||||
if (step < 0) {
|
||||
scene--
|
||||
|
||||
if (scene < 0) {
|
||||
act--
|
||||
|
||||
if (act < 0) {
|
||||
return START_STEP.position
|
||||
}
|
||||
|
||||
scene = show.value.acts[act].scenes.length - 1
|
||||
}
|
||||
|
||||
step = show.value.acts[act].scenes[scene].steps.length - 1
|
||||
}
|
||||
|
||||
return { act, scene, step }
|
||||
}
|
58
ui/src/syncing.ts
Executable file
58
ui/src/syncing.ts
Executable file
|
@ -0,0 +1,58 @@
|
|||
import ReconnectingWebSocket from "reconnecting-websocket"
|
||||
import { show, ShowPosition, state } from "./state"
|
||||
import { ref, toRaw } from "vue"
|
||||
|
||||
let socket: ReconnectingWebSocket | null = null
|
||||
let timeDifference = 0
|
||||
|
||||
export const getSyncedTime = () => Date.now() - timeDifference
|
||||
export const syncedTime = ref(Date.now())
|
||||
|
||||
const setSyncedTimeRef = () => {
|
||||
syncedTime.value = getSyncedTime()
|
||||
requestAnimationFrame(setSyncedTimeRef)
|
||||
}
|
||||
|
||||
requestAnimationFrame(setSyncedTimeRef)
|
||||
|
||||
export const connect = () => new Promise<void>(resolve => {
|
||||
if (socket !== null) return
|
||||
|
||||
const url = new URL(window.location.toString())
|
||||
url.protocol = "ws"
|
||||
url.pathname = "/api/ws"
|
||||
socket = new ReconnectingWebSocket(url.toString(), [], {
|
||||
reconnectionDelayGrowFactor: 1,
|
||||
minReconnectionDelay: 1000
|
||||
})
|
||||
|
||||
let isFirstMessage = true
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
isFirstMessage = true
|
||||
})
|
||||
|
||||
socket.addEventListener("message", event => {
|
||||
const data = JSON.parse(event.data as string)
|
||||
|
||||
if (isFirstMessage) {
|
||||
isFirstMessage = false
|
||||
show.value = data
|
||||
console.log("Show:", data)
|
||||
} else {
|
||||
Object.assign(state, data.state)
|
||||
timeDifference = Date.now() - data.timestamp
|
||||
console.log("New state:", data.state)
|
||||
console.log("New time difference:", timeDifference)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export async function goToPosition(position: ShowPosition) {
|
||||
await fetch("/api/go", { method: "POST", body: JSON.stringify(position), headers: { "Content-Type": "application/json" } })
|
||||
}
|
||||
|
||||
export async function setMessage(message: string) {
|
||||
await fetch("/api/message", { method: "POST", body: message })
|
||||
}
|
3
ui/src/vite-env.d.ts
vendored
Executable file
3
ui/src/vite-env.d.ts
vendored
Executable file
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite-plugin-pages/client"/>
|
||||
/// <reference types="unplugin-icons/types/vue"/>
|
||||
/// <reference types="vite/client"/>
|
20
ui/tsconfig.json
Executable file
20
ui/tsconfig.json
Executable file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ES2021",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": false,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"strictNullChecks": true,
|
||||
"downlevelIteration": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"node_modules",
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
25
ui/vite.config.ts
Executable file
25
ui/vite.config.ts
Executable file
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig, splitVendorChunkPlugin } from "vite"
|
||||
import vuePlugin from "@vitejs/plugin-vue"
|
||||
import windicssPlugin from "vite-plugin-windicss"
|
||||
import pagesPlugin from "vite-plugin-pages"
|
||||
import iconsPlugin from "unplugin-icons/vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
splitVendorChunkPlugin(),
|
||||
vuePlugin(),
|
||||
pagesPlugin({
|
||||
syncIndex: false
|
||||
}),
|
||||
windicssPlugin(),
|
||||
iconsPlugin()
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
44
ui/windi.config.ts
Executable file
44
ui/windi.config.ts
Executable file
|
@ -0,0 +1,44 @@
|
|||
import { defineConfig } from "windicss/helpers"
|
||||
import colors from "windicss/colors"
|
||||
import lineClampPlugin from "windicss/plugin/line-clamp"
|
||||
|
||||
const generateValues = (max: number, fn: (step: number) => any) => {
|
||||
const object: Record<number, any> = {}
|
||||
|
||||
for (let i = 1; i <= max; i++) {
|
||||
object[i] = fn(i)
|
||||
}
|
||||
|
||||
return object
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
theme: {
|
||||
colors: {
|
||||
black: colors.black,
|
||||
white: colors.white,
|
||||
gray: colors.stone,
|
||||
red: colors.red,
|
||||
yellow: colors.amber,
|
||||
orange: colors.orange,
|
||||
green: colors.green,
|
||||
blue: colors.blue,
|
||||
violet: colors.fuchsia,
|
||||
light: colors.light,
|
||||
dark: colors.dark,
|
||||
transparent: colors.transparent
|
||||
},
|
||||
fontSize: {
|
||||
...generateValues(30, step => `${step * 0.25}rem`),
|
||||
4: "1.2rem",
|
||||
3: "1.1rem",
|
||||
2: "1rem",
|
||||
s1: "0.9rem",
|
||||
s2: "0.8rem",
|
||||
s3: "0.7rem"
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
lineClampPlugin
|
||||
]
|
||||
})
|
Loading…
Add table
Reference in a new issue