UNPKG

@ngx-telly/plugin-vu-meter

Version:

Telly - Vu meter plugin

457 lines (449 loc) 32.8 kB
import * as i0 from '@angular/core'; import { Injectable, inject, signal, Input, HostBinding, Component, input, HostListener, makeEnvironmentProviders } from '@angular/core'; import { BehaviorSubject, Subject, Subscription, skip, tap, map, filter } from 'rxjs'; import { PlayerComponent } from '@ngx-telly/player'; import { eventType, VideoEvent } from '@ngx-telly/player/core'; import { AsyncPipe } from '@angular/common'; class VuMeterService { show$ = new BehaviorSubject(false); reconfigure$ = new Subject(); on() { this.show$.next(true); } off() { this.show$.next(false); } toggle() { this.show$.next(!this.show$.value); } reconfigure() { this.reconfigure$.next(); } isVisible() { return this.show$.value; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: VuMeterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: VuMeterService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: VuMeterService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class TellyVuMeterComponent { display = 'none'; fftSize = 1024; channelCount = 2; channelNames = []; peakHoldTime = 300; peakDecayRate = 0.1; peakAccelerationFactor = 4; updateRateMs = 40; service = inject(VuMeterService); player = inject(PlayerComponent); subscription = new Subscription(); audioNodes = { context: undefined, splitter: undefined, source: undefined, analyzers: [], dataArrays: [], }; analysisState = { isAnalyzing: false, peakHoldValues: [], peakHoldTimers: [], }; gestureAbortController; reconfigureTimeout; destroyed = false; levels = signal([], ...(ngDevMode ? [{ debugName: "levels" }] : /* istanbul ignore next */ [])); initializing = signal(false, ...(ngDevMode ? [{ debugName: "initializing" }] : /* istanbul ignore next */ [])); reconfiguring = signal(false, ...(ngDevMode ? [{ debugName: "reconfiguring" }] : /* istanbul ignore next */ [])); showVolumeWarning = signal(false, ...(ngDevMode ? [{ debugName: "showVolumeWarning" }] : /* istanbul ignore next */ [])); ngOnInit() { this.setupVisibilitySubscription(); } ngOnChanges(changes) { if (changes['channelCount'] && !changes['channelCount'].isFirstChange()) { this.updateChannelCount(changes['channelCount'].currentValue); } } ngOnDestroy() { this.destroyed = true; this.service.off(); if (this.gestureAbortController) { this.gestureAbortController.abort(); } if (this.reconfigureTimeout) { clearTimeout(this.reconfigureTimeout); } this.stopAnalysis(); this.disconnectAudioGraph(); this.cleanupAudioContext(); this.subscription.unsubscribe(); } setupVisibilitySubscription() { this.subscription.add(this.service.show$ .pipe(skip(1), tap((show) => this.player.media.indicate(`VU Meter ${show ? 'ON' : 'OFF'}`)), tap((show) => { if (show) { this.initializeAudioAndStartAnalysis(); } else { this.stopAnalysis(); this.disconnectAudioGraph(); } }), map((show) => (show ? 'flex' : 'none'))) .subscribe((display) => (this.display = display))); this.subscription.add(this.service.reconfigure$.subscribe(() => this.reconfigure())); // when playback resumes after ending, the captureStream audio tracks are // replaced — reconnect the source so the analyzers see fresh data this.subscription.add(this.player.media.events .pipe(eventType([VideoEvent.Playing]), filter(() => this.analysisState.isAnalyzing && this.usesCaptureStream())) .subscribe(() => this.reconnectSource())); } reconfigure() { this.reconfiguring.set(true); this.stopAnalysis(); this.disconnectAudioGraph(); this.reconfigureTimeout = setTimeout(() => { this.reconfigureTimeout = undefined; this.reconfiguring.set(false); this.initializeAudioAndStartAnalysis(); }, 1000); } updateChannelCount(newCount) { this.channelCount = newCount; if (this.audioNodes.source && this.audioNodes.context) { const wasAnalyzing = this.analysisState.isAnalyzing; this.stopAnalysis(); this.disconnectAudioGraph(); if (wasAnalyzing) { this.connectAudioGraphAndStartAnalysis(); } } } initializeAudioAndStartAnalysis() { if (this.destroyed) return; if (!this.audioNodes.context) { this.initializing.set(true); this.initializeAudioContext() .then(() => { if (!this.destroyed) { this.connectAudioGraphAndStartAnalysis(); } }) .catch((error) => { if (error.name !== 'AbortError') { console.error('Failed to initialize audio for VU meter:', error); } }) .finally(() => this.initializing.set(false)); } else { this.connectAudioGraphAndStartAnalysis(); } } async initializeAudioContext() { if (this.audioNodes.context) { // resume a suspended context (e.g. created before user gesture) if (this.audioNodes.context.state === 'suspended') { await this.audioNodes.context.resume(); } return; } this.audioNodes.context = new AudioContext(); // if the context is suspended due to autoplay policy, wait for user gesture if (this.audioNodes.context.state === 'suspended') { await this.waitForUserGesture(); await this.audioNodes.context.resume(); } if (!this.audioNodes.source && this.audioNodes.context) { const mediaEl = this.player.video.nativeElement; try { if (typeof mediaEl.captureStream === 'function') { const stream = mediaEl.captureStream(); // wait for audio tracks if none are available yet if (stream.getAudioTracks().length === 0) { await this.waitForAudioTrack(stream); } if (this.destroyed) return; this.audioNodes.source = this.audioNodes.context.createMediaStreamSource(stream); } else { this.audioNodes.source = this.audioNodes.context.createMediaElementSource(mediaEl); this.audioNodes.source.connect(this.audioNodes.context.destination); this.showVolumeWarning.set(true); } } catch (err) { if (err?.name !== 'AbortError') { console.error('Error initializing audio source:', err); } } } } waitForUserGesture() { const controller = new AbortController(); this.gestureAbortController = controller; const { signal } = controller; return new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason); return; } const events = ['click', 'touchstart', 'keydown']; const handler = () => { this.gestureAbortController = undefined; resolve(); }; events.forEach((e) => document.addEventListener(e, handler, { once: true, signal })); signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); }); }); } waitForAudioTrack(stream) { return new Promise((resolve, reject) => { if (this.destroyed) { reject(new DOMException('Aborted', 'AbortError')); return; } const onTrack = (event) => { if (event.track.kind === 'audio') { stream.removeEventListener('addtrack', onTrack); resolve(); } }; stream.addEventListener('addtrack', onTrack); // also resolve if audio tracks appear before the listener fires const checkInterval = setInterval(() => { if (this.destroyed) { clearInterval(checkInterval); stream.removeEventListener('addtrack', onTrack); reject(new DOMException('Aborted', 'AbortError')); return; } if (stream.getAudioTracks().length > 0) { clearInterval(checkInterval); stream.removeEventListener('addtrack', onTrack); resolve(); } }, 200); }); } usesCaptureStream() { return this.audioNodes.source instanceof MediaStreamAudioSourceNode; } reconnectSource() { if (!this.audioNodes.context || !this.usesCaptureStream()) return; const mediaEl = this.player.video.nativeElement; const stream = mediaEl.captureStream(); if (stream.getAudioTracks().length === 0) return; // tear down old graph and source, rebuild with fresh stream this.stopAnalysis(); this.disconnectAudioGraph(); if (this.audioNodes.source) { this.audioNodes.source.disconnect(); } this.audioNodes.source = this.audioNodes.context.createMediaStreamSource(stream); this.connectAudioGraphAndStartAnalysis(); } connectAudioGraphAndStartAnalysis() { if (!this.audioNodes.context || !this.audioNodes.source) { console.warn('Audio context or source not initialized'); return; } this.buildAudioGraph(); this.startAnalysis(); } buildAudioGraph() { if (!this.audioNodes.context || !this.audioNodes.source) return; this.audioNodes.splitter = this.audioNodes.context.createChannelSplitter(this.channelCount); this.audioNodes.analyzers = Array(this.channelCount) .fill(0) .map(() => { const analyzer = this.audioNodes.context.createAnalyser(); analyzer.fftSize = this.fftSize; analyzer.smoothingTimeConstant = 0.8; return analyzer; }); this.audioNodes.source.connect(this.audioNodes.splitter); this.audioNodes.analyzers.forEach((analyzer, i) => { this.audioNodes.splitter.connect(analyzer, i); }); this.audioNodes.dataArrays = this.audioNodes.analyzers.map((analyzer) => new Float32Array(analyzer.fftSize)); } startAnalysis() { if (this.analysisState.isAnalyzing) { return; } this.analysisState.isAnalyzing = true; this.analysisState.peakHoldValues = new Array(this.channelCount).fill(0); this.analysisState.peakHoldTimers = new Array(this.channelCount).fill(0); const channelNames = this.getChannelNames(this.channelCount); this.runAnalysisLoop(channelNames); } runAnalysisLoop(channelNames) { if (!this.analysisState.isAnalyzing) { return; } this.audioNodes.analyzers.forEach((analyzer, i) => { analyzer.getFloatTimeDomainData(this.audioNodes.dataArrays[i]); }); const levels = this.audioNodes.dataArrays.map((dataArray, i) => this.calculateChannelLevel(dataArray, channelNames[i], i)); this.levels.set(levels); this.analysisState.analysisTimeout = setTimeout(() => this.runAnalysisLoop(channelNames), this.updateRateMs); } calculateChannelLevel(dataArray, channelName, channelIndex) { let sumSquares = 0; for (let i = 0; i < dataArray.length; i++) { sumSquares += dataArray[i] * dataArray[i]; } const rms = Math.sqrt(sumSquares / dataArray.length); const db = Math.round(20 * Math.log10(Math.max(rms, 0.0000001))); const normalized = Math.max(0, Math.min(1, (db + 60) / 60)); const peak = this.updatePeakHold(normalized, channelIndex); return { channel: channelName, rms, db, normalized, peak, }; } updatePeakHold(currentLevel, channelIndex) { if (currentLevel > this.analysisState.peakHoldValues[channelIndex]) { this.analysisState.peakHoldValues[channelIndex] = currentLevel; this.analysisState.peakHoldTimers[channelIndex] = Date.now(); } else { const timeSinceLastPeak = Date.now() - this.analysisState.peakHoldTimers[channelIndex]; if (timeSinceLastPeak > this.peakHoldTime) { const decayTime = timeSinceLastPeak - this.peakHoldTime; const decaySeconds = decayTime / 1000; const smoothingFactor = Math.min(decaySeconds, 1); const currentDecayRate = this.peakDecayRate * Math.pow(this.peakAccelerationFactor, decaySeconds) * smoothingFactor; this.analysisState.peakHoldValues[channelIndex] = Math.max(0, this.analysisState.peakHoldValues[channelIndex] - currentDecayRate); } } return this.analysisState.peakHoldValues[channelIndex]; } stopAnalysis() { this.analysisState.isAnalyzing = false; if (this.analysisState.analysisTimeout) { clearTimeout(this.analysisState.analysisTimeout); this.analysisState.analysisTimeout = undefined; } } getChannelNames(count) { if (this.channelNames.length) { return this.channelNames; } switch (count) { case 1: return ['M']; case 2: return ['L', 'R']; case 3: return ['L', 'R', 'LFE']; case 4: return ['FL', 'FR', 'SL', 'SR']; case 5: return ['FL', 'FR', 'C', 'SL', 'SR']; case 6: return ['FL', 'FR', 'C', 'LFE', 'SL', 'SR']; case 7: return ['FL', 'FR', 'C', 'LFE', 'SL', 'SR', 'BC']; case 8: return ['FL', 'FR', 'C', 'LFE', 'SL', 'SR', 'BL', 'BR']; default: return Array(count) .fill(0) .map((_, i) => `C${i + 1}`); } } disconnectAudioGraph() { if (this.audioNodes.splitter) { this.audioNodes.analyzers.forEach((analyzer) => { analyzer.disconnect(); }); this.audioNodes.splitter.disconnect(); } this.audioNodes.splitter = undefined; this.audioNodes.analyzers = []; this.audioNodes.dataArrays = []; } cleanupAudioContext() { if (this.audioNodes.source && this.audioNodes.context) { this.audioNodes.source.disconnect(); } if (this.audioNodes.context) { this.audioNodes.context.close().catch((err) => { console.error('Error closing audio context:', err); }); } this.audioNodes.source = undefined; this.audioNodes.context = undefined; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: TellyVuMeterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.16", type: TellyVuMeterComponent, isStandalone: true, selector: "telly-vu-meter", inputs: { fftSize: "fftSize", channelCount: "channelCount", channelNames: "channelNames", peakHoldTime: "peakHoldTime", peakDecayRate: "peakDecayRate", peakAccelerationFactor: "peakAccelerationFactor", updateRateMs: "updateRateMs" }, host: { properties: { "style.display": "this.display" } }, usesOnChanges: true, ngImport: i0, template: "@if ((service.show$ | async) && !reconfiguring() && !initializing() && levels().length) {\n <div class=\"vu-meter-container\">\n <div class=\"vu-meter-panel\">\n @if (showVolumeWarning()) {\n <div class=\"warning-icon\" title=\"VU meter levels follow player volume\">\u26A0\uFE0F</div>\n }\n <div class=\"vu-meter-channels\">\n @for (level of levels(); track level.channel) {\n <div class=\"channel-meter\">\n <div class=\"channel-label\">{{ level.channel }}</div>\n <div class=\"meter-container\">\n <div class=\"meter-background\">\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment yellow\"></div>\n <div class=\"meter-segment yellow\"></div>\n <div class=\"meter-segment red\"></div>\n <div class=\"meter-segment red\"></div>\n </div>\n <div class=\"meter-background-full\"\n [style.clip-path]=\"'polygon(0 100%, 100% 100%, 100% ' + (100 - level.normalized * 100) + '%, 0 ' + (100 - level.normalized * 100) + '%)'\">\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full yellow\"></div>\n <div class=\"meter-segment-full yellow\"></div>\n <div class=\"meter-segment-full red\"></div>\n <div class=\"meter-segment-full red\"></div>\n </div>\n <div class=\"peak-indicator\"\n [style.bottom.%]=\"level.peak * 100\"\n [class.visible]=\"level.peak > 0.01\"></div>\n </div>\n </div>\n }\n </div>\n </div>\n </div>\n}\n\n@if (initializing() || reconfiguring()) {\n <div class=\"vu-meter-container\">\n <div class=\"vu-meter-panel\">\n <div class=\"vu-meter-channels\">\n <div class=\"channel-meter\">\n <div class=\"loading-info\">\n <div>Audio</div>\n <div>Loading</div>\n <div class=\"loading-animation\">\n <div class=\"spinner\"></div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n}\n", styles: [":host{color:var(--telly-text)!important;position:absolute;right:1rem;bottom:6rem;z-index:1000}.vu-meter-container{padding:16px;font-family:monospace;border-radius:12px;border:1px solid rgba(54,54,54,.05);box-shadow:0 5px 15px #0000007f;box-sizing:border-box;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);background-blend-mode:overlay;background-color:#50505080;color:#fffc}.vu-meter-panel{display:flex;flex-direction:column;gap:12px}.db-mark{position:relative}.vu-meter-channels{display:flex;flex-direction:row;gap:12px;justify-content:center}.channel-meter{display:flex;flex-direction:column;align-items:center;gap:8px;min-width:32px}.channel-label{color:#2d2;font-size:11px;font-weight:700;min-width:32px;text-align:center;background:#ffffff1a;border-radius:4px;padding:2px 4px;border:1px solid rgba(255,255,255,.1)}.meter-container{position:relative;width:28px;height:140px;background:linear-gradient(180deg,#1414141a,#0a0a0a33,#05050566);border-radius:8px;border:1px solid rgba(0,0,0,.2);box-shadow:inset 0 2px 8px #0000001a,inset 0 -1px 2px #ffffff0d,0 2px 4px #0000001a;overflow:hidden}.meter-container:before{content:\"\";position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.03) 0%,transparent 30%,transparent 70%,rgba(255,255,255,.02) 100%);border-radius:6px;pointer-events:none}.meter-background{position:absolute;inset:0;display:flex;flex-direction:column-reverse}.meter-segment{flex:1;border-top:1px solid rgba(0,0,0,.2);position:relative}.meter-segment:last-child{border-top:none}.meter-segment.green{background:linear-gradient(180deg,#44ff4440,#22c82226);box-shadow:inset 0 1px #44ff441a}.meter-segment.yellow{background:linear-gradient(180deg,#ffff4440,#c8c82226);box-shadow:inset 0 1px #ffff441a}.meter-segment.red{background:linear-gradient(180deg,#ff444440,#c8222226);box-shadow:inset 0 1px #ff44441a}.meter-segment-full.green{background:linear-gradient(180deg,#6f6,#4f4,#2d2);box-shadow:0 0 8px #4f4c,inset 0 1px #ffffff4d,inset 0 -1px #0003}.meter-segment-full.yellow{background:linear-gradient(180deg,#ff6,#ff4,#dd2);box-shadow:0 0 8px #ff4c,inset 0 1px #ffffff4d,inset 0 -1px #0003}.meter-segment-full.red{background:linear-gradient(180deg,#f66,#f44,#d22);box-shadow:0 0 8px #f44c,inset 0 1px #ffffff4d,inset 0 -1px #0003}.meter-background-full{position:absolute;inset:0;display:flex;flex-direction:column-reverse;transition:clip-path .08s ease-out;border-radius:6px;overflow:hidden}.meter-segment-full{flex:1;border-top:1px solid rgba(0,0,0,.2);position:relative}.meter-segment-full:last-child{border-top:none}.meter-segment-full:after{content:\"\";position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.1) 0%,transparent 50%,rgba(255,255,255,.05) 100%);pointer-events:none}.peak-indicator{position:absolute;left:0;right:0;height:2px;background:linear-gradient(90deg,#fffc,#fff,#fffc);box-shadow:0 0 8px #ffffffe6,0 0 4px #fff9,inset 0 1px #fff6;opacity:0;transition:opacity .15s ease-out;border-radius:2px;z-index:10}.peak-indicator.visible{opacity:1}.loading-info>div{color:#2d2;text-align:center;min-height:12px}.loading-animation{margin-top:8px;display:flex;align-items:center;justify-content:center;gap:8px}.spinner{width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-radius:50%;border-top-color:#2d2;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.warning-icon{position:absolute;top:8px;right:8px;font-size:14px;cursor:help;z-index:10}\n"], dependencies: [{ kind: "pipe", type: AsyncPipe, name: "async" }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: TellyVuMeterComponent, decorators: [{ type: Component, args: [{ selector: 'telly-vu-meter', imports: [AsyncPipe], template: "@if ((service.show$ | async) && !reconfiguring() && !initializing() && levels().length) {\n <div class=\"vu-meter-container\">\n <div class=\"vu-meter-panel\">\n @if (showVolumeWarning()) {\n <div class=\"warning-icon\" title=\"VU meter levels follow player volume\">\u26A0\uFE0F</div>\n }\n <div class=\"vu-meter-channels\">\n @for (level of levels(); track level.channel) {\n <div class=\"channel-meter\">\n <div class=\"channel-label\">{{ level.channel }}</div>\n <div class=\"meter-container\">\n <div class=\"meter-background\">\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment green\"></div>\n <div class=\"meter-segment yellow\"></div>\n <div class=\"meter-segment yellow\"></div>\n <div class=\"meter-segment red\"></div>\n <div class=\"meter-segment red\"></div>\n </div>\n <div class=\"meter-background-full\"\n [style.clip-path]=\"'polygon(0 100%, 100% 100%, 100% ' + (100 - level.normalized * 100) + '%, 0 ' + (100 - level.normalized * 100) + '%)'\">\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full green\"></div>\n <div class=\"meter-segment-full yellow\"></div>\n <div class=\"meter-segment-full yellow\"></div>\n <div class=\"meter-segment-full red\"></div>\n <div class=\"meter-segment-full red\"></div>\n </div>\n <div class=\"peak-indicator\"\n [style.bottom.%]=\"level.peak * 100\"\n [class.visible]=\"level.peak > 0.01\"></div>\n </div>\n </div>\n }\n </div>\n </div>\n </div>\n}\n\n@if (initializing() || reconfiguring()) {\n <div class=\"vu-meter-container\">\n <div class=\"vu-meter-panel\">\n <div class=\"vu-meter-channels\">\n <div class=\"channel-meter\">\n <div class=\"loading-info\">\n <div>Audio</div>\n <div>Loading</div>\n <div class=\"loading-animation\">\n <div class=\"spinner\"></div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n}\n", styles: [":host{color:var(--telly-text)!important;position:absolute;right:1rem;bottom:6rem;z-index:1000}.vu-meter-container{padding:16px;font-family:monospace;border-radius:12px;border:1px solid rgba(54,54,54,.05);box-shadow:0 5px 15px #0000007f;box-sizing:border-box;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);background-blend-mode:overlay;background-color:#50505080;color:#fffc}.vu-meter-panel{display:flex;flex-direction:column;gap:12px}.db-mark{position:relative}.vu-meter-channels{display:flex;flex-direction:row;gap:12px;justify-content:center}.channel-meter{display:flex;flex-direction:column;align-items:center;gap:8px;min-width:32px}.channel-label{color:#2d2;font-size:11px;font-weight:700;min-width:32px;text-align:center;background:#ffffff1a;border-radius:4px;padding:2px 4px;border:1px solid rgba(255,255,255,.1)}.meter-container{position:relative;width:28px;height:140px;background:linear-gradient(180deg,#1414141a,#0a0a0a33,#05050566);border-radius:8px;border:1px solid rgba(0,0,0,.2);box-shadow:inset 0 2px 8px #0000001a,inset 0 -1px 2px #ffffff0d,0 2px 4px #0000001a;overflow:hidden}.meter-container:before{content:\"\";position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.03) 0%,transparent 30%,transparent 70%,rgba(255,255,255,.02) 100%);border-radius:6px;pointer-events:none}.meter-background{position:absolute;inset:0;display:flex;flex-direction:column-reverse}.meter-segment{flex:1;border-top:1px solid rgba(0,0,0,.2);position:relative}.meter-segment:last-child{border-top:none}.meter-segment.green{background:linear-gradient(180deg,#44ff4440,#22c82226);box-shadow:inset 0 1px #44ff441a}.meter-segment.yellow{background:linear-gradient(180deg,#ffff4440,#c8c82226);box-shadow:inset 0 1px #ffff441a}.meter-segment.red{background:linear-gradient(180deg,#ff444440,#c8222226);box-shadow:inset 0 1px #ff44441a}.meter-segment-full.green{background:linear-gradient(180deg,#6f6,#4f4,#2d2);box-shadow:0 0 8px #4f4c,inset 0 1px #ffffff4d,inset 0 -1px #0003}.meter-segment-full.yellow{background:linear-gradient(180deg,#ff6,#ff4,#dd2);box-shadow:0 0 8px #ff4c,inset 0 1px #ffffff4d,inset 0 -1px #0003}.meter-segment-full.red{background:linear-gradient(180deg,#f66,#f44,#d22);box-shadow:0 0 8px #f44c,inset 0 1px #ffffff4d,inset 0 -1px #0003}.meter-background-full{position:absolute;inset:0;display:flex;flex-direction:column-reverse;transition:clip-path .08s ease-out;border-radius:6px;overflow:hidden}.meter-segment-full{flex:1;border-top:1px solid rgba(0,0,0,.2);position:relative}.meter-segment-full:last-child{border-top:none}.meter-segment-full:after{content:\"\";position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.1) 0%,transparent 50%,rgba(255,255,255,.05) 100%);pointer-events:none}.peak-indicator{position:absolute;left:0;right:0;height:2px;background:linear-gradient(90deg,#fffc,#fff,#fffc);box-shadow:0 0 8px #ffffffe6,0 0 4px #fff9,inset 0 1px #fff6;opacity:0;transition:opacity .15s ease-out;border-radius:2px;z-index:10}.peak-indicator.visible{opacity:1}.loading-info>div{color:#2d2;text-align:center;min-height:12px}.loading-animation{margin-top:8px;display:flex;align-items:center;justify-content:center;gap:8px}.spinner{width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-radius:50%;border-top-color:#2d2;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.warning-icon{position:absolute;top:8px;right:8px;font-size:14px;cursor:help;z-index:10}\n"] }] }], propDecorators: { display: [{ type: HostBinding, args: ['style.display'] }], fftSize: [{ type: Input }], channelCount: [{ type: Input }], channelNames: [{ type: Input }], peakHoldTime: [{ type: Input }], peakDecayRate: [{ type: Input }], peakAccelerationFactor: [{ type: Input }], updateRateMs: [{ type: Input }] } }); const equalizer = 'M10,20H14V4H10V20M4,20H8V12H4V20M16,9V20H20V9H16Z'; const gauge = 'M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12C20,14.4 19,16.5 17.3,18C15.9,16.7 14,16 12,16C10,16 8.2,16.7 6.7,18C5,16.5 4,14.4 4,12A8,8 0 0,1 12,4M14,5.89C13.62,5.9 13.26,6.15 13.1,6.54L11.81,9.77L11.71,10C11,10.13 10.41,10.6 10.14,11.26C9.73,12.29 10.23,13.45 11.26,13.86C12.29,14.27 13.45,13.77 13.86,12.74C14.12,12.08 14,11.32 13.57,10.76L13.67,10.5L14.96,7.29L14.97,7.26C15.17,6.75 14.92,6.17 14.41,5.96C14.28,5.91 14.15,5.89 14,5.89M10,6A1,1 0 0,0 9,7A1,1 0 0,0 10,8A1,1 0 0,0 11,7A1,1 0 0,0 10,6M7,9A1,1 0 0,0 6,10A1,1 0 0,0 7,11A1,1 0 0,0 8,10A1,1 0 0,0 7,9M17,9A1,1 0 0,0 16,10A1,1 0 0,0 17,11A1,1 0 0,0 18,10A1,1 0 0,0 17,9'; class TellyVuMeterToggleComponent { icon = input(gauge, ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ [])); size = input('1.4rem', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ [])); service = inject(VuMeterService); title = 'VU Meter'; onClick() { this.service.toggle(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: TellyVuMeterToggleComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.16", type: TellyVuMeterToggleComponent, isStandalone: true, selector: "telly-vu-meter-toggle", inputs: { icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "pointerdown": "onClick()" }, properties: { "attr.tip": "this.title" } }, ngImport: i0, template: "<svg\n viewBox=\"0 0 24 24\"\n [style.width]=\"size()\"\n>\n <path\n fill=\"white\"\n [attr.d]=\"icon()\"\n />\n</svg>\n\n", styles: [""] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: TellyVuMeterToggleComponent, decorators: [{ type: Component, args: [{ selector: 'telly-vu-meter-toggle', imports: [], template: "<svg\n viewBox=\"0 0 24 24\"\n [style.width]=\"size()\"\n>\n <path\n fill=\"white\"\n [attr.d]=\"icon()\"\n />\n</svg>\n\n" }] }], propDecorators: { icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], title: [{ type: HostBinding, args: ['attr.tip'] }], onClick: [{ type: HostListener, args: ['pointerdown'] }] } }); function provideTellyVuMeter() { return makeEnvironmentProviders([VuMeterService]); } /** * Generated bundle index. Do not edit. */ export { TellyVuMeterComponent, TellyVuMeterToggleComponent, VuMeterService, provideTellyVuMeter }; //# sourceMappingURL=ngx-telly-plugin-vu-meter.mjs.map