saltfish 0.3.86 → 0.3.91

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.
@@ -699,32 +699,8 @@ class PlayerStateMachine {
699
699
  this.config = config;
700
700
  this.currentState = config.initial;
701
701
  this.context = initialContext;
702
- this.setupDefaultActions();
703
702
  this.runEntryActions(this.currentState);
704
703
  }
705
- /**
706
- * Set up default action handlers for common operations
707
- */
708
- setupDefaultActions() {
709
- this.actionHandlers = {
710
- ...this.actionHandlers,
711
- logStateEntry: (_context) => {
712
- log(`State Machine: Entered ${this.currentState} state`);
713
- },
714
- logErrorEvent: (_context, event) => {
715
- if ((event == null ? void 0 : event.type) === "ERROR") {
716
- log(`State Machine: ERROR event received with message: ${event.error.message}`);
717
- }
718
- },
719
- logStepTransition: (_context, event) => {
720
- if ((event == null ? void 0 : event.type) === "TRANSITION_TO_STEP") {
721
- log(`State Machine: Transitioning to new step: ${event.step.id}`);
722
- }
723
- },
724
- logErrorRecovery: () => {
725
- }
726
- };
727
- }
728
704
  /**
729
705
  * Register custom action handlers
730
706
  * @param actions - Object mapping action names to handler functions
@@ -833,18 +809,14 @@ const playerStateMachineConfig = {
833
809
  on: {
834
810
  "INITIALIZE": { target: "idle" },
835
811
  "LOAD_MANIFEST": { target: "loading" }
836
- },
837
- entry: ["logStateEntry"]
812
+ }
838
813
  },
839
814
  "loading": {
840
815
  on: {
841
816
  "MANIFEST_LOADED": { target: "paused" },
842
- "ERROR": {
843
- target: "error",
844
- actions: ["logErrorEvent"]
845
- }
817
+ "ERROR": { target: "error" }
846
818
  },
847
- entry: ["logStateEntry", "showLoadingState"],
819
+ entry: ["showLoadingState"],
848
820
  exit: ["hideLoadingState"]
849
821
  },
850
822
  "playing": {
@@ -853,15 +825,12 @@ const playerStateMachineConfig = {
853
825
  "MINIMIZE": { target: "minimized" },
854
826
  "VIDEO_FINISHED_WAIT": { target: "waitingForInteraction" },
855
827
  "AUTOPLAY_FALLBACK": { target: "autoplayBlocked" },
856
- "TRANSITION_TO_STEP": {
857
- target: "playing",
858
- actions: ["logStepTransition"]
859
- },
828
+ "TRANSITION_TO_STEP": { target: "playing" },
860
829
  "ERROR": { target: "error" },
861
830
  "COMPLETE_PLAYLIST": { target: "completed" },
862
831
  "COMPLETE_PLAYLIST_WAITING_FOR_INTERACTION": { target: "completedWaitingForInteraction" }
863
832
  },
864
- entry: ["logStateEntry", "startVideoPlayback", "showVideoControls", "hidePlayButton"],
833
+ entry: ["startVideoPlayback", "showVideoControls", "hidePlayButton"],
865
834
  exit: ["pauseVideoPlayback"]
866
835
  },
867
836
  "paused": {
@@ -870,19 +839,16 @@ const playerStateMachineConfig = {
870
839
  "START_IDLE_MODE": { target: "idleMode" },
871
840
  "AUTOPLAY_FALLBACK": { target: "autoplayBlocked" },
872
841
  "MINIMIZE": { target: "minimized" },
873
- "TRANSITION_TO_STEP": {
874
- target: "playing",
875
- actions: ["logStepTransition"]
876
- }
842
+ "TRANSITION_TO_STEP": { target: "playing" }
877
843
  },
878
- entry: ["logStateEntry", "pauseVideoPlayback", "showPlayButton"]
844
+ entry: ["pauseVideoPlayback", "showPlayButton"]
879
845
  },
880
846
  "minimized": {
881
847
  on: {
882
848
  "MAXIMIZE": { target: "playing" },
883
849
  "EXIT": { target: "closing" }
884
850
  },
885
- entry: ["logStateEntry", "pauseVideoPlayback"]
851
+ entry: ["pauseVideoPlayback"]
886
852
  },
887
853
  "waitingForInteraction": {
888
854
  on: {
@@ -891,12 +857,9 @@ const playerStateMachineConfig = {
891
857
  "MINIMIZE": { target: "minimized" },
892
858
  "COMPLETE_PLAYLIST": { target: "completed" },
893
859
  "COMPLETE_PLAYLIST_WAITING_FOR_INTERACTION": { target: "completedWaitingForInteraction" },
894
- "TRANSITION_TO_STEP": {
895
- target: "playing",
896
- actions: ["logStepTransition"]
897
- }
860
+ "TRANSITION_TO_STEP": { target: "playing" }
898
861
  },
899
- entry: ["logStateEntry", "showPlayButton"]
862
+ entry: ["showPlayButton"]
900
863
  },
901
864
  "autoplayBlocked": {
902
865
  on: {
@@ -904,7 +867,7 @@ const playerStateMachineConfig = {
904
867
  "TRANSITION_TO_STEP": { target: "playing" },
905
868
  "MINIMIZE": { target: "minimized" }
906
869
  },
907
- entry: ["logStateEntry", "enterCompactMode", "startMutedLoopedVideo", "hideVideoControls", "showPlayButton", "enablePlayButtonProminent"],
870
+ entry: ["enterCompactMode", "startMutedLoopedVideo", "hideVideoControls", "showPlayButton", "enablePlayButtonProminent"],
908
871
  exit: ["disablePlayButtonProminent", "showVideoControls", "exitCompactMode"]
909
872
  },
910
873
  "idleMode": {
@@ -913,43 +876,37 @@ const playerStateMachineConfig = {
913
876
  "TRANSITION_TO_STEP": { target: "playing" },
914
877
  "MINIMIZE": { target: "minimized" }
915
878
  },
916
- entry: ["logStateEntry", "startIdleModeVideo", "hideVideoControls", "showPlayButton", "enablePlayButtonProminent"],
879
+ entry: ["startIdleModeVideo", "hideVideoControls", "showPlayButton", "enablePlayButtonProminent"],
917
880
  exit: ["disablePlayButtonProminent", "showVideoControls", "exitCompactMode"]
918
881
  },
919
882
  "error": {
920
883
  on: {
921
884
  "INITIALIZE": { target: "idle" },
922
- "PLAY": {
923
- target: "playing",
924
- actions: ["logErrorRecovery"]
925
- },
885
+ "PLAY": { target: "playing" },
926
886
  "AUTOPLAY_FALLBACK": { target: "autoplayBlocked" }
927
887
  },
928
- entry: ["logStateEntry", "handleError", "showPlayButton"],
888
+ entry: ["handleError", "showPlayButton"],
929
889
  exit: ["hideError"]
930
890
  },
931
891
  "completedWaitingForInteraction": {
932
892
  on: {
933
893
  "COMPLETE_PLAYLIST": { target: "completed" },
934
894
  "INITIALIZE": { target: "idle" },
935
- "TRANSITION_TO_STEP": {
936
- target: "playing",
937
- actions: ["logStepTransition"]
938
- },
895
+ "TRANSITION_TO_STEP": { target: "playing" },
939
896
  "PLAY": { target: "playing" },
940
897
  "MINIMIZE": { target: "minimized" }
941
898
  },
942
- entry: ["logStateEntry", "showPlayButton"]
899
+ entry: ["showPlayButton"]
943
900
  },
944
901
  "completed": {
945
902
  on: {
946
903
  "INITIALIZE": { target: "idle" }
947
904
  },
948
- entry: ["logStateEntry", "trackPlaylistComplete"]
905
+ entry: ["trackPlaylistComplete"]
949
906
  },
950
907
  "closing": {
951
908
  on: {},
952
- entry: ["logStateEntry", "triggerPlaylistDismissed"]
909
+ entry: ["triggerPlaylistDismissed"]
953
910
  }
954
911
  }
955
912
  };
@@ -1212,6 +1169,13 @@ const saltfishStore = createStore()(
1212
1169
  const newState = _stateMachine.send(event);
1213
1170
  return newState;
1214
1171
  };
1172
+ const dispatch = (event) => {
1173
+ const newState = _stateMachine.send(event);
1174
+ set2((state) => {
1175
+ state.currentState = newState;
1176
+ });
1177
+ return newState;
1178
+ };
1215
1179
  return {
1216
1180
  // State
1217
1181
  config: null,
@@ -1273,7 +1237,51 @@ const saltfishStore = createStore()(
1273
1237
  },
1274
1238
  setUserData: (userData) => {
1275
1239
  set2((state) => {
1276
- state.userData = userData;
1240
+ var _a;
1241
+ const incoming = (userData == null ? void 0 : userData.watchedPlaylists) || {};
1242
+ const existing = ((_a = state.userData) == null ? void 0 : _a.watchedPlaylists) || {};
1243
+ const merged = { ...incoming };
1244
+ for (const [playlistId, local] of Object.entries(existing)) {
1245
+ if (!local) {
1246
+ continue;
1247
+ }
1248
+ const next = merged[playlistId];
1249
+ const localTerminal = local.status === "completed" || local.status === "dismissed";
1250
+ const nextTerminal = !!next && (next.status === "completed" || next.status === "dismissed");
1251
+ if (localTerminal && !nextTerminal) {
1252
+ merged[playlistId] = local;
1253
+ log(`Store: setUserData preserved local '${local.status}' status for playlist ${playlistId} (incoming was '${(next == null ? void 0 : next.status) ?? "absent"}')`);
1254
+ } else if (next) {
1255
+ const visitCount = Math.max(local.visitCount ?? 0, next.visitCount ?? 0);
1256
+ if ((next.visitCount ?? 0) !== visitCount) {
1257
+ merged[playlistId] = { ...next, visitCount };
1258
+ }
1259
+ }
1260
+ }
1261
+ state.userData = { ...userData, watchedPlaylists: merged };
1262
+ });
1263
+ },
1264
+ updateWatchedPlaylist: (playlistId, status, currentStepId) => {
1265
+ set2((state) => {
1266
+ if (!state.userData) {
1267
+ state.userData = { watchedPlaylists: {} };
1268
+ }
1269
+ if (!state.userData.watchedPlaylists) {
1270
+ state.userData.watchedPlaylists = {};
1271
+ }
1272
+ const existing = state.userData.watchedPlaylists[playlistId];
1273
+ const previousStatus = existing == null ? void 0 : existing.status;
1274
+ const previousVisitCount = (existing == null ? void 0 : existing.visitCount) ?? 0;
1275
+ const completesVisit = (status === "completed" || status === "dismissed") && previousStatus !== status;
1276
+ const now = Date.now();
1277
+ state.userData.watchedPlaylists[playlistId] = {
1278
+ status,
1279
+ // Completed playlists restart from the beginning, so they keep no step.
1280
+ currentStepId: status === "completed" ? null : currentStepId ?? state.currentStepId ?? null,
1281
+ timestamp: now,
1282
+ lastProgressAt: now,
1283
+ visitCount: completesVisit ? previousVisitCount + 1 : previousVisitCount
1284
+ };
1277
1285
  });
1278
1286
  },
1279
1287
  setManifest: (manifest, startStepId) => {
@@ -1319,9 +1327,7 @@ const saltfishStore = createStore()(
1319
1327
  goToStep: (stepId) => {
1320
1328
  const { manifest } = get();
1321
1329
  if (stepId === "completed") {
1322
- set2((state) => {
1323
- state.currentState = transitionState({ type: "COMPLETE_PLAYLIST" });
1324
- });
1330
+ dispatch({ type: "COMPLETE_PLAYLIST" });
1325
1331
  return;
1326
1332
  }
1327
1333
  if (manifest && manifest.steps.some((step) => step.id === stepId)) {
@@ -1406,9 +1412,7 @@ const saltfishStore = createStore()(
1406
1412
  });
1407
1413
  },
1408
1414
  completePlaylist: () => {
1409
- set2((state) => {
1410
- state.currentState = transitionState({ type: "COMPLETE_PLAYLIST" });
1411
- });
1415
+ dispatch({ type: "COMPLETE_PLAYLIST" });
1412
1416
  },
1413
1417
  // Add new method to reset playlist state while preserving config and user data
1414
1418
  resetForNewPlaylist: () => {
@@ -1445,9 +1449,7 @@ const saltfishStore = createStore()(
1445
1449
  },
1446
1450
  // Method to send events to the state machine without exposing it
1447
1451
  sendStateMachineEvent: (event) => {
1448
- set2((state) => {
1449
- state.currentState = transitionState(event);
1450
- });
1452
+ dispatch(event);
1451
1453
  },
1452
1454
  // New action to update progress when transitioning to completion waiting state
1453
1455
  updateProgressWithCompletion: (playlistId, currentStepId) => {
@@ -3222,14 +3224,110 @@ class PlaylistOrchestrator {
3222
3224
  destroy() {
3223
3225
  }
3224
3226
  }
3227
+ class CursorAnimationScheduler {
3228
+ constructor(managers, logPrefix) {
3229
+ __publicField(this, "listener", null);
3230
+ __publicField(this, "videoElement", null);
3231
+ __publicField(this, "stepId", null);
3232
+ this.managers = managers;
3233
+ this.logPrefix = logPrefix;
3234
+ }
3235
+ /**
3236
+ * Cleans up the active cursor animation time listener.
3237
+ * @param stepId - Optional step ID to verify we're cleaning up the right listener
3238
+ */
3239
+ cleanup(stepId) {
3240
+ if (stepId && this.stepId && stepId !== this.stepId) {
3241
+ log(`${this.logPrefix}: Skipping cleanup - stepId mismatch (requested: ${stepId}, current: ${this.stepId})`);
3242
+ return;
3243
+ }
3244
+ if (this.listener && this.videoElement) {
3245
+ this.videoElement.removeEventListener("timeupdate", this.listener);
3246
+ this.listener = null;
3247
+ this.videoElement = null;
3248
+ this.stepId = null;
3249
+ }
3250
+ }
3251
+ /**
3252
+ * Schedules a cursor animation to run either immediately or at a specific video time.
3253
+ */
3254
+ schedule(animation, stepId) {
3255
+ this.cleanup();
3256
+ let showAtSeconds = animation.showAtSeconds ?? 0;
3257
+ if (typeof showAtSeconds !== "number" || !isFinite(showAtSeconds)) {
3258
+ log(`${this.logPrefix}: Invalid showAtSeconds value (${showAtSeconds}), defaulting to 0`);
3259
+ showAtSeconds = 0;
3260
+ }
3261
+ if (showAtSeconds < 0) {
3262
+ log(`${this.logPrefix}: Negative showAtSeconds value (${showAtSeconds}), treating as immediate (0)`);
3263
+ showAtSeconds = 0;
3264
+ }
3265
+ if (showAtSeconds <= 0) {
3266
+ log(`${this.logPrefix}: Starting immediate cursor animation for step ${stepId}`);
3267
+ this.managers.cursorManager.animate(animation);
3268
+ return;
3269
+ }
3270
+ log(`${this.logPrefix}: Scheduling cursor animation for step ${stepId} at ${showAtSeconds} seconds`);
3271
+ const videoElement = this.managers.videoManager.getVideoElement();
3272
+ if (!videoElement) {
3273
+ log(`${this.logPrefix}: Warning - No video element found, cannot schedule delayed cursor animation`);
3274
+ return;
3275
+ }
3276
+ let animationTriggered = false;
3277
+ let warningLogged = false;
3278
+ const timeUpdateHandler = () => {
3279
+ if (animationTriggered) {
3280
+ return;
3281
+ }
3282
+ const currentTime = videoElement.currentTime;
3283
+ const duration = videoElement.duration;
3284
+ if (duration && !isNaN(duration) && showAtSeconds > duration && !warningLogged) {
3285
+ log(`${this.logPrefix}: showAtSeconds (${showAtSeconds}s) exceeds video duration (${duration}s) for step ${stepId}. Triggering animation immediately.`);
3286
+ warningLogged = true;
3287
+ animationTriggered = true;
3288
+ this.cleanup();
3289
+ this.managers.cursorManager.animate(animation);
3290
+ return;
3291
+ }
3292
+ if (currentTime >= showAtSeconds) {
3293
+ animationTriggered = true;
3294
+ log(`${this.logPrefix}: Triggering cursor animation at ${currentTime} seconds`);
3295
+ this.cleanup();
3296
+ this.managers.cursorManager.animate(animation);
3297
+ }
3298
+ };
3299
+ const endedHandler = () => {
3300
+ if (animationTriggered) {
3301
+ return;
3302
+ }
3303
+ const store = getSaltfishStore();
3304
+ if (store.currentStepId !== stepId) {
3305
+ log(`${this.logPrefix}: Video ended but step changed (was ${stepId}, now ${store.currentStepId}). Not triggering cursor animation.`);
3306
+ this.cleanup(stepId);
3307
+ return;
3308
+ }
3309
+ const duration = videoElement.duration;
3310
+ log(`${this.logPrefix}: Video ended before cursor animation could trigger (showAtSeconds: ${showAtSeconds}s, duration: ${duration}s). Triggering at end.`);
3311
+ animationTriggered = true;
3312
+ this.managers.cursorManager.animate(animation);
3313
+ this.cleanup();
3314
+ };
3315
+ this.listener = () => {
3316
+ timeUpdateHandler();
3317
+ };
3318
+ this.videoElement = videoElement;
3319
+ this.stepId = stepId;
3320
+ videoElement.addEventListener("timeupdate", this.listener);
3321
+ videoElement.addEventListener("ended", endedHandler, { once: true });
3322
+ }
3323
+ }
3225
3324
  class StateMachineActionHandler {
3226
3325
  constructor(managers) {
3227
3326
  __publicField(this, "managers");
3228
3327
  __publicField(this, "destroyCallback", null);
3229
- __publicField(this, "cursorAnimationListener", null);
3230
- __publicField(this, "cursorAnimationVideoElement", null);
3231
- __publicField(this, "cursorAnimationStepId", null);
3328
+ __publicField(this, "cursorScheduler");
3232
3329
  this.managers = managers;
3330
+ this.cursorScheduler = new CursorAnimationScheduler(managers, "StateMachineActionHandler");
3233
3331
  }
3234
3332
  /**
3235
3333
  * Set the destroy callback for player destruction
@@ -3243,63 +3341,25 @@ class StateMachineActionHandler {
3243
3341
  registerStateMachineActions() {
3244
3342
  const store = getSaltfishStore();
3245
3343
  store.registerStateMachineActions({
3246
- startVideoPlayback: (context) => {
3247
- this.handleStartVideoPlayback(context);
3248
- },
3249
- pauseVideoPlayback: () => {
3250
- this.handlePauseVideoPlayback();
3251
- },
3252
- startMutedLoopedVideo: () => {
3253
- this.handleStartMutedLoopedVideo();
3254
- },
3255
- startIdleModeVideo: (context) => {
3256
- this.handleStartIdleModeVideo(context);
3257
- },
3258
- trackPlaylistComplete: () => {
3259
- this.handleTrackPlaylistComplete();
3260
- },
3261
- handleError: (context) => {
3262
- this.handleError(context);
3263
- },
3264
- hideError: () => {
3265
- this.handleHideError();
3266
- },
3267
- showLoadingState: () => {
3268
- this.handleShowLoadingState();
3269
- },
3270
- hideLoadingState: () => {
3271
- this.handleHideLoadingState();
3272
- },
3273
- hideVideoControls: () => {
3274
- this.handleHideVideoControls();
3275
- },
3276
- showVideoControls: () => {
3277
- this.handleShowVideoControls();
3278
- },
3279
- showPlayButton: () => {
3280
- this.handleShowPlayButton();
3281
- },
3282
- hidePlayButton: () => {
3283
- this.handleHidePlayButton();
3284
- },
3285
- enablePlayButtonProminent: () => {
3286
- this.handleEnablePlayButtonProminent();
3287
- },
3288
- disablePlayButtonProminent: () => {
3289
- this.handleDisablePlayButtonProminent();
3290
- },
3291
- enterCompactMode: () => {
3292
- this.handleEnterCompactMode();
3293
- },
3294
- exitCompactMode: () => {
3295
- this.handleExitCompactMode();
3296
- },
3297
- triggerPlaylistDismissed: () => {
3298
- this.handleTriggerPlaylistDismissed();
3299
- },
3300
- scheduleDestroy: () => {
3301
- this.handleScheduleDestroy();
3302
- }
3344
+ startVideoPlayback: (context) => this.handleStartVideoPlayback(context),
3345
+ startIdleModeVideo: (context) => this.handleStartIdleModeVideo(context),
3346
+ handleError: (context) => this.handleError(context),
3347
+ pauseVideoPlayback: () => this.handlePauseVideoPlayback(),
3348
+ startMutedLoopedVideo: () => this.handleStartMutedLoopedVideo(),
3349
+ trackPlaylistComplete: () => this.handleTrackPlaylistComplete(),
3350
+ hideError: () => this.handleHideError(),
3351
+ showLoadingState: () => this.handleShowLoadingState(),
3352
+ hideLoadingState: () => this.handleHideLoadingState(),
3353
+ hideVideoControls: () => this.handleHideVideoControls(),
3354
+ showVideoControls: () => this.handleShowVideoControls(),
3355
+ showPlayButton: () => this.handleShowPlayButton(),
3356
+ hidePlayButton: () => this.handleHidePlayButton(),
3357
+ enablePlayButtonProminent: () => this.handleEnablePlayButtonProminent(),
3358
+ disablePlayButtonProminent: () => this.handleDisablePlayButtonProminent(),
3359
+ enterCompactMode: () => this.handleEnterCompactMode(),
3360
+ exitCompactMode: () => this.handleExitCompactMode(),
3361
+ triggerPlaylistDismissed: () => this.handleTriggerPlaylistDismissed(),
3362
+ scheduleDestroy: () => this.handleScheduleDestroy()
3303
3363
  });
3304
3364
  }
3305
3365
  /**
@@ -3367,87 +3427,7 @@ class StateMachineActionHandler {
3367
3427
  return null;
3368
3428
  }
3369
3429
  destroy() {
3370
- this.cleanupCursorAnimationListener();
3371
- }
3372
- /**
3373
- * Cleans up the active cursor animation time listener
3374
- * @param stepId - Optional step ID to verify we're cleaning up the right listener
3375
- */
3376
- cleanupCursorAnimationListener(stepId) {
3377
- if (stepId && this.cursorAnimationStepId && stepId !== this.cursorAnimationStepId) {
3378
- log(`StateMachineActionHandler: Skipping cleanup - stepId mismatch (requested: ${stepId}, current: ${this.cursorAnimationStepId})`);
3379
- return;
3380
- }
3381
- if (this.cursorAnimationListener && this.cursorAnimationVideoElement) {
3382
- this.cursorAnimationVideoElement.removeEventListener("timeupdate", this.cursorAnimationListener);
3383
- this.cursorAnimationListener = null;
3384
- this.cursorAnimationVideoElement = null;
3385
- this.cursorAnimationStepId = null;
3386
- }
3387
- }
3388
- /**
3389
- * Schedules a cursor animation to run either immediately or at a specific video time
3390
- * Note: Works for both video and audio-only steps (audio files use the same video element)
3391
- */
3392
- scheduleCursorAnimation(animation, stepId) {
3393
- this.cleanupCursorAnimationListener();
3394
- let showAtSeconds = animation.showAtSeconds ?? 0;
3395
- if (typeof showAtSeconds !== "number" || !isFinite(showAtSeconds)) {
3396
- showAtSeconds = 0;
3397
- }
3398
- if (showAtSeconds < 0) {
3399
- showAtSeconds = 0;
3400
- }
3401
- if (showAtSeconds <= 0) {
3402
- this.managers.cursorManager.animate(animation);
3403
- } else {
3404
- const videoElement = this.managers.videoManager.getVideoElement();
3405
- if (!videoElement) {
3406
- return;
3407
- }
3408
- let animationTriggered = false;
3409
- let warningLogged = false;
3410
- const timeUpdateHandler = () => {
3411
- if (animationTriggered) {
3412
- return;
3413
- }
3414
- const currentTime = videoElement.currentTime;
3415
- const duration = videoElement.duration;
3416
- if (duration && !isNaN(duration) && showAtSeconds > duration && !warningLogged) {
3417
- warningLogged = true;
3418
- animationTriggered = true;
3419
- this.cleanupCursorAnimationListener();
3420
- this.managers.cursorManager.animate(animation);
3421
- return;
3422
- }
3423
- if (currentTime >= showAtSeconds) {
3424
- animationTriggered = true;
3425
- this.cleanupCursorAnimationListener();
3426
- this.managers.cursorManager.animate(animation);
3427
- }
3428
- };
3429
- const endedHandler = () => {
3430
- if (!animationTriggered) {
3431
- const store = getSaltfishStore();
3432
- if (store.currentStepId !== stepId) {
3433
- log(`StateMachineActionHandler: Video ended but step changed (was ${stepId}, now ${store.currentStepId}). Not triggering cursor animation.`);
3434
- this.cleanupCursorAnimationListener(stepId);
3435
- return;
3436
- }
3437
- videoElement.duration;
3438
- animationTriggered = true;
3439
- this.managers.cursorManager.animate(animation);
3440
- this.cleanupCursorAnimationListener();
3441
- }
3442
- };
3443
- this.cursorAnimationListener = () => {
3444
- timeUpdateHandler();
3445
- };
3446
- this.cursorAnimationVideoElement = videoElement;
3447
- this.cursorAnimationStepId = stepId;
3448
- videoElement.addEventListener("timeupdate", this.cursorAnimationListener);
3449
- videoElement.addEventListener("ended", endedHandler, { once: true });
3450
- }
3430
+ this.cursorScheduler.cleanup();
3451
3431
  }
3452
3432
  /**
3453
3433
  * Validates URL requirement for a specific step with retry logic
@@ -3494,158 +3474,155 @@ class StateMachineActionHandler {
3494
3474
  this.managers.uiManager.showPlayer();
3495
3475
  const videoUrl = this.getVideoUrl(currentStep);
3496
3476
  const isAudioFallback = this.isUsingAudioFallback(currentStep);
3497
- if (isAudioFallback) {
3498
- const store = getSaltfishStore();
3499
- const manifest = store.manifest;
3500
- const posterUrl = currentStep.gifUrl;
3501
- const avatarThumbnailUrl = manifest == null ? void 0 : manifest.avatarThumbnailUrl;
3502
- this.managers.videoManager.showAudioFallbackOverlay(posterUrl, avatarThumbnailUrl);
3503
- } else {
3504
- this.managers.videoManager.hideAudioFallbackOverlay();
3505
- }
3477
+ this.showOrHideAudioOverlay(currentStep, isAudioFallback);
3506
3478
  try {
3507
3479
  this.managers.interactionManager.clearButtons();
3508
3480
  if (currentStep.buttons) {
3509
3481
  this.managers.interactionManager.createButtons(currentStep.buttons);
3510
3482
  }
3511
- log(`StateMachineActionHandler: Step has cursor animations: ${!!(currentStep.cursorAnimations && currentStep.cursorAnimations.length > 0)}`);
3512
3483
  const hasSpecialTransitions = currentStep.buttons && currentStep.buttons.length > 0 || currentStep.transitions.some(
3513
3484
  (t) => t.type === "dom-click" || t.type === "url-path" || t.type === "dom-element-visible"
3514
3485
  );
3515
- const completionPolicy = hasSpecialTransitions ? "manual" : "auto";
3516
3486
  if (hasSpecialTransitions) {
3517
3487
  log("StateMachineActionHandler: Setting up transitions immediately for step with special transitions");
3518
3488
  this.managers.transitionManager.setupTransitions(currentStep, false, true);
3519
3489
  }
3520
- this.managers.videoManager.setCompletionPolicy(completionPolicy, () => {
3521
- var _a;
3522
- const store = getSaltfishStore();
3523
- if (!hasSpecialTransitions) {
3524
- const timeoutTransition = currentStep.transitions.find((t) => t.type === "timeout");
3525
- const hasNonZeroTimeout = timeoutTransition && timeoutTransition.timeout && timeoutTransition.timeout > 0;
3526
- if (hasNonZeroTimeout) {
3527
- log(`StateMachineActionHandler: Setting up transitions after video ended with ${timeoutTransition.timeout}ms delay`);
3528
- this.managers.transitionManager.setupTransitions(currentStep, false);
3529
- } else {
3530
- log("StateMachineActionHandler: Setting up transitions after video ended (immediate)");
3531
- this.managers.transitionManager.setupTransitions(currentStep, true);
3532
- }
3533
- const hasValidNextSteps = currentStep.transitions.some((transition) => {
3534
- return store.manifest.steps.some((s) => s.id === transition.nextStep);
3535
- });
3536
- if (!hasValidNextSteps) {
3537
- log("StateMachineActionHandler: No valid next steps found, completing playlist");
3538
- store.sendStateMachineEvent({
3539
- type: "COMPLETE_PLAYLIST"
3540
- });
3541
- }
3542
- } else {
3543
- const timeoutTransition = currentStep.transitions.find((t) => t.type === "timeout");
3544
- const hasStepTransitionButtons = (_a = currentStep.buttons) == null ? void 0 : _a.some(
3545
- (button) => button.action.type === "goto" || button.action.type === "next"
3546
- );
3547
- if (timeoutTransition && !hasStepTransitionButtons) {
3548
- const timeout = timeoutTransition.timeout || 0;
3549
- log(`StateMachineActionHandler: Video ended, setting up timeout transition to ${timeoutTransition.nextStep} with ${timeout}ms delay`);
3550
- setTimeout(() => {
3551
- var _a2;
3552
- const currentStore = getSaltfishStore();
3553
- if (currentStore.currentStepId === currentStep.id && (currentStore.currentState === "waitingForInteraction" || currentStore.currentState === "playing")) {
3554
- log(`StateMachineActionHandler: Timeout expired (${timeout}ms), transitioning to ${timeoutTransition.nextStep}`);
3555
- (_a2 = currentStore.goToStep) == null ? void 0 : _a2.call(currentStore, timeoutTransition.nextStep);
3556
- } else {
3557
- log(`StateMachineActionHandler: Timeout cancelled - step changed or state is ${currentStore.currentState}`);
3558
- }
3559
- }, timeout);
3560
- store.sendStateMachineEvent({
3561
- type: "VIDEO_FINISHED_WAIT",
3562
- step: currentStep
3563
- });
3564
- } else {
3565
- if (hasStepTransitionButtons) {
3566
- log("StateMachineActionHandler: Video ended, has step-transition button (goto/next) - waiting for user to click button");
3567
- } else {
3568
- log("StateMachineActionHandler: Video ended, waiting for user interaction");
3569
- }
3570
- store.sendStateMachineEvent({
3571
- type: "VIDEO_FINISHED_WAIT",
3572
- step: currentStep
3573
- });
3574
- }
3575
- }
3576
- });
3490
+ this.managers.videoManager.setCompletionPolicy(
3491
+ hasSpecialTransitions ? "manual" : "auto",
3492
+ () => this.handleStepVideoEnded(currentStep, hasSpecialTransitions)
3493
+ );
3577
3494
  log("StateMachineActionHandler: Starting async video load");
3578
- const loadTranscriptForStep = () => {
3579
- var _a, _b;
3580
- const store = getSaltfishStore();
3581
- const captionsEnabled = ((_a = store.manifest) == null ? void 0 : _a.captions) ?? true;
3582
- const language = (_b = store.userData) == null ? void 0 : _b.language;
3583
- let transcript = currentStep.transcript;
3584
- if (language && currentStep.translations && currentStep.translations[language]) {
3585
- const translatedTranscript = currentStep.translations[language].transcript;
3586
- if (translatedTranscript) {
3587
- transcript = translatedTranscript;
3588
- log(`StateMachineActionHandler: Loading translated transcript for step ${currentStep.id} in language ${language}`);
3589
- } else {
3590
- log(`StateMachineActionHandler: No translated transcript found for language ${language}, using default`);
3591
- }
3592
- } else if (language) {
3593
- log(`StateMachineActionHandler: No translation available for language ${language}, using default transcript`);
3594
- }
3595
- if (transcript) {
3596
- log(`StateMachineActionHandler: Loading transcript for step ${currentStep.id}, initially visible: ${captionsEnabled}`);
3597
- this.managers.videoManager.loadTranscript(transcript, captionsEnabled);
3598
- } else {
3599
- log(`StateMachineActionHandler: No transcript available for step ${currentStep.id}`);
3600
- this.managers.videoManager.loadTranscript(null, true);
3601
- }
3602
- };
3603
- this.managers.videoManager.loadVideo(videoUrl).then(() => {
3604
- log("StateMachineActionHandler: Video loaded successfully, playing");
3605
- this.managers.uiManager.hideError();
3606
- loadTranscriptForStep();
3607
- if (currentStep.cursorAnimations && currentStep.cursorAnimations.length > 0) {
3608
- log(`StateMachineActionHandler: Setting cursor visibility and scheduling animation for step ${currentStep.id}`);
3609
- this.managers.cursorManager.setShouldShowCursor(true);
3610
- this.scheduleCursorAnimation(currentStep.cursorAnimations[0], currentStep.id);
3611
- } else {
3612
- log(`StateMachineActionHandler: Setting cursor visibility to false for step ${currentStep.id} - step has no cursor animations`);
3613
- this.managers.cursorManager.setShouldShowCursor(false);
3614
- }
3615
- if (isAudioFallback) {
3616
- this.managers.videoManager.startAudioVisualization();
3495
+ this.managers.videoManager.loadVideo(videoUrl).then(() => this.handleStepVideoLoaded(currentStep, isAudioFallback)).catch((error2) => this.handleStepVideoLoadError(error2, currentStep));
3496
+ } catch (error2) {
3497
+ }
3498
+ }
3499
+ /**
3500
+ * Shows the audio-fallback overlay for audio-only steps, or hides it for regular video.
3501
+ */
3502
+ showOrHideAudioOverlay(step, isAudioFallback) {
3503
+ if (isAudioFallback) {
3504
+ const manifest = getSaltfishStore().manifest;
3505
+ this.managers.videoManager.showAudioFallbackOverlay(step.gifUrl, manifest == null ? void 0 : manifest.avatarThumbnailUrl);
3506
+ } else {
3507
+ this.managers.videoManager.hideAudioFallbackOverlay();
3508
+ }
3509
+ }
3510
+ /**
3511
+ * Runs when the current step's video ends. Decides how the playlist advances:
3512
+ * - auto policy: set up the step's transitions, or complete if there are none;
3513
+ * - manual policy: arm a timeout fallback (if any) and move to the waiting state.
3514
+ */
3515
+ handleStepVideoEnded(currentStep, hasSpecialTransitions) {
3516
+ var _a;
3517
+ const store = getSaltfishStore();
3518
+ if (!hasSpecialTransitions) {
3519
+ const timeoutTransition2 = currentStep.transitions.find((t) => t.type === "timeout");
3520
+ const hasNonZeroTimeout = timeoutTransition2 && timeoutTransition2.timeout && timeoutTransition2.timeout > 0;
3521
+ if (hasNonZeroTimeout) {
3522
+ log(`StateMachineActionHandler: Setting up transitions after video ended with ${timeoutTransition2.timeout}ms delay`);
3523
+ this.managers.transitionManager.setupTransitions(currentStep, false);
3524
+ } else {
3525
+ this.managers.transitionManager.setupTransitions(currentStep, true);
3526
+ }
3527
+ const hasValidNextSteps = currentStep.transitions.some(
3528
+ (transition) => store.manifest.steps.some((s) => s.id === transition.nextStep)
3529
+ );
3530
+ if (!hasValidNextSteps) {
3531
+ store.sendStateMachineEvent({ type: "COMPLETE_PLAYLIST" });
3532
+ }
3533
+ return;
3534
+ }
3535
+ const timeoutTransition = currentStep.transitions.find((t) => t.type === "timeout");
3536
+ const hasStepTransitionButtons = (_a = currentStep.buttons) == null ? void 0 : _a.some(
3537
+ (button) => button.action.type === "goto" || button.action.type === "next"
3538
+ );
3539
+ if (timeoutTransition && !hasStepTransitionButtons) {
3540
+ const timeout = timeoutTransition.timeout || 0;
3541
+ log(`StateMachineActionHandler: Video ended, setting up timeout transition to ${timeoutTransition.nextStep} with ${timeout}ms delay`);
3542
+ setTimeout(() => {
3543
+ var _a2;
3544
+ const currentStore = getSaltfishStore();
3545
+ if (currentStore.currentStepId === currentStep.id && (currentStore.currentState === "waitingForInteraction" || currentStore.currentState === "playing")) {
3546
+ log(`StateMachineActionHandler: Timeout expired (${timeout}ms), transitioning to ${timeoutTransition.nextStep}`);
3547
+ (_a2 = currentStore.goToStep) == null ? void 0 : _a2.call(currentStore, timeoutTransition.nextStep);
3617
3548
  } else {
3618
- this.managers.videoManager.initializeAudioForVideo();
3549
+ log(`StateMachineActionHandler: Timeout cancelled - step changed or state is ${currentStore.currentState}`);
3619
3550
  }
3620
- this.managers.videoManager.play();
3621
- const nextVideoUrl = this.findNextVideoUrl(currentStep);
3622
- if (nextVideoUrl) {
3623
- log(`StateMachineActionHandler: Preloading next video: ${nextVideoUrl}`);
3624
- this.managers.videoManager.preloadNextVideo(nextVideoUrl);
3625
- }
3626
- }).catch((error2) => {
3627
- var _a;
3628
- log(`StateMachineActionHandler: Error loading video: ${error2}`);
3629
- this.managers.uiManager.showError(
3630
- error2 instanceof Error ? error2 : new Error(`Failed to load video: ${error2}`),
3631
- "video"
3632
- );
3633
- const store = getSaltfishStore();
3634
- const errorObj = error2 instanceof Error ? error2 : new Error(`Failed to load video: ${error2}`);
3635
- const enrichedError = error2;
3636
- this.managers.eventManager.trigger("error", {
3637
- timestamp: Date.now(),
3638
- playlistId: ((_a = store.manifest) == null ? void 0 : _a.id) || void 0,
3639
- stepId: currentStep.id,
3640
- error: errorObj,
3641
- errorType: "video",
3642
- videoUrl: enrichedError == null ? void 0 : enrichedError.videoUrl,
3643
- mediaErrorCode: enrichedError == null ? void 0 : enrichedError.mediaErrorCode,
3644
- mediaErrorMessage: enrichedError == null ? void 0 : enrichedError.mediaErrorMessage,
3645
- failureReason: enrichedError == null ? void 0 : enrichedError.failureReason
3646
- });
3647
- });
3648
- } catch (error2) {
3551
+ }, timeout);
3552
+ }
3553
+ store.sendStateMachineEvent({ type: "VIDEO_FINISHED_WAIT", step: currentStep });
3554
+ }
3555
+ /**
3556
+ * Runs after the step's video has loaded: shows transcript, cursor animation,
3557
+ * starts/initializes audio, plays, and preloads the next video.
3558
+ */
3559
+ handleStepVideoLoaded(currentStep, isAudioFallback) {
3560
+ this.managers.uiManager.hideError();
3561
+ this.loadTranscriptForStep(currentStep);
3562
+ if (currentStep.cursorAnimations && currentStep.cursorAnimations.length > 0) {
3563
+ log(`StateMachineActionHandler: Setting cursor visibility and scheduling animation for step ${currentStep.id}`);
3564
+ this.managers.cursorManager.setShouldShowCursor(true);
3565
+ this.cursorScheduler.schedule(currentStep.cursorAnimations[0], currentStep.id);
3566
+ } else {
3567
+ log(`StateMachineActionHandler: Setting cursor visibility to false for step ${currentStep.id} - step has no cursor animations`);
3568
+ this.managers.cursorManager.setShouldShowCursor(false);
3569
+ }
3570
+ if (isAudioFallback) {
3571
+ this.managers.videoManager.startAudioVisualization();
3572
+ } else {
3573
+ this.managers.videoManager.initializeAudioForVideo();
3574
+ }
3575
+ this.managers.videoManager.play();
3576
+ const nextVideoUrl = this.findNextVideoUrl(currentStep);
3577
+ if (nextVideoUrl) {
3578
+ this.managers.videoManager.preloadNextVideo(nextVideoUrl);
3579
+ }
3580
+ }
3581
+ /**
3582
+ * Runs when the step's video fails to load: shows an error and emits an
3583
+ * enriched error event for analytics.
3584
+ */
3585
+ handleStepVideoLoadError(error2, currentStep) {
3586
+ var _a;
3587
+ const errorObj = error2 instanceof Error ? error2 : new Error(`Failed to load video: ${error2}`);
3588
+ this.managers.uiManager.showError(errorObj, "video");
3589
+ const store = getSaltfishStore();
3590
+ const enrichedError = error2;
3591
+ this.managers.eventManager.trigger("error", {
3592
+ timestamp: Date.now(),
3593
+ playlistId: ((_a = store.manifest) == null ? void 0 : _a.id) || void 0,
3594
+ stepId: currentStep.id,
3595
+ error: errorObj,
3596
+ errorType: "video",
3597
+ videoUrl: enrichedError == null ? void 0 : enrichedError.videoUrl,
3598
+ mediaErrorCode: enrichedError == null ? void 0 : enrichedError.mediaErrorCode,
3599
+ mediaErrorMessage: enrichedError == null ? void 0 : enrichedError.mediaErrorMessage,
3600
+ failureReason: enrichedError == null ? void 0 : enrichedError.failureReason
3601
+ });
3602
+ }
3603
+ /**
3604
+ * Loads the transcript for a step, preferring a translated transcript when a
3605
+ * language is configured. Falls back to clearing the transcript if none exists.
3606
+ */
3607
+ loadTranscriptForStep(step) {
3608
+ var _a, _b;
3609
+ const store = getSaltfishStore();
3610
+ const captionsEnabled = ((_a = store.manifest) == null ? void 0 : _a.captions) ?? true;
3611
+ const language = (_b = store.userData) == null ? void 0 : _b.language;
3612
+ let transcript = step.transcript;
3613
+ if (language && step.translations && step.translations[language]) {
3614
+ const translatedTranscript = step.translations[language].transcript;
3615
+ if (translatedTranscript) {
3616
+ transcript = translatedTranscript;
3617
+ log(`StateMachineActionHandler: Loading translated transcript for step ${step.id} in language ${language}`);
3618
+ }
3619
+ }
3620
+ if (transcript) {
3621
+ log(`StateMachineActionHandler: Loading transcript for step ${step.id}, initially visible: ${captionsEnabled}`);
3622
+ this.managers.videoManager.loadTranscript(transcript, captionsEnabled);
3623
+ } else {
3624
+ log(`StateMachineActionHandler: No transcript available for step ${step.id}`);
3625
+ this.managers.videoManager.loadTranscript(null, true);
3649
3626
  }
3650
3627
  }
