mirror of
https://github.com/curtgrimes/obsidian-metronome-plugin.git
synced 2026-07-22 06:10:24 +00:00
Add stopAfter and countdown options. See #6.
This commit is contained in:
parent
a8ecaf1585
commit
4d1fa91b2b
11 changed files with 8329 additions and 22 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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.
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -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)_
|
||||
|
||||

|
||||
|
||||
### `stopAfter` and `countdown`
|
||||
|
||||
You can have the metronome stop after an amount of time:
|
||||
|
||||
````markdown
|
||||
```metronome
|
||||
bpm: 120
|
||||
meter:4/4
|
||||
stopAfter: 12s
|
||||
```
|
||||
````
|
||||
|
||||

|
||||
|
||||
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
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
8036
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
74
src/components/DisableAutoStopTimerButton.vue
Normal file
74
src/components/DisableAutoStopTimerButton.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
106
src/hooks/useAutoStopTimer.ts
Normal file
106
src/hooks/useAutoStopTimer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue