Add stopAfter and countdown options. See #6.

This commit is contained in:
Curt Grimes 2022-01-04 23:05:34 -06:00
parent a8ecaf1585
commit 4d1fa91b2b
11 changed files with 8329 additions and 22 deletions

1
.gitignore vendored
View file

@ -7,7 +7,6 @@
# npm
node_modules
package-lock.json
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.

View file

@ -66,17 +66,19 @@ tockNotes: A4
These are all the options you can change when creating a metronome. Only the `bpm` option is required.
| Option | Type | Description | Default |
| ------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| `bpm` | number or string | Tempo in [beats per minute](https://en.wikipedia.org/wiki/Tempo). Must be greater than 0. Supports simple math expressions using addition, subtraction, multiplication, and division, because sometimes you want to double a tempo (`60*2`), halve it (`140/2`) or perhaps increase or decrease a tempo but keep track of what the original value was for your reference later (`90+12`). | (required) |
| `meter` | string | The [time signature](https://en.wikipedia.org/wiki/Time_signature) (meter) to play in. The bottom number must be 1, 2, 4, 8, 16, 32, or 64. Examples: `4/4`, `3/4`, `6/8`, `12/8`. | `4/4` |
| `muted` | `yes` or `no` | Whether or not the metronome's audio starts muted. | `yes` |
| `autoStart` | `yes` or `no` | Whether or not the metronome starts flashing visually right away. If `autoStart` is `yes` and `muted` is `no`, then the metronome's sound will also start playing immediately. | `yes` |
| `size` | `small`, `medium`, `large`, or `xlarge` | Control the size of the metronome in the note. | `small` |
| `style` | `pulse`, `pendulum`, `line`, `dots` | Control the style of the metronome. `pulse` makes the whole area flash color. `pendulum` shows an illustration of a metronome with a swinging pendulum. `line` shows a vertical line moving left and right. `dots` shows dots. Only `pulse` is available when `size` is `small`. The other styles are only available on `size` = `medium` and up. | `pulse` |
| `instrument` | `click`, `beep`, `AMSynth`, `DuoSynth`, `FMSynth`, `MembraneSynth`, `MetalSynth`, `MonoSynth`, or `PluckSynth` | Change the metronome's instrument. | `click` |
| `tickNotes` | string | This determines the note(s) played on the **downbeat**, specified in [scientific pitch notation](https://en.wikipedia.org/wiki/Scientific_pitch_notation). (For example, middle C is C4.) Multiple tones can be played at the same time by providing a comma-separated list of tones. If there is no meter, every beat is considered a downbeat. Has no effect when `instrument` is `click`. | `C6`<br/>`F5,A5,C5` |
| `tockNotes` | string | This determines the note(s) played on the **upbeat**, specified in [scientific pitch notation](https://en.wikipedia.org/wiki/Scientific_pitch_notation). (For example, middle C is C4.) Multiple tones can be played at the same time by providing a comma-separated list of tones. If there is no meter, every beat is considered a downbeat. Has no effect when `instrument` is `click`. | `C5`<br/>`F4,A4,C4 ` |
| Option | Type | Description | Default |
| ------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `bpm` | number or string | Tempo in [beats per minute](https://en.wikipedia.org/wiki/Tempo). Must be greater than 0. Supports simple math expressions using addition, subtraction, multiplication, and division, because sometimes you want to double a tempo (`60*2`), halve it (`140/2`) or perhaps increase or decrease a tempo but keep track of what the original value was for your reference later (`90+12`). | (required) |
| `meter` | string | The [time signature](https://en.wikipedia.org/wiki/Time_signature) (meter) to play in. The bottom number must be 1, 2, 4, 8, 16, 32, or 64. Examples: `4/4`, `3/4`, `6/8`, `12/8`. | `4/4` |
| `muted` | `yes` or `no` | Whether or not the metronome's audio starts muted. | `yes` |
| `autoStart` | `yes` or `no` | Whether or not the metronome starts flashing visually right away. If `autoStart` is `yes` and `muted` is `no`, then the metronome's sound will also start playing immediately. | `yes` |
| `stopAfter` | duration like `30s`, `1h`, `1h30m` | Automatically stop the metronome after the given duration. | none |
| `countdown` | `always`, or duration like `30s`, `1h`, `1h30m` | When used in conjunction with `stopAfter`, this controls how long the "Stopping in..." countdown button appears for before the metronome automatically stops. Set to `always` to always show the countdown. | `10s`, or the value of `stopAfter` if shorter than 10 seconds |
| `size` | `small`, `medium`, `large`, or `xlarge` | Control the size of the metronome in the note. | `small` |
| `style` | `pulse`, `pendulum`, `line`, `dots` | Control the style of the metronome. `pulse` makes the whole area flash color. `pendulum` shows an illustration of a metronome with a swinging pendulum. `line` shows a vertical line moving left and right. `dots` shows dots. Only `pulse` is available when `size` is `small`. The other styles are only available on `size` = `medium` and up. | `pulse` |
| `instrument` | `click`, `beep`, `AMSynth`, `DuoSynth`, `FMSynth`, `MembraneSynth`, `MetalSynth`, `MonoSynth`, or `PluckSynth` | Change the metronome's instrument. | `click` |
| `tickNotes` | string | This determines the note(s) played on the **downbeat**, specified in [scientific pitch notation](https://en.wikipedia.org/wiki/Scientific_pitch_notation). (For example, middle C is C4.) Multiple tones can be played at the same time by providing a comma-separated list of tones. If there is no meter, every beat is considered a downbeat. Has no effect when `instrument` is `click`. | `C6`<br/>`F5,A5,C5` |
| `tockNotes` | string | This determines the note(s) played on the **upbeat**, specified in [scientific pitch notation](https://en.wikipedia.org/wiki/Scientific_pitch_notation). (For example, middle C is C4.) Multiple tones can be played at the same time by providing a comma-separated list of tones. If there is no meter, every beat is considered a downbeat. Has no effect when `instrument` is `click`. | `C5`<br/>`F4,A4,C4 ` |
## Examples
@ -230,10 +232,44 @@ style: dots
```
````
_Theme: [Solarized](https://github.com/Slowbad/obsidian-solarized) (dark)_
![](images/demo-style-dots.gif)
### `stopAfter` and `countdown`
You can have the metronome stop after an amount of time:
````markdown
```metronome
bpm: 120
meter:4/4
stopAfter: 12s
```
````
![](images/demo-stop-after.gif)
By default the countdown button appears for the last 10 seconds of the `stopAfter` duration, but you can have it show for a longer period of time:
````markdown
```metronome
bpm: 120
meter:4/4
stopAfter: 1m
countdown: 30s
```
````
Or you can have the countdown button always show:
````markdown
```metronome
bpm: 120
meter:4/4
stopAfter: 1m
countdown: always
```
````
### Works with themes
The metronome works great with Obsidian's community themes in light or dark mode.

BIN
images/demo-stop-after.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

8036
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,9 @@
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-vue": "^8.2.0",
"format-duration": "^1.4.0",
"obsidian": "^0.13.11",
"parse-duration": "^1.0.2",
"sass": "^1.45.1",
"stylelint": "^14.2.0",
"stylelint-config-standard": "^24.0.0",

View file

@ -2,14 +2,16 @@
const props = defineProps<{
size?: "small" | "medium" | "large";
disabled?: boolean;
bordered?: boolean;
}>();
</script>
<template>
<button
class="button"
:class="['button', $props.class]"
:disabled="disabled"
:data-size="props.size || 'small'"
:data-bordered="bordered"
v-bind="{ size }"
>
<slot />
@ -27,13 +29,16 @@ const props = defineProps<{
outline: 0 !important;
box-shadow: none !important;
border-radius: 0.2rem;
width: 2.5rem;
min-width: 2.5rem;
width: min-content;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
color: var(--text-normal);
font-size: inherit;
white-space: nowrap;
&[disabled] {
pointer-events: none;
@ -57,6 +62,27 @@ const props = defineProps<{
padding: 0.35rem;
}
&[data-bordered="true"] {
position: relative;
padding: 0 0.5rem !important;
margin: 0 0.5rem !important;
&::before {
content: "";
z-index: -1;
background: var(
--background-secondary-alt,
rgba(0, 0, 0, 0)
) !important;
opacity: 0.5;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
& > * {
display: flex;
align-items: center;

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import Button from "./Button.vue";
import t from "../i18n/locale";
import formatDuration from "format-duration";
const props = defineProps<{
expiringSoon?: boolean;
timeLeft?: number;
}>();
const maxTimeLeftWhenExpiring = ref<Number>(0);
const timeLeftFormatted = computed(() =>
Math.ceil(props.timeLeft) <= 60
? Math.ceil(props.timeLeft)
: formatDuration(Math.ceil(props.timeLeft) * 1000)
);
watch(
() => props.expiringSoon,
() => {
if (
props.expiringSoon &&
props.timeLeft > maxTimeLeftWhenExpiring.value
) {
maxTimeLeftWhenExpiring.value = props.timeLeft;
}
}
);
</script>
<template>
<Button
v-if="expiringSoon"
:aria-label="t(`Cancel`)"
v-bind="{ size: props.size }"
bordered
class="disable-auto-stop-timer-button"
:style="{
'--total-time': `${maxTimeLeftWhenExpiring}s`,
'--time-left': `${timeLeft}s`,
}"
>
<div class="auto-stop-timer-progress-bar"></div>
{{ expiringSoon ? t(`Stopping in %1...`, timeLeftFormatted) : null }}
</Button>
</template>
<style lang="scss" scoped>
.disable-auto-stop-timer-button {
position: relative;
font-variant-numeric: tabular-nums;
.auto-stop-timer-progress-bar {
position: absolute;
height: 10%;
top: 0;
left: 0;
background: var(--text-accent-hover);
animation: fillBar var(--total-time) forwards linear;
@keyframes fillBar {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
}
}
</style>

View file

@ -4,6 +4,7 @@ import OverlayToggle from "./OverlayToggle.vue";
import Controls from "./Controls.vue";
import Status from "./Status.vue";
import MuteToggle from "./MuteToggle.vue";
import DisableAutoStopTimerButton from "./DisableAutoStopTimerButton.vue";
import Visualization from "./visualizations/Visualization.vue";
import {
ref,
@ -19,6 +20,7 @@ import { playTick, playTickUpbeat, playTock, playSynth } from "../sounds";
import { useTick } from "../hooks/useTick";
import { useParentMarkdownWrapperVisibilityWatcher } from "../hooks/useParentMarkdownWrapperVisibilityWatcher";
import { useCSSAnimationSynchronizer } from "../hooks/useCSSAnimationSynchronizer";
import { useAutoStopTimer } from "../hooks/useAutoStopTimer";
const props = defineProps<{
bpm: MetronomeCodeBlockParameters["bpm"];
@ -27,6 +29,8 @@ const props = defineProps<{
size: MetronomeCodeBlockParameters["size"];
style: MetronomeCodeBlockParameters["style"];
autoStart: MetronomeCodeBlockParameters["autoStart"];
stopAfter: MetronomeCodeBlockParameters["stopAfter"];
countdown: MetronomeCodeBlockParameters["countdown"];
instrument: MetronomeCodeBlockParameters["instrument"];
tickNotes: MetronomeCodeBlockParameters["tickNotes"];
tockNotes: MetronomeCodeBlockParameters["tockNotes"];
@ -47,11 +51,17 @@ const {
} = useTick(meter);
const parentWrapperIsVisible =
useParentMarkdownWrapperVisibilityWatcher(metronome);
const { haltAnimationStyle } = useCSSAnimationSynchronizer({
synchronizeElement: metronome,
onBeat,
});
const {
startAutoStopTimer,
resetAutoStopTimer,
timeLeft,
expired,
expiringSoon,
} = useAutoStopTimer();
// Do sounds
onTick(
@ -76,7 +86,6 @@ onTock(
: playSynth(props.tockNotes, props.instrument))
);
const emit = defineEmits(["didCreateInterval"]);
const interval = ref(null);
const startInterval = () => {
@ -85,6 +94,10 @@ const startInterval = () => {
doBeat,
props.bpm.getBeatDurationSeconds(meter) * 1000
);
if (props.stopAfter) {
startAutoStopTimer(props.stopAfter, props.countdown);
}
}
};
@ -97,11 +110,18 @@ watch(
startInterval();
} else {
stopInterval();
resetAutoStopTimer();
}
},
{ immediate: true }
);
watch(expired, () => {
if (expired.value) {
started.value = false;
}
});
onBeforeUnmount(stopInterval);
</script>
@ -147,6 +167,11 @@ onBeforeUnmount(stopInterval);
:meter="meter"
:size="props.size"
/>
<DisableAutoStopTimerButton
:expiringSoon="expiringSoon"
:timeLeft="timeLeft"
@click="resetAutoStopTimer"
/>
</Controls>
</div>
</template>

View file

@ -0,0 +1,106 @@
import { onBeforeUnmount, Ref, ref, watch, computed, ComputedRef } from "vue";
import parseDuration from "parse-duration";
export function useAutoStopTimer(): {
startAutoStopTimer: CallableFunction;
resetAutoStopTimer: CallableFunction;
timeLeft: Ref<number>;
expired: ComputedRef<boolean>;
expiringSoon: Ref<boolean>;
autoStopDurationInvalid: Ref<boolean>;
} {
let expireTimestampMilliseconds: number | null = null;
const autoStopDurationInvalid = ref<boolean>(false);
let autoStopInterval: number;
const timeLeftSeconds = ref<number>(0);
const expiringSoonSeconds = ref<number>(0);
const alwaysExpiringSoon = ref<boolean>(false);
const timerStarted = ref<boolean>(false);
const expired = computed<boolean>(() => timeLeftSeconds.value <= 0);
const expiringSoon = computed<boolean>(
() =>
timerStarted.value &&
(alwaysExpiringSoon.value ||
(timeLeftSeconds.value &&
timeLeftSeconds.value < expiringSoonSeconds.value))
);
const getTimeLeftSeconds = (): number | null =>
expireTimestampMilliseconds
? (expireTimestampMilliseconds - Date.now()) / 1000
: null;
const runAutoStopTimerIteration = () => {
timeLeftSeconds.value = getTimeLeftSeconds();
if (timeLeftSeconds.value <= 0) {
timeLeftSeconds.value = 0;
}
if (!expired.value) {
// Schedule next iteration
const offsetMillisecondsToStartTimeoutOnSecondBoundary =
(timeLeftSeconds.value % 1) * 1000;
autoStopInterval = window.setTimeout(
runAutoStopTimerIteration,
(expiringSoon.value ? 200 : 1000) +
offsetMillisecondsToStartTimeoutOnSecondBoundary
);
}
};
const startAutoStopTimer = (
duration: string,
expiringSoonDuration: string
): void => {
resetAutoStopTimer();
timerStarted.value = true;
const durationMilliseconds = parseDuration(duration);
expiringSoonSeconds.value =
(parseDuration(expiringSoonDuration) || 10 * 1000) / 1000;
alwaysExpiringSoon.value =
(expiringSoonDuration || "").toUpperCase() ===
"always".toUpperCase();
if (durationMilliseconds === null) {
// We were given a duration string but
// we failed to parse it. It's invalid.
autoStopDurationInvalid.value = true;
return;
}
// We successfully got a duration
expireTimestampMilliseconds = Date.now() + durationMilliseconds;
runAutoStopTimerIteration();
};
const stopTimer = () => {
timerStarted.value = false;
window.clearTimeout(autoStopInterval);
};
const resetAutoStopTimer = (): void => {
stopTimer();
timeLeftSeconds.value = expireTimestampMilliseconds;
};
watch(expired, () => {
if (expired.value) {
stopTimer();
}
});
onBeforeUnmount(stopTimer);
return {
startAutoStopTimer,
resetAutoStopTimer,
timeLeft: timeLeftSeconds,
expired,
expiringSoon,
autoStopDurationInvalid,
};
}

View file

@ -6,4 +6,6 @@ export default {
"%1 BPM": "%1 BPM",
"Invalid BPM": "Invalid BPM",
"Invalid or unsupported meter": "Invalid or unsupported meter",
"Stopping in %1...": "Stopping in %1...",
Cancel: "Cancel",
};

View file

@ -18,6 +18,8 @@ export interface MetronomeCodeBlockParameters {
size: MetronomeSize;
style: MetronomeStyle;
autoStart: boolean;
stopAfter: string;
countdown: string;
instrument: MetronomeInstrument;
tickNotes: Frequency[];
tockNotes: Frequency[];
@ -38,9 +40,6 @@ export default class MetronomePlugin extends Plugin {
const app = createApp(Metronome, {
...parameters,
onDidCreateInterval(interval: number) {
child.registerInterval(interval);
},
});
this.metronomeAppInstances.push(app);
@ -85,7 +84,9 @@ export default class MetronomePlugin extends Plugin {
meter: values.meter ? Meter.fromString(values.meter) : null,
size: isMetronomeSize(values.size) ? values.size : "small",
style: isMetronomeStyle(values.style) ? values.style : "pulse",
autoStart: values.autostart ? values.autoStart === "yes" : null,
autoStart: values.autostart ? values.autostart === "yes" : null,
stopAfter: values.stopafter ? values.stopafter : null,
countdown: values.countdown ? values.countdown : "10s",
instrument: isMetronomeInstrument(values.instrument)
? values.instrument
: "click",