3651
3628
  handlePauseVideoPlayback() {
@@ -3668,15 +3645,7 @@ class StateMachineActionHandler {
3668
3645
  const videoUrl = this.getVideoUrl(currentStep);
3669
3646
  const isAudioFallback = this.isUsingAudioFallback(currentStep);
3670
3647
  this.managers.uiManager.showPlayer();
3671
- if (isAudioFallback) {
3672
- const store = getSaltfishStore();
3673
- const manifest = store.manifest;
3674
- const posterUrl = currentStep.gifUrl;
3675
- const avatarThumbnailUrl = manifest == null ? void 0 : manifest.avatarThumbnailUrl;
3676
- this.managers.videoManager.showAudioFallbackOverlay(posterUrl, avatarThumbnailUrl);
3677
- } else {
3678
- this.managers.videoManager.hideAudioFallbackOverlay();
3679
- }
3648
+ this.showOrHideAudioOverlay(currentStep, isAudioFallback);
3680
3649
  this.managers.videoManager.loadVideo(videoUrl).then(() => {
3681
3650
  this.managers.uiManager.hideError();
3682
3651
  if (isAudioFallback) {
@@ -4134,12 +4103,11 @@ const _ManagerOrchestrator = class _ManagerOrchestrator {
4134
4103
  // Store updater unsubscribe functions
4135
4104
  __publicField(this, "uiUpdaterUnsubscribe", null);
4136
4105
  __publicField(this, "eventUpdaterUnsubscribe", null);
4137
- __publicField(this, "cursorAnimationListener", null);
4138
- __publicField(this, "cursorAnimationVideoElement", null);
4139
- __publicField(this, "cursorAnimationStepId", null);
4106
+ __publicField(this, "cursorScheduler");
4140
4107
  // Initialization state
4141
4108
  __publicField(this, "isInitialized", false);
4142
4109
  this.managers = managers;
4110
+ this.cursorScheduler = new CursorAnimationScheduler(managers, "ManagerOrchestrator");
4143
4111
  }
4144
4112
  /**
4145
4113
  * Set initialization state
@@ -4181,86 +4149,6 @@ const _ManagerOrchestrator = class _ManagerOrchestrator {
4181
4149
  stepTimeoutUnsubscribe();
4182
4150
  };
4183
4151
  }
4184
- /**
4185
- * Cleans up the active cursor animation time listener
4186
- * @param stepId - Optional step ID to verify we're cleaning up the right listener
4187
- */
4188
- cleanupCursorAnimationListener(stepId) {
4189
- if (stepId && this.cursorAnimationStepId && stepId !== this.cursorAnimationStepId) {
4190
- log(`ManagerOrchestrator: Skipping cleanup - stepId mismatch (requested: ${stepId}, current: ${this.cursorAnimationStepId})`);
4191
- return;
4192
- }
4193
- if (this.cursorAnimationListener && this.cursorAnimationVideoElement) {
4194
- this.cursorAnimationVideoElement.removeEventListener("timeupdate", this.cursorAnimationListener);
4195
- this.cursorAnimationListener = null;
4196
- this.cursorAnimationVideoElement = null;
4197
- this.cursorAnimationStepId = null;
4198
- }
4199
- }
4200
- /**
4201
- * Schedules a cursor animation to run either immediately or at a specific video time
4202
- * Note: Works for both video and audio-only steps (audio files use the same video element)
4203
- */
4204
- scheduleCursorAnimation(animation, stepId) {
4205
- this.cleanupCursorAnimationListener();
4206
- let showAtSeconds = animation.showAtSeconds ?? 0;
4207
- if (typeof showAtSeconds !== "number" || !isFinite(showAtSeconds)) {
4208
- showAtSeconds = 0;
4209
- }
4210
- if (showAtSeconds < 0) {
4211
- showAtSeconds = 0;
4212
- }
4213
- if (showAtSeconds <= 0) {
4214
- this.managers.cursorManager.animate(animation);
4215
- } else {
4216
- const videoElement = this.managers.videoManager.getVideoElement();
4217
- if (!videoElement) {
4218
- return;
4219
- }
4220
- let animationTriggered = false;
4221
- let warningLogged = false;
4222
- const timeUpdateHandler = () => {
4223
- if (animationTriggered) {
4224
- return;
4225
- }
4226
- const currentTime = videoElement.currentTime;
4227
- const duration = videoElement.duration;
4228
- if (duration && !isNaN(duration) && showAtSeconds > duration && !warningLogged) {
4229
- warningLogged = true;
4230
- animationTriggered = true;
4231
- this.cleanupCursorAnimationListener();
4232
- this.managers.cursorManager.animate(animation);
4233
- return;
4234
- }
4235
- if (currentTime >= showAtSeconds) {
4236
- animationTriggered = true;
4237
- this.cleanupCursorAnimationListener();
4238
- this.managers.cursorManager.animate(animation);
4239
- }
4240
- };
4241
- const endedHandler = () => {
4242
- if (!animationTriggered) {
4243
- const store = getSaltfishStore();
4244
- if (store.currentStepId !== stepId) {
4245
- log(`ManagerOrchestrator: Video ended but step changed (was ${stepId}, now ${store.currentStepId}). Not triggering cursor animation.`);
4246
- this.cleanupCursorAnimationListener(stepId);
4247
- return;
4248
- }
4249
- videoElement.duration;
4250
- animationTriggered = true;
4251
- this.managers.cursorManager.animate(animation);
4252
- this.cleanupCursorAnimationListener();
4253
- }
4254
- };
4255
- this.cursorAnimationListener = () => {
4256
- timeUpdateHandler();
4257
- };
4258
- this.cursorAnimationVideoElement = videoElement;
4259
- this.cursorAnimationStepId = stepId;
4260
- videoElement.addEventListener("timeupdate", this.cursorAnimationListener);
4261
- videoElement.addEventListener("ended", endedHandler, { once: true });
4262
- }
4263
- }
4264
4152
  /**
4265
4153
  * Handle store state changes
4266
4154
  */
@@ -4292,7 +4180,7 @@ const _ManagerOrchestrator = class _ManagerOrchestrator {
4292
4180
  const currentStep = manifest == null ? void 0 : manifest.steps.find((step) => step.id === store.currentStepId);
4293
4181
  if ((currentStep == null ? void 0 : currentStep.cursorAnimations) && currentStep.cursorAnimations.length > 0) {
4294
4182
  this.managers.cursorManager.setShouldShowCursor(true);
4295
- this.scheduleCursorAnimation(currentStep.cursorAnimations[0], store.currentStepId || "unknown");
4183
+ this.cursorScheduler.schedule(currentStep.cursorAnimations[0], store.currentStepId || "unknown");
4296
4184
  }
4297
4185
  } else if (store.currentState === "completed" || store.currentState === "closing") {
4298
4186
  this.cleanupPlaylist();
@@ -4311,7 +4199,7 @@ const _ManagerOrchestrator = class _ManagerOrchestrator {
4311
4199
  */
4312
4200
  cleanupCurrentPlaylist() {
4313
4201
  try {
4314
- this.cleanupCursorAnimationListener();
4202
+ this.cursorScheduler.cleanup();
4315
4203
  if (this.eventUpdaterUnsubscribe) {
4316
4204
  this.eventUpdaterUnsubscribe();
4317
4205
  this.eventUpdaterUnsubscribe = null;
@@ -4402,7 +4290,7 @@ const _ManagerOrchestrator = class _ManagerOrchestrator {
4402
4290
  isMinimized: store.isMinimized,
4403
4291
  manifestId: (_a = store.manifest) == null ? void 0 : _a.id
4404
4292
  });
4405
- this.cleanupCursorAnimationListener();
4293
+ this.cursorScheduler.cleanup();
4406
4294
  if (this.uiUpdaterUnsubscribe) {
4407
4295
  this.uiUpdaterUnsubscribe();
4408
4296
  this.uiUpdaterUnsubscribe = null;
@@ -5552,27 +5440,37 @@ class VideoControlsUI {
5552
5440
  event.preventDefault();
5553
5441
  });
5554
5442
  this.container.appendChild(this.ccButton);
5555
- if (videoElement) {
5556
- videoElement.addEventListener("seeking", this.handleSeeking);
5557
- videoElement.addEventListener("seeked", this.handleSeeked);
5558
- videoElement.addEventListener("timeupdate", this.handleDetailedTimeUpdate);
5443
+ this.attachProgressListeners(videoElement);
5444
+ }
5445
+ /**
5446
+ * Attaches progress-tracking listeners (seeking/seeked/timeupdate) to a video element.
5447
+ */
5448
+ attachProgressListeners(video) {
5449
+ if (!video) {
5450
+ return;
5451
+ }
5452
+ video.addEventListener("seeking", this.handleSeeking);
5453
+ video.addEventListener("seeked", this.handleSeeked);
5454
+ video.addEventListener("timeupdate", this.handleDetailedTimeUpdate);
5455
+ }
5456
+ /**
5457
+ * Removes progress-tracking listeners from a video element.
5458
+ */
5459
+ detachProgressListeners(video) {
5460
+ if (!video) {
5461
+ return;
5559
5462
  }
5463
+ video.removeEventListener("seeking", this.handleSeeking);
5464
+ video.removeEventListener("seeked", this.handleSeeked);
5465
+ video.removeEventListener("timeupdate", this.handleDetailedTimeUpdate);
5560
5466
  }
5561
5467
  /**
5562
5468
  * Updates the video element reference (used when swapping videos)
5563
5469
  */
5564
5470
  updateVideoElement(videoElement) {
5565
- if (this.videoElement) {
5566
- this.videoElement.removeEventListener("seeking", this.handleSeeking);
5567
- this.videoElement.removeEventListener("seeked", this.handleSeeked);
5568
- this.videoElement.removeEventListener("timeupdate", this.handleDetailedTimeUpdate);
5569
- }
5471
+ this.detachProgressListeners(this.videoElement);
5570
5472
  this.videoElement = videoElement;
5571
- if (videoElement) {
5572
- videoElement.addEventListener("seeking", this.handleSeeking);
5573
- videoElement.addEventListener("seeked", this.handleSeeked);
5574
- videoElement.addEventListener("timeupdate", this.handleDetailedTimeUpdate);
5575
- }
5473
+ this.attachProgressListeners(videoElement);
5576
5474
  }
5577
5475
  /**
5578
5476
  * Resets controls for new video
@@ -5597,11 +5495,7 @@ class VideoControlsUI {
5597
5495
  */
5598
5496
  destroy() {
5599
5497
  this.stopProgressTracking();
5600
- if (this.videoElement) {
5601
- this.videoElement.removeEventListener("seeking", this.handleSeeking);
5602
- this.videoElement.removeEventListener("seeked", this.handleSeeked);
5603
- this.videoElement.removeEventListener("timeupdate", this.handleDetailedTimeUpdate);
5604
- }
5498
+ this.detachProgressListeners(this.videoElement);
5605
5499
  if (this.controlsElement) {
5606
5500
  const controlsConfig = this.deviceHandler.getControlsConfig();
5607
5501
  if (controlsConfig.useTouch) {
@@ -6302,6 +6196,31 @@ class VideoManager {
6302
6196
  getInactiveVideo() {
6303
6197
  return this.activeVideoIndex === 0 ? this.nextVideo : this.currentVideo;
6304
6198
  }
6199
+ /**
6200
+ * Runs a callback against each existing video element (current + next).
6201
+ */
6202
+ forEachVideo(fn) {
6203
+ for (const video of [this.currentVideo, this.nextVideo]) {
6204
+ if (video) {
6205
+ fn(video);
6206
+ }
6207
+ }
6208
+ }
6209
+ /**
6210
+ * Applies the global mute state from the store to a single video element.
6211
+ */
6212
+ applyStoreMuteState(video) {
6213
+ video.muted = getSaltfishStore().isMuted;
6214
+ }
6215
+ /**
6216
+ * Clears the pending autoplay fallback timeout, if any.
6217
+ */
6218
+ clearAutoplayFallbackTimeout() {
6219
+ if (this.autoplayFallbackTimeout !== null) {
6220
+ window.clearTimeout(this.autoplayFallbackTimeout);
6221
+ this.autoplayFallbackTimeout = null;
6222
+ }
6223
+ }
6305
6224
  /**
6306
6225
  * Swaps between the two video elements
6307
6226
  */
@@ -6312,8 +6231,7 @@ class VideoManager {
6312
6231
  return;
6313
6232
  }
6314
6233
  activeVideo.pause();
6315
- const store = getSaltfishStore();
6316
- inactiveVideo.muted = store.isMuted;
6234
+ this.applyStoreMuteState(inactiveVideo);
6317
6235
  activeVideo.classList.add("sf-hidden");
6318
6236
  inactiveVideo.classList.remove("sf-hidden");
6319
6237
  this.activeVideoIndex = this.activeVideoIndex === 0 ? 1 : 0;
@@ -6336,14 +6254,13 @@ class VideoManager {
6336
6254
  if (this.controls) {
6337
6255
  this.controls.reset90PercentTrigger();
6338
6256
  }
6339
- const store = getSaltfishStore();
6340
- activeVideo.muted = store.isMuted;
6257
+ this.applyStoreMuteState(activeVideo);
6341
6258
  try {
6342
6259
  if (this.controls) {
6343
6260
  this.controls.reset();
6344
6261
  }
6345
- const store2 = getSaltfishStore();
6346
- const isPersistenceEnabled = ((_a = store2.playlistOptions) == null ? void 0 : _a.persistence) ?? true;
6262
+ const store = getSaltfishStore();
6263
+ const isPersistenceEnabled = ((_a = store.playlistOptions) == null ? void 0 : _a.persistence) ?? true;
6347
6264
  if (this.currentVideoUrl === url && activeVideo.src && (activeVideo.src === url || activeVideo.src.endsWith(url))) {
6348
6265
  if (isPersistenceEnabled) {
6349
6266
  const savedPosition = this.playbackPositions.get(url);
@@ -6481,8 +6398,7 @@ class VideoManager {
6481
6398
  inactiveVideo.src = url;
6482
6399
  inactiveVideo.load();
6483
6400
  inactiveVideo.preload = "auto";
6484
- const store = getSaltfishStore();
6485
- inactiveVideo.muted = store.isMuted;
6401
+ this.applyStoreMuteState(inactiveVideo);
6486
6402
  } else {
6487
6403
  fetch(url).then((response) => {
6488
6404
  if (!response.ok) {
@@ -6639,10 +6555,7 @@ class VideoManager {
6639
6555
  this.nextVideoUrl = "";
6640
6556
  this.activeVideoIndex = 0;
6641
6557
  this.hasUserInteracted = false;
6642
- if (this.autoplayFallbackTimeout !== null) {
6643
- window.clearTimeout(this.autoplayFallbackTimeout);
6644
- this.autoplayFallbackTimeout = null;
6645
- }
6558
+ this.clearAutoplayFallbackTimeout();
6646
6559
  this.completionPolicy = "auto";
6647
6560
  this.videoEndedCallback = null;
6648
6561
  this.transcriptManager.reset();
@@ -6674,10 +6587,7 @@ class VideoManager {
6674
6587
  }
6675
6588
  this.transcriptManager.destroy();
6676
6589
  this.hideAudioFallbackOverlay();
6677
- if (this.autoplayFallbackTimeout !== null) {
6678
- window.clearTimeout(this.autoplayFallbackTimeout);
6679
- this.autoplayFallbackTimeout = null;
6680
- }
6590
+ this.clearAutoplayFallbackTimeout();
6681
6591
  if (this.deviceChangeCleanup) {
6682
6592
  this.deviceChangeCleanup();
6683
6593
  this.deviceChangeCleanup = null;
@@ -6696,31 +6606,19 @@ class VideoManager {
6696
6606
  * Adds event listeners to the video elements
6697
6607
  */
6698
6608
  addEventListeners() {
6699
- const currentVideo = this.currentVideo;
6700
- const nextVideo = this.nextVideo;
6701
- if (currentVideo) {
6702
- currentVideo.addEventListener("ended", this.handleVideoEnded);
6703
- currentVideo.addEventListener("error", this.handleVideoError);
6704
- }
6705
- if (nextVideo) {
6706
- nextVideo.addEventListener("ended", this.handleVideoEnded);
6707
- nextVideo.addEventListener("error", this.handleVideoError);
6708
- }
6609
+ this.forEachVideo((video) => {
6610
+ video.addEventListener("ended", this.handleVideoEnded);
6611
+ video.addEventListener("error", this.handleVideoError);
6612
+ });
6709
6613
  }
6710
6614
  /**
6711
6615
  * Removes event listeners from the video elements
6712
6616
  */
6713
6617
  removeEventListeners() {
6714
- const currentVideo = this.currentVideo;
6715
- const nextVideo = this.nextVideo;
6716
- if (currentVideo) {
6717
- currentVideo.removeEventListener("ended", this.handleVideoEnded);
6718
- currentVideo.removeEventListener("error", this.handleVideoError);
6719
- }
6720
- if (nextVideo) {
6721
- nextVideo.removeEventListener("ended", this.handleVideoEnded);
6722
- nextVideo.removeEventListener("error", this.handleVideoError);
6723
- }
6618
+ this.forEachVideo((video) => {
6619
+ video.removeEventListener("ended", this.handleVideoEnded);
6620
+ video.removeEventListener("error", this.handleVideoError);
6621
+ });
6724
6622
  }
6725
6623
  /**
6726
6624
  * Sets the muted state of both video elements
@@ -6810,10 +6708,8 @@ class VideoManager {
6810
6708
  markUserInteraction() {
6811
6709
  if (!this.hasUserInteracted) {
6812
6710
  this.hasUserInteracted = true;
6813
- if (this.autoplayFallbackTimeout !== null) {
6814
- window.clearTimeout(this.autoplayFallbackTimeout);
6815
- this.autoplayFallbackTimeout = null;
6816
- }
6711
+ if (this.autoplayFallbackTimeout !== null) ;
6712
+ this.clearAutoplayFallbackTimeout();
6817
6713
  }
6818
6714
  }
6819
6715
  /**
@@ -6828,10 +6724,7 @@ class VideoManager {
6828
6724
  */
6829
6725
  resetUserInteraction() {
6830
6726
  this.hasUserInteracted = false;
6831
- if (this.autoplayFallbackTimeout !== null) {
6832
- window.clearTimeout(this.autoplayFallbackTimeout);
6833
- this.autoplayFallbackTimeout = null;
6834
- }
6727
+ this.clearAutoplayFallbackTimeout();
6835
6728
  }
6836
6729
  /**
6837
6730
  * Handles autoplay fallback click specifically for mobile Chrome
@@ -10492,7 +10385,8 @@ class TriggerManager {
10492
10385
  }
10493
10386
  const playlistData = watchedPlaylists && watchedPlaylists[playlistId];
10494
10387
  const visitCount = (playlistData == null ? void 0 : playlistData.visitCount) ?? ((playlistData == null ? void 0 : playlistData.status) === "completed" || (playlistData == null ? void 0 : playlistData.status) === "dismissed" ? 1 : 0);
10495
- return visitCount < maxVisits;
10388
+ const effectiveVisits = visitCount + ((playlistData == null ? void 0 : playlistData.status) === "in_progress" ? 1 : 0);
10389
+ return effectiveVisits < maxVisits;
10496
10390
  }
10497
10391
  /**
10498
10392
  * Normalizes a URL by removing trailing slash (unless it's the root path)
@@ -11519,7 +11413,6 @@ class PlaylistManager extends EventSubscriberManager {
11519
11413
  */
11520
11414
  constructor(eventManager, storageManager2) {
11521
11415
  super(eventManager);
11522
- __publicField(this, "isUpdatingWatchedPlaylists", false);
11523
11416
  __publicField(this, "playlistLoader");
11524
11417
  __publicField(this, "storageManager");
11525
11418
  this.playlistLoader = new PlaylistLoader();
@@ -11552,84 +11445,56 @@ class PlaylistManager extends EventSubscriberManager {
11552
11445
  });
11553
11446
  }
11554
11447
  /**
11555
- * Updates the watched playlist status locally and in the backend
11448
+ * Updates the watched playlist status.
11449
+ *
11450
+ * The in-memory update is done first, atomically, via the store action so that
11451
+ * rapid status changes (per-step `in_progress` updates followed by the final
11452
+ * `completed` update) can never overwrite each other — this is what keeps the
11453
+ * `maxVisits`/"once" triggers correct on SPA navigation. Persistence (backend
11454
+ * for identified users, localStorage for anonymous) is a follow-up side effect
11455
+ * that mirrors the record the store just computed; it never gates the in-memory
11456
+ * update.
11457
+ *
11556
11458
  * @param playlistId - ID of the playlist
11557
- * @param status - New status ('in_progress' or 'completed')
11459
+ * @param status - New status
11558
11460
  * @param currentStepId - Optional current step ID
11559
11461
  */
11560
11462
  async updateWatchedPlaylistStatus(playlistId, status, currentStepId) {
11561
- var _a, _b, _c, _d, _e, _f;
11562
- if (this.isUpdatingWatchedPlaylists) {
11463
+ var _a, _b, _c, _d, _e;
11464
+ const store = getSaltfishStore();
11465
+ store.updateWatchedPlaylist(playlistId, status, currentStepId);
11466
+ const record = (_b = (_a = store.userData) == null ? void 0 : _a.watchedPlaylists) == null ? void 0 : _b[playlistId];
11467
+ const isAnonymous = !((_c = store == null ? void 0 : store.config) == null ? void 0 : _c.token) || !((_d = store == null ? void 0 : store.user) == null ? void 0 : _d.id) || ((_e = store == null ? void 0 : store.user) == null ? void 0 : _e.__isAnonymous);
11468
+ if (isAnonymous) {
11469
+ if (record) {
11470
+ this.persistAnonymousWatchedPlaylist(playlistId, record);
11471
+ }
11563
11472
  return;
11564
11473
  }
11565
- this.isUpdatingWatchedPlaylists = true;
11474
+ const apiUrl = `https://player.saltfish.ai/clients/${store.config.token}/users/${store.user.id}/playlists/${playlistId}`;
11566
11475
  try {
11567
- const store = getSaltfishStore();
11568
- const currentUserData = store.userData || {};
11569
- const currentWatchedPlaylists = currentUserData.watchedPlaylists || {};
11570
- const existingPlaylistData = currentWatchedPlaylists[playlistId];
11571
- const previousStatus = existingPlaylistData == null ? void 0 : existingPlaylistData.status;
11572
- const currentVisitCount = (existingPlaylistData == null ? void 0 : existingPlaylistData.visitCount) ?? 0;
11573
- const shouldIncrementVisitCount = (status === "completed" || status === "dismissed") && previousStatus !== status;
11574
- const updatedPlaylistData = {
11575
- status,
11576
- // Don't save currentStepId for completed playlists - they should restart from beginning
11577
- currentStepId: status === "completed" ? null : currentStepId || store.currentStepId || null,
11578
- timestamp: Date.now(),
11579
- // Use timestamp for consistency with checkAndResumeInProgressPlaylist
11580
- lastProgressAt: Date.now(),
11581
- // Keep for backward compatibility
11582
- visitCount: shouldIncrementVisitCount ? currentVisitCount + 1 : currentVisitCount
11583
- };
11584
- const updatedWatchedPlaylists = {
11585
- ...currentWatchedPlaylists,
11586
- [playlistId]: updatedPlaylistData
11587
- };
11588
- store.setUserData({
11589
- ...currentUserData,
11590
- watchedPlaylists: updatedWatchedPlaylists
11476
+ const response = await fetch(apiUrl, {
11477
+ method: "POST",
11478
+ headers: { "Content-Type": "application/json" },
11479
+ body: JSON.stringify({ status, currentStepId: (record == null ? void 0 : record.currentStepId) ?? null })
11591
11480
  });
11592
- log(`PlaylistManager: Updated local watched playlists for ${playlistId} with status ${status}`);
11593
- if (!((_a = store == null ? void 0 : store.config) == null ? void 0 : _a.token) || !((_b = store == null ? void 0 : store.user) == null ? void 0 : _b.id) || ((_c = store == null ? void 0 : store.user) == null ? void 0 : _c.__isAnonymous)) {
11594
- log(`PlaylistManager: Cannot update backend - token: ${!!((_d = store == null ? void 0 : store.config) == null ? void 0 : _d.token)}, userId: ${!!((_e = store == null ? void 0 : store.user) == null ? void 0 : _e.id)}, anonymous: ${(_f = store == null ? void 0 : store.user) == null ? void 0 : _f.__isAnonymous}`);
11595
- this.updateAnonymousUserWatchedPlaylists(playlistId, status, currentStepId || store.currentStepId || null);
11596
- return;
11597
- }
11598
- const apiUrl = `https://player.saltfish.ai/clients/${store.config.token}/users/${store.user.id}/playlists/${playlistId}`;
11599
- log(`PlaylistManager: Making API call to update status - URL: ${apiUrl}, Status: ${status}`);
11600
- try {
11601
- const response = await fetch(apiUrl, {
11602
- method: "POST",
11603
- headers: {
11604
- "Content-Type": "application/json"
11605
- },
11606
- body: JSON.stringify({
11607
- status,
11608
- currentStepId: currentStepId || store.currentStepId || null
11609
- })
11610
- });
11611
- if (!response.ok) {
11612
- throw new Error(`Failed to update watched playlist status: ${response.statusText} (${response.status})`);
11613
- }
11614
- log(`PlaylistManager: Successfully updated watched playlist status via API for ${playlistId} to '${status}'`);
11615
- } catch (error2) {
11616
- console.error(`PlaylistManager: Error updating watched playlist status for ${playlistId}:`, error2);
11481
+ if (!response.ok) {
11482
+ throw new Error(`Failed to update watched playlist status: ${response.statusText} (${response.status})`);
11617
11483
  }
11618
- } finally {
11619
- this.isUpdatingWatchedPlaylists = false;
11484
+ log(`PlaylistManager: Successfully updated watched playlist status via API for ${playlistId} to '${status}'`);
11485
+ } catch (error2) {
11486
+ console.error(`PlaylistManager: Error updating watched playlist status for ${playlistId}:`, error2);
11620
11487
  }
11621
11488
  }
11622
11489
  /**
11623
- * Updates anonymous user watched playlist status in localStorage
11624
- * @param playlistId - ID of the playlist
11625
- * @param status - New status ('in_progress', 'completed', or 'dismissed')
11626
- * @param currentStepId - Optional current step ID
11490
+ * Mirrors a computed watched-playlist record into the anonymous user's
11491
+ * localStorage so it survives a full page reload.
11627
11492
  */
11628
- updateAnonymousUserWatchedPlaylists(playlistId, status, currentStepId) {
11493
+ persistAnonymousWatchedPlaylist(playlistId, record) {
11629
11494
  if (typeof window === "undefined") {
11630
11495
  return;
11631
11496
  }
11632
- let anonymousUserData = this.storageManager.getAnonymousUserData() || {
11497
+ const anonymousUserData = this.storageManager.getAnonymousUserData() || {
11633
11498
  userId: "anonymous",
11634
11499
  userData: {},
11635
11500
  watchedPlaylists: {},
@@ -11638,21 +11503,10 @@ class PlaylistManager extends EventSubscriberManager {
11638
11503
  if (!anonymousUserData.watchedPlaylists) {
11639
11504
  anonymousUserData.watchedPlaylists = {};
11640
11505
  }
11641
- const existingPlaylistData = anonymousUserData.watchedPlaylists[playlistId];
11642
- const previousStatus = existingPlaylistData == null ? void 0 : existingPlaylistData.status;
11643
- const currentVisitCount = (existingPlaylistData == null ? void 0 : existingPlaylistData.visitCount) ?? 0;
11644
- const shouldIncrementVisitCount = (status === "completed" || status === "dismissed") && previousStatus !== status;
11645
- anonymousUserData.watchedPlaylists[playlistId] = {
11646
- status,
11647
- currentStepId: currentStepId || null,
11648
- timestamp: Date.now(),
11649
- // Use timestamp for consistency with checkAndResumeInProgressPlaylist
11650
- lastProgressAt: Date.now(),
11651
- // Keep for backward compatibility
11652
- visitCount: shouldIncrementVisitCount ? currentVisitCount + 1 : currentVisitCount
11653
- };
11506
+ anonymousUserData.watchedPlaylists[playlistId] = record;
11654
11507
  anonymousUserData.timestamp = Date.now();
11655
- this.storageManager.setAnonymousUserData(anonymousUserData);
11508
+ const success = this.storageManager.setAnonymousUserData(anonymousUserData);
11509
+ log(`PlaylistManager: ${success ? "Updated" : "Failed to update"} anonymous user localStorage for playlist ${playlistId} with status ${record.status}`);
11656
11510
  }
11657
11511
  /**
11658
11512
  * Loads a playlist manifest and sets up the store
@@ -12273,10 +12127,10 @@ class UIManager {
12273
12127
  }
12274
12128
  const token = (_a2 = currentStore.config) == null ? void 0 : _a2.token;
12275
12129
  if (token) {
12276
- window.open(`https://www.saltfish.ai/demos?clientId=${token}`, "_blank");
12130
+ window.open(`https://www.saltfish.ai/?utm_source=player&utm_medium=logo&clientId=${token}`, "_blank");
12277
12131
  } else {
12278
12132
  console.warn("UIManager: No token available, falling back to saltfish.ai homepage");
12279
- window.open("https://www.saltfish.ai/", "_blank");
12133
+ window.open("https://www.saltfish.ai/?utm_source=player&utm_medium=logo", "_blank");
12280
12134
  }
12281
12135
  });
12282
12136
  }
@@ -12901,7 +12755,7 @@ const SaltfishPlayer$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.de
12901
12755
  __proto__: null,
12902
12756
  SaltfishPlayer
12903
12757
  }, Symbol.toStringTag, { value: "Module" }));
12904
- const version = "0.3.86";
12758
+ const version = "0.3.91";
12905
12759
  const packageJson = {
12906
12760
  version
12907
12761
  };