unified-video-framework 1.4.438 → 1.4.440

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.
@@ -20,7 +20,19 @@ import {
20
20
  VideoSegment,
21
21
  ChapterEvents
22
22
  } from './chapters/types/ChapterTypes';
23
- import type { FlashNewsTickerConfig, BroadcastStyleConfig, BroadcastTheme, TickerStyleVariant } from './react/types/FlashNewsTickerTypes';
23
+ import type {
24
+ FlashNewsTickerConfig,
25
+ FlashNewsTickerItem,
26
+ BroadcastStyleConfig,
27
+ BroadcastTheme,
28
+ TickerStyleVariant,
29
+ TickerLayoutStyle,
30
+ IntroAnimationType,
31
+ TwoLineDisplayConfig,
32
+ DecorativeShapesConfig,
33
+ ItemCyclingConfig,
34
+ SeparatorType
35
+ } from './react/types/FlashNewsTickerTypes';
24
36
  import YouTubeExtractor from './utils/YouTubeExtractor';
25
37
  import { DRMManager, DRMErrorHandler } from './drm';
26
38
  import type { DRMInitResult, DRMError } from './drm';
@@ -71,6 +83,19 @@ export class WebPlayer extends BasePlayer {
71
83
  private flashTickerTopElement: HTMLDivElement | null = null;
72
84
  private flashTickerBottomElement: HTMLDivElement | null = null;
73
85
 
86
+ // Professional Ticker State (for item cycling and intro animations)
87
+ private tickerCurrentItemIndex: number = 0;
88
+ private tickerCycleTimer: number | null = null;
89
+ private tickerConfig: FlashNewsTickerConfig | null = null;
90
+ private tickerHeadlineElement: HTMLDivElement | null = null;
91
+ private tickerDetailElement: HTMLDivElement | null = null;
92
+ private tickerIntroOverlay: HTMLDivElement | null = null;
93
+ private tickerProgressBar: HTMLDivElement | null = null;
94
+ private tickerProgressFill: HTMLDivElement | null = null;
95
+ private tickerIsPaused: boolean = false;
96
+ private tickerPauseStartTime: number = 0;
97
+ private tickerRemainingTime: number = 0;
98
+
74
99
  // Free preview gate state
75
100
  private previewGateHit: boolean = false;
76
101
  private paymentSuccessTime: number = 0;
@@ -2550,6 +2575,11 @@ export class WebPlayer extends BasePlayer {
2550
2575
  private createTickerElement(config: FlashNewsTickerConfig, position: 'top' | 'bottom'): HTMLDivElement {
2551
2576
  // Route to broadcast style if configured
2552
2577
  if (config.styleVariant === 'broadcast') {
2578
+ const layoutStyle = config.broadcastStyle?.layoutStyle || 'broadcast';
2579
+ // Route to professional ticker for two-line or professional layouts
2580
+ if (layoutStyle === 'two-line' || layoutStyle === 'professional') {
2581
+ return this.createProfessionalTickerElement(config, position);
2582
+ }
2553
2583
  return this.createBroadcastTickerElement(config, position);
2554
2584
  }
2555
2585
 
@@ -2917,14 +2947,19 @@ export class WebPlayer extends BasePlayer {
2917
2947
  const style = document.createElement('style');
2918
2948
  style.id = 'uvf-ticker-animation';
2919
2949
  style.textContent = `
2950
+ /* Basic ticker scroll */
2920
2951
  @keyframes ticker-scroll {
2921
2952
  0% { transform: translateX(0%); }
2922
2953
  100% { transform: translateX(-100%); }
2923
2954
  }
2955
+
2956
+ /* Globe rotation */
2924
2957
  @keyframes globe-rotate {
2925
2958
  0% { transform: rotate(0deg); }
2926
2959
  100% { transform: rotate(360deg); }
2927
2960
  }
2961
+
2962
+ /* LIVE badge animations */
2928
2963
  @keyframes live-pulse {
2929
2964
  0%, 100% { opacity: 1; transform: scale(1); }
2930
2965
  50% { opacity: 0.85; transform: scale(1.02); }
@@ -2933,6 +2968,85 @@ export class WebPlayer extends BasePlayer {
2933
2968
  0%, 100% { opacity: 1; }
2934
2969
  50% { opacity: 0.3; }
2935
2970
  }
2971
+
2972
+ /* Intro Animations */
2973
+ @keyframes intro-slide-in {
2974
+ 0% { transform: translateX(-100%); opacity: 0; }
2975
+ 20% { transform: translateX(0); opacity: 1; }
2976
+ 80% { transform: translateX(0); opacity: 1; }
2977
+ 100% { transform: translateX(100%); opacity: 0; }
2978
+ }
2979
+
2980
+ @keyframes intro-flash {
2981
+ 0%, 100% { opacity: 0; }
2982
+ 10%, 30%, 50%, 70%, 90% { opacity: 1; background: rgba(255,255,255,0.3); }
2983
+ 20%, 40%, 60%, 80% { opacity: 1; background: transparent; }
2984
+ }
2985
+
2986
+ @keyframes intro-scale {
2987
+ 0% { transform: scale(0); opacity: 0; }
2988
+ 20% { transform: scale(1.2); opacity: 1; }
2989
+ 30% { transform: scale(1); }
2990
+ 80% { transform: scale(1); opacity: 1; }
2991
+ 100% { transform: scale(0); opacity: 0; }
2992
+ }
2993
+
2994
+ @keyframes intro-pulse {
2995
+ 0% { transform: scale(0.8); opacity: 0; }
2996
+ 25% { transform: scale(1.1); opacity: 1; }
2997
+ 50% { transform: scale(1); opacity: 1; }
2998
+ 75% { transform: scale(1); opacity: 1; }
2999
+ 100% { transform: scale(0.8); opacity: 0; }
3000
+ }
3001
+
3002
+ @keyframes intro-shake {
3003
+ 0% { transform: translateX(0); opacity: 0; }
3004
+ 10% { opacity: 1; }
3005
+ 15%, 35%, 55%, 75% { transform: translateX(-5px); }
3006
+ 25%, 45%, 65%, 85% { transform: translateX(5px); }
3007
+ 90% { transform: translateX(0); opacity: 1; }
3008
+ 100% { transform: translateX(0); opacity: 0; }
3009
+ }
3010
+
3011
+ @keyframes intro-none {
3012
+ 0% { opacity: 1; }
3013
+ 80% { opacity: 1; }
3014
+ 100% { opacity: 0; }
3015
+ }
3016
+
3017
+ /* Item Transitions */
3018
+ @keyframes item-fade-out {
3019
+ from { opacity: 1; }
3020
+ to { opacity: 0; }
3021
+ }
3022
+ @keyframes item-fade-in {
3023
+ from { opacity: 0; }
3024
+ to { opacity: 1; }
3025
+ }
3026
+ @keyframes item-slide-out {
3027
+ from { transform: translateY(0); opacity: 1; }
3028
+ to { transform: translateY(-100%); opacity: 0; }
3029
+ }
3030
+ @keyframes item-slide-in {
3031
+ from { transform: translateY(100%); opacity: 0; }
3032
+ to { transform: translateY(0); opacity: 1; }
3033
+ }
3034
+
3035
+ /* Decorative Separator Animations */
3036
+ @keyframes separator-pulse {
3037
+ 0%, 100% { opacity: 0.8; transform: scaleY(1); }
3038
+ 50% { opacity: 1; transform: scaleY(1.1); }
3039
+ }
3040
+
3041
+ @keyframes chevron-pulse {
3042
+ 0%, 100% { transform: translateX(0); opacity: 0.8; }
3043
+ 50% { transform: translateX(3px); opacity: 1; }
3044
+ }
3045
+
3046
+ @keyframes diamond-spin {
3047
+ from { transform: rotate(0deg); }
3048
+ to { transform: rotate(360deg); }
3049
+ }
2936
3050
  `;
2937
3051
  document.head.appendChild(style);
2938
3052
  }
@@ -2951,6 +3065,775 @@ export class WebPlayer extends BasePlayer {
2951
3065
  return Math.max(totalWidth / speed, 15); // Minimum 15s for smooth scrolling
2952
3066
  }
2953
3067
 
3068
+ /**
3069
+ * Create professional TV-style ticker with two-line display and item cycling
3070
+ */
3071
+ private createProfessionalTickerElement(config: FlashNewsTickerConfig, position: 'top' | 'bottom'): HTMLDivElement {
3072
+ const broadcastStyle = config.broadcastStyle || {};
3073
+ const theme = broadcastStyle.theme || 'breaking-red';
3074
+ const twoLineConfig = broadcastStyle.twoLineDisplay || {};
3075
+ const itemCycling = config.itemCycling || {};
3076
+
3077
+ // Store config for item cycling
3078
+ this.tickerConfig = config;
3079
+ this.tickerCurrentItemIndex = 0;
3080
+
3081
+ // Get theme colors
3082
+ const themeColors = this.getBroadcastThemeColors(theme, broadcastStyle);
3083
+
3084
+ const ticker = document.createElement('div');
3085
+ ticker.className = `uvf-flash-ticker ticker-${position} ticker-professional`;
3086
+
3087
+ const bottomOffset = config.bottomOffset || 0;
3088
+ const topOffset = config.topOffset || 0;
3089
+ const headerHeight = broadcastStyle.headerHeight || 28;
3090
+
3091
+ // Calculate body height based on two-line config
3092
+ const topLineConfig = twoLineConfig.topLine || {};
3093
+ const bottomLineConfig = twoLineConfig.bottomLine || {};
3094
+ const topLineHeight = topLineConfig.minHeight || 32;
3095
+ const bottomLineHeight = bottomLineConfig.height || 28;
3096
+ const bodyHeight = twoLineConfig.enabled !== false ? topLineHeight + bottomLineHeight : (config.height || 36);
3097
+ const progressHeight = itemCycling.showProgress ? (itemCycling.progressHeight || 3) : 0;
3098
+ const totalHeight = headerHeight + bodyHeight + progressHeight;
3099
+
3100
+ ticker.style.cssText = `
3101
+ position: absolute;
3102
+ left: 0;
3103
+ right: 0;
3104
+ height: ${totalHeight}px;
3105
+ ${position === 'top' ? `top: ${topOffset}px;` : `bottom: ${bottomOffset}px;`}
3106
+ overflow: hidden;
3107
+ pointer-events: ${itemCycling.pauseOnHover !== false ? 'auto' : 'none'};
3108
+ display: flex;
3109
+ flex-direction: column;
3110
+ `;
3111
+
3112
+ // Create header row
3113
+ const header = this.createProfessionalHeader(broadcastStyle, themeColors, headerHeight);
3114
+ ticker.appendChild(header);
3115
+
3116
+ // Create body with two-line display
3117
+ const body = this.createTwoLineBody(config, twoLineConfig, themeColors.bodyBg, bodyHeight);
3118
+ ticker.appendChild(body);
3119
+
3120
+ // Create progress bar if enabled
3121
+ if (itemCycling.showProgress) {
3122
+ const progressBar = this.createProgressBar(itemCycling);
3123
+ ticker.appendChild(progressBar);
3124
+ }
3125
+
3126
+ // Create intro overlay (hidden by default)
3127
+ const introOverlay = this.createIntroOverlay(broadcastStyle);
3128
+ ticker.appendChild(introOverlay);
3129
+ this.tickerIntroOverlay = introOverlay;
3130
+
3131
+ // Add hover pause functionality
3132
+ if (itemCycling.pauseOnHover !== false && itemCycling.enabled) {
3133
+ ticker.addEventListener('mouseenter', () => this.pauseItemCycling());
3134
+ ticker.addEventListener('mouseleave', () => this.resumeItemCycling());
3135
+ }
3136
+
3137
+ // Add all animation keyframes
3138
+ this.ensureTickerAnimations();
3139
+
3140
+ // Start item cycling if enabled
3141
+ if (itemCycling.enabled && config.items && config.items.length > 1) {
3142
+ // Initial display with intro animation if configured
3143
+ const firstItem = config.items[0];
3144
+ if (firstItem.showIntro) {
3145
+ this.playIntroAnimation(firstItem).then(() => {
3146
+ this.startItemCycling();
3147
+ });
3148
+ } else {
3149
+ this.startItemCycling();
3150
+ }
3151
+ }
3152
+
3153
+ return ticker;
3154
+ }
3155
+
3156
+ /**
3157
+ * Create professional header row with globe, separator, text, and LIVE badge
3158
+ */
3159
+ private createProfessionalHeader(
3160
+ broadcastStyle: BroadcastStyleConfig,
3161
+ themeColors: { headerBg: string; bodyBg: string },
3162
+ headerHeight: number
3163
+ ): HTMLDivElement {
3164
+ const header = document.createElement('div');
3165
+ header.className = 'uvf-ticker-header-row';
3166
+ header.style.cssText = `
3167
+ display: flex;
3168
+ align-items: center;
3169
+ height: ${headerHeight}px;
3170
+ background: ${themeColors.headerBg};
3171
+ padding: 0 12px;
3172
+ position: relative;
3173
+ `;
3174
+
3175
+ // Add globe graphic if enabled
3176
+ if (broadcastStyle.showGlobe !== false) {
3177
+ const globe = this.createGlobeElement(broadcastStyle.animateGlobe !== false);
3178
+ header.appendChild(globe);
3179
+ }
3180
+
3181
+ // Add decorative separator between globe and text
3182
+ if (broadcastStyle.decorativeShapes?.headerSeparator) {
3183
+ const separator = this.createDecorativeSeparator(broadcastStyle.decorativeShapes.headerSeparator);
3184
+ header.appendChild(separator);
3185
+ } else if (broadcastStyle.showGlobe !== false) {
3186
+ // Default simple separator
3187
+ const defaultSeparator = document.createElement('div');
3188
+ defaultSeparator.className = 'uvf-ticker-separator';
3189
+ defaultSeparator.style.cssText = `
3190
+ width: 2px;
3191
+ height: 18px;
3192
+ background: rgba(255,255,255,0.6);
3193
+ margin: 0 10px;
3194
+ `;
3195
+ header.appendChild(defaultSeparator);
3196
+ }
3197
+
3198
+ // Add header text (BREAKING NEWS)
3199
+ const headerText = document.createElement('span');
3200
+ headerText.className = 'uvf-ticker-header-text';
3201
+ headerText.textContent = broadcastStyle.headerText || 'BREAKING NEWS';
3202
+ headerText.style.cssText = `
3203
+ color: ${broadcastStyle.headerTextColor || '#ffffff'};
3204
+ font-size: ${broadcastStyle.headerFontSize || 16}px;
3205
+ font-weight: 800;
3206
+ text-transform: uppercase;
3207
+ letter-spacing: 1px;
3208
+ text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
3209
+ `;
3210
+ header.appendChild(headerText);
3211
+
3212
+ // Add LIVE badge if enabled
3213
+ if (broadcastStyle.showLiveBadge !== false) {
3214
+ const liveBadge = this.createLiveBadgeElement(broadcastStyle.pulseLiveBadge !== false);
3215
+ header.appendChild(liveBadge);
3216
+ }
3217
+
3218
+ return header;
3219
+ }
3220
+
3221
+ /**
3222
+ * Create decorative separator element
3223
+ */
3224
+ private createDecorativeSeparator(config: NonNullable<DecorativeShapesConfig['headerSeparator']>): HTMLDivElement {
3225
+ const separator = document.createElement('div');
3226
+ separator.className = 'uvf-ticker-separator';
3227
+
3228
+ const width = config.width || 2;
3229
+ const height = config.height || 20;
3230
+ const color = config.color || '#ffffff';
3231
+ const animated = config.animated !== false;
3232
+ const type = config.type || 'line';
3233
+
3234
+ let animationStyle = '';
3235
+ let content = '';
3236
+
3237
+ switch (type) {
3238
+ case 'pulse-line':
3239
+ animationStyle = animated ? 'animation: separator-pulse 1.5s ease-in-out infinite;' : '';
3240
+ break;
3241
+ case 'chevron':
3242
+ content = '›';
3243
+ animationStyle = animated ? 'animation: chevron-pulse 1s ease-in-out infinite;' : '';
3244
+ break;
3245
+ case 'diamond':
3246
+ content = '◆';
3247
+ animationStyle = animated ? 'animation: diamond-spin 3s linear infinite;' : '';
3248
+ break;
3249
+ case 'dot':
3250
+ content = '•';
3251
+ animationStyle = animated ? 'animation: dot-blink 1s ease-in-out infinite;' : '';
3252
+ break;
3253
+ default:
3254
+ // Simple line
3255
+ break;
3256
+ }
3257
+
3258
+ if (type === 'line' || type === 'pulse-line') {
3259
+ separator.style.cssText = `
3260
+ width: ${width}px;
3261
+ height: ${height}px;
3262
+ background: ${color};
3263
+ margin: 0 10px;
3264
+ opacity: 0.8;
3265
+ ${animationStyle}
3266
+ `;
3267
+ } else {
3268
+ separator.textContent = content;
3269
+ separator.style.cssText = `
3270
+ color: ${color};
3271
+ font-size: ${height}px;
3272
+ margin: 0 10px;
3273
+ opacity: 0.8;
3274
+ display: flex;
3275
+ align-items: center;
3276
+ justify-content: center;
3277
+ ${animationStyle}
3278
+ `;
3279
+ }
3280
+
3281
+ return separator;
3282
+ }
3283
+
3284
+ /**
3285
+ * Create two-line body with static headline and scrolling detail
3286
+ */
3287
+ private createTwoLineBody(
3288
+ config: FlashNewsTickerConfig,
3289
+ twoLineConfig: TwoLineDisplayConfig,
3290
+ bodyBg: string,
3291
+ bodyHeight: number
3292
+ ): HTMLDivElement {
3293
+ const body = document.createElement('div');
3294
+ body.className = 'uvf-ticker-body-row';
3295
+ body.style.cssText = `
3296
+ display: flex;
3297
+ flex-direction: column;
3298
+ height: ${bodyHeight}px;
3299
+ background: ${bodyBg};
3300
+ overflow: hidden;
3301
+ position: relative;
3302
+ `;
3303
+
3304
+ const topLineConfig = twoLineConfig.topLine || {};
3305
+ const bottomLineConfig = twoLineConfig.bottomLine || {};
3306
+
3307
+ // Get first item for initial display
3308
+ const firstItem = config.items?.[0];
3309
+
3310
+ // Create headline line (top - static)
3311
+ const headlineLine = document.createElement('div');
3312
+ headlineLine.className = 'uvf-ticker-headline-line';
3313
+ this.tickerHeadlineElement = headlineLine;
3314
+
3315
+ const topLineHeight = topLineConfig.minHeight || 32;
3316
+ const topLineFontSize = topLineConfig.fontSize || 16;
3317
+ const topLineLineHeight = topLineConfig.lineHeight || 1.3;
3318
+ const topLineMultiLine = topLineConfig.multiLine !== false;
3319
+ const topLineMaxLines = topLineConfig.maxLines || 2;
3320
+
3321
+ headlineLine.style.cssText = `
3322
+ display: flex;
3323
+ align-items: center;
3324
+ justify-content: center;
3325
+ min-height: ${topLineHeight}px;
3326
+ padding: ${topLineConfig.padding || 8}px 12px;
3327
+ background: ${topLineConfig.backgroundColor || 'transparent'};
3328
+ overflow: hidden;
3329
+ text-align: center;
3330
+ `;
3331
+
3332
+ const headlineText = document.createElement('span');
3333
+ headlineText.className = 'uvf-ticker-headline-text';
3334
+
3335
+ // Support multi-line text with CSS
3336
+ headlineText.style.cssText = `
3337
+ color: ${topLineConfig.textColor || config.textColor || '#ffffff'};
3338
+ font-size: ${topLineFontSize}px;
3339
+ font-weight: 700;
3340
+ line-height: ${topLineLineHeight};
3341
+ text-align: center;
3342
+ width: 100%;
3343
+ ${topLineMultiLine ? `
3344
+ display: -webkit-box;
3345
+ -webkit-line-clamp: ${topLineMaxLines};
3346
+ -webkit-box-orient: vertical;
3347
+ overflow: hidden;
3348
+ text-overflow: ellipsis;
3349
+ white-space: normal;
3350
+ word-wrap: break-word;
3351
+ ` : `
3352
+ white-space: nowrap;
3353
+ overflow: hidden;
3354
+ text-overflow: ellipsis;
3355
+ `}
3356
+ `;
3357
+
3358
+ // Set initial headline content
3359
+ if (firstItem) {
3360
+ const headlineContent = firstItem.headline || firstItem.text;
3361
+ if (firstItem.headlineHtml) {
3362
+ headlineText.innerHTML = this.formatHeadlineText(firstItem.headlineHtml, topLineConfig, true);
3363
+ } else if (headlineContent) {
3364
+ headlineText.innerHTML = this.formatHeadlineText(headlineContent, topLineConfig, false);
3365
+ }
3366
+ }
3367
+
3368
+ headlineLine.appendChild(headlineText);
3369
+ body.appendChild(headlineLine);
3370
+
3371
+ // Add separator line between headline and detail
3372
+ if (twoLineConfig.showSeparator !== false) {
3373
+ const separatorLine = document.createElement('div');
3374
+ separatorLine.style.cssText = `
3375
+ height: 1px;
3376
+ background: ${twoLineConfig.separatorColor || 'rgba(255,255,255,0.2)'};
3377
+ margin: 0 12px;
3378
+ `;
3379
+ body.appendChild(separatorLine);
3380
+ }
3381
+
3382
+ // Create detail line (bottom - scrolling)
3383
+ const detailLine = document.createElement('div');
3384
+ detailLine.className = 'uvf-ticker-detail-line';
3385
+ this.tickerDetailElement = detailLine;
3386
+
3387
+ const bottomLineHeight = bottomLineConfig.height || 28;
3388
+ const bottomLineFontSize = bottomLineConfig.fontSize || 14;
3389
+ const bottomLineSpeed = bottomLineConfig.speed || 80;
3390
+
3391
+ detailLine.style.cssText = `
3392
+ display: flex;
3393
+ align-items: center;
3394
+ height: ${bottomLineHeight}px;
3395
+ background: ${bottomLineConfig.backgroundColor || 'transparent'};
3396
+ overflow: hidden;
3397
+ position: relative;
3398
+ `;
3399
+
3400
+ // Create scrolling track for detail
3401
+ const track = document.createElement('div');
3402
+ track.className = 'uvf-ticker-track';
3403
+
3404
+ // Calculate scroll duration based on content
3405
+ const detailText = firstItem?.text || '';
3406
+ const textWidth = detailText.length * 10; // Approximate
3407
+ const duration = Math.max(textWidth / bottomLineSpeed, 10);
3408
+
3409
+ track.style.cssText = `
3410
+ display: flex;
3411
+ white-space: nowrap;
3412
+ animation: ticker-scroll ${duration}s linear infinite;
3413
+ will-change: transform;
3414
+ padding-left: 100%;
3415
+ `;
3416
+
3417
+ // Render detail text multiple times for seamless loop
3418
+ const renderDetail = (text: string, html?: string) => {
3419
+ for (let i = 0; i < 10; i++) {
3420
+ const span = document.createElement('span');
3421
+ if (html) {
3422
+ span.innerHTML = html;
3423
+ } else {
3424
+ span.textContent = text;
3425
+ }
3426
+ span.style.cssText = `
3427
+ color: ${bottomLineConfig.textColor || config.textColor || '#ffffff'};
3428
+ font-size: ${bottomLineFontSize}px;
3429
+ font-weight: 500;
3430
+ margin-right: 100px;
3431
+ display: inline-flex;
3432
+ align-items: center;
3433
+ `;
3434
+ track.appendChild(span);
3435
+ }
3436
+ };
3437
+
3438
+ if (firstItem) {
3439
+ renderDetail(firstItem.text, firstItem.html);
3440
+ }
3441
+
3442
+ detailLine.appendChild(track);
3443
+ body.appendChild(detailLine);
3444
+
3445
+ return body;
3446
+ }
3447
+
3448
+ /**
3449
+ * Create progress bar for item cycling
3450
+ */
3451
+ private createProgressBar(itemCycling: ItemCyclingConfig): HTMLDivElement {
3452
+ const progressBar = document.createElement('div');
3453
+ progressBar.className = 'uvf-ticker-progress';
3454
+ this.tickerProgressBar = progressBar;
3455
+
3456
+ const height = itemCycling.progressHeight || 3;
3457
+ progressBar.style.cssText = `
3458
+ height: ${height}px;
3459
+ background: rgba(0,0,0,0.3);
3460
+ position: relative;
3461
+ overflow: hidden;
3462
+ `;
3463
+
3464
+ const progressFill = document.createElement('div');
3465
+ progressFill.className = 'uvf-progress-fill';
3466
+ this.tickerProgressFill = progressFill;
3467
+
3468
+ progressFill.style.cssText = `
3469
+ height: 100%;
3470
+ width: 0%;
3471
+ background: ${itemCycling.progressColor || '#ffffff'};
3472
+ transition: width 0.1s linear;
3473
+ `;
3474
+
3475
+ progressBar.appendChild(progressFill);
3476
+ return progressBar;
3477
+ }
3478
+
3479
+ /**
3480
+ * Create intro overlay for "JUST IN" / "BREAKING" animations
3481
+ */
3482
+ private createIntroOverlay(broadcastStyle: BroadcastStyleConfig): HTMLDivElement {
3483
+ const overlay = document.createElement('div');
3484
+ overlay.className = 'uvf-ticker-intro-overlay';
3485
+ overlay.style.cssText = `
3486
+ position: absolute;
3487
+ top: 0;
3488
+ left: 0;
3489
+ right: 0;
3490
+ bottom: 0;
3491
+ display: none;
3492
+ align-items: center;
3493
+ justify-content: center;
3494
+ background: rgba(200, 0, 0, 0.95);
3495
+ z-index: 10;
3496
+ `;
3497
+
3498
+ const introText = document.createElement('span');
3499
+ introText.className = 'uvf-intro-text';
3500
+ introText.style.cssText = `
3501
+ color: #ffffff;
3502
+ font-size: 24px;
3503
+ font-weight: 900;
3504
+ text-transform: uppercase;
3505
+ letter-spacing: 3px;
3506
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
3507
+ `;
3508
+ introText.textContent = 'BREAKING';
3509
+
3510
+ overlay.appendChild(introText);
3511
+ return overlay;
3512
+ }
3513
+
3514
+ /**
3515
+ * Play intro animation for a news item
3516
+ */
3517
+ private playIntroAnimation(item: FlashNewsTickerItem): Promise<void> {
3518
+ return new Promise((resolve) => {
3519
+ if (!this.tickerIntroOverlay || !item.showIntro) {
3520
+ resolve();
3521
+ return;
3522
+ }
3523
+
3524
+ const broadcastStyle = this.tickerConfig?.broadcastStyle || {};
3525
+ const animation = item.introAnimation || broadcastStyle.defaultIntroAnimation || 'slide-in';
3526
+ const duration = item.introDuration || broadcastStyle.defaultIntroDuration || 2000;
3527
+ const introText = item.introText || 'BREAKING';
3528
+
3529
+ // Update intro text
3530
+ const textElement = this.tickerIntroOverlay.querySelector('.uvf-intro-text') as HTMLSpanElement;
3531
+ if (textElement) {
3532
+ textElement.textContent = introText;
3533
+ }
3534
+
3535
+ // Apply animation
3536
+ this.tickerIntroOverlay.style.display = 'flex';
3537
+ this.tickerIntroOverlay.style.animation = `intro-${animation} ${duration}ms ease-out forwards`;
3538
+
3539
+ // Hide after animation completes
3540
+ setTimeout(() => {
3541
+ if (this.tickerIntroOverlay) {
3542
+ this.tickerIntroOverlay.style.display = 'none';
3543
+ this.tickerIntroOverlay.style.animation = '';
3544
+ }
3545
+ resolve();
3546
+ }, duration);
3547
+ });
3548
+ }
3549
+
3550
+ /**
3551
+ * Start automatic item cycling
3552
+ */
3553
+ private startItemCycling(): void {
3554
+ if (!this.tickerConfig?.items || this.tickerConfig.items.length <= 1) return;
3555
+
3556
+ const itemCycling = this.tickerConfig.itemCycling || {};
3557
+ if (!itemCycling.enabled) return;
3558
+
3559
+ const currentItem = this.tickerConfig.items[this.tickerCurrentItemIndex];
3560
+ const duration = (currentItem.duration || itemCycling.defaultDuration || 10) * 1000;
3561
+
3562
+ // Start progress animation
3563
+ this.animateProgress(duration);
3564
+
3565
+ // Set timer for next item
3566
+ this.tickerCycleTimer = window.setTimeout(() => {
3567
+ this.transitionToNextItem();
3568
+ }, duration);
3569
+ }
3570
+
3571
+ /**
3572
+ * Stop item cycling
3573
+ */
3574
+ private stopItemCycling(): void {
3575
+ if (this.tickerCycleTimer) {
3576
+ clearTimeout(this.tickerCycleTimer);
3577
+ this.tickerCycleTimer = null;
3578
+ }
3579
+ }
3580
+
3581
+ /**
3582
+ * Pause item cycling (for hover)
3583
+ */
3584
+ private pauseItemCycling(): void {
3585
+ if (!this.tickerCycleTimer || this.tickerIsPaused) return;
3586
+
3587
+ this.tickerIsPaused = true;
3588
+ this.tickerPauseStartTime = Date.now();
3589
+
3590
+ // Calculate remaining time
3591
+ const itemCycling = this.tickerConfig?.itemCycling || {};
3592
+ const currentItem = this.tickerConfig?.items?.[this.tickerCurrentItemIndex];
3593
+ const totalDuration = (currentItem?.duration || itemCycling.defaultDuration || 10) * 1000;
3594
+
3595
+ // Stop the timer
3596
+ clearTimeout(this.tickerCycleTimer);
3597
+ this.tickerCycleTimer = null;
3598
+
3599
+ // Pause progress bar animation
3600
+ if (this.tickerProgressFill) {
3601
+ const computedWidth = window.getComputedStyle(this.tickerProgressFill).width;
3602
+ this.tickerProgressFill.style.transition = 'none';
3603
+ this.tickerProgressFill.style.width = computedWidth;
3604
+ }
3605
+ }
3606
+
3607
+ /**
3608
+ * Resume item cycling (after hover)
3609
+ */
3610
+ private resumeItemCycling(): void {
3611
+ if (!this.tickerIsPaused) return;
3612
+
3613
+ this.tickerIsPaused = false;
3614
+
3615
+ // Calculate remaining time based on progress
3616
+ const itemCycling = this.tickerConfig?.itemCycling || {};
3617
+ const currentItem = this.tickerConfig?.items?.[this.tickerCurrentItemIndex];
3618
+ const totalDuration = (currentItem?.duration || itemCycling.defaultDuration || 10) * 1000;
3619
+
3620
+ // Get current progress percentage
3621
+ let remainingTime = totalDuration;
3622
+ if (this.tickerProgressFill) {
3623
+ const currentWidth = parseFloat(this.tickerProgressFill.style.width) || 0;
3624
+ remainingTime = totalDuration * (1 - currentWidth / 100);
3625
+ }
3626
+
3627
+ // Resume progress animation
3628
+ this.animateProgress(remainingTime);
3629
+
3630
+ // Set timer for remaining time
3631
+ this.tickerCycleTimer = window.setTimeout(() => {
3632
+ this.transitionToNextItem();
3633
+ }, remainingTime);
3634
+ }
3635
+
3636
+ /**
3637
+ * Animate progress bar
3638
+ */
3639
+ private animateProgress(duration: number): void {
3640
+ if (!this.tickerProgressFill) return;
3641
+
3642
+ // Reset to start
3643
+ this.tickerProgressFill.style.transition = 'none';
3644
+ this.tickerProgressFill.style.width = '0%';
3645
+
3646
+ // Force reflow
3647
+ this.tickerProgressFill.offsetHeight;
3648
+
3649
+ // Animate to 100%
3650
+ this.tickerProgressFill.style.transition = `width ${duration}ms linear`;
3651
+ this.tickerProgressFill.style.width = '100%';
3652
+ }
3653
+
3654
+ /**
3655
+ * Transition to the next news item
3656
+ */
3657
+ private async transitionToNextItem(): Promise<void> {
3658
+ if (!this.tickerConfig?.items || this.tickerConfig.items.length <= 1) return;
3659
+
3660
+ const itemCycling = this.tickerConfig.itemCycling || {};
3661
+ const transitionType = itemCycling.transitionType || 'fade';
3662
+ const transitionDuration = itemCycling.transitionDuration || 500;
3663
+
3664
+ // Move to next item
3665
+ this.tickerCurrentItemIndex = (this.tickerCurrentItemIndex + 1) % this.tickerConfig.items.length;
3666
+ const nextItem = this.tickerConfig.items[this.tickerCurrentItemIndex];
3667
+
3668
+ // Play intro animation if configured
3669
+ if (nextItem.showIntro) {
3670
+ await this.playIntroAnimation(nextItem);
3671
+ }
3672
+
3673
+ // Apply transition animation
3674
+ await this.applyItemTransition(transitionType, transitionDuration, nextItem);
3675
+
3676
+ // Continue cycling
3677
+ this.startItemCycling();
3678
+ }
3679
+
3680
+ /**
3681
+ * Apply transition animation between items
3682
+ */
3683
+ private applyItemTransition(
3684
+ transitionType: string,
3685
+ duration: number,
3686
+ item: FlashNewsTickerItem
3687
+ ): Promise<void> {
3688
+ return new Promise((resolve) => {
3689
+ const headline = this.tickerHeadlineElement?.querySelector('.uvf-ticker-headline-text') as HTMLSpanElement;
3690
+ const detailTrack = this.tickerDetailElement?.querySelector('.uvf-ticker-track') as HTMLDivElement;
3691
+
3692
+ if (!headline || !detailTrack) {
3693
+ this.updateItemContent(item);
3694
+ resolve();
3695
+ return;
3696
+ }
3697
+
3698
+ if (transitionType === 'none') {
3699
+ this.updateItemContent(item);
3700
+ resolve();
3701
+ return;
3702
+ }
3703
+
3704
+ // Apply fade or slide out animation
3705
+ const outAnimation = transitionType === 'slide' ? 'item-slide-out' : 'item-fade-out';
3706
+ const inAnimation = transitionType === 'slide' ? 'item-slide-in' : 'item-fade-in';
3707
+
3708
+ headline.style.animation = `${outAnimation} ${duration / 2}ms ease-out forwards`;
3709
+
3710
+ setTimeout(() => {
3711
+ // Update content
3712
+ this.updateItemContent(item);
3713
+
3714
+ // Apply fade or slide in animation
3715
+ headline.style.animation = `${inAnimation} ${duration / 2}ms ease-out forwards`;
3716
+
3717
+ setTimeout(() => {
3718
+ headline.style.animation = '';
3719
+ resolve();
3720
+ }, duration / 2);
3721
+ }, duration / 2);
3722
+ });
3723
+ }
3724
+
3725
+ /**
3726
+ * Format headline text with optional forced line breaks
3727
+ */
3728
+ private formatHeadlineText(text: string, topLineConfig: { forceMultiLine?: boolean; breakAt?: number | string }, isHtml: boolean = false): string {
3729
+ if (!topLineConfig.forceMultiLine) {
3730
+ // If it's HTML, return as-is; otherwise escape for safety
3731
+ return isHtml ? text : this.escapeHtml(text);
3732
+ }
3733
+
3734
+ // Calculate break position
3735
+ let breakPosition: number;
3736
+ const breakAt = topLineConfig.breakAt || '50%';
3737
+
3738
+ if (typeof breakAt === 'string' && breakAt.endsWith('%')) {
3739
+ const percentage = parseFloat(breakAt) / 100;
3740
+ breakPosition = Math.floor(text.length * percentage);
3741
+ } else {
3742
+ breakPosition = typeof breakAt === 'number' ? breakAt : Math.floor(text.length / 2);
3743
+ }
3744
+
3745
+ // Find the nearest space to break at (for better readability)
3746
+ let actualBreakPos = breakPosition;
3747
+
3748
+ // Look for a space near the break position
3749
+ const searchRange = Math.min(15, Math.floor(text.length / 4)); // Search within ~15 chars or 25% of text
3750
+ for (let i = 0; i <= searchRange; i++) {
3751
+ // Check forward first, then backward
3752
+ if (breakPosition + i < text.length && text[breakPosition + i] === ' ') {
3753
+ actualBreakPos = breakPosition + i;
3754
+ break;
3755
+ }
3756
+ if (breakPosition - i >= 0 && text[breakPosition - i] === ' ') {
3757
+ actualBreakPos = breakPosition - i;
3758
+ break;
3759
+ }
3760
+ }
3761
+
3762
+ // Split and join with <br>
3763
+ const line1 = text.substring(0, actualBreakPos).trim();
3764
+ const line2 = text.substring(actualBreakPos).trim();
3765
+
3766
+ if (isHtml) {
3767
+ // For HTML content, try to insert <br> at a reasonable position
3768
+ return `${line1}<br>${line2}`;
3769
+ }
3770
+
3771
+ return `${this.escapeHtml(line1)}<br>${this.escapeHtml(line2)}`;
3772
+ }
3773
+
3774
+ /**
3775
+ * Escape HTML special characters
3776
+ */
3777
+ private escapeHtml(text: string): string {
3778
+ const div = document.createElement('div');
3779
+ div.textContent = text;
3780
+ return div.innerHTML;
3781
+ }
3782
+
3783
+ /**
3784
+ * Update headline and detail content for current item
3785
+ */
3786
+ private updateItemContent(item: FlashNewsTickerItem): void {
3787
+ // Update headline
3788
+ const headline = this.tickerHeadlineElement?.querySelector('.uvf-ticker-headline-text') as HTMLSpanElement;
3789
+ if (headline) {
3790
+ const topLineConfig = this.tickerConfig?.broadcastStyle?.twoLineDisplay?.topLine || {};
3791
+ const headlineContent = item.headline || item.text;
3792
+
3793
+ if (item.headlineHtml) {
3794
+ headline.innerHTML = this.formatHeadlineText(item.headlineHtml, topLineConfig, true);
3795
+ } else if (headlineContent) {
3796
+ headline.innerHTML = this.formatHeadlineText(headlineContent, topLineConfig, false);
3797
+ }
3798
+ }
3799
+
3800
+ // Update detail track
3801
+ const detailTrack = this.tickerDetailElement?.querySelector('.uvf-ticker-track') as HTMLDivElement;
3802
+ if (detailTrack) {
3803
+ // Clear existing content
3804
+ detailTrack.innerHTML = '';
3805
+
3806
+ // Get style config
3807
+ const bottomLineConfig = this.tickerConfig?.broadcastStyle?.twoLineDisplay?.bottomLine || {};
3808
+ const bottomLineFontSize = bottomLineConfig.fontSize || 14;
3809
+
3810
+ // Re-render detail text
3811
+ for (let i = 0; i < 10; i++) {
3812
+ const span = document.createElement('span');
3813
+ if (item.html) {
3814
+ span.innerHTML = item.html;
3815
+ } else {
3816
+ span.textContent = item.text;
3817
+ }
3818
+ span.style.cssText = `
3819
+ color: ${bottomLineConfig.textColor || this.tickerConfig?.textColor || '#ffffff'};
3820
+ font-size: ${bottomLineFontSize}px;
3821
+ font-weight: 500;
3822
+ margin-right: 100px;
3823
+ display: inline-flex;
3824
+ align-items: center;
3825
+ `;
3826
+ detailTrack.appendChild(span);
3827
+ }
3828
+
3829
+ // Update scroll speed
3830
+ const bottomLineSpeed = bottomLineConfig.speed || 80;
3831
+ const textWidth = item.text.length * 10;
3832
+ const duration = Math.max(textWidth / bottomLineSpeed, 10);
3833
+ detailTrack.style.animation = `ticker-scroll ${duration}s linear infinite`;
3834
+ }
3835
+ }
3836
+
2954
3837
  setAutoQuality(enabled: boolean): void {
2955
3838
  this.autoQuality = enabled;
2956
3839