unified-video-framework 1.4.437 → 1.4.439

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;
@@ -172,6 +197,8 @@ export class WebPlayer extends BasePlayer {
172
197
  private currentRetryAttempt: number = 0;
173
198
  private lastFailedUrl: string = ''; // Track last failed URL to avoid duplicate error handling
174
199
  private isFallbackPosterMode: boolean = false; // True when showing fallback poster (no playable sources)
200
+ private hlsErrorRetryCount: number = 0; // Track HLS error recovery attempts
201
+ private readonly MAX_HLS_ERROR_RETRIES: number = 3; // Max HLS recovery attempts before fallback
175
202
 
176
203
  // Live stream detection
177
204
  private lastDuration: number = 0; // Track duration changes to detect live streams
@@ -727,6 +754,7 @@ export class WebPlayer extends BasePlayer {
727
754
  this.currentRetryAttempt = 0;
728
755
  this.lastFailedUrl = '';
729
756
  this.isFallbackPosterMode = false; // Reset fallback poster mode
757
+ this.hlsErrorRetryCount = 0; // Reset HLS error retry count
730
758
 
731
759
  // Clean up previous instances
732
760
  await this.cleanup();
@@ -1122,6 +1150,9 @@ export class WebPlayer extends BasePlayer {
1122
1150
  this.hls.attachMedia(this.video);
1123
1151
 
1124
1152
  this.hls.on(window.Hls.Events.MANIFEST_PARSED, (event: any, data: any) => {
1153
+ // Reset HLS error retry count on successful manifest load
1154
+ this.hlsErrorRetryCount = 0;
1155
+
1125
1156
  // Extract quality levels
1126
1157
  this.qualities = data.levels.map((level: any, index: number) => ({
1127
1158
  height: level.height,
@@ -1164,27 +1195,101 @@ export class WebPlayer extends BasePlayer {
1164
1195
  }
1165
1196
  }
1166
1197
 
1167
- private handleHLSError(data: any): void {
1198
+ private async handleHLSError(data: any): Promise<void> {
1168
1199
  const Hls = window.Hls;
1200
+
1201
+ this.debugLog(`🔴 HLS Error: type=${data.type}, details=${data.details}, fatal=${data.fatal}`);
1202
+
1203
+ // Check if we've exceeded max retry attempts
1204
+ if (this.hlsErrorRetryCount >= this.MAX_HLS_ERROR_RETRIES) {
1205
+ this.debugLog(`🔴 HLS max retries (${this.MAX_HLS_ERROR_RETRIES}) exceeded, triggering fallback`);
1206
+ this.hls?.destroy();
1207
+ this.hls = null;
1208
+
1209
+ // Try fallback sources
1210
+ const fallbackLoaded = await this.tryFallbackSource({
1211
+ code: 'HLS_ERROR',
1212
+ message: data.details,
1213
+ type: data.type,
1214
+ fatal: true,
1215
+ details: data
1216
+ });
1217
+
1218
+ if (!fallbackLoaded) {
1219
+ this.handleError({
1220
+ code: 'HLS_ERROR',
1221
+ message: `HLS stream failed after ${this.MAX_HLS_ERROR_RETRIES} retries: ${data.details}`,
1222
+ type: 'media',
1223
+ fatal: true,
1224
+ details: data
1225
+ });
1226
+ }
1227
+ return;
1228
+ }
1229
+
1169
1230
  switch (data.type) {
1170
1231
  case Hls.ErrorTypes.NETWORK_ERROR:
1171
- console.error('Fatal network error, trying to recover');
1172
- this.hls.startLoad();
1232
+ this.hlsErrorRetryCount++;
1233
+ this.debugLog(`🔴 Fatal network error (attempt ${this.hlsErrorRetryCount}/${this.MAX_HLS_ERROR_RETRIES}), trying to recover`);
1234
+
1235
+ // For manifest errors (like 404), recovery won't help - go straight to fallback
1236
+ if (data.details === 'manifestLoadError' || data.details === 'manifestParsingError') {
1237
+ this.debugLog(`🔴 Manifest error detected (${data.details}), skipping recovery - triggering fallback`);
1238
+ this.hls?.destroy();
1239
+ this.hls = null;
1240
+
1241
+ const fallbackLoaded = await this.tryFallbackSource({
1242
+ code: 'HLS_MANIFEST_ERROR',
1243
+ message: data.details,
1244
+ type: 'network',
1245
+ fatal: true,
1246
+ details: data
1247
+ });
1248
+
1249
+ if (!fallbackLoaded) {
1250
+ this.handleError({
1251
+ code: 'HLS_MANIFEST_ERROR',
1252
+ message: `Failed to load HLS manifest: ${data.details}`,
1253
+ type: 'media',
1254
+ fatal: true,
1255
+ details: data
1256
+ });
1257
+ }
1258
+ } else {
1259
+ // For other network errors, try recovery
1260
+ this.hls?.startLoad();
1261
+ }
1173
1262
  break;
1263
+
1174
1264
  case Hls.ErrorTypes.MEDIA_ERROR:
1175
- console.error('Fatal media error, trying to recover');
1176
- this.hls.recoverMediaError();
1265
+ this.hlsErrorRetryCount++;
1266
+ this.debugLog(`🔴 Fatal media error (attempt ${this.hlsErrorRetryCount}/${this.MAX_HLS_ERROR_RETRIES}), trying to recover`);
1267
+ this.hls?.recoverMediaError();
1177
1268
  break;
1269
+
1178
1270
  default:
1179
- console.error('Fatal error, cannot recover');
1180
- this.handleError({
1271
+ this.debugLog(`🔴 Fatal unrecoverable HLS error: ${data.details}`);
1272
+ this.hls?.destroy();
1273
+ this.hls = null;
1274
+
1275
+ // Try fallback sources for unrecoverable errors
1276
+ const fallbackLoaded = await this.tryFallbackSource({
1181
1277
  code: 'HLS_ERROR',
1182
1278
  message: data.details,
1183
1279
  type: 'media',
1184
1280
  fatal: true,
1185
1281
  details: data
1186
1282
  });
1187
- this.hls.destroy();
1283
+
1284
+ if (!fallbackLoaded) {
1285
+ this.handleError({
1286
+ code: 'HLS_ERROR',
1287
+ message: data.details,
1288
+ type: 'media',
1289
+ fatal: true,
1290
+ details: data
1291
+ });
1292
+ }
1188
1293
  break;
1189
1294
  }
1190
1295
  }
@@ -2470,6 +2575,11 @@ export class WebPlayer extends BasePlayer {
2470
2575
  private createTickerElement(config: FlashNewsTickerConfig, position: 'top' | 'bottom'): HTMLDivElement {
2471
2576
  // Route to broadcast style if configured
2472
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
+ }
2473
2583
  return this.createBroadcastTickerElement(config, position);
2474
2584
  }
2475
2585
 
@@ -2837,14 +2947,19 @@ export class WebPlayer extends BasePlayer {
2837
2947
  const style = document.createElement('style');
2838
2948
  style.id = 'uvf-ticker-animation';
2839
2949
  style.textContent = `
2950
+ /* Basic ticker scroll */
2840
2951
  @keyframes ticker-scroll {
2841
2952
  0% { transform: translateX(0%); }
2842
2953
  100% { transform: translateX(-100%); }
2843
2954
  }
2955
+
2956
+ /* Globe rotation */
2844
2957
  @keyframes globe-rotate {
2845
2958
  0% { transform: rotate(0deg); }
2846
2959
  100% { transform: rotate(360deg); }
2847
2960
  }
2961
+
2962
+ /* LIVE badge animations */
2848
2963
  @keyframes live-pulse {
2849
2964
  0%, 100% { opacity: 1; transform: scale(1); }
2850
2965
  50% { opacity: 0.85; transform: scale(1.02); }
@@ -2853,6 +2968,85 @@ export class WebPlayer extends BasePlayer {
2853
2968
  0%, 100% { opacity: 1; }
2854
2969
  50% { opacity: 0.3; }
2855
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
+ }
2856
3050
  `;
2857
3051
  document.head.appendChild(style);
2858
3052
  }
@@ -2871,6 +3065,715 @@ export class WebPlayer extends BasePlayer {
2871
3065
  return Math.max(totalWidth / speed, 15); // Minimum 15s for smooth scrolling
2872
3066
  }
2873
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
+ min-height: ${topLineHeight}px;
3325
+ padding: ${topLineConfig.padding || 8}px 12px;
3326
+ background: ${topLineConfig.backgroundColor || 'transparent'};
3327
+ overflow: hidden;
3328
+ ${topLineMultiLine ? '' : 'white-space: nowrap;'}
3329
+ `;
3330
+
3331
+ const headlineText = document.createElement('span');
3332
+ headlineText.className = 'uvf-ticker-headline-text';
3333
+
3334
+ // Support multi-line text with CSS
3335
+ headlineText.style.cssText = `
3336
+ color: ${topLineConfig.textColor || config.textColor || '#ffffff'};
3337
+ font-size: ${topLineFontSize}px;
3338
+ font-weight: 700;
3339
+ line-height: ${topLineLineHeight};
3340
+ ${topLineMultiLine ? `
3341
+ display: -webkit-box;
3342
+ -webkit-line-clamp: ${topLineMaxLines};
3343
+ -webkit-box-orient: vertical;
3344
+ overflow: hidden;
3345
+ text-overflow: ellipsis;
3346
+ white-space: normal;
3347
+ word-wrap: break-word;
3348
+ ` : `
3349
+ white-space: nowrap;
3350
+ overflow: hidden;
3351
+ text-overflow: ellipsis;
3352
+ `}
3353
+ `;
3354
+
3355
+ // Set initial headline content
3356
+ if (firstItem) {
3357
+ if (firstItem.headlineHtml) {
3358
+ headlineText.innerHTML = firstItem.headlineHtml;
3359
+ } else if (firstItem.headline) {
3360
+ headlineText.textContent = firstItem.headline;
3361
+ } else {
3362
+ // Fallback to text if no headline
3363
+ headlineText.textContent = firstItem.text;
3364
+ }
3365
+ }
3366
+
3367
+ headlineLine.appendChild(headlineText);
3368
+ body.appendChild(headlineLine);
3369
+
3370
+ // Add separator line between headline and detail
3371
+ if (twoLineConfig.showSeparator !== false) {
3372
+ const separatorLine = document.createElement('div');
3373
+ separatorLine.style.cssText = `
3374
+ height: 1px;
3375
+ background: ${twoLineConfig.separatorColor || 'rgba(255,255,255,0.2)'};
3376
+ margin: 0 12px;
3377
+ `;
3378
+ body.appendChild(separatorLine);
3379
+ }
3380
+
3381
+ // Create detail line (bottom - scrolling)
3382
+ const detailLine = document.createElement('div');
3383
+ detailLine.className = 'uvf-ticker-detail-line';
3384
+ this.tickerDetailElement = detailLine;
3385
+
3386
+ const bottomLineHeight = bottomLineConfig.height || 28;
3387
+ const bottomLineFontSize = bottomLineConfig.fontSize || 14;
3388
+ const bottomLineSpeed = bottomLineConfig.speed || 80;
3389
+
3390
+ detailLine.style.cssText = `
3391
+ display: flex;
3392
+ align-items: center;
3393
+ height: ${bottomLineHeight}px;
3394
+ background: ${bottomLineConfig.backgroundColor || 'transparent'};
3395
+ overflow: hidden;
3396
+ position: relative;
3397
+ `;
3398
+
3399
+ // Create scrolling track for detail
3400
+ const track = document.createElement('div');
3401
+ track.className = 'uvf-ticker-track';
3402
+
3403
+ // Calculate scroll duration based on content
3404
+ const detailText = firstItem?.text || '';
3405
+ const textWidth = detailText.length * 10; // Approximate
3406
+ const duration = Math.max(textWidth / bottomLineSpeed, 10);
3407
+
3408
+ track.style.cssText = `
3409
+ display: flex;
3410
+ white-space: nowrap;
3411
+ animation: ticker-scroll ${duration}s linear infinite;
3412
+ will-change: transform;
3413
+ padding-left: 100%;
3414
+ `;
3415
+
3416
+ // Render detail text multiple times for seamless loop
3417
+ const renderDetail = (text: string, html?: string) => {
3418
+ for (let i = 0; i < 10; i++) {
3419
+ const span = document.createElement('span');
3420
+ if (html) {
3421
+ span.innerHTML = html;
3422
+ } else {
3423
+ span.textContent = text;
3424
+ }
3425
+ span.style.cssText = `
3426
+ color: ${bottomLineConfig.textColor || config.textColor || '#ffffff'};
3427
+ font-size: ${bottomLineFontSize}px;
3428
+ font-weight: 500;
3429
+ margin-right: 100px;
3430
+ display: inline-flex;
3431
+ align-items: center;
3432
+ `;
3433
+ track.appendChild(span);
3434
+ }
3435
+ };
3436
+
3437
+ if (firstItem) {
3438
+ renderDetail(firstItem.text, firstItem.html);
3439
+ }
3440
+
3441
+ detailLine.appendChild(track);
3442
+ body.appendChild(detailLine);
3443
+
3444
+ return body;
3445
+ }
3446
+
3447
+ /**
3448
+ * Create progress bar for item cycling
3449
+ */
3450
+ private createProgressBar(itemCycling: ItemCyclingConfig): HTMLDivElement {
3451
+ const progressBar = document.createElement('div');
3452
+ progressBar.className = 'uvf-ticker-progress';
3453
+ this.tickerProgressBar = progressBar;
3454
+
3455
+ const height = itemCycling.progressHeight || 3;
3456
+ progressBar.style.cssText = `
3457
+ height: ${height}px;
3458
+ background: rgba(0,0,0,0.3);
3459
+ position: relative;
3460
+ overflow: hidden;
3461
+ `;
3462
+
3463
+ const progressFill = document.createElement('div');
3464
+ progressFill.className = 'uvf-progress-fill';
3465
+ this.tickerProgressFill = progressFill;
3466
+
3467
+ progressFill.style.cssText = `
3468
+ height: 100%;
3469
+ width: 0%;
3470
+ background: ${itemCycling.progressColor || '#ffffff'};
3471
+ transition: width 0.1s linear;
3472
+ `;
3473
+
3474
+ progressBar.appendChild(progressFill);
3475
+ return progressBar;
3476
+ }
3477
+
3478
+ /**
3479
+ * Create intro overlay for "JUST IN" / "BREAKING" animations
3480
+ */
3481
+ private createIntroOverlay(broadcastStyle: BroadcastStyleConfig): HTMLDivElement {
3482
+ const overlay = document.createElement('div');
3483
+ overlay.className = 'uvf-ticker-intro-overlay';
3484
+ overlay.style.cssText = `
3485
+ position: absolute;
3486
+ top: 0;
3487
+ left: 0;
3488
+ right: 0;
3489
+ bottom: 0;
3490
+ display: none;
3491
+ align-items: center;
3492
+ justify-content: center;
3493
+ background: rgba(200, 0, 0, 0.95);
3494
+ z-index: 10;
3495
+ `;
3496
+
3497
+ const introText = document.createElement('span');
3498
+ introText.className = 'uvf-intro-text';
3499
+ introText.style.cssText = `
3500
+ color: #ffffff;
3501
+ font-size: 24px;
3502
+ font-weight: 900;
3503
+ text-transform: uppercase;
3504
+ letter-spacing: 3px;
3505
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
3506
+ `;
3507
+ introText.textContent = 'BREAKING';
3508
+
3509
+ overlay.appendChild(introText);
3510
+ return overlay;
3511
+ }
3512
+
3513
+ /**
3514
+ * Play intro animation for a news item
3515
+ */
3516
+ private playIntroAnimation(item: FlashNewsTickerItem): Promise<void> {
3517
+ return new Promise((resolve) => {
3518
+ if (!this.tickerIntroOverlay || !item.showIntro) {
3519
+ resolve();
3520
+ return;
3521
+ }
3522
+
3523
+ const broadcastStyle = this.tickerConfig?.broadcastStyle || {};
3524
+ const animation = item.introAnimation || broadcastStyle.defaultIntroAnimation || 'slide-in';
3525
+ const duration = item.introDuration || broadcastStyle.defaultIntroDuration || 2000;
3526
+ const introText = item.introText || 'BREAKING';
3527
+
3528
+ // Update intro text
3529
+ const textElement = this.tickerIntroOverlay.querySelector('.uvf-intro-text') as HTMLSpanElement;
3530
+ if (textElement) {
3531
+ textElement.textContent = introText;
3532
+ }
3533
+
3534
+ // Apply animation
3535
+ this.tickerIntroOverlay.style.display = 'flex';
3536
+ this.tickerIntroOverlay.style.animation = `intro-${animation} ${duration}ms ease-out forwards`;
3537
+
3538
+ // Hide after animation completes
3539
+ setTimeout(() => {
3540
+ if (this.tickerIntroOverlay) {
3541
+ this.tickerIntroOverlay.style.display = 'none';
3542
+ this.tickerIntroOverlay.style.animation = '';
3543
+ }
3544
+ resolve();
3545
+ }, duration);
3546
+ });
3547
+ }
3548
+
3549
+ /**
3550
+ * Start automatic item cycling
3551
+ */
3552
+ private startItemCycling(): void {
3553
+ if (!this.tickerConfig?.items || this.tickerConfig.items.length <= 1) return;
3554
+
3555
+ const itemCycling = this.tickerConfig.itemCycling || {};
3556
+ if (!itemCycling.enabled) return;
3557
+
3558
+ const currentItem = this.tickerConfig.items[this.tickerCurrentItemIndex];
3559
+ const duration = (currentItem.duration || itemCycling.defaultDuration || 10) * 1000;
3560
+
3561
+ // Start progress animation
3562
+ this.animateProgress(duration);
3563
+
3564
+ // Set timer for next item
3565
+ this.tickerCycleTimer = window.setTimeout(() => {
3566
+ this.transitionToNextItem();
3567
+ }, duration);
3568
+ }
3569
+
3570
+ /**
3571
+ * Stop item cycling
3572
+ */
3573
+ private stopItemCycling(): void {
3574
+ if (this.tickerCycleTimer) {
3575
+ clearTimeout(this.tickerCycleTimer);
3576
+ this.tickerCycleTimer = null;
3577
+ }
3578
+ }
3579
+
3580
+ /**
3581
+ * Pause item cycling (for hover)
3582
+ */
3583
+ private pauseItemCycling(): void {
3584
+ if (!this.tickerCycleTimer || this.tickerIsPaused) return;
3585
+
3586
+ this.tickerIsPaused = true;
3587
+ this.tickerPauseStartTime = Date.now();
3588
+
3589
+ // Calculate remaining time
3590
+ const itemCycling = this.tickerConfig?.itemCycling || {};
3591
+ const currentItem = this.tickerConfig?.items?.[this.tickerCurrentItemIndex];
3592
+ const totalDuration = (currentItem?.duration || itemCycling.defaultDuration || 10) * 1000;
3593
+
3594
+ // Stop the timer
3595
+ clearTimeout(this.tickerCycleTimer);
3596
+ this.tickerCycleTimer = null;
3597
+
3598
+ // Pause progress bar animation
3599
+ if (this.tickerProgressFill) {
3600
+ const computedWidth = window.getComputedStyle(this.tickerProgressFill).width;
3601
+ this.tickerProgressFill.style.transition = 'none';
3602
+ this.tickerProgressFill.style.width = computedWidth;
3603
+ }
3604
+ }
3605
+
3606
+ /**
3607
+ * Resume item cycling (after hover)
3608
+ */
3609
+ private resumeItemCycling(): void {
3610
+ if (!this.tickerIsPaused) return;
3611
+
3612
+ this.tickerIsPaused = false;
3613
+
3614
+ // Calculate remaining time based on progress
3615
+ const itemCycling = this.tickerConfig?.itemCycling || {};
3616
+ const currentItem = this.tickerConfig?.items?.[this.tickerCurrentItemIndex];
3617
+ const totalDuration = (currentItem?.duration || itemCycling.defaultDuration || 10) * 1000;
3618
+
3619
+ // Get current progress percentage
3620
+ let remainingTime = totalDuration;
3621
+ if (this.tickerProgressFill) {
3622
+ const currentWidth = parseFloat(this.tickerProgressFill.style.width) || 0;
3623
+ remainingTime = totalDuration * (1 - currentWidth / 100);
3624
+ }
3625
+
3626
+ // Resume progress animation
3627
+ this.animateProgress(remainingTime);
3628
+
3629
+ // Set timer for remaining time
3630
+ this.tickerCycleTimer = window.setTimeout(() => {
3631
+ this.transitionToNextItem();
3632
+ }, remainingTime);
3633
+ }
3634
+
3635
+ /**
3636
+ * Animate progress bar
3637
+ */
3638
+ private animateProgress(duration: number): void {
3639
+ if (!this.tickerProgressFill) return;
3640
+
3641
+ // Reset to start
3642
+ this.tickerProgressFill.style.transition = 'none';
3643
+ this.tickerProgressFill.style.width = '0%';
3644
+
3645
+ // Force reflow
3646
+ this.tickerProgressFill.offsetHeight;
3647
+
3648
+ // Animate to 100%
3649
+ this.tickerProgressFill.style.transition = `width ${duration}ms linear`;
3650
+ this.tickerProgressFill.style.width = '100%';
3651
+ }
3652
+
3653
+ /**
3654
+ * Transition to the next news item
3655
+ */
3656
+ private async transitionToNextItem(): Promise<void> {
3657
+ if (!this.tickerConfig?.items || this.tickerConfig.items.length <= 1) return;
3658
+
3659
+ const itemCycling = this.tickerConfig.itemCycling || {};
3660
+ const transitionType = itemCycling.transitionType || 'fade';
3661
+ const transitionDuration = itemCycling.transitionDuration || 500;
3662
+
3663
+ // Move to next item
3664
+ this.tickerCurrentItemIndex = (this.tickerCurrentItemIndex + 1) % this.tickerConfig.items.length;
3665
+ const nextItem = this.tickerConfig.items[this.tickerCurrentItemIndex];
3666
+
3667
+ // Play intro animation if configured
3668
+ if (nextItem.showIntro) {
3669
+ await this.playIntroAnimation(nextItem);
3670
+ }
3671
+
3672
+ // Apply transition animation
3673
+ await this.applyItemTransition(transitionType, transitionDuration, nextItem);
3674
+
3675
+ // Continue cycling
3676
+ this.startItemCycling();
3677
+ }
3678
+
3679
+ /**
3680
+ * Apply transition animation between items
3681
+ */
3682
+ private applyItemTransition(
3683
+ transitionType: string,
3684
+ duration: number,
3685
+ item: FlashNewsTickerItem
3686
+ ): Promise<void> {
3687
+ return new Promise((resolve) => {
3688
+ const headline = this.tickerHeadlineElement?.querySelector('.uvf-ticker-headline-text') as HTMLSpanElement;
3689
+ const detailTrack = this.tickerDetailElement?.querySelector('.uvf-ticker-track') as HTMLDivElement;
3690
+
3691
+ if (!headline || !detailTrack) {
3692
+ this.updateItemContent(item);
3693
+ resolve();
3694
+ return;
3695
+ }
3696
+
3697
+ if (transitionType === 'none') {
3698
+ this.updateItemContent(item);
3699
+ resolve();
3700
+ return;
3701
+ }
3702
+
3703
+ // Apply fade or slide out animation
3704
+ const outAnimation = transitionType === 'slide' ? 'item-slide-out' : 'item-fade-out';
3705
+ const inAnimation = transitionType === 'slide' ? 'item-slide-in' : 'item-fade-in';
3706
+
3707
+ headline.style.animation = `${outAnimation} ${duration / 2}ms ease-out forwards`;
3708
+
3709
+ setTimeout(() => {
3710
+ // Update content
3711
+ this.updateItemContent(item);
3712
+
3713
+ // Apply fade or slide in animation
3714
+ headline.style.animation = `${inAnimation} ${duration / 2}ms ease-out forwards`;
3715
+
3716
+ setTimeout(() => {
3717
+ headline.style.animation = '';
3718
+ resolve();
3719
+ }, duration / 2);
3720
+ }, duration / 2);
3721
+ });
3722
+ }
3723
+
3724
+ /**
3725
+ * Update headline and detail content for current item
3726
+ */
3727
+ private updateItemContent(item: FlashNewsTickerItem): void {
3728
+ // Update headline
3729
+ const headline = this.tickerHeadlineElement?.querySelector('.uvf-ticker-headline-text') as HTMLSpanElement;
3730
+ if (headline) {
3731
+ if (item.headlineHtml) {
3732
+ headline.innerHTML = item.headlineHtml;
3733
+ } else if (item.headline) {
3734
+ headline.textContent = item.headline;
3735
+ } else {
3736
+ headline.textContent = item.text;
3737
+ }
3738
+ }
3739
+
3740
+ // Update detail track
3741
+ const detailTrack = this.tickerDetailElement?.querySelector('.uvf-ticker-track') as HTMLDivElement;
3742
+ if (detailTrack) {
3743
+ // Clear existing content
3744
+ detailTrack.innerHTML = '';
3745
+
3746
+ // Get style config
3747
+ const bottomLineConfig = this.tickerConfig?.broadcastStyle?.twoLineDisplay?.bottomLine || {};
3748
+ const bottomLineFontSize = bottomLineConfig.fontSize || 14;
3749
+
3750
+ // Re-render detail text
3751
+ for (let i = 0; i < 10; i++) {
3752
+ const span = document.createElement('span');
3753
+ if (item.html) {
3754
+ span.innerHTML = item.html;
3755
+ } else {
3756
+ span.textContent = item.text;
3757
+ }
3758
+ span.style.cssText = `
3759
+ color: ${bottomLineConfig.textColor || this.tickerConfig?.textColor || '#ffffff'};
3760
+ font-size: ${bottomLineFontSize}px;
3761
+ font-weight: 500;
3762
+ margin-right: 100px;
3763
+ display: inline-flex;
3764
+ align-items: center;
3765
+ `;
3766
+ detailTrack.appendChild(span);
3767
+ }
3768
+
3769
+ // Update scroll speed
3770
+ const bottomLineSpeed = bottomLineConfig.speed || 80;
3771
+ const textWidth = item.text.length * 10;
3772
+ const duration = Math.max(textWidth / bottomLineSpeed, 10);
3773
+ detailTrack.style.animation = `ticker-scroll ${duration}s linear infinite`;
3774
+ }
3775
+ }
3776
+
2874
3777
  setAutoQuality(enabled: boolean): void {
2875
3778
  this.autoQuality = enabled;
2876
3779