@panelwave/player 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1433,8 +1433,14 @@ interface SequenceAudioTrack {
1433
1433
  muted?: boolean;
1434
1434
  /** Optional explicit panel range start */
1435
1435
  startPanelId?: string;
1436
- /** Optional explicit panel range end */
1436
+ /** Optional explicit panel range end (span 'work': may be in a later chapter) */
1437
1437
  endPanelId?: string;
1438
+ /**
1439
+ * How long the track lasts, as authored (format 1.8.0, additive; default
1440
+ * 'custom'): 'page' / 'chapter' = that panel range, 'work' = keeps playing
1441
+ * across chapter boundaries until endPanelId (else the last panel).
1442
+ */
1443
+ span?: 'custom' | 'page' | 'chapter' | 'work';
1438
1444
  }
1439
1445
  /**
1440
1446
  * Chapter containing panels and navigation graph
@@ -3232,6 +3238,8 @@ interface AudioTrack {
3232
3238
  volume?: number;
3233
3239
  fadeIn?: number;
3234
3240
  fadeOut?: number;
3241
+ /** Playback speed (0.25-4; 1 = as recorded). */
3242
+ playbackRate?: number;
3235
3243
  }
3236
3244
  /**
3237
3245
  * Playback state
@@ -3304,9 +3312,9 @@ declare class AudioEngineService {
3304
3312
  */
3305
3313
  resumeContext(): Promise<void>;
3306
3314
  /**
3307
- * Play audio track
3315
+ * Play audio track, optionally from `offsetSeconds` into the file.
3308
3316
  */
3309
- play(track: AudioTrack): Promise<void>;
3317
+ play(track: AudioTrack, offsetSeconds?: number): Promise<void>;
3310
3318
  /**
3311
3319
  * Stop audio track
3312
3320
  */
@@ -3421,6 +3429,119 @@ declare class AudioEngineService {
3421
3429
  static ɵprov: i0.ɵɵInjectableDeclaration<AudioEngineService>;
3422
3430
  }
3423
3431
 
