UNPKG

@rse/analogclock

Version:

Analog Clock for OBS Studio or vMix

407 lines (390 loc) 17.2 kB
/* ** AnalogClock ~ Analog Clock for OBS Studio or vMix ** Copyright (c) 2021-2024 Dr. Ralf S. Engelschall <rse@engelschall.com> ** ** Permission is hereby granted, free of charge, to any person obtaining ** a copy of this software and associated documentation files (the ** "Software"), to deal in the Software without restriction, including ** without limitation the rights to use, copy, modify, merge, publish, ** distribute, sublicense, and/or sell copies of the Software, and to ** permit persons to whom the Software is furnished to do so, subject to ** the following conditions: ** ** The above copyright notice and this permission notice shall be included ** in all copies or substantial portions of the Software. ** ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* the clock knowledge encapsulation */ class AnalogClock { constructor (props = {}) { /* take over properties */ this.props = { size: "100%", opacity: 1.0, background0: "transparent", background1: "#555555", background2: "#f0f0f0", background3: "#ffcc66", background4: "#ff6666", ticks: "#333333", digits: "#666666", pointer1: "#000000", pointer2: "#222222", pointer3: "#cc0000", segment1: "#b06820", segment2: "#f4dbc2", segment3: "#2068b0", segment4: "#c2dbf4", segment5: "#c03000", segment6: "#ff6030", overrun: false, silent: false, moving: true, lang: "en", ...props } /* initialize internal state */ this.duration = 0 this.remaining = 0 this.started = null this.show = false this.ending = 0 this.ended = false this.flashed = false this.ticked = false this.left = {} this.at = {} this.soundid = null this.svg = null this.svg2 = null this.svg3 = null this.svgRefs = {} this.stopping = false /* create DOM fragment */ this.el = $(` <div class="analogclock"> <div class="canvas"> <div class="svg1"></div> <div class="svg2"></div> <div class="svg3"></div> </div> </div> `) $(this.el) .css("width", this.props.size) .css("height", this.props.size) .css("opacity", this.props.opacity) this.elCanvas = $(".canvas", this.el).get(0) this.elSVG1 = $(".canvas .svg1", this.el).get(0) this.elSVG2 = $(".canvas .svg2", this.el).get(0) this.elSVG3 = $(".canvas .svg3", this.el).get(0) /* inject DOM fragment into DOM tree */ $(".analogclock-container").append(this.el) /* override background color */ $("body").css("background-color", this.props.background0) } start (options = {}) { /* setup an update interval */ this.stopping = false const cb = async () => { if (this.stopping) return if (this.ending > 0) { for (const p of [ 33, 66 ]) { if (this.at[p] && !this.at[p].done && moment().isSameOrAfter(moment.unix(this.at[p].time))) { if (!this.props.silent) { const id = soundvm.play(`time-at-${p}p-${this.props.lang}`) await new Promise((resolve) => soundvm.once("play", resolve, id)) } this.at[p].done = true break } } for (const i of [ 5, 4, 3, 2, 1 ]) { if (this.left[i] && this.left[i].time > 0 && !this.left[i].done && moment().isSameOrAfter(moment.unix(this.left[i].time))) { if (!this.props.silent) { const id = soundvm.play(`time-left-${i}m-${this.props.lang}`) await new Promise((resolve) => soundvm.once("play", resolve, id)) } this.left[i].done = true if (!this.flashed) { this.flashed = true this.attention(5, "soft") if (!this.props.silent) soundfx.play("jingle2") } break } } if (!this.ended && moment().isSameOrAfter(moment.unix(this.ending))) { /* end timer */ if (!this.props.silent) { const id = soundvm.play(`time-left-0m-${this.props.lang}`) await new Promise((resolve) => soundvm.once("play", resolve, id)) soundfx.play("scale1") } this.ended = true this.ending = 0 this.attention(5, "hard").then(() => { if (options.autostop) this.stop() }) } } this.update() requestAnimationFrame(cb) } requestAnimationFrame(cb) /* once render timer and fly it in */ setTimeout(() => { this.update() if (!this.props.silent) soundfx.play("slide4") anime.animate(this.elCanvas, { duration: 1000, autoplay: true, ease: "outBounce", delay: 200, ...(this.props.moving ? { bottom: [ 2000, 0 ] } : {}), opacity: [ 1.0, 1.0 ] }) }, 0) /* optionally start timer */ this.timer(options) } stop () { /* fly timer out and stop updating */ if (!this.props.silent) soundfx.play("whoosh2") return anime.animate(this.elCanvas, { duration: 1000, autoplay: true, ease: "outSine", delay: 0, opacity: [ 1.0, 0.0 ] }).finished.then(() => { this.stopping = true }) } timer (options) { /* determine duration */ this.duration = 0 if (options.duration !== undefined) { this.duration = moment.duration(parseInt(options.duration), "m").asSeconds() if (this.duration > (60 * 60)) this.duration = (60 * 60) } else if (options.until !== undefined) { this.duration = moment.duration(moment(options.until).diff(moment())).asSeconds() if (this.duration < 0) this.duration = 1 } /* determine the duration-related information */ if (this.duration > 0) { const now = moment().unix() this.started = now this.ending = now + this.duration if (options.fraction !== undefined) this.ending = Math.ceil(this.ending / (5 * 60)) * (5 * 60) this.ended = false this.flashed = false this.left = { 5: { time: (this.ending - this.started) > 5 * 60 ? this.ending - 5 * 60 : 0, done: false }, 4: { time: (this.ending - this.started) > 4 * 60 ? this.ending - 4 * 60 : 0, done: false }, 3: { time: (this.ending - this.started) > 3 * 60 ? this.ending - 3 * 60 : 0, done: false }, 2: { time: (this.ending - this.started) > 2 * 60 ? this.ending - 2 * 60 : 0, done: false }, 1: { time: (this.ending - this.started) > 1 * 60 ? this.ending - 1 * 60 : 0, done: false } } this.at = { 33: { time: this.started + ((this.ending - this.started) / 3), done: false }, 66: { time: this.ending - ((this.ending - this.started) / 3), done: false } } this.segFrom = (this.started / 60) % 60 this.segNow = this.segFrom this.segTo = (this.ending / 60) % 60 } else if (this.duration === 0) { this.ending = 0 this.ended = true } /* clear existing segments */ if (this.svgRefs.segment1) this.svgRefs.segment1.clear() if (this.svgRefs.segment2) this.svgRefs.segment2.clear() if (this.svgRefs.segment3) this.svgRefs.segment3.clear() if (this.svgRefs.segment4) this.svgRefs.segment4.clear() if (this.svgRefs.segment5) this.svgRefs.segment5.clear() if (this.svgRefs.segment6) this.svgRefs.segment6.clear() } attention (level = 1, type = "soft") { /* fly timer out and stop updating */ let opacity = [] for (let i = 0; i < level; i++) opacity = opacity.concat([ 0.0, 0.5 ]) opacity = opacity.concat([ 0.0 ]) return anime.animate(type === "soft" ? this.elSVG2 : this.elSVG3, { duration: level * 1000, autoplay: true, ease: "inOutSine", delay: 0, opacity }).finished } hint (type) { if (this.props.silent) return Promise.resolve() let id if (type === "message") id = soundvm.play(`hint-message-${this.props.lang}`) else if (type === "faster") id = soundvm.play(`speed-faster-${this.props.lang}`) else if (type === "slower") id = soundvm.play(`speed-slower-${this.props.lang}`) else throw new Error("invalid hint type") return new Promise((resolve) => soundvm.once("play", resolve, id)) } update () { const W = 500 const H = 500 if (this.svg2 === null) { /* initially render overlay */ const el = this.elSVG2 const svg = SVG().addTo(el).viewbox(0, 0, W, H) this.svg2 = svg const R = Math.ceil(W / 2) svg.circle(R * 2).move(0, 0).fill(this.props.background3) } if (this.svg3 === null) { /* initially render overlay */ const el = this.elSVG3 const svg = SVG().addTo(el).viewbox(0, 0, W, H) this.svg3 = svg const R = Math.ceil(W / 2) svg.circle(R * 2).move(0, 0).fill(this.props.background4) } if (this.svg === null) { /* initially render clock */ const el = this.elSVG1 const svg = SVG().addTo(el).viewbox(0, 0, W, H) this.svg = svg const R = Math.ceil(W / 2) /* create backgrounds */ svg.circle(R * 2).move(0, 0).fill(this.props.background1) svg.circle(R * 2 - 20).move(10, 10).fill(this.props.background2) this.svgRefs.segment1 = svg.group() this.svgRefs.segment2 = svg.group() this.svgRefs.segment3 = svg.group() this.svgRefs.segment4 = svg.group() this.svgRefs.segment5 = svg.group() this.svgRefs.segment6 = svg.group() svg.circle(40).move(R - 20, R - 20).fill(this.props.background1) /* create tick lines */ for (let i = 1; i <= 60; i++) { const w = i % 15 === 0 ? 8 : (i % 5 === 0 ? 8 : 2) const l = i % 15 === 0 ? 30 : (i % 5 === 0 ? 20 : 20) svg.line(0, 0, 0, l) .move(R, 30) .rotate((360 / 60) * i, R, R) .stroke({ color: this.props.ticks, width: w }) .css({ "stroke-linecap": "round" }) if (i % 5 === 0) { const g = svg.group() const digit = (i / 5).toString() const t = g.text(digit) .fill(this.props.digits) .font({ family: "TypoPRO Source Sans Pro", anchor: "middle", size: (i / 5) % 3 === 0 ? 65 : 55, weight: (i / 5) % 3 === 0 ? "bold" : "normal" }) g.center(R, (i / 5) % 3 === 0 ? 100 : 90) .rotate((360 / 12) * (i / 5), R, R) t.rotate(-(360 / 12) * (i / 5)) } } /* create pointers */ this.svgRefs.p1 = svg.line(0, 0, 0, R - (30 + 100)) .move(R, 30 + 100) .stroke({ color: this.props.pointer1, width: 25 }) .css({ "stroke-linecap": "round" }) this.svgRefs.p2 = svg.line(0, 0, 0, R - 30) .move(R, 30) .stroke({ color: this.props.pointer2, width: 15 }) .css({ "stroke-linecap": "round" }) this.svgRefs.p3 = svg.line(0, 0, 0, R - 30) .move(R, 30) .stroke({ color: this.props.pointer3, width: 5 }) .css({ "stroke-linecap": "round" }) } /* update clock pointers */ const el = this.elSVG1 const R = Math.ceil(W / 2) const now = moment() const HH = now.hours() const M = now.minutes() const S = now.seconds() const MS = now.milliseconds() this.svgRefs.p1.untransform().rotate((360 / 12) * (HH % 12) + (360 / 12) / 60 * M, R, R) this.svgRefs.p2.untransform().rotate((360 / 60) * M + (360 / 60) / 60 * S, R, R) this.svgRefs.p3.untransform().rotate((360 / 60) * S + (360 / 60) / 1000 * MS, R, R) if (S === 0 && !this.ticked) { if (!this.props.silent) soundfx.play("click5") this.ticked = true } else if (S > 0) this.ticked = false /* redraw clock segments */ const makeSegment = (seg, rad1, rad2, max, b, col) => { const x1 = R + Math.cos(rad1) * (R - b) const y1 = R - Math.sin(rad1) * (R - b) const x2 = R + Math.cos(rad2) * (R - b) const y2 = R - Math.sin(rad2) * (R - b) seg.clear() seg.path().M(R, R).L(x1, y1).A(R - b, R - b, 0, max, 1, { x: x2, y: y2 }).Z().fill(col) } const b = 10 this.segNow = M + (1 / 60) * S const deg1 = (360 / 60) * this.segFrom const deg2 = (360 / 60) * this.segNow const deg3 = (360 / 60) * this.segTo const rad1 = SVG.utils.radians(90 - deg1) const rad2 = SVG.utils.radians(90 - deg2) const rad3 = SVG.utils.radians(90 - deg3) if (this.segFrom && !this.ended) { const max12 = deg2 > deg1 ? (deg2 - deg1 > 180 ? 1 : 0) : (deg1 - deg2 > 180 ? 0 : 1) const max23 = deg3 > deg2 ? (deg3 - deg2 > 180 ? 1 : 0) : (deg2 - deg3 > 180 ? 0 : 1) makeSegment(this.svgRefs.segment1, rad1, rad2, max12, 0, this.props.segment1) makeSegment(this.svgRefs.segment2, rad1, rad2, max12, b, this.props.segment2) makeSegment(this.svgRefs.segment3, rad2, rad3, max23, 0, this.props.segment3) makeSegment(this.svgRefs.segment4, rad2, rad3, max23, b, this.props.segment4) } else if (this.segFrom && this.ended && this.props.overrun && this.duration > 0) { const max13 = deg3 > deg1 ? (deg3 - deg1 > 180 ? 1 : 0) : (deg1 - deg3 > 180 ? 0 : 1) const max32 = deg2 > deg3 ? (deg2 - deg3 > 180 ? 1 : 0) : (deg3 - deg2 > 180 ? 0 : 1) this.svgRefs.segment3.clear() this.svgRefs.segment4.clear() makeSegment(this.svgRefs.segment1, rad1, rad3, max13, 0, this.props.segment1) makeSegment(this.svgRefs.segment2, rad1, rad3, max13, b, this.props.segment2) makeSegment(this.svgRefs.segment5, rad3, rad2, max32, 0, this.props.segment5) makeSegment(this.svgRefs.segment6, rad3, rad2, max32, b, this.props.segment6) } else if (this.segFrom && this.ended && !(this.props.overrun && this.duration > 0)) { this.svgRefs.segment1.clear() this.svgRefs.segment2.clear() this.svgRefs.segment3.clear() this.svgRefs.segment4.clear() this.svgRefs.segment5.clear() this.svgRefs.segment6.clear() } } }