3432
+ /**
3433
+ * Sequence Audio Service
3434
+ *
3435
+ * Plays a work's timeline audio (`chapters[].sequenceAudioTracks` — music,
3436
+ * ambience, voiceover beds) while the reader moves through the panels:
3437
+ *
3438
+ * - entering a panel starts every track that covers it, at the right offset
3439
+ * (a jump into the middle of a scene picks the music up where it is);
3440
+ * - a track keeps playing while the reader stays inside its range, so
3441
+ * music does not restart on every panel; leaving the range fades it out
3442
+ * (its `fadeOut`, else 250 ms);
3443
+ * - a track that starts or ends inside a panel is delayed / stopped there;
3444
+ * - `span: 'work'` tracks continue across chapter boundaries.
3445
+ *
3446
+ * Placement follows the CMS timeline (see sequence-audio-timeline.ts). Roles
3447
+ * map onto the engine buses, so the reader's Audio / SFX / Speech toggles
3448
+ * apply. Tracks refused by the autoplay policy are retried on the first
3449
+ * user gesture.
3450
+ */
3451
+
3452
+ /** Fade when a track ends because the reader left its range (unless it has its own fadeOut). */
3453
+ declare const SEQUENCE_AUDIO_LEAVE_FADE_MS = 250;
3454
+ declare class SequenceAudioService implements OnDestroy {
3455
+ private readonly engine;
3456
+ private readonly manifestService;
3457
+ private readonly gesture;
3458
+ /** Tracks the current panel wants, by engine id. */
3459
+ private desired;
3460
+ /** Refused by the autoplay policy; retried on the first gesture. */
3461
+ private blocked;
3462
+ private warned;
3463
+ private layoutCache?;
3464
+ private readonly gestureSub;
3465
+ constructor();
3466
+ ngOnDestroy(): void;
3467
+ /**
3468
+ * Follow the reader: call on every panel change (and when the page format
3469
+ * changes). `panelId` undefined = silence (cover, locked panel, teardown).
3470
+ */
3471
+ syncPanel(panelId: string | undefined, format: string | null): void;
3472
+ /** Stop everything this service started. */
3473
+ stopAll(): void;
3474
+ /** Engine ids of the tracks the current panel wants (started, pending or blocked). */
3475
+ getDesiredTrackIds(): string[];
3476
+ private layout;
3477
+ private schedule;
3478
+ /** Stop the track inside this panel when its range ends here. */
3479
+ private armStop;
3480
+ private start;
3481
+ private release;
3482
+ private leaveFade;
3483
+ private onUserGesture;
3484
+ private isAutoplayRefusal;
3485
+ private warnOnce;
3486
+ static ɵfac: i0.ɵɵFactoryDeclaration<SequenceAudioService, never>;
3487
+ static ɵprov: i0.ɵɵInjectableDeclaration<SequenceAudioService>;
3488
+ }
3489
+
3490
+ /**
3491
+ * Timeline (sequence) audio placement for the reader — the same model as the
3492
+ * CMS editor's timeline: the work is one line of panels (chapters in order →
3493
+ * the active format's pages → reading order), each panel lasting its
3494
+ * `durationMs` (else the work's seconds-per-panel, else 5 s).
3495
+ *
3496
+ * A chapter's `sequenceAudioTracks` sit on that line relative to their
3497
+ * chapter's start. `span` (format 1.8.0): 'page' / 'chapter' tracks cover
3498
+ * their startPanelId..endPanelId range; a 'work' track keeps playing across
3499
+ * chapter boundaries until endPanelId (else the work's last panel); every
3500
+ * other track ends with its chapter.
3501
+ *
3502
+ * The reader does not follow a clock, so the timeline is used per panel:
3503
+ * entering a panel tells which tracks cover it, from which offset, and
3504
+ * whether one starts or ends inside it (`cuesForPanel`).
3505
+ */
3506
+
3507
+ interface TimelineEntry {
3508
+ panelId: string;
3509
+ chapterId: string;
3510
+ startMs: number;
3511
+ endMs: number;
3512
+ }
3513
+ interface WorkTimeline {
3514
+ entries: TimelineEntry[];
3515
+ byPanel: Map<string, TimelineEntry>;
3516
+ chapters: Map<string, {
3517
+ startMs: number;
3518
+ endMs: number;
3519
+ }>;
3520
+ endMs: number;
3521
+ }
3522
+ interface TrackRange {
3523
+ track: SequenceAudioTrack;
3524
+ chapterId: string;
3525
+ startMs: number;
3526
+ endMs: number;
3527
+ }
3528
+ /** What to do with a track when the reader enters a panel. */
3529
+ interface PanelCue {
3530
+ range: TrackRange;
3531
+ /** The track starts this long after the panel is entered (0 = at once). */
3532
+ delayMs: number;
3533
+ /** Timeline position inside the track when it starts (ms). */
3534
+ offsetMs: number;
3535
+ /** The track ends inside this panel, this long after entering it. */
3536
+ stopAfterMs?: number;
3537
+ }
3538
+ /** The work's panel line for a page format (null = every page). */
3539
+ declare function buildWorkTimeline(manifest: PanelWaveManifest | null | undefined, format: string | null, defaultPanelMs?: number): WorkTimeline;
3540
+ /** Where each (format-matching, unmuted) timeline track sits on the work line. */
3541
+ declare function resolveTrackRanges(manifest: PanelWaveManifest | null | undefined, timeline: WorkTimeline, format: string | null): TrackRange[];
3542
+ /** The tracks that sound while the reader is on `entry`'s panel. */
3543
+ declare function cuesForPanel(ranges: readonly TrackRange[], entry: TimelineEntry): PanelCue[];
3544
+
3424
3545
  /**
3425
3546
  * Panel Audio Service
3426
3547
  *
@@ -3497,12 +3618,6 @@ declare class PanelAudioService implements OnDestroy {
3497
3618
  private isAutoplayRefusal;
3498
3619
  private resolve;
3499
3620
  private resolveTrack;
3500
- /**
3501
- * First variant the browser reports it can play; otherwise the first one
3502
- * (the element will surface the real error).
3503
- */
3504
- private pickVariant;
3505
- private createProbe;
3506
3621
  private clampGain;
3507
3622
  private warnOnce;
3508
3623
  static ɵfac: i0.ɵɵFactoryDeclaration<PanelAudioService, never>;
@@ -6614,6 +6729,7 @@ declare class PlayerShellComponent implements OnInit, OnChanges, AfterViewChecke
6614
6729
  private readonly cdr;
6615
6730
  private readonly audioEngine;
6616
6731
  private readonly panelAudio;
6732
+ private readonly sequenceAudio;
6617
6733
  /**
6618
6734
  * Reading order per panel id (1-based, across chapters), computed once per
6619
6735
  * manifest. Reported as `panelOrder` on panel_view / funnel events and as
@@ -7547,6 +7663,14 @@ declare class SpeechBubblesComponent implements OnChanges, AfterViewInit, OnDest
7547
7663
  * screens (phones) get smaller lettering.
7548
7664
  */
7549
7665
  readingScale: number;
7666
+ /**
7667
+ * Page view: place a tailed balloon by its AUTHORED tail tip (the speaker
7668
+ * on the artwork) instead of centring its body on the stored box, and keep
7669
+ * the tail at its authored length when the MIN_LETTERING_PX floor boosts
7670
+ * the balloon — the CMS editor's page view rule (PanelRenderer), which the
7671
+ * lettered PDF must reproduce. Panel view keeps the centre placement.
7672
+ */
7673
+ anchorTailTip: boolean;
7550
7674
  /**
7551
7675
  * Work-level balloon config defaults (from settings.typography.balloon_config)
7552
7676
  */
@@ -7617,7 +7741,7 @@ declare class SpeechBubblesComponent implements OnChanges, AfterViewInit, OnDest
7617
7741
  */
7618
7742
  onBubbleClick(bubble: SpeechBubble, event: MouseEvent): void;
7619
7743
  static ɵfac: i0.ɵɵFactoryDeclaration<SpeechBubblesComponent, never>;
7620
- static ɵcmp: i0.ɵɵComponentDeclaration<SpeechBubblesComponent, "pw-speech-bubbles", never, { "bubbles": { "alias": "bubbles"; "required": false; }; "locale": { "alias": "locale"; "required": false; }; "containerWidth": { "alias": "containerWidth"; "required": false; }; "containerHeight": { "alias": "containerHeight"; "required": false; }; "readingScale": { "alias": "readingScale"; "required": false; }; "workBalloonConfig": { "alias": "workBalloonConfig"; "required": false; }; "characters": { "alias": "characters"; "required": false; }; }, { "bubbleClick": "bubbleClick"; "bubbleAudioPlay": "bubbleAudioPlay"; }, never, never, true, never>;
7744
+ static ɵcmp: i0.ɵɵComponentDeclaration<SpeechBubblesComponent, "pw-speech-bubbles", never, { "bubbles": { "alias": "bubbles"; "required": false; }; "locale": { "alias": "locale"; "required": false; }; "containerWidth": { "alias": "containerWidth"; "required": false; }; "containerHeight": { "alias": "containerHeight"; "required": false; }; "readingScale": { "alias": "readingScale"; "required": false; }; "anchorTailTip": { "alias": "anchorTailTip"; "required": false; }; "workBalloonConfig": { "alias": "workBalloonConfig"; "required": false; }; "characters": { "alias": "characters"; "required": false; }; }, { "bubbleClick": "bubbleClick"; "bubbleAudioPlay": "bubbleAudioPlay"; }, never, never, true, never>;
7621
7745
  }
7622
7746
 
7623
7747
  /**
@@ -8133,5 +8257,5 @@ declare class PlayerComponent {
8133
8257
  static ɵcmp: i0.ɵɵComponentDeclaration<PlayerComponent, "pw-player", never, {}, {}, never, never, true, never>;
8134
8258
  }
8135
8259
 
8136
- export { ANONYMOUS_READER, ActionModalComponent, AgeGateComponent, AssetUrlService, AudioEngineService, BranchChooserComponent, CANVAS_PANEL_BUDGET, CanvasCameraService, CanvasStageComponent, ComicBalloon, DEFAULT_BALLOON_CONFIG, DEFAULT_PAGE_BACKGROUND, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_VIDEO_MUTED, DEFAULT_VIDEO_PLAY_MODE, DEFAULT_VIDEO_START_MODE, EntitlementError, EntitlementService, ExportService, FlowEngineService, HotspotActionService, HotspotsOverlayComponent, HttpEntitlementAdapter, ImageCacheService, LOCATION_PARAMS, ManifestService, MockEntitlementAdapter, NullEntitlementAdapter, PANEL_AUDIO_LEAVE_FADE_MS, PanelAudioService, PaywallOverlayComponent, PaywallService, PlayerComponent, PlayerEvent, PlayerService, PlayerShellComponent, PlayerStateService, PluginHostService, PluginSandboxComponent, PreloadService, PwIconComponent, RESERVED_NAMESPACES, SpeechBubblesComponent, THUMBNAIL_WIDTH, TrackingService, TranslationService, UserGestureService, VISIBILITY_THRESHOLD, VariableStoreService, VideoControllerService, VideoLayerComponent, VideoSequencerService, VisibilityService, applyVariantOverrides, balloonConfigToRenderOptions, balloonConfigToTailOptions, busForRole, chapterOrderFromManifest, coverImageSrc, createBalloon, evaluatePanelAccess, evaluateWorkAccess, findWorkGateRule, fromManifestRule, isExtraLocked, isLockedPanel, locationUrl, mergeBalloonConfig, pageAspectRatio, pageFormatOf, pageFormatsOf, pagesForFormat, panelThumbnailSrc, parseLocationSearch, pickPageFormat, rankPageFormats, readingOrderFromManifest, resolveMuted, resolvePageBackground, resolvePanelVariant, resolvePanels, resolvePlayMode, resolveStartMode, resolveVideoConfig, rulesFromManifest, satisfiesRule, screenClassFor, selectVariant, toEntitlementStatus };
8137
- export type { AccessDecision, AgeVerificationResult, AnimatableProperty, AnimationEasing, AnimationKeyframe, AssetBase, AssetCatalogItem, AssetCatalogItemAudio, AssetCatalogItemImage, AssetCatalogItemJson, AssetCatalogItemPluginPayload, AssetCatalogItemSubtitle, AssetCatalogItemVector, AssetCatalogItemVideo, AssetCategory, AssetCommon, AssetRef, AssetReference, AssetVariant, Assets, AudioLayer, AudioRole, AudioTrack, AudioVariant, BalloonConfig, BalloonConfigOverride, BalloonOptions, BalloonRenderResult, BalloonType, Bookmark, BoundingBox, BranchChoice, BubbleStyle, BubbleTail, CameraMove, CameraMovePath, CameraState, CameraZoomProfile, CanvasBackground, CanvasCameraPolicy, CanvasDecoration, CanvasFitMode, CanvasFreeRoam, CanvasLayout, CanvasOverviewPolicy, CanvasPlacement, CanvasRevealMode, Chapter, Character$1 as Character, ContentWarning, Creator, DecisionEvent, DeepLinkTarget, DeviceVariables, Edge, EffectiveVideoConfig, EntitlementAdapter$1 as EntitlementAdapter, EntitlementContext, EntitlementErrorCode, EntitlementSnapshot, EntitlementSnapshotResponse, EntitlementStatus, EntitlementVariables, ErrorEvent$1 as ErrorEvent, EvaluatorEntitlementType, EvaluatorRule, EvaluatorScope, ExtraImage, ExtraItem, Extras, FormatView, Graph, HideBorderConfig, Hotspot, HotspotAction, HotspotCircle, HotspotGoToAction, HotspotOpenExtrasAction, HotspotOpenModalAction, HotspotPluginEventAction, HotspotPolygon, HotspotRect, HotspotSetVariablesAction, HotspotShape, HotspotUiEffect, HttpEntitlementAdapterConfig, ImageLayer, ImageVariant, JsonLogic, JsonLogicExpression, JsonValue, JsonVariant, Layer, LayerKind, LayerViewMode, LocaleCode, LocalizedString, LockReason, Meta, Mutation, MutationOperation, NavigationPath, NodeMetadata, NormalizedPoint, NormalizedRect, OutputFormat, OutputPreset, OverlayState, Page, PageLayout, PageVisual, Panel, PanelAnimations, PanelAudioRole, PanelAudioTrack, PanelChangeEvent, PanelPartial, PanelPlacement, PanelRef, PanelVariant, PanelWaveManifest, PanelwaveHeader, Paywall, PaywallAction, PaywallEvent, PaywallGate$1 as PaywallGate, PaywallProduct, PaywallRule, PlaybackState, PlayerError, PlayerErrorCode, PlayerEventData, PlayerLocation, PlayerOptions, PlayerPanelChangeEvent, PlayerPreferences, PlayerState, PlayerStatistics, PlayerView, PluginInstance$1 as PluginInstance, PluginLayer, PreferenceVariables, PreloadItem, PreloadPriority, PreloadSettings, PreloadStatus$1 as PreloadStatus, PreviewInfo, PurchaseInfo, ReadingProgress, ResolvedPanel, ResolvedPanelAudio, ScreenClass, SequenceAudioTrack, SequencedVideo, SequencedVideoInfo, Settings, SpeechBubble, StagePlacement, SubtitleVariant, TailConfig, TailCurve, TailOptions, TextLayer, TextStyle, Tracking, TrackingConfig$1 as TrackingConfig, TrackingEvent, Transform, Transition, TraversalOptions, TraversalResult, UIDefaults, UISettings, UserInfo, UserVariables, VariableChangeEvent, VariableContext, VariableDefinition, VariableMutation, VariableScope, VariableStore, VariableType, VariableValidationResult, Variables, VariantContext, VectorVariant, VideoEvent, VideoLayer, VideoPlayMode, VideoPoster, VideoStartMode, VideoState, VideoStatus, VideoTrackingPayload, VideoTrigger, VideoVariant, ViewMode, ViewportState, VisibilityChange, WorldPoint, WorldRect };
8260
+ export { ANONYMOUS_READER, ActionModalComponent, AgeGateComponent, AssetUrlService, AudioEngineService, BranchChooserComponent, CANVAS_PANEL_BUDGET, CanvasCameraService, CanvasStageComponent, ComicBalloon, DEFAULT_BALLOON_CONFIG, DEFAULT_PAGE_BACKGROUND, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_VIDEO_MUTED, DEFAULT_VIDEO_PLAY_MODE, DEFAULT_VIDEO_START_MODE, EntitlementError, EntitlementService, ExportService, FlowEngineService, HotspotActionService, HotspotsOverlayComponent, HttpEntitlementAdapter, ImageCacheService, LOCATION_PARAMS, ManifestService, MockEntitlementAdapter, NullEntitlementAdapter, PANEL_AUDIO_LEAVE_FADE_MS, PanelAudioService, PaywallOverlayComponent, PaywallService, PlayerComponent, PlayerEvent, PlayerService, PlayerShellComponent, PlayerStateService, PluginHostService, PluginSandboxComponent, PreloadService, PwIconComponent, RESERVED_NAMESPACES, SEQUENCE_AUDIO_LEAVE_FADE_MS, SequenceAudioService, SpeechBubblesComponent, THUMBNAIL_WIDTH, TrackingService, TranslationService, UserGestureService, VISIBILITY_THRESHOLD, VariableStoreService, VideoControllerService, VideoLayerComponent, VideoSequencerService, VisibilityService, applyVariantOverrides, balloonConfigToRenderOptions, balloonConfigToTailOptions, buildWorkTimeline, busForRole, chapterOrderFromManifest, coverImageSrc, createBalloon, cuesForPanel, evaluatePanelAccess, evaluateWorkAccess, findWorkGateRule, fromManifestRule, isExtraLocked, isLockedPanel, locationUrl, mergeBalloonConfig, pageAspectRatio, pageFormatOf, pageFormatsOf, pagesForFormat, panelThumbnailSrc, parseLocationSearch, pickPageFormat, rankPageFormats, readingOrderFromManifest, resolveMuted, resolvePageBackground, resolvePanelVariant, resolvePanels, resolvePlayMode, resolveStartMode, resolveTrackRanges, resolveVideoConfig, rulesFromManifest, satisfiesRule, screenClassFor, selectVariant, toEntitlementStatus };
8261
+ export type { AccessDecision, AgeVerificationResult, AnimatableProperty, AnimationEasing, AnimationKeyframe, AssetBase, AssetCatalogItem, AssetCatalogItemAudio, AssetCatalogItemImage, AssetCatalogItemJson, AssetCatalogItemPluginPayload, AssetCatalogItemSubtitle, AssetCatalogItemVector, AssetCatalogItemVideo, AssetCategory, AssetCommon, AssetRef, AssetReference, AssetVariant, Assets, AudioLayer, AudioRole, AudioTrack, AudioVariant, BalloonConfig, BalloonConfigOverride, BalloonOptions, BalloonRenderResult, BalloonType, Bookmark, BoundingBox, BranchChoice, BubbleStyle, BubbleTail, CameraMove, CameraMovePath, CameraState, CameraZoomProfile, CanvasBackground, CanvasCameraPolicy, CanvasDecoration, CanvasFitMode, CanvasFreeRoam, CanvasLayout, CanvasOverviewPolicy, CanvasPlacement, CanvasRevealMode, Chapter, Character$1 as Character, ContentWarning, Creator, DecisionEvent, DeepLinkTarget, DeviceVariables, Edge, EffectiveVideoConfig, EntitlementAdapter$1 as EntitlementAdapter, EntitlementContext, EntitlementErrorCode, EntitlementSnapshot, EntitlementSnapshotResponse, EntitlementStatus, EntitlementVariables, ErrorEvent$1 as ErrorEvent, EvaluatorEntitlementType, EvaluatorRule, EvaluatorScope, ExtraImage, ExtraItem, Extras, FormatView, Graph, HideBorderConfig, Hotspot, HotspotAction, HotspotCircle, HotspotGoToAction, HotspotOpenExtrasAction, HotspotOpenModalAction, HotspotPluginEventAction, HotspotPolygon, HotspotRect, HotspotSetVariablesAction, HotspotShape, HotspotUiEffect, HttpEntitlementAdapterConfig, ImageLayer, ImageVariant, JsonLogic, JsonLogicExpression, JsonValue, JsonVariant, Layer, LayerKind, LayerViewMode, LocaleCode, LocalizedString, LockReason, Meta, Mutation, MutationOperation, NavigationPath, NodeMetadata, NormalizedPoint, NormalizedRect, OutputFormat, OutputPreset, OverlayState, Page, PageLayout, PageVisual, Panel, PanelAnimations, PanelAudioRole, PanelAudioTrack, PanelChangeEvent, PanelCue, PanelPartial, PanelPlacement, PanelRef, PanelVariant, PanelWaveManifest, PanelwaveHeader, Paywall, PaywallAction, PaywallEvent, PaywallGate$1 as PaywallGate, PaywallProduct, PaywallRule, PlaybackState, PlayerError, PlayerErrorCode, PlayerEventData, PlayerLocation, PlayerOptions, PlayerPanelChangeEvent, PlayerPreferences, PlayerState, PlayerStatistics, PlayerView, PluginInstance$1 as PluginInstance, PluginLayer, PreferenceVariables, PreloadItem, PreloadPriority, PreloadSettings, PreloadStatus$1 as PreloadStatus, PreviewInfo, PurchaseInfo, ReadingProgress, ResolvedPanel, ResolvedPanelAudio, ScreenClass, SequenceAudioTrack, SequencedVideo, SequencedVideoInfo, Settings, SpeechBubble, StagePlacement, SubtitleVariant, TailConfig, TailCurve, TailOptions, TextLayer, TextStyle, TimelineEntry, TrackRange, Tracking, TrackingConfig$1 as TrackingConfig, TrackingEvent, Transform, Transition, TraversalOptions, TraversalResult, UIDefaults, UISettings, UserInfo, UserVariables, VariableChangeEvent, VariableContext, VariableDefinition, VariableMutation, VariableScope, VariableStore, VariableType, VariableValidationResult, Variables, VariantContext, VectorVariant, VideoEvent, VideoLayer, VideoPlayMode, VideoPoster, VideoStartMode, VideoState, VideoStatus, VideoTrackingPayload, VideoTrigger, VideoVariant, ViewMode, ViewportState, VisibilityChange, WorkTimeline, WorldPoint, WorldRect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panelwave/player",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Open-source Angular library for PanelWave interactive graphic novels",
5
5
  "keywords": [
6
6
  "angular",