From 0b4f6f560459248560d934f33b8389b491e1abb0 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 21:22:39 +0530 Subject: [PATCH 1/5] chore(deps): update the PostHog Android SDK to 3.75.0 --- app/dependencies/releaseRuntimeClasspath.txt | 12 ++++++------ .../main/java/cx/aswin/boxlore/BoxLoreApplication.kt | 2 +- .../dependencies/releaseRuntimeClasspath.txt | 12 ++++++------ gradle/libs.versions.toml | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/dependencies/releaseRuntimeClasspath.txt b/app/dependencies/releaseRuntimeClasspath.txt index 379ee8aeb..78036e706 100644 --- a/app/dependencies/releaseRuntimeClasspath.txt +++ b/app/dependencies/releaseRuntimeClasspath.txt @@ -235,14 +235,14 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava com.google.j2objc:j2objc-annotations:3.0.0 com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0 com.materialkolor:material-color-utilities-jvm:5.0.1 -com.posthog:posthog-android:3.45.0 -com.posthog:posthog:6.15.0 +com.posthog:posthog-android:3.75.0 +com.posthog:posthog:6.48.0 com.prof18.rssparser:rssparser-android:6.1.7 com.prof18.rssparser:rssparser:6.1.7 com.squareup.curtains:curtains:1.2.5 -com.squareup.okhttp3:logging-interceptor:4.12.0 +com.squareup.okhttp3:logging-interceptor:4.12.1 com.squareup.okhttp3:okhttp-android:5.4.0 -com.squareup.okhttp3:okhttp-bom:4.12.0 +com.squareup.okhttp3:okhttp-bom:4.12.1 com.squareup.okhttp3:okhttp-coroutines:5.4.0 com.squareup.okhttp3:okhttp:5.4.0 com.squareup.okio:okio-jvm:3.17.0 @@ -280,8 +280,8 @@ org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22 org.jetbrains.kotlin:kotlin-bom:1.8.22 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22 org.jetbrains.kotlin:kotlin-stdlib-common:2.4.10 -org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.10 -org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.10 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.21 org.jetbrains.kotlin:kotlin-stdlib:2.4.10 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0 diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index 405c52778..273f70c5a 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -373,7 +373,7 @@ class BoxLoreApplication : val environment = PostHogWiring.environment(debug = BuildConfig.DEBUG, isolatedTests = BuildConfig.BOXLORE_ISOLATED_TESTS) val config = PostHogAndroidConfig( - apiKey = BuildConfig.POSTHOG_API_KEY, + projectToken = BuildConfig.POSTHOG_API_KEY, host = BuildConfig.POSTHOG_HOST, ).apply { captureApplicationLifecycleEvents = true diff --git a/core/playback/dependencies/releaseRuntimeClasspath.txt b/core/playback/dependencies/releaseRuntimeClasspath.txt index fdb322961..a37740acf 100644 --- a/core/playback/dependencies/releaseRuntimeClasspath.txt +++ b/core/playback/dependencies/releaseRuntimeClasspath.txt @@ -176,14 +176,14 @@ com.google.guava:guava:33.3.1-android com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava com.google.j2objc:j2objc-annotations:3.0.0 com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0 -com.posthog:posthog-android:3.45.0 -com.posthog:posthog:6.15.0 +com.posthog:posthog-android:3.75.0 +com.posthog:posthog:6.48.0 com.prof18.rssparser:rssparser-android:6.1.7 com.prof18.rssparser:rssparser:6.1.7 com.squareup.curtains:curtains:1.2.5 -com.squareup.okhttp3:logging-interceptor:4.12.0 +com.squareup.okhttp3:logging-interceptor:4.12.1 com.squareup.okhttp3:okhttp-android:5.4.0 -com.squareup.okhttp3:okhttp-bom:4.12.0 +com.squareup.okhttp3:okhttp-bom:4.12.1 com.squareup.okhttp3:okhttp-coroutines:5.4.0 com.squareup.okhttp3:okhttp:5.4.0 com.squareup.okio:okio-jvm:3.17.0 @@ -197,8 +197,8 @@ org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22 org.jetbrains.kotlin:kotlin-bom:1.8.0 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22 org.jetbrains.kotlin:kotlin-stdlib-common:2.4.10 -org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.10 -org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.10 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.21 org.jetbrains.kotlin:kotlin-stdlib:2.4.10 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b33db2a81..9ba7f5c0c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ castFramework = "22.3.1" palette = "1.0.0" material = "1.12.0" materialColorUtilities = "5.0.1" -posthog = "3.45.0" +posthog = "3.75.0" work = "2.9.0" rssParser = "6.1.7" jsoup = "1.23.2" From 353fce9380db94cb40df8f42546d88866eab3a7f Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 22:32:49 +0530 Subject: [PATCH 2/5] feat(analytics): own NPS survey sheet, asked after an episode finishes The NPS survey is now Home's own sheet reporting to a PostHog api survey, so PostHog draws no surveys and the old delegate, trigger and survey UI are gone. It appears only right after an episode finishes in this run, with 3 finished episodes, a week installed, outside the first run and quiet days, and every 90 days; its history lives in a file backups leave out. 9s and 10s get a Play Store button, so the promoter review hand-off is removed, and the milestone review prompt also waits for a finish moment. feedback_submitted is retired for survey shown / sent / dismissed. --- app/README.md | 15 +- .../cx/aswin/boxlore/BoxLoreApplication.kt | 19 +- .../java/cx/aswin/boxlore/MainActivity.kt | 17 - .../surveys/BoxcastPostHogSurveysDelegate.kt | 63 ---- .../boxlore/surveys/NpsSurveyBranching.kt | 39 --- .../boxlore/surveys/NpsSurveyTriggers.kt | 68 ---- .../surveys/internal/ActivityProvider.kt | 53 --- .../surveys/internal/BoxcastSurveyHost.kt | 301 ----------------- .../internal/theme/SurveyAppearance.kt | 175 ---------- .../surveys/internal/theme/SurveyColor.kt | 222 ------------- .../surveys/internal/ui/BottomSection.kt | 34 -- .../surveys/internal/ui/ConfirmationScreen.kt | 77 ----- .../surveys/internal/ui/FocusRestorer.kt | 32 -- .../surveys/internal/ui/NumberRating.kt | 158 --------- .../boxlore/surveys/internal/ui/OpenText.kt | 43 --- .../surveys/internal/ui/QuestionHeader.kt | 46 --- .../surveys/internal/ui/SurveySheet.kt | 249 -------------- app/src/main/res/xml/backup_rules.xml | 2 + .../main/res/xml/data_extraction_rules.xml | 4 + .../BackgroundCheckBackupRulesTest.kt | 17 +- .../boxlore/surveys/NpsSurveyBranchingTest.kt | 121 ------- .../surveys/TabletNoticeSurveyGateTest.kt | 35 -- config/detekt/baseline.xml | 3 - core/analytics/README.md | 9 +- .../aswin/boxlore/core/analytics/Analytics.kt | 5 - .../core/analytics/AnalyticsGlossary.kt | 4 +- .../boxlore/core/analytics/AnalyticsHelper.kt | 60 ++-- .../core/analytics/RecordingAnalytics.kt | 21 -- .../core/analytics/SurveyAnalyticsTracks.kt | 60 ++++ .../GlossaryAllEventsEmissionTest.kt | 10 +- .../analytics/RecordingAnalyticsExtraTest.kt | 29 +- .../core/analytics/RecordingAnalyticsTest.kt | 5 +- .../core/analytics/SurveyAnalyticsTest.kt | 62 ++++ core/catalog/README.md | 2 +- .../catalog/EngagementPromptCoordinator.kt | 93 +++--- .../EngagementPromptCoordinatorSurveyTest.kt | 73 +++++ core/designsystem/README.md | 4 +- .../main/assets/echo-character/manifest.json | 2 +- .../components/BoxloreModalBottomSheet.kt | 11 +- core/prefs/README.md | 3 + .../core/prefs/EngagementPreferences.kt | 50 +-- .../core/prefs/EngagementPromptConstants.kt | 3 - .../boxlore/core/prefs/NpsSurveySchedule.kt | 51 +++ .../boxlore/core/prefs/NpsSurveyStore.kt | 62 ++++ .../core/prefs/UserPreferencesRepository.kt | 10 - .../core/prefs/NpsSurveyScheduleTest.kt | 78 +++++ .../boxlore/core/prefs/NpsSurveyStoreTest.kt | 73 +++++ .../prefs/UserPreferencesRepositoryTest.kt | 26 +- core/testing/README.md | 2 +- .../architecture/GlossaryCoverageGuardTest.kt | 3 +- docs/ANALYTICS_EVENT_GLOSSARY.md | 4 +- docs/analytics/event_glossary.csv | 4 +- docs/analytics/glossary_emission_coverage.csv | 4 +- feature/home/README.md | 10 +- .../feature/home/HomeListeningHistoryItem.kt | 3 + .../aswin/boxlore/feature/home/HomeScreen.kt | 20 +- .../boxlore/feature/home/HomeViewModel.kt | 28 +- .../feature/home/HomeViewModelLoadData.kt | 35 +- .../aswin/boxlore/feature/home/NpsSurvey.kt | 193 +++++++++++ .../boxlore/feature/home/NpsSurveyHost.kt | 53 +++ .../feature/home/ReviewPromptRecorder.kt | 1 - .../feature/home/components/NpsSurveyParts.kt | 200 ++++++++++++ .../feature/home/components/NpsSurveySheet.kt | 303 ++++++++++++++++++ .../feature/home/components/ReviewSheet.kt | 87 ++--- .../home/HomeListeningHistoryItemTest.kt | 15 + .../feature/home/NpsSurveyControllerTest.kt | 172 ++++++++++ .../components/NpsSurveySheetAdaptiveTest.kt | 206 ++++++++++++ .../components/NpsSurveySheetPolicyTest.kt | 72 +++++ .../boxlore/feature/library/LibraryScreen.kt | 2 - release-notes/upcoming.md | 3 +- 70 files changed, 1911 insertions(+), 2108 deletions(-) delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyBranching.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyTriggers.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ActivityProvider.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/BoxcastSurveyHost.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyAppearance.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyColor.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/BottomSection.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/ConfirmationScreen.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/FocusRestorer.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/NumberRating.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/OpenText.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/QuestionHeader.kt delete mode 100644 app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/SurveySheet.kt delete mode 100644 app/src/test/java/cx/aswin/boxlore/surveys/NpsSurveyBranchingTest.kt delete mode 100644 app/src/test/java/cx/aswin/boxlore/surveys/TabletNoticeSurveyGateTest.kt create mode 100644 core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTracks.kt create mode 100644 core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt create mode 100644 core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt create mode 100644 core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt create mode 100644 core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt create mode 100644 core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt create mode 100644 feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt create mode 100644 feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt create mode 100644 feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveyParts.kt create mode 100644 feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt create mode 100644 feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt create mode 100644 feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt create mode 100644 feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetPolicyTest.kt diff --git a/app/README.md b/app/README.md index ab15b3da8..4854d8aa1 100644 --- a/app/README.md +++ b/app/README.md @@ -2,7 +2,7 @@ ## Purpose -The application module owns the Android app shell: `BoxLoreApplication`, `MainActivity`, navigation wiring, FCM entry points, in-app announcements, surveys, OPML import UI, and the single production composition root in `AppContainer`. It wires feature screens and core services together; feature UI and data engines remain in their owning modules. +The application module owns the Android app shell: `BoxLoreApplication`, `MainActivity`, navigation wiring, FCM entry points, in-app announcements, OPML import UI, and the single production composition root in `AppContainer`. It wires feature screens and core services together; feature UI and data engines remain in their owning modules. ## Public API @@ -68,17 +68,17 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - The library widget's Most listened sort uses `:core:database` `sortedByListening`, so a show switched to its RSS feed counts the history its old Podcast Index show keeps (`Podcast.listenerShowIds`; decision D8). - `DownloadServiceLauncherHolder` is installed with `MediaDownloadService::class.java` so `:core:downloads` can launch the foreground download service without depending on `:core:playback`. - `LegacyWorkerFactory` maps legacy worker class names to current worker implementations for WorkManager continuity. -- `MainActivity` hosts theme, edge-to-edge setup, app update hooks, surveys, library import state (full-screen `OpmlImportDialog` overlay so onboarding welcome never shows through), the selected floating or classic navigation presentation, and the player overlay. Its start/stop lifecycle informs the application-scoped playback repository whether UI position polling is needed; background audio, persistence, notifications, Cast, and MediaSession behavior remain service-owned. +- `MainActivity` hosts theme, edge-to-edge setup, app update hooks, library import state (full-screen `OpmlImportDialog` overlay so onboarding welcome never shows through), the selected floating or classic navigation presentation, and the player overlay. Its start/stop lifecycle informs the application-scoped playback repository whether UI position polling is needed; background audio, persistence, notifications, Cast, and MediaSession behavior remain service-owned. - **Feed fix off switches** (`sync/FeedFixSwitches`): PostHog kill switches for the feed and episode fixes, passed into core modules as lambdas. A fix runs unless its flag is on; no answer (PostHog not ready, flag missing or inactive, an error) keeps it on. `feed-fix-match-baseline-off` sends the feed-only extras' comparison list back from the 1,000-item `episodes/match` list to the 100-item page (`PodcastRepository.useEpisodeMatchBaseline`). `feed-fix-address-recheck-off`, `feed-fix-sync-fallback-off` and `feed-fix-moved-feed-off` turn off the subscription sync's dead-address re-check, its `/sync` fallback for failed feeds, and following moved feeds (`FeedFixToggles`). `FeedFixSwitchesTest` covers the default. The same fail-open switches pause the v2 build's jobs: `upgrade-repair-off` (the one-time repair of saved feed addresses after the update), `address-check-off` (the daily address check of followed shows), both without marking the work done, and `v2-discovery-off` (discovery back on the v1 routes). These three are read through `sync/SavedKillSwitch`, because the repair can start on a headless launch before PostHog is set up: once PostHog is ready its answer decides and is saved; while it is still starting, the last saved answer is used, and the job runs only when none was saved. - **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the daily address check and broken-feed reports read `addressCheck` (`address-check-off`, through `SubscriptionForegroundSync`, below); show pages read `feed_first` (`PodcastRepository.isFeedFirstShow`, below); the feed address repair reads `upgradeRepair`. - **Analytics switch and tags** (`BoxLoreApplication`, `analytics/PostHogWiring`): - `AnalyticsHelper.initPolicy` runs before PostHog setup with the app version, so a switch saved by another version is ignored. - `PostHogWiring.onFlagsLoaded` gives PostHog one flags-loaded callback that runs the rollout memory, then `AnalyticsHelper.refreshAnalyticsPolicy` (the `analytics-policy-v1` event-level switch). A step that throws doesn't stop the next one; the first failure is rethrown after both ran. - - Explicit flag reloads (the NPS remote trigger, the feature announcement in `BoxLoreAppRoot`) go through `AnalyticsHelper.reloadFeatureFlags`, so the switch follows them too; the rollout memory still waits for the next startup, `identify` or `reset` load. + - Explicit flag reloads (the feature announcement in `BoxLoreAppRoot`) go through `AnalyticsHelper.reloadFeatureFlags`, so the switch follows them too; the rollout memory still waits for the next startup, `identify` or `reset` load. - `AnalyticsBeforeSend` gets the same environment tags that setup registers (`PostHogWiring.environment`), so every event, `Application Installed` included, carries `is_internal` and `app_environment`. - `PostHogWiringTest` covers the tags for release, debug and isolated-test builds and the callback's order and failure handling. - After setup, `ListenInterruptions.reportLeftover` (`:core:playback`) sends `playback_interrupted` for a listen the last run left unfinished because Android ended the app. - - App-owned surfaces send page views through `TrackScreenView`: `connection_check`, `nps_survey` (trail only; the survey is counted when shown) and `nps_survey_confirmation`, `cross_device_email`, `in_app_announcement` (trail only), `lore_queue_conflict`, `tablet_alpha_notice`, `app_updates`, and the import steps `library_import_pick`/`_progress`/`_ask`/`_failed`. + - App-owned surfaces send page views through `TrackScreenView`: `connection_check`, `cross_device_email`, `in_app_announcement` (trail only), `lore_queue_conflict`, `tablet_alpha_notice`, `app_updates`, and the import steps `library_import_pick`/`_progress`/`_ask`/`_failed`. - See `:core:analytics`. - **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). - **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. @@ -86,7 +86,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker. - **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, one broken-feed report limiter (`feedReportLimiter` over the `boxlore_feed_reports` stamps, the same instance `FeedAddressUpkeep.reportLimiter` gives the daily address check, so both claim inside one daily limit), `feedTelemetry` for `feed_fetch_result`, and `isFollowed` (`podcasts.isSubscribed`), so the show page leaves followed shows' reports to their own refresh. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process). - **Feed address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (`upgrade-repair-off`) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds` (`AddressCheckAnswer.fetchWith`), the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker.request` (returns once WorkManager has saved the request, so step 4 never records one lost to process death), `userSyncCoordinator.requestIdentityCatchUp` (once every followed show was read again, the next sync runs the identity catch-up again), `connectivityStatus` and `feedTelemetry`. -- NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). +- The NPS survey is Home's own sheet (`feature/home`), reporting to a PostHog `api` survey; PostHog draws no surveys (`surveys = false`), so the SDK can update freely. `BoxLoreApplication` makes `EngagementPromptCoordinator` with the install-only `NpsSurveyStore` (`datastore/boxlore_nps_survey.preferences_pb`, left out of legacy backup, cloud backup and device transfer: `BackgroundCheckBackupRulesTest`) and counts each start with `onAppLaunched`. - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. - `ArtworkNavigationHostTheme` captures source colors via a synchronous destination listener, paints the slide/fade underlay with the departing page’s background, and gives entering loaders temporary inherited colors. Regular pages (including Home) and their navbar use the saved app theme as one unit from the first frame, while only their loaders ease back after the slide; detail loaders keep their own entry-scoped handoff until artwork is resolved. Back also starts with the departing palette before restoring the returning page’s own colors, including rapid returns while its composition is still retained; cold starts, opt-out and theme changes reset the fallback safely. The root Scaffold and predictive-Back wrapper use the current page background so shrinking the page does not reveal the saved-theme surface. Navigation routes, duration, gesture handling and playback remain unchanged. @@ -137,7 +137,6 @@ src/main/java/cx/aswin/boxlore/ NewEpisodeFcmLogic.kt NewEpisodePushHydration.kt lifecycle/ - surveys/ ui/ announcement/ libraryimport/ @@ -188,7 +187,7 @@ Unacknowledged tablet launches first show an opaque alpha notice with the Home f - `AppContainer` is created once from `BoxLoreApplication.onCreate` and is application-scoped. - Workers resolve dependencies through installed holders before doing background work. - Media3 services lazily resolve the shared graph after application startup. -- UI composition, navigation, OPML import state, surveys, and player overlay state are Activity-scoped. +- UI composition, navigation, OPML import state, and player overlay state are Activity-scoped. - `BoxLoreAppRoot` stacks the mini player using designsystem's `appMiniPlayerTopOffset`, including Android's bottom system inset for both navigation styles. Classic's internally padded navbar and Floating's externally padded navbar both retain the matching player gap under gesture or three-button navigation. Compact Floating docking uses the same bottom inset. Routes remain Home / Explore / Library / Lore (`learn`) for both presentations. ## Episode links, pushes and shares @@ -302,6 +301,6 @@ An initial update-check state must perform a lookup even when timestamps survive Tablet transport keys use the existing playback repository actions and saved seek increments. The stable text-input interceptor leaves Space/arrows with focused editors and survives resizing. Shortcuts apply only after focused content declines a key: a focused control keeps Space activation, and arrows move focus or adjust sliders unless the shell itself holds focus. JVM regressions cover keyboard/mouse ownership, centered modal phone parity and dynamic reduced motion. -The Activity owns the single adaptive window/posture collector above the app and cross-device sign-in dialog. Its UI snapshot also feeds the separate survey Compose portal, so portals do not create another window collector. Custom update/announcement dialogs and the import overlay use bounded, fold-safe tablet regions while keeping their existing content and dismissal actions. +The Activity owns the single adaptive window/posture collector above the app and cross-device sign-in dialog. The NPS survey sheet lives inside Home, so it uses the same collector. Custom update/announcement dialogs and the import overlay use bounded, fold-safe tablet regions while keeping their existing content and dismissal actions. Local-only HistoryTabletAuditCaptureTest renders loaded/empty History in both tablet orientations and themes using fixed history and insight data. It does not read or modify listener history. diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index 273f70c5a..e599172a2 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -25,7 +25,6 @@ import cx.aswin.boxlore.core.network.FirebaseAuthAuthenticator import cx.aswin.boxlore.core.network.NetworkModule import cx.aswin.boxlore.core.prefs.UserPreferencesRepository import cx.aswin.boxlore.core.ranking.LearningEventLog -import cx.aswin.boxlore.surveys.BoxcastPostHogSurveysDelegate import cx.aswin.boxlore.sync.CloudSyncWorker import cx.aswin.boxlore.ui.StartupWorkGate import cx.aswin.boxlore.widgets.HomeScreenWidgetsInstaller @@ -180,7 +179,12 @@ class BoxLoreApplication : userPreferencesRepository = userPreferencesRepository, adaptiveScorer = { container.adaptiveCandidateScorer }, ) - engagementPromptCoordinator = EngagementPromptCoordinator(userPreferencesRepository) + engagementPromptCoordinator = + EngagementPromptCoordinator(userPreferencesRepository, cx.aswin.boxlore.core.prefs.NpsSurveyStore(this)) + applicationScope.launch { + val installedAtMs = runCatching { packageManager.getPackageInfo(packageName, 0).firstInstallTime }.getOrDefault(0L) + engagementPromptCoordinator.onAppLaunched(installedAtMs) + } scheduleOptionalStartup() // Live learner signal log: on by default in debug; release stays off unless the @@ -380,15 +384,8 @@ class BoxLoreApplication : captureScreenViews = false captureDeepLinks = false debug = BuildConfig.DEBUG - // PostHog Surveys with a Material3 1.5-compatible delegate (the published - // posthog-android-surveys-compose:0.1.0 module crashes against our M3 pin). - surveys = true - surveysConfig.surveysDelegate = - BoxcastPostHogSurveysDelegate( - context = this@BoxLoreApplication, - userPrefs = userPreferencesRepository, - engagementCoordinator = engagementPromptCoordinator, - ) + // The NPS survey is boxlore's own sheet (Home), reporting to an API survey; PostHog draws none. + surveys = false onFeatureFlags = PostHogWiring.onFlagsLoaded( // LEGACY(own-ids): staged feed-first rollout; remove at S7 with rememberRolloutAnswers. diff --git a/app/src/main/java/cx/aswin/boxlore/MainActivity.kt b/app/src/main/java/cx/aswin/boxlore/MainActivity.kt index ecc36cd11..92ec17682 100644 --- a/app/src/main/java/cx/aswin/boxlore/MainActivity.kt +++ b/app/src/main/java/cx/aswin/boxlore/MainActivity.kt @@ -13,11 +13,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope import com.posthog.PostHog import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.playback.PlaybackRepository -import cx.aswin.boxlore.surveys.NpsSurveyTriggers import cx.aswin.boxlore.ui.BoxLoreAppRoot import cx.aswin.boxlore.updates.PlayAppUpdateHelper @@ -38,18 +36,9 @@ class MainActivity : ComponentActivity() { @Suppress("unused") private var isFirstResumeAfterLaunch = true - private val surveyPrefs by lazy { - (application as BoxLoreApplication).userPreferencesRepository - } - private val engagementCoordinator by lazy { - (application as BoxLoreApplication).engagementPromptCoordinator - } - @Volatile private var playbackRepositoryRef: PlaybackRepository? = null - private fun isCurrentlyPlaying(): Boolean = playbackRepositoryRef?.playerState?.value?.isPlaying == true - private val updateLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult(), @@ -165,12 +154,6 @@ class MainActivity : ComponentActivity() { } private fun resumeForegroundPrompts() { - NpsSurveyTriggers.check( - surveyPrefs = surveyPrefs, - engagementCoordinator = engagementCoordinator, - isCurrentlyPlaying = ::isCurrentlyPlaying, - scope = lifecycleScope, - ) appUpdates.foreground() if (appUpdates.usesPlay) playAppUpdateHelper.resumeInProgressUpdate() } diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt b/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt deleted file mode 100644 index 7eec081f3..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt +++ /dev/null @@ -1,63 +0,0 @@ -package cx.aswin.boxlore.surveys - -import android.app.Application -import android.content.Context -import com.posthog.surveys.OnPostHogSurveyClosed -import com.posthog.surveys.OnPostHogSurveyResponse -import com.posthog.surveys.OnPostHogSurveyShown -import com.posthog.surveys.PostHogDisplaySurvey -import com.posthog.surveys.PostHogSurveysDelegate -import cx.aswin.boxlore.core.catalog.EngagementPromptCoordinator -import cx.aswin.boxlore.core.prefs.UserPreferencesRepository -import cx.aswin.boxlore.surveys.internal.ActivityProvider -import cx.aswin.boxlore.surveys.internal.BoxcastSurveyHost - -/** - * Material3 1.5-compatible survey UI delegate. - * - * Replaces `posthog-android-surveys-compose:0.1.0`, which crashes at runtime - * against Material3 1.5 because it calls a removed `ModalBottomSheetProperties` - * constructor. - */ -class BoxcastPostHogSurveysDelegate( - context: Context, - private val userPrefs: UserPreferencesRepository, - private val engagementCoordinator: EngagementPromptCoordinator, -) : PostHogSurveysDelegate { - private val application: Application = context.applicationContext as Application - private val activityProvider = ActivityProvider() - private val host = - BoxcastSurveyHost( - activityProvider = activityProvider, - userPrefs = userPrefs, - onSurveyDisplayed = { - // PostHog records the display itself ("survey shown"); a copy here counted it twice. - engagementCoordinator.onSurveyDisplayed() - }, - onFirstRatingSubmitted = { score -> - engagementCoordinator.onNpsRatingSubmitted(score) - }, - ) - - init { - application.registerActivityLifecycleCallbacks(activityProvider) - } - - override fun renderSurvey( - survey: PostHogDisplaySurvey, - onSurveyShown: OnPostHogSurveyShown, - onSurveyResponse: OnPostHogSurveyResponse, - onSurveyClosed: OnPostHogSurveyClosed, - ) { - host.show( - survey = survey, - onSurveyShown = onSurveyShown, - onSurveyResponse = onSurveyResponse, - onSurveyClosed = onSurveyClosed, - ) - } - - override fun cleanupSurveys() { - host.cleanup() - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyBranching.kt b/app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyBranching.kt deleted file mode 100644 index 29f232658..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyBranching.kt +++ /dev/null @@ -1,39 +0,0 @@ -package cx.aswin.boxlore.surveys - -import com.posthog.surveys.PostHogDisplaySurvey -import com.posthog.surveys.PostHogNextSurveyQuestion - -/** - * PostHog NPS survey "Boxlore NPS + Feature Ideas" branches by score into three - * open-text paths, but open questions cannot reliably persist `branching: end` via - * the surveys API. Without an end step, the SDK walks into the next path - * (e.g. detractors see "What do you love most?"). - * - * After each path's last question, force survey completion. - */ -internal object NpsSurveyBranching { - /** Last question id in the detractor / passive / promoter open-text paths. */ - private val endAfterQuestionIds = - setOf( - // "What's the one thing we should fix or build first?" - "163c6220-7b03-46b8-9073-f18435bb3eeb", - // "What feature would make boxlore a must-have for you?" - "bb126737-6122-47ed-b2e5-50b3b4fa0bd8", - // "What would you love to see us build next?" - "7ec7a537-cc62-4577-90c7-d05a49bb397a", - ) - - fun adjustNext( - survey: PostHogDisplaySurvey, - answeredQuestionIndex: Int, - sdkNext: PostHogNextSurveyQuestion?, - ): PostHogNextSurveyQuestion? { - if (sdkNext?.isSurveyCompleted == true) return sdkNext - val answeredId = survey.questions.getOrNull(answeredQuestionIndex)?.id ?: return sdkNext - if (answeredId !in endAfterQuestionIds) return sdkNext - return PostHogNextSurveyQuestion( - questionIndex = answeredQuestionIndex, - isSurveyCompleted = true, - ) - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyTriggers.kt b/app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyTriggers.kt deleted file mode 100644 index ddf3c16d1..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/NpsSurveyTriggers.kt +++ /dev/null @@ -1,68 +0,0 @@ -package cx.aswin.boxlore.surveys - -import android.util.Log -import com.posthog.PostHog -import cx.aswin.boxlore.core.analytics.AnalyticsHelper -import cx.aswin.boxlore.core.catalog.EngagementPromptCoordinator -import cx.aswin.boxlore.core.prefs.UserPreferencesRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * Fires NPS survey trigger events on app open. - * - * 1. Console remote trigger: if the `survey-nps-remote-trigger` flag is enabled for this - * user, fire the manual trigger and reload flags so the one-shot trigger doesn't repeat. - * 2. Deferred auto trigger: if the survey was marked pending (ep 3 reached, possibly during - * background playback) and hasn't fired yet, fire it now and mark it fired. - * - * PostHog owns the survey's display conditions (onboarding status, audience flag, wait - * period), so this only emits the trigger events. - */ -object NpsSurveyTriggers { - private const val TAG = "NpsSurvey" - - fun check( - surveyPrefs: UserPreferencesRepository, - engagementCoordinator: EngagementPromptCoordinator, - isCurrentlyPlaying: () -> Boolean, - scope: CoroutineScope, - ) { - // The flag can only be read once PostHog is set up; queued callbacks run on the setup - // thread, so the check itself goes back to [scope]. - cx.aswin.boxlore.core.analytics.PostHogStartup.runWhenReady { - scope.launch { - try { - if ( - PostHog.isFeatureEnabled("survey-nps-remote-trigger") && - engagementCoordinator.canShowProactivePrompt(isCurrentlyPlaying()) - ) { - AnalyticsHelper.trackSurveyNpsManualTrigger(source = "remote_flag") - AnalyticsHelper.reloadFeatureFlags() - } - } catch (e: Exception) { - Log.e(TAG, "Remote trigger check failed", e) - } - } - } - - scope.launch { - try { - if ( - surveyPrefs.isNpsSurveyPending() && - !surveyPrefs.hasNpsSurveyFired() && - engagementCoordinator.canShowProactivePrompt(isCurrentlyPlaying()) && - surveyPrefs.isEngagementCooldownElapsed() - ) { - AnalyticsHelper.trackSurveyNpsEligible( - completedEpisodes = surveyPrefs.npsSurveyCompletedCount(), - triggerContext = "app_open", - ) - surveyPrefs.markNpsSurveyFired() - } - } catch (e: Exception) { - Log.e(TAG, "Deferred trigger check failed", e) - } - } - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ActivityProvider.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ActivityProvider.kt deleted file mode 100644 index 2b470d345..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ActivityProvider.kt +++ /dev/null @@ -1,53 +0,0 @@ -package cx.aswin.boxlore.surveys.internal - -import android.app.Activity -import android.app.Application -import android.os.Bundle -import java.lang.ref.WeakReference - -/** - * Tracks the foreground [Activity] so survey UI can attach to the visible window. - * Uses [WeakReference] to avoid leaking destroyed activities. - */ -internal class ActivityProvider : Application.ActivityLifecycleCallbacks { - @Volatile - private var foreground: WeakReference? = null - - /** Invoked when a tracked activity is destroyed (used to dismiss survey dialogs). */ - var onActivityDestroyedListener: ((Activity) -> Unit)? = null - - /** Invoked on resume so surveys can reattach after config changes or backgrounding. */ - var onActivityResumedListener: ((Activity) -> Unit)? = null - - /** The activity currently in the foreground, if any. */ - val foregroundActivity: Activity? - get() = foreground?.get() - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit - - override fun onActivityStarted(activity: Activity) { - foreground = WeakReference(activity) - } - - override fun onActivityResumed(activity: Activity) { - foreground = WeakReference(activity) - onActivityResumedListener?.invoke(activity) - } - - override fun onActivityPaused(activity: Activity) = Unit - - override fun onActivityStopped(activity: Activity) { - if (foreground?.get() === activity) { - foreground = null - } - } - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - - override fun onActivityDestroyed(activity: Activity) { - if (foreground?.get() === activity) { - foreground = null - } - onActivityDestroyedListener?.invoke(activity) - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/BoxcastSurveyHost.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/BoxcastSurveyHost.kt deleted file mode 100644 index 99b128940..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/BoxcastSurveyHost.kt +++ /dev/null @@ -1,301 +0,0 @@ -package cx.aswin.boxlore.surveys.internal - -import android.app.Activity -import android.graphics.Color -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.Window -import android.view.WindowManager -import androidx.activity.ComponentDialog -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry -import androidx.compose.runtime.saveable.SaveableStateRegistry -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.graphics.drawable.toDrawable -import androidx.lifecycle.lifecycleScope -import com.posthog.PostHog -import com.posthog.surveys.OnPostHogSurveyClosed -import com.posthog.surveys.OnPostHogSurveyResponse -import com.posthog.surveys.OnPostHogSurveyShown -import com.posthog.surveys.PostHogDisplaySurvey -import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme -import cx.aswin.boxlore.core.prefs.UserPreferencesRepository -import cx.aswin.boxlore.surveys.internal.ui.SurveySheet -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -/** - * Presents PostHog surveys in a Compose [ComponentDialog] attached to the foreground activity. - * Survives configuration changes by preserving saveable state and reattaching on resume. - */ -internal class BoxcastSurveyHost( - private val activityProvider: ActivityProvider, - private val userPrefs: UserPreferencesRepository, - private val onSurveyDisplayed: () -> Unit, - private val onFirstRatingSubmitted: (Int?) -> Unit, -) { - private val mainHandler = Handler(Looper.getMainLooper()) - private val scope = - CoroutineScope( - SupervisorJob() + - Dispatchers.IO + - CoroutineExceptionHandler { _, throwable -> - Log.e(TAG, "Survey host side-effect failed", throwable) - }, - ) - - private var dialog: ComponentDialog? = null - private var composeView: ComposeView? = null - private var hostActivity: Activity? = null - private var currentSurvey: PostHogDisplaySurvey? = null - private var onShownCallback: OnPostHogSurveyShown? = null - private var onResponseCallback: OnPostHogSurveyResponse? = null - private var onClosedCallback: OnPostHogSurveyClosed? = null - private var shownReported = false - private var awaitingForeground = false - private var startupPresentation: Job? = null - private var saveableRegistry: SaveableStateRegistry? = null - private var savedSurveyState: Map>? = null - - init { - activityProvider.onActivityDestroyedListener = { destroyed -> - if (destroyed === hostActivity) { - if (destroyed.isChangingConfigurations) { - preserveForConfigChange() - } else { - dismissInternal(notifyClosed = true) - } - } - } - activityProvider.onActivityResumedListener = { resumed -> - if (currentSurvey != null && (savedSurveyState != null || awaitingForeground)) { - present(resumed) - } - } - } - - /** Queues the survey and presents it on the current foreground activity. */ - fun show( - survey: PostHogDisplaySurvey, - onSurveyShown: OnPostHogSurveyShown, - onSurveyResponse: OnPostHogSurveyResponse, - onSurveyClosed: OnPostHogSurveyClosed, - ) { - runOnMain { - dismissInternal(notifyClosed = true) - - currentSurvey = survey - onShownCallback = onSurveyShown - onResponseCallback = onSurveyResponse - onClosedCallback = onSurveyClosed - present(activityProvider.foregroundActivity) - } - } - - /** Dismisses any visible survey without notifying PostHog of a close event. */ - fun cleanup() { - runOnMain { dismissInternal(notifyClosed = false) } - } - - private fun present(activity: Activity?) { - val survey = currentSurvey ?: return - - if (activity == null || activity.isFinishing) { - awaitingForeground = true - return - } - - if (deferForTabletNotice(activity, survey)) return - - val presented = - guard("presenting the survey") { - awaitingForeground = false - hostActivity = activity - - val registry = - SaveableStateRegistry( - restoredValues = savedSurveyState, - canBeSaved = { true }, - ) - saveableRegistry = registry - savedSurveyState = null - - val themeConfig = userPrefs.cachedThemeConfig - - val view = - ComposeView(activity).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val darkTheme = - when (themeConfig) { - "light" -> false - "dark" -> true - else -> isSystemInDarkTheme() - } - BoxLoreTheme( - darkTheme = darkTheme, - dynamicColor = userPrefs.cachedUseDynamicColor, - themeBrand = userPrefs.cachedThemeBrand, - surfaceStyle = userPrefs.cachedSurfaceStyle, - fontRoundness = - cx.aswin.boxlore.core.designsystem.theme.FontRoundness.axisValue( - userPrefs.cachedFontRoundness, - ), - ) { - CompositionLocalProvider( - LocalSaveableStateRegistry provides registry, - cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout provides ((activity as? cx.aswin.boxlore.MainActivity)?.windowLayoutState?.value ?: cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout.Unspecified), - ) { - SurveySheet( - survey = survey, - onSurveyShown = { reportShownOnce() }, - onSubmit = { questionIndex, response -> - onResponseCallback?.invoke(survey, questionIndex, response) - }, - onClose = { dismissInternal(notifyClosed = true) }, - onFirstRatingSubmitted = onFirstRatingSubmitted, - ) - } - } - } - } - - val componentDialog = - ComponentDialog(activity).apply { - setContentView(view) - setCancelable(false) - setCanceledOnTouchOutside(false) - configureWindow(window) - } - - dialog = componentDialog - composeView = view - componentDialog.show() - } - - if (!presented) { - dismissInternal(notifyClosed = false) - } - } - - private fun reportShownOnce() { - if (shownReported) return - val survey = currentSurvey ?: return - shownReported = true - onSurveyDisplayed() - scope.launch { - runCatching { - if (!userPrefs.hasNpsSurveyFired()) { - userPrefs.markNpsSurveyFired() - } - }.onFailure { Log.e(TAG, "Failed to mark NPS survey fired", it) } - } - onShownCallback?.invoke(survey) - } - - private fun configureWindow(window: Window?) { - window ?: return - window.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) - window.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT, - ) - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) - } - - private fun preserveForConfigChange() { - guard("preserving the survey across a configuration change") { - savedSurveyState = saveableRegistry?.performSave() - saveableRegistry = null - composeView?.disposeComposition() - dialog?.let { if (it.isShowing) it.dismiss() } - } - - dialog = null - composeView = null - hostActivity = null - } - - private fun deferForTabletNotice(activity: Activity, survey: PostHogDisplaySurvey): Boolean { - startupPresentation?.cancel() - startupPresentation = null - if (activity !is cx.aswin.boxlore.MainActivity || !activity.tabletAlphaNoticePending) return false - awaitingForeground = true - startupPresentation = activity.lifecycleScope.launch { - awaitTabletNoticeDismissal { activity.tabletAlphaNoticePending } - startupPresentation = null - if (currentSurvey === survey && activityProvider.foregroundActivity === activity) present(activity) - } - return true - } - - private fun dismissInternal(notifyClosed: Boolean) { - startupPresentation?.cancel() - startupPresentation = null - val activeDialog = dialog - val activeView = composeView - val survey = currentSurvey - val onClosed = onClosedCallback - val wasShown = shownReported - - dialog = null - composeView = null - hostActivity = null - currentSurvey = null - onShownCallback = null - onResponseCallback = null - onClosedCallback = null - shownReported = false - awaitingForeground = false - saveableRegistry = null - savedSurveyState = null - - guard("tearing down the survey") { - activeView?.disposeComposition() - activeDialog?.let { d -> - if (d.isShowing) { - d.dismiss() - } - } - } - - if (notifyClosed && wasShown && survey != null && onClosed != null) { - onClosed(survey) - } - } - - private inline fun guard(action: String, block: () -> Unit): Boolean = try { - block() - true - } catch (e: Exception) { - PostHog.getConfig()?.logger?.log( - "Surveys: $action failed, skipping the survey. $e", - ) - false - } - - private fun runOnMain(block: () -> Unit) { - if (Looper.myLooper() == Looper.getMainLooper()) { - block() - } else { - mainHandler.post(block) - } - } - - companion object { - private const val TAG = "BoxcastSurveyHost" - } -} - -internal suspend fun awaitTabletNoticeDismissal(pending: () -> Boolean) { - snapshotFlow(pending).first { !it } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyAppearance.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyAppearance.kt deleted file mode 100644 index 0788b68e5..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyAppearance.kt +++ /dev/null @@ -1,175 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.theme - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.graphics.Color -import com.posthog.surveys.PostHogDisplaySurveyAppearance -import com.posthog.surveys.PostHogDisplaySurveyTextContentType - -/** Resolved survey colors and copy, merged from PostHog appearance and Material theme defaults. */ -internal data class ResolvedSurveyAppearance( - val backgroundColor: Color, - val borderColor: Color, - val submitButtonColor: Color, - val submitButtonTextColor: Color, - val submitButtonText: String, - val textColor: Color, - val questionTextColor: Color, - val descriptionTextColor: Color, - val placeholder: String, - val placeholderTextColor: Color, - val inputBackgroundColor: Color, - val inputTextColor: Color, - val ratingButtonColor: Color, - val ratingButtonActiveColor: Color, - val ratingButtonActiveTextColor: Color, - val displayThankYouMessage: Boolean, - val thankYouMessageHeader: String, - val thankYouMessageDescription: String?, - val thankYouMessageDescriptionContentType: PostHogDisplaySurveyTextContentType, - val thankYouMessageCloseButtonText: String, -) - -/** CompositionLocal holding the active survey appearance for child composables. */ -internal val LocalSurveyAppearance = - compositionLocalOf { - error("LocalSurveyAppearance not provided") - } - -/** Reads the current survey appearance from [LocalSurveyAppearance]. */ -@Composable -@ReadOnlyComposable -internal fun localAppearance(): ResolvedSurveyAppearance = LocalSurveyAppearance.current - -internal data class MaterialSurveyDefaults( - val background: Color, - val onSurface: Color, - val onSurfaceVariant: Color, - val primary: Color, - val onPrimary: Color, - val primaryContainer: Color, - val onPrimaryContainer: Color, - val surfaceContainerHigh: Color, - val surfaceContainerHighest: Color, - val outlineVariant: Color, -) - -@Composable -@ReadOnlyComposable -private fun materialSurveyDefaults(): MaterialSurveyDefaults { - val scheme = MaterialTheme.colorScheme - return MaterialSurveyDefaults( - background = scheme.surface, - onSurface = scheme.onSurface, - onSurfaceVariant = scheme.onSurfaceVariant, - primary = scheme.primary, - onPrimary = scheme.onPrimary, - primaryContainer = scheme.primaryContainer, - onPrimaryContainer = scheme.onPrimaryContainer, - surfaceContainerHigh = scheme.surfaceContainerHigh, - surfaceContainerHighest = scheme.surfaceContainerHighest, - outlineVariant = scheme.outlineVariant, - ) -} - -private const val DEFAULT_PLACEHOLDER = "Start typing..." -private const val DEFAULT_THANK_YOU_HEADER = "Thank you for your feedback!" -private const val DEFAULT_THANK_YOU_CLOSE = "Close" - -/** Maps PostHog survey appearance settings onto Material theme defaults. */ -@Composable -@ReadOnlyComposable -internal fun PostHogDisplaySurveyAppearance?.resolveAppearance(): ResolvedSurveyAppearance { - val defaults = materialSurveyDefaults() - return resolve(defaults) -} - -internal fun PostHogDisplaySurveyAppearance?.resolve(defaults: MaterialSurveyDefaults,): ResolvedSurveyAppearance { - val backgroundColor = parseSurveyColorOrDefault(this?.backgroundColor, defaults.background) - val submitButtonColor = parseSurveyColorOrDefault(this?.submitButtonColor, defaults.primary) - val submitButtonTextColor = - parseSurveyColorOrDefault(this?.submitButtonTextColor, defaults.onPrimary) - val rawTextColor = parseSurveyColorOrDefault(this?.textColor, defaults.onSurface) - val rawDescriptionTextColor = - parseSurveyColorOrDefault(this?.descriptionTextColor, defaults.onSurfaceVariant) - // PostHog may send hardcoded light-mode text colors (e.g. black) from the dashboard - // config. The actual sheet background is always MaterialTheme.colorScheme.surface - // (defaults.background), not the PostHog backgroundColor, so contrast must be checked - // against the real rendered surface to prevent black-on-black in dark mode. - val sheetBackground = defaults.background - val textColor = - if (hasAdequateContrast(rawTextColor, sheetBackground)) rawTextColor else defaults.onSurface - val descriptionTextColor = - if (hasAdequateContrast(rawDescriptionTextColor, sheetBackground)) { - rawDescriptionTextColor - } else { - defaults.onSurfaceVariant - } - val borderColor = parseSurveyColorOrDefault(this?.borderColor, defaults.outlineVariant) - val ratingButtonColor = - parseSurveyColorOrDefault(this?.ratingButtonColor, defaults.surfaceContainerHigh) - .let { color -> - if (!sheetBackground.isLight() && color.isLight()) { - defaults.surfaceContainerHigh - } else { - color - } - } - val ratingButtonActiveColor = - parseSurveyColorOrDefault(this?.ratingButtonActiveColor, defaults.primaryContainer) - val ratingButtonActiveTextColor = defaults.onPrimaryContainer - val submitButtonText = this?.submitButtonText?.takeIf { it.isNotBlank() } ?: "Submit" - val questionTextColor = - if (hasAdequateContrast(rawTextColor, sheetBackground)) rawTextColor else defaults.onSurface - val inputBackgroundColor = - parseSurveyColorOrDefault(this?.inputBackground, defaults.surfaceContainerHighest) - val rawInputTextColor = - parseSurveyColorOrDefault(this?.inputTextColor, defaults.onSurface) - val inputTextColor = - if (hasAdequateContrast(rawInputTextColor, inputBackgroundColor)) { - rawInputTextColor - } else { - defaults.onSurface - } - val placeholderTextColor = defaults.onSurfaceVariant.copy(alpha = 0.7f) - val placeholder = this?.placeholder?.takeIf { it.isNotBlank() } ?: DEFAULT_PLACEHOLDER - val displayThankYouMessage = this?.displayThankYouMessage ?: false - val thankYouMessageHeader = - this?.thankYouMessageHeader?.takeIf { it.isNotBlank() } ?: DEFAULT_THANK_YOU_HEADER - val thankYouMessageDescription = this?.thankYouMessageDescription?.takeIf { it.isNotBlank() } - val thankYouMessageDescriptionContentType = - this?.thankYouMessageDescriptionContentType ?: PostHogDisplaySurveyTextContentType.TEXT - val thankYouMessageCloseButtonText = - this?.thankYouMessageCloseButtonText?.takeIf { it.isNotBlank() } ?: DEFAULT_THANK_YOU_CLOSE - - return ResolvedSurveyAppearance( - backgroundColor = backgroundColor, - borderColor = borderColor, - submitButtonColor = submitButtonColor, - submitButtonTextColor = submitButtonTextColor, - submitButtonText = submitButtonText, - textColor = textColor, - questionTextColor = questionTextColor, - descriptionTextColor = descriptionTextColor, - placeholder = placeholder, - placeholderTextColor = placeholderTextColor, - inputBackgroundColor = inputBackgroundColor, - inputTextColor = inputTextColor, - ratingButtonColor = ratingButtonColor, - ratingButtonActiveColor = ratingButtonActiveColor, - ratingButtonActiveTextColor = ratingButtonActiveTextColor, - displayThankYouMessage = displayThankYouMessage, - thankYouMessageHeader = thankYouMessageHeader, - thankYouMessageDescription = thankYouMessageDescription, - thankYouMessageDescriptionContentType = thankYouMessageDescriptionContentType, - thankYouMessageCloseButtonText = thankYouMessageCloseButtonText, - ) -} - -private fun parseSurveyColorOrDefault(input: String?, default: Color): Color { - if (input.isNullOrBlank()) return default - val parsed = parseSurveyColor(input) - return if (parsed == Color.Transparent) default else parsed -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyColor.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyColor.kt deleted file mode 100644 index c38a97ec0..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/theme/SurveyColor.kt +++ /dev/null @@ -1,222 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.theme - -import androidx.compose.ui.graphics.Color - -/** - * Parses a survey-appearance color string into a Compose [Color]. - * - * Supports the web color forms PostHog appearance settings produce: named CSS - * colors (`"red"`, `"transparent"`), `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, - * and the same forms without the leading `#`. Returns [Color.Transparent] when - * the input is null, blank, or unrecognized. - */ -internal fun parseSurveyColor(input: String?): Color { - val raw = input?.trim()?.takeIf { it.isNotEmpty() } ?: return Color.Transparent - val keyed = cssColorTable[raw.uppercase()] - val withAlphaPadding: String = keyed?.let { if (it.length == 8) it else it + "ff" } ?: raw - val noHash = if (withAlphaPadding.startsWith("#")) withAlphaPadding.drop(1) else withAlphaPadding - val expanded = - when (noHash.length) { - 3, 4 -> noHash.map { c -> "$c$c" }.joinToString(separator = "") - else -> noHash - } - val full = if (expanded.length <= 7) expanded + "ff" else expanded - if (full.length != 8 || full.any { !isHexDigit(it) }) return Color.Transparent - - val v = full.toLong(16) - val r = ((v shr 24) and 0xFF) / 255f - val g = ((v shr 16) and 0xFF) / 255f - val b = ((v shr 8) and 0xFF) / 255f - val a = (v and 0xFF) / 255f - return Color(r, g, b, a) -} - -/** - * Returns black or white text that meets WCAG-ish contrast against the - * receiver (luminance < 0.6 → white). - */ -internal fun Color.contrastingTextColor(): Color = if (isLight()) Color.Black else Color.White - -/** - * Whether the color reads as "light" (luminance ≥ 0.6, the threshold used for - * picking contrasting text). - */ -internal fun Color.isLight(): Boolean { - val luminance = 0.2126f * red + 0.7152f * green + 0.0722f * blue - return luminance >= 0.6f -} - -/** - * Returns true when [foreground] has enough perceptual contrast against [background] - * to remain legible. Uses a simplified relative-luminance ratio check; the threshold - * of 1.8 catches obvious clashes like black-on-black or white-on-white while still - * accepting most intentional PostHog color pairings. - */ -internal fun hasAdequateContrast(foreground: Color, background: Color): Boolean { - fun luminance(c: Color): Float = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue - - val fgL = luminance(foreground) + 0.05f - val bgL = luminance(background) + 0.05f - val ratio = maxOf(fgL, bgL) / minOf(fgL, bgL) - return ratio >= 1.8f -} - -private fun isHexDigit(c: Char): Boolean = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' - -// CSS named-color table (140 entries + transparency aliases). Uppercased keys. -// Values are 6- or 8-char hex without the leading "#". 8-char entries already -// include an alpha byte; 6-char entries get "ff" appended at lookup time. -private val cssColorTable: Map = - mapOf( - "" to "00000000", - "CLEAR" to "00000000", - "TRANSPARENT" to "00000000", - "ALICEBLUE" to "F0F8FF", - "ANTIQUEWHITE" to "FAEBD7", - "AQUA" to "00FFFF", - "AQUAMARINE" to "7FFFD4", - "AZURE" to "F0FFFF", - "BEIGE" to "F5F5DC", - "BISQUE" to "FFE4C4", - "BLACK" to "000000", - "BLANCHEDALMOND" to "FFEBCD", - "BLUE" to "0000FF", - "BLUEVIOLET" to "8A2BE2", - "BROWN" to "A52A2A", - "BURLYWOOD" to "DEB887", - "CADETBLUE" to "5F9EA0", - "CHARTREUSE" to "7FFF00", - "CHOCOLATE" to "D2691E", - "CORAL" to "FF7F50", - "CORNFLOWERBLUE" to "6495ED", - "CORNSILK" to "FFF8DC", - "CRIMSON" to "DC143C", - "CYAN" to "00FFFF", - "DARKBLUE" to "00008B", - "DARKCYAN" to "008B8B", - "DARKGOLDENROD" to "B8860B", - "DARKGRAY" to "A9A9A9", - "DARKGREY" to "A9A9A9", - "DARKGREEN" to "006400", - "DARKKHAKI" to "BDB76B", - "DARKMAGENTA" to "8B008B", - "DARKOLIVEGREEN" to "556B2F", - "DARKORANGE" to "FF8C00", - "DARKORCHID" to "9932CC", - "DARKRED" to "8B0000", - "DARKSALMON" to "E9967A", - "DARKSEAGREEN" to "8FBC8F", - "DARKSLATEBLUE" to "483D8B", - "DARKSLATEGRAY" to "2F4F4F", - "DARKSLATEGREY" to "2F4F4F", - "DARKTURQUOISE" to "00CED1", - "DARKVIOLET" to "9400D3", - "DEEPPINK" to "FF1493", - "DEEPSKYBLUE" to "00BFFF", - "DIMGRAY" to "696969", - "DIMGREY" to "696969", - "DODGERBLUE" to "1E90FF", - "FIREBRICK" to "B22222", - "FLORALWHITE" to "FFFAF0", - "FORESTGREEN" to "228B22", - "FUCHSIA" to "FF00FF", - "GAINSBORO" to "DCDCDC", - "GHOSTWHITE" to "F8F8FF", - "GOLD" to "FFD700", - "GOLDENROD" to "DAA520", - "GRAY" to "808080", - "GREY" to "808080", - "GREEN" to "008000", - "GREENYELLOW" to "ADFF2F", - "HONEYDEW" to "F0FFF0", - "HOTPINK" to "FF69B4", - "INDIANRED" to "CD5C5C", - "INDIGO" to "4B0082", - "IVORY" to "FFFFF0", - "KHAKI" to "F0E68C", - "LAVENDER" to "E6E6FA", - "LAVENDERBLUSH" to "FFF0F5", - "LAWNGREEN" to "7CFC00", - "LEMONCHIFFON" to "FFFACD", - "LIGHTBLUE" to "ADD8E6", - "LIGHTCORAL" to "F08080", - "LIGHTCYAN" to "E0FFFF", - "LIGHTGOLDENRODYELLOW" to "FAFAD2", - "LIGHTGRAY" to "D3D3D3", - "LIGHTGREY" to "D3D3D3", - "LIGHTGREEN" to "90EE90", - "LIGHTPINK" to "FFB6C1", - "LIGHTSALMON" to "FFA07A", - "LIGHTSEAGREEN" to "20B2AA", - "LIGHTSKYBLUE" to "87CEFA", - "LIGHTSLATEGRAY" to "778899", - "LIGHTSLATEGREY" to "778899", - "LIGHTSTEELBLUE" to "B0C4DE", - "LIGHTYELLOW" to "FFFFE0", - "LIME" to "00FF00", - "LIMEGREEN" to "32CD32", - "LINEN" to "FAF0E6", - "MAGENTA" to "FF00FF", - "MAROON" to "800000", - "MEDIUMAQUAMARINE" to "66CDAA", - "MEDIUMBLUE" to "0000CD", - "MEDIUMORCHID" to "BA55D3", - "MEDIUMPURPLE" to "9370DB", - "MEDIUMSEAGREEN" to "3CB371", - "MEDIUMSLATEBLUE" to "7B68EE", - "MEDIUMSPRINGGREEN" to "00FA9A", - "MEDIUMTURQUOISE" to "48D1CC", - "MEDIUMVIOLETRED" to "C71585", - "MIDNIGHTBLUE" to "191970", - "MINTCREAM" to "F5FFFA", - "MISTYROSE" to "FFE4E1", - "MOCCASIN" to "FFE4B5", - "NAVAJOWHITE" to "FFDEAD", - "NAVY" to "000080", - "OLDLACE" to "FDF5E6", - "OLIVE" to "808000", - "OLIVEDRAB" to "6B8E23", - "ORANGE" to "FFA500", - "ORANGERED" to "FF4500", - "ORCHID" to "DA70D6", - "PALEGOLDENROD" to "EEE8AA", - "PALEGREEN" to "98FB98", - "PALETURQUOISE" to "AFEEEE", - "PALEVIOLETRED" to "DB7093", - "PAPAYAWHIP" to "FFEFD5", - "PEACHPUFF" to "FFDAB9", - "PERU" to "CD853F", - "PINK" to "FFC0CB", - "PLUM" to "DDA0DD", - "POWDERBLUE" to "B0E0E6", - "PURPLE" to "800080", - "REBECCAPURPLE" to "663399", - "RED" to "FF0000", - "ROSYBROWN" to "BC8F8F", - "ROYALBLUE" to "4169E1", - "SADDLEBROWN" to "8B4513", - "SALMON" to "FA8072", - "SANDYBROWN" to "F4A460", - "SEAGREEN" to "2E8B57", - "SEASHELL" to "FFF5EE", - "SIENNA" to "A0522D", - "SILVER" to "C0C0C0", - "SKYBLUE" to "87CEEB", - "SLATEBLUE" to "6A5ACD", - "SLATEGRAY" to "708090", - "SLATEGREY" to "708090", - "SNOW" to "FFFAFA", - "SPRINGGREEN" to "00FF7F", - "STEELBLUE" to "4682B4", - "TAN" to "D2B48C", - "TEAL" to "008080", - "THISTLE" to "D8BFD8", - "TOMATO" to "FF6347", - "TURQUOISE" to "40E0D0", - "VIOLET" to "EE82EE", - "WHEAT" to "F5DEB3", - "WHITE" to "FFFFFF", - "WHITESMOKE" to "F5F5F5", - "YELLOW" to "FFFF00", - "YELLOWGREEN" to "9ACD32", - ) diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/BottomSection.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/BottomSection.kt deleted file mode 100644 index abb15f900..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/BottomSection.kt +++ /dev/null @@ -1,34 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import cx.aswin.boxlore.surveys.internal.theme.localAppearance - -/** Primary submit button for survey and confirmation screens. */ -@Composable -internal fun BottomSection(label: String, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit,) { - val appearance = localAppearance() - Button( - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(16.dp), - modifier = - modifier - .fillMaxWidth() - .height(52.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = appearance.submitButtonColor, - contentColor = appearance.submitButtonTextColor, - ), - ) { - Text(text = label) - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/ConfirmationScreen.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/ConfirmationScreen.kt deleted file mode 100644 index bc94e1e21..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/ConfirmationScreen.kt +++ /dev/null @@ -1,77 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.posthog.surveys.PostHogDisplaySurveyTextContentType -import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.surveys.internal.theme.localAppearance - -/** Post-submit thank-you screen shown when the survey appearance enables it. */ -@Composable -internal fun ConfirmationScreen(onClose: () -> Unit) { - TrackScreenView("nps_survey_confirmation") - val appearance = localAppearance() - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - modifier = Modifier.size(56.dp), - ) { - androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Rounded.Favorite, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = appearance.thankYouMessageHeader, - style = MaterialTheme.typography.headlineSmall, - fontWeight = GoogleSansWeight.semiBold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - val description = appearance.thankYouMessageDescription - if (!description.isNullOrBlank() && - appearance.thankYouMessageDescriptionContentType == PostHogDisplaySurveyTextContentType.TEXT - ) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - BottomSection( - label = appearance.thankYouMessageCloseButtonText, - enabled = true, - onClick = onClose, - ) - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/FocusRestorer.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/FocusRestorer.kt deleted file mode 100644 index 52c388902..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/FocusRestorer.kt +++ /dev/null @@ -1,32 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged - -/** - * Restores text-field focus after configuration changes or dialog re-entry. - * Used by [OpenText] so keyboard focus survives activity recreation. - */ -@Composable -internal fun Modifier.restoreFocusOnReentry(): Modifier { - val focusRequester = remember { FocusRequester() } - var wasFocused by rememberSaveable { mutableStateOf(false) } - val restoreFocus = remember { wasFocused } - LaunchedEffect(Unit) { - if (restoreFocus) { - focusRequester.requestFocus() - } - } - return this - .focusRequester(focusRequester) - .onFocusChanged { wasFocused = it.isFocused } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/NumberRating.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/NumberRating.kt deleted file mode 100644 index 9a35ca967..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/NumberRating.kt +++ /dev/null @@ -1,158 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.posthog.surveys.PostHogDisplayRatingQuestion -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.surveys.internal.theme.ResolvedSurveyAppearance -import cx.aswin.boxlore.surveys.internal.theme.contrastingTextColor -import cx.aswin.boxlore.surveys.internal.theme.localAppearance - -/** Connected 0–10 (or custom-range) rating bar with scale endpoint labels. */ -@Composable -internal fun NumberRating(question: PostHogDisplayRatingQuestion, selectedValue: Int?, onSelect: (Int?) -> Unit,) { - val appearance = localAppearance() - val values = (question.scaleLowerBound..question.scaleUpperBound).toList() - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .height(52.dp) - .clip(RoundedCornerShape(16.dp)) - .background(appearance.ratingButtonColor) - .border(1.dp, appearance.borderColor, RoundedCornerShape(16.dp)), - ) { - values.forEachIndexed { index, value -> - RatingSegment( - value = value, - isSelected = selectedValue == value, - appearance = appearance, - drawBorderRight = index < values.lastIndex, - onSelect = onSelect, - ) - } - } - - RatingScaleLabels( - lower = question.lowerBoundLabel, - upper = question.upperBoundLabel, - appearance = appearance, - ) - } -} - -@Composable -private fun RowScope.RatingSegment( - value: Int, - isSelected: Boolean, - appearance: ResolvedSurveyAppearance, - drawBorderRight: Boolean, - onSelect: (Int?) -> Unit, -) { - val targetBg = - if (isSelected) appearance.ratingButtonActiveColor else Color.Transparent - val animatedBg by animateColorAsState( - targetValue = targetBg, - label = "rating-segment-bg", - ) - val textColor = - if (isSelected) { - appearance.ratingButtonActiveTextColor - } else { - appearance.ratingButtonColor.contrastingTextColor().copy(alpha = 0.6f) - } - - Box( - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .background(animatedBg) - .selectable( - selected = isSelected, - onClick = { onSelect(if (isSelected) null else value) }, - role = Role.RadioButton, - ) - .drawBehind { - if (drawBorderRight) { - val strokeWidthPx = 1.dp.toPx() - drawLine( - color = appearance.borderColor, - start = Offset(size.width - strokeWidthPx / 2f, 8.dp.toPx()), - end = - Offset( - size.width - strokeWidthPx / 2f, - size.height - 8.dp.toPx(), - ), - strokeWidth = strokeWidthPx, - ) - } - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = value.toString(), - color = textColor, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) GoogleSansWeight.semiBold else GoogleSansWeight.medium, - textAlign = TextAlign.Center, - maxLines = 1, - ) - } -} - -@Composable -private fun RatingScaleLabels(lower: String, upper: String, appearance: ResolvedSurveyAppearance,) { - if (lower.isBlank() && upper.isBlank()) return - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 2.dp), - ) { - Text( - text = lower, - style = MaterialTheme.typography.bodySmall, - color = appearance.descriptionTextColor, - modifier = Modifier.weight(1f), - ) - Text( - text = upper, - style = MaterialTheme.typography.bodySmall, - color = appearance.descriptionTextColor, - textAlign = TextAlign.End, - modifier = Modifier.weight(1f), - ) - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/OpenText.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/OpenText.kt deleted file mode 100644 index 5519d9d8f..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/OpenText.kt +++ /dev/null @@ -1,43 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import cx.aswin.boxlore.surveys.internal.theme.localAppearance - -/** Multiline free-text answer field styled to match engagement sheets. */ -@Composable -internal fun OpenText(value: String, onValueChange: (String) -> Unit,) { - val appearance = localAppearance() - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = - Modifier - .fillMaxWidth() - .height(150.dp) - .restoreFocusOnReentry(), - placeholder = { - androidx.compose.material3.Text( - text = appearance.placeholder, - color = appearance.placeholderTextColor, - ) - }, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = appearance.inputTextColor), - shape = RoundedCornerShape(16.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedContainerColor = appearance.inputBackgroundColor, - unfocusedContainerColor = appearance.inputBackgroundColor, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = appearance.borderColor, - cursorColor = appearance.inputTextColor, - ), - ) -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/QuestionHeader.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/QuestionHeader.kt deleted file mode 100644 index 956a838c7..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/QuestionHeader.kt +++ /dev/null @@ -1,46 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.posthog.surveys.PostHogDisplaySurveyQuestion -import com.posthog.surveys.PostHogDisplaySurveyTextContentType -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight -import cx.aswin.boxlore.surveys.internal.theme.localAppearance - -/** Centered question title and optional description for a survey step. */ -@Composable -internal fun QuestionHeader(question: PostHogDisplaySurveyQuestion) { - val appearance = localAppearance() - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = question.question, - style = MaterialTheme.typography.headlineSmall, - fontWeight = GoogleSansWeight.semiBold, - color = appearance.questionTextColor, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - val description = question.questionDescription - if (!description.isNullOrBlank() && - question.questionDescriptionContentType == PostHogDisplaySurveyTextContentType.TEXT - ) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = appearance.descriptionTextColor, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/SurveySheet.kt b/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/SurveySheet.kt deleted file mode 100644 index 08d520b84..000000000 --- a/app/src/main/java/cx/aswin/boxlore/surveys/internal/ui/SurveySheet.kt +++ /dev/null @@ -1,249 +0,0 @@ -package cx.aswin.boxlore.surveys.internal.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Campaign -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.posthog.surveys.PostHogDisplayOpenQuestion -import com.posthog.surveys.PostHogDisplayRatingQuestion -import com.posthog.surveys.PostHogDisplaySurvey -import com.posthog.surveys.PostHogDisplaySurveyQuestion -import com.posthog.surveys.PostHogDisplaySurveyRatingType -import com.posthog.surveys.PostHogNextSurveyQuestion -import com.posthog.surveys.PostHogSurveyResponse -import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView -import cx.aswin.boxlore.core.designsystem.components.EngagementBottomSheetScaffold -import cx.aswin.boxlore.surveys.NpsSurveyBranching -import cx.aswin.boxlore.surveys.internal.theme.LocalSurveyAppearance -import cx.aswin.boxlore.surveys.internal.theme.localAppearance -import cx.aswin.boxlore.surveys.internal.theme.resolveAppearance -import kotlinx.coroutines.launch - -private sealed interface SurveySubmitStep { - data object Dismiss : SurveySubmitStep - - data object ShowConfirmation : SurveySubmitStep - - data class NextQuestion(val index: Int) : SurveySubmitStep -} - -/** Determines the next UI step after a survey answer is submitted. */ -private fun surveySubmitStep(next: PostHogNextSurveyQuestion?, displayThankYouMessage: Boolean,): SurveySubmitStep = when { - next == null -> SurveySubmitStep.Dismiss - next.isSurveyCompleted -> - if (displayThankYouMessage) { - SurveySubmitStep.ShowConfirmation - } else { - SurveySubmitStep.Dismiss - } - else -> SurveySubmitStep.NextQuestion(next.questionIndex) -} - -/** - * Root NPS survey bottom sheet: question flow, rating capture, and optional thank-you screen. - * - * @param onFirstRatingSubmitted Fired once when the user submits their first numeric rating. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun SurveySheet( - survey: PostHogDisplaySurvey, - onSurveyShown: () -> Unit, - onSubmit: (questionIndex: Int, response: PostHogSurveyResponse) -> PostHogNextSurveyQuestion?, - onClose: () -> Unit, - onFirstRatingSubmitted: (Int?) -> Unit = {}, -) { - TrackScreenView("nps_survey", send = {}) // the survey is counted when it is shown - val appearance = survey.appearance.resolveAppearance() - val coroutineScope = rememberCoroutineScope() - val hostSaveableRegistry = LocalSaveableStateRegistry.current - - val sheetState = - rememberModalBottomSheetState( - skipPartiallyExpanded = true, - ) - - var currentQuestionIndex by rememberSaveable { mutableStateOf(0) } - var showingConfirmation by rememberSaveable { mutableStateOf(false) } - var hasReportedFirstRating by rememberSaveable { mutableStateOf(false) } - val question = survey.questions.getOrNull(currentQuestionIndex) - - LaunchedEffect(survey.id) { - onSurveyShown() - } - - if (question == null && !showingConfirmation) { - LaunchedEffect(Unit) { onClose() } - return - } - - val dismissSheet: () -> Unit = { - coroutineScope.launch { - sheetState.hide() - onClose() - } - } - - val onSubmitResponse: (PostHogSurveyResponse) -> Unit = { response -> - if (!hasReportedFirstRating && response is PostHogSurveyResponse.Rating) { - hasReportedFirstRating = true - onFirstRatingSubmitted(response.rating) - } - val answeredIndex = currentQuestionIndex - val next = - NpsSurveyBranching.adjustNext( - survey = survey, - answeredQuestionIndex = answeredIndex, - sdkNext = onSubmit(answeredIndex, response), - ) - when (val step = surveySubmitStep(next, appearance.displayThankYouMessage)) { - SurveySubmitStep.Dismiss -> dismissSheet() - SurveySubmitStep.ShowConfirmation -> showingConfirmation = true - is SurveySubmitStep.NextQuestion -> currentQuestionIndex = step.index - } - } - - EngagementBottomSheetScaffold( - onDismissRequest = onClose, - sheetState = sheetState, - centeredDismissAllowed = true, - onCloseClick = dismissSheet, - ) { - CompositionLocalProvider( - LocalSurveyAppearance provides appearance, - LocalSaveableStateRegistry provides hostSaveableRegistry, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (showingConfirmation || question == null) { - ConfirmationScreen(onClose = dismissSheet) - } else { - SurveyQuestionBody( - question = question, - onSubmit = onSubmitResponse, - onClose = dismissSheet, - ) - } - } - } - } -} - -@Composable -private fun SurveyQuestionBody(question: PostHogDisplaySurveyQuestion, onSubmit: (PostHogSurveyResponse) -> Unit, onClose: () -> Unit,) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(56.dp), - ) { - androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Rounded.Campaign, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - QuestionHeader(question) - - Spacer(modifier = Modifier.height(28.dp)) - - when (question) { - is PostHogDisplayRatingQuestion -> RatingQuestionBody(question, onSubmit, onClose) - is PostHogDisplayOpenQuestion -> OpenTextQuestionBody(question, onSubmit) - else -> - UnsupportedQuestionPlaceholder( - buttonLabel = question.buttonText ?: "Close", - onClose = onClose, - ) - } -} - -@Composable -private fun RatingQuestionBody(question: PostHogDisplayRatingQuestion, onSubmit: (PostHogSurveyResponse) -> Unit, onClose: () -> Unit,) { - var rating by rememberSaveable(question.id) { mutableStateOf(null) } - val canSubmit = question.isOptional || rating != null - - if (question.ratingType == PostHogDisplaySurveyRatingType.EMOJI) { - UnsupportedQuestionPlaceholder( - buttonLabel = question.buttonText ?: localAppearance().submitButtonText, - onClose = { onSubmit(PostHogSurveyResponse.Rating(rating)) }, - ) - } else { - NumberRating( - question = question, - selectedValue = rating, - onSelect = { rating = it }, - ) - Spacer(modifier = Modifier.height(28.dp)) - BottomSection( - label = question.buttonText ?: localAppearance().submitButtonText, - enabled = canSubmit, - onClick = { onSubmit(PostHogSurveyResponse.Rating(rating)) }, - ) - } -} - -@Composable -private fun OpenTextQuestionBody(question: PostHogDisplayOpenQuestion, onSubmit: (PostHogSurveyResponse) -> Unit,) { - var text by rememberSaveable(question.id) { mutableStateOf("") } - val canSubmit = question.isOptional || text.isNotBlank() - - OpenText(value = text, onValueChange = { text = it }) - Spacer(modifier = Modifier.height(28.dp)) - BottomSection( - label = question.buttonText ?: localAppearance().submitButtonText, - enabled = canSubmit, - onClick = { - val trimmed = text.trim() - onSubmit(PostHogSurveyResponse.Text(if (trimmed.isEmpty()) null else trimmed)) - }, - ) -} - -@Composable -private fun UnsupportedQuestionPlaceholder(buttonLabel: String, onClose: () -> Unit,) { - val appearance = localAppearance() - androidx.compose.material3.Text( - text = - "This question type is not yet supported in the app survey UI. " + - "Please update the app to respond to this survey.", - style = MaterialTheme.typography.bodyMedium, - color = appearance.textColor, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(28.dp)) - BottomSection(label = buttonLabel, enabled = true, onClick = onClose) -} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 66853ba67..50d056a76 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -12,6 +12,8 @@ + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 8af2b5be8..78d0e49a0 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -7,6 +7,8 @@ + + @@ -29,6 +31,8 @@ + + diff --git a/app/src/test/java/cx/aswin/boxlore/lifecycle/BackgroundCheckBackupRulesTest.kt b/app/src/test/java/cx/aswin/boxlore/lifecycle/BackgroundCheckBackupRulesTest.kt index 47b5e0e33..7c9a24069 100644 --- a/app/src/test/java/cx/aswin/boxlore/lifecycle/BackgroundCheckBackupRulesTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/lifecycle/BackgroundCheckBackupRulesTest.kt @@ -15,20 +15,25 @@ import org.xmlpull.v1.XmlPullParser @Config(sdk = [34], application = Application::class) class BackgroundCheckBackupRulesTest { @Test fun consentIsExcludedFromLegacyBackupCloudBackupAndDeviceTransfer() { + assertExcludedEverywhere("datastore/auto_download_background.preferences_pb") + } + + /** A restored phone is a new install: it must not inherit when the NPS survey last appeared. */ + @Test fun npsSurveyHistoryIsExcludedFromLegacyBackupCloudBackupAndDeviceTransfer() { + assertExcludedEverywhere("datastore/boxlore_nps_survey.preferences_pb") + } + + private fun assertExcludedEverywhere(path: String) { val context = ApplicationProvider.getApplicationContext() for ((resource, expected) in listOf(R.xml.backup_rules to 1, R.xml.data_extraction_rules to 2)) { val xml = context.resources.getXml(resource) var exclusions = 0 while (xml.next() != XmlPullParser.END_DOCUMENT) { if (xml.eventType != XmlPullParser.START_TAG || xml.name != "exclude") continue - if (xml.getAttributeValue(null, "domain") == "file" && - xml.getAttributeValue(null, "path") == "datastore/auto_download_background.preferences_pb" - ) { - exclusions++ - } + if (xml.getAttributeValue(null, "domain") == "file" && xml.getAttributeValue(null, "path") == path) exclusions++ } xml.close() - assertEquals(expected, exclusions) + assertEquals(path, expected, exclusions) } } } diff --git a/app/src/test/java/cx/aswin/boxlore/surveys/NpsSurveyBranchingTest.kt b/app/src/test/java/cx/aswin/boxlore/surveys/NpsSurveyBranchingTest.kt deleted file mode 100644 index db2534ddd..000000000 --- a/app/src/test/java/cx/aswin/boxlore/surveys/NpsSurveyBranchingTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package cx.aswin.boxlore.surveys - -import com.posthog.surveys.PostHogDisplayOpenQuestion -import com.posthog.surveys.PostHogDisplayRatingQuestion -import com.posthog.surveys.PostHogDisplaySurvey -import com.posthog.surveys.PostHogDisplaySurveyRatingType -import com.posthog.surveys.PostHogDisplaySurveyTextContentType -import com.posthog.surveys.PostHogNextSurveyQuestion -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class NpsSurveyBranchingTest { - @Test - fun adjustNext_endsAfterDetractorPathLastQuestion() { - val survey = sampleSurvey() - val sdkNext = PostHogNextSurveyQuestion(questionIndex = 3, isSurveyCompleted = false) - - val adjusted = - NpsSurveyBranching.adjustNext( - survey = survey, - answeredQuestionIndex = 2, - sdkNext = sdkNext, - ) - - requireNotNull(adjusted) - assertTrue(adjusted.isSurveyCompleted) - } - - @Test - fun adjustNext_endsAfterPassivePathLastQuestion() { - val survey = sampleSurvey() - val sdkNext = PostHogNextSurveyQuestion(questionIndex = 5, isSurveyCompleted = false) - - val adjusted = - NpsSurveyBranching.adjustNext( - survey = survey, - answeredQuestionIndex = 4, - sdkNext = sdkNext, - ) - - requireNotNull(adjusted) - assertTrue(adjusted.isSurveyCompleted) - } - - @Test - fun adjustNext_keepsMidPathQuestionOpen() { - val survey = sampleSurvey() - val sdkNext = PostHogNextSurveyQuestion(questionIndex = 2, isSurveyCompleted = false) - - val adjusted = - NpsSurveyBranching.adjustNext( - survey = survey, - answeredQuestionIndex = 1, - sdkNext = sdkNext, - ) - - requireNotNull(adjusted) - assertFalse(adjusted.isSurveyCompleted) - assertEquals(2, adjusted.questionIndex) - } - - @Test - fun adjustNext_respectsSdkAlreadyCompleted() { - val survey = sampleSurvey() - val sdkNext = PostHogNextSurveyQuestion(questionIndex = 2, isSurveyCompleted = true) - - val adjusted = - NpsSurveyBranching.adjustNext( - survey = survey, - answeredQuestionIndex = 2, - sdkNext = sdkNext, - ) - - requireNotNull(adjusted) - assertTrue(adjusted.isSurveyCompleted) - } - - private fun sampleSurvey(): PostHogDisplaySurvey { - val questions = - listOf( - PostHogDisplayRatingQuestion( - id = "6cfc6cdc-8556-4a87-9b4c-5f5826fc2503", - question = "NPS", - questionDescription = null, - questionDescriptionContentType = PostHogDisplaySurveyTextContentType.TEXT, - isOptional = false, - buttonText = null, - ratingType = PostHogDisplaySurveyRatingType.NUMBER, - scaleLowerBound = 0, - scaleUpperBound = 10, - lowerBoundLabel = "Not likely", - upperBoundLabel = "Very likely", - ), - open("b8007ccc-1903-4b95-9a1c-d9c16a7d9862", "disappointed"), - open("163c6220-7b03-46b8-9073-f18435bb3eeb", "fix first"), - open("7f66e852-a867-4697-b41a-20eff60cf244", "better"), - open("bb126737-6122-47ed-b2e5-50b3b4fa0bd8", "must-have"), - open("cffbaf8c-7e00-4b7c-b184-10ae52e4b5ae", "love most"), - open("7ec7a537-cc62-4577-90c7-d05a49bb397a", "build next"), - ) - return PostHogDisplaySurvey( - id = "019f504e-2b74-0000-ba36-7554e845c7b1", - name = "Boxlore NPS + Feature Ideas", - questions = questions, - appearance = null, - startDate = null, - endDate = null, - ) - } - - private fun open(id: String, question: String,): PostHogDisplayOpenQuestion = PostHogDisplayOpenQuestion( - id = id, - question = question, - questionDescription = null, - questionDescriptionContentType = PostHogDisplaySurveyTextContentType.TEXT, - isOptional = true, - buttonText = null, - ) -} diff --git a/app/src/test/java/cx/aswin/boxlore/surveys/TabletNoticeSurveyGateTest.kt b/app/src/test/java/cx/aswin/boxlore/surveys/TabletNoticeSurveyGateTest.kt deleted file mode 100644 index 3124d1f05..000000000 --- a/app/src/test/java/cx/aswin/boxlore/surveys/TabletNoticeSurveyGateTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package cx.aswin.boxlore.surveys - -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.Snapshot -import cx.aswin.boxlore.surveys.internal.awaitTabletNoticeDismissal -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class TabletNoticeSurveyGateTest { - @Test fun `queued survey waits for OK before it can present`() = runTest { - val pending = mutableStateOf(true) - var released = false - val waiting = launch { - awaitTabletNoticeDismissal { pending.value } - released = true - } - runCurrent() - assertFalse(released) - pending.value = false - Snapshot.sendApplyNotifications() - runCurrent() - waiting.join() - assertTrue(released) - } - - @Test fun `ordinary phone startup never queues a survey behind the tablet notice`() = runTest { - awaitTabletNoticeDismissal { false } - } -} diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 4bd2b4eee..9fd1fa957 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -13,7 +13,6 @@ ComplexCondition:ExploreViewModel.kt$ExploreViewModel$_isLoadingMore.value || !_hasMorePages.value || _searchQuery.value.isNotEmpty() || _currentVibe.value != null ComplexCondition:HomeScreen.kt$route != null && route != "home" && !route.startsWith("episode") && !route.startsWith("podcast") ComplexCondition:ImageOptimization.kt$key != "w" && key != "h" && key != "max-w" && key != "max-h" && key != "width" && key != "height" && key != "fit" && key != "auto" && key != "q" && key != "format" - ComplexCondition:NpsSurveyTriggers.kt$NpsSurveyTriggers$surveyPrefs.isNpsSurveyPending() && !surveyPrefs.hasNpsSurveyFired() && engagementCoordinator.canShowProactivePrompt(isCurrentlyPlaying()) && surveyPrefs.isEngagementCooldownElapsed() ComplexCondition:OnboardingScreen.kt$isOnboardingCompleted && ( uiState.currentStep == OnboardingStep.WELCOME || uiState.currentStep == OnboardingStep.AI_ONBOARDING || uiState.currentStep == OnboardingStep.GENRES ) ComplexCondition:PlaybackQueueCoordinator.kt$PlaybackQueueCoordinator$movedEpisodeId != null && fromQueueIndex != toQueueIndex && fromQueueIndex >= 0 && toQueueIndex >= 0 ComplexCondition:PodcastInfoScreen.kt$state.searchResults == null && itemIndex == feedItems.lastIndex && state.hasMoreEpisodes && !state.isLoadingMore @@ -82,7 +81,6 @@ CyclomaticComplexMethod:QueueManager.kt$QueueManager$private fun EpisodeItem.toDomain(podcast: cx.aswin.boxlore.core.model.Podcast): cx.aswin.boxlore.core.model.Episode CyclomaticComplexMethod:QueueRepository.kt$QueueRepository$suspend fun addToQueue( episode: EpisodeItem, podcast: cx.aswin.boxlore.core.model.Podcast?, contextType: String? = null, contextSourceId: String? = null, ) CyclomaticComplexMethod:ReviewSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedbackSheet( appVersion: String, onSubmit: suspend (category: String, message: String, version: String, email: String) -> Boolean, onRateInstead: () -> Unit, onDismissRequest: () -> Unit, ) - CyclomaticComplexMethod:ReviewSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReviewPromptSheet( completedCount: Int, onDismissRequest: () -> Unit, onNavigateToReview: () -> Unit, onNavigateToFeedback: () -> Unit, variant: ReviewPromptVariant = ReviewPromptVariant.Milestone, ) CyclomaticComplexMethod:RoomLocalCatalog.kt$RoomLocalCatalog$override suspend fun upsertSubscribedPodcast(podcast: Podcast) CyclomaticComplexMethod:RssFeedClient.kt$RssFeedClient$private fun mergeParsedFeeds(libraryFeed: ParsedRssFeed?, customFeed: ParsedRssFeed?,): ParsedRssFeed? CyclomaticComplexMethod:RssFeedClient.kt$RssFeedClient$private fun parseEpisodeElement(parser: XmlPullParser, name: String, qualifiedName: String, episode: MutableEpisode,) @@ -206,7 +204,6 @@ LongMethod:PrivacySettingsPage.kt$@Composable internal fun PrivacySettingsPage( deletionId: String, isDeletionExpanded: Boolean, actions: PrivacySettingsActions, onBack: () -> Unit, ) LongMethod:ReviewSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedbackSheet( appVersion: String, onSubmit: suspend (category: String, message: String, version: String, email: String) -> Boolean, onRateInstead: () -> Unit, onDismissRequest: () -> Unit, ) LongMethod:ReviewSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostReviewSheet( onDismissRequest: () -> Unit, onNavigateToFeedback: () -> Unit, ) - LongMethod:ReviewSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReviewPromptSheet( completedCount: Int, onDismissRequest: () -> Unit, onNavigateToReview: () -> Unit, onNavigateToFeedback: () -> Unit, variant: ReviewPromptVariant = ReviewPromptVariant.Milestone, ) LongMethod:SettingsScreen.kt$@Composable fun SettingsScreen( config: SettingsScreenConfig, repositories: SettingsRepositories, regionSettings: RegionSettings = RegionSettings(), appearanceSettings: AppearanceSettings = AppearanceSettings(), playbackSettings: PlaybackSettings = PlaybackSettings(), libraryBackupWriters: LibraryBackupWriters = LibraryBackupWriters(), downloadsNavigation: DownloadsNavigation = DownloadsNavigation(), ) LongMethod:SmartDownloadsDashboard.kt$@Composable fun SmartDownloadsDashboardCard( userPrefs: cx.aswin.boxlore.core.prefs.UserPreferencesRepository, downloads: List<cx.aswin.boxlore.core.database.DownloadedEpisodeEntity>, onSettingsClick: () -> Unit, onSyncNow: () -> Unit, isSyncing: Boolean = false ) LongMethod:SmartDownloadsSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SmartDownloadsSettingsScreen( userPrefs: UserPreferencesRepository, onBack: () -> Unit ) diff --git a/core/analytics/README.md b/core/analytics/README.md index abdd6fda8..35c1d9ca2 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -55,6 +55,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/ AnalyticsBeforeSend.kt # PostHog beforeSend: levels + environment tags on every event *AnalyticsTracks.kt # domain emit implementations SupportAnalyticsTracks.kt # Support events + support-world flag read + SurveyAnalyticsTracks.kt # survey shown / sent / dismissed + wording payload FeedAnalyticsTracks.kt # feed_fetch_result / feed_url_corrected ErrorReporter.kt EntryPointContext.kt # the entry details allowed onto playback events @@ -90,7 +91,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Do **not** emit glossary `app_open`, `app_background`, or volume `install_attributed`. - Install channel: set-once person `install_channel` only (`trackInstallChannelAttributed`). Set-once properties go through `AnalyticsEmit.personSetOnce` (the SDK's set-once slot); nested as `$set_once` under `personSet` they landed as one person property named `$set_once`, which hid `install_channel` from every person before this fix. - Setup route: `onboarding_method` and `user_intent` follow the latest route (`$set`); `first_onboarding_method`, `first_user_intent` and `initial_podcasts_subscribed` are set once, so a later import from Home doesn't overwrite how the listener first set up. -- Feedback: `feedback_submitted` is only the NPS survey's trigger (`nps_eligible`, `nps_manual`), because the survey shows on any of them. A Home review prompt is `engagement_prompt_shown`, a hand-off to Play `store_review_opened` (`in_app_review` or `store_page`), and an accepted feedback form `feedback_sent` (categories only, never the text or email). +- NPS survey: boxlore draws it (Home) and sends PostHog's standard survey events through `SurveyAnalyticsTracks`: `survey shown`, `survey sent` (one `$survey_submission_id` per survey, the score at once as a partial send, the follow-up completing it, every answer so far as `$survey_response_`) and `survey dismissed` (only before any answer). `surveyCopyOverrides` reads a flag payload's string values so the wording can change without a release. `feedback_submitted`, the old popover survey's trigger, is retired with its façades; older versions still send it. A Home review prompt is `engagement_prompt_shown`, a hand-off to Play `store_review_opened` (`in_app_review` or `store_page`), and an accepted feedback form `feedback_sent` (categories only, never the text or email). - Explore: the For You rail being shown is `explore_surface_impression`; `explore_recommendation_tapped` is taps only and now carries `recipe_id` beside `episode_id` (29 episode events). Home's category chips are `home_surface_tapped` (`category_filter`). - Playback events take only `EntryPointContext.ALLOWED_KEYS` from a play's context, and none replaces a field the event set; media item extras (artwork links, Android Auto keys, the stream address) never reach analytics. - Echo's chat sends what the listener typed capped at 500 characters, and never the reply text. @@ -106,10 +107,10 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Use `AnalyticsHelper.installRecordingSink(recorder)` when testing classes or callbacks that interact directly with the singleton `AnalyticsHelper`. It records every event whatever its level, so wiring tests still see events that are off; pass `applyLevels = true` to test the levels. - `ScreenViewsTest` covers the screen trail, `screen_viewed`, every leaving summary going out as `screen_left`, and the screens added to plays. `PlayerSessionAggregatorTest` covers the transcript counter (regression: it read a name the player never logged). - `ListenAnalyticsTest` covers redacted error messages and error-tracking copies (a nested playlist error with a signed URL), the listen record's fields, the qualified threshold, the car as a client surface, context not overwriting the record, `playback_interrupted`, the listen fields on `playback_started`, and errors reaching error tracking. -- `AnalyticsPolicyTest` covers the default levels, sampling by bucket, the switch payload (base, per version, bad input), the remembered switch and its version check, bucket persistence through `Store` (a saved bucket is reused; a missing or out-of-range one is picked and saved), `AnalyticsEmit` dropping and tagging, and `AnalyticsBeforeSend` tagging (replacing another build's tags), dropping SDK events and keeping a queued event's sample share, the identity events that ignore the switch, and an explicit flag reload applying the switch; a source scan keeps every `PostHog.reloadFeatureFlags` call inside `AnalyticsHelper`. `AnalyticsBudgetTest` checks that every glossary event has a level matching `DEFAULT_LEVELS` and a monthly estimate, and that estimated monthly events per active install stay within the budget (225; about 212 with the listening record and page views; `screen_viewed`'s 20 a month is an estimate to re-measure). The budget counts glossary events only, not SDK identity, flag, survey or exception events. -- `PersonSetOnceTest` covers the install channel and the first setup route going through the set-once slot. `AnalyticsHelperStateTest` checks review prompts never send the survey trigger; `RecordingAnalyticsTest` covers the entry-detail allowlist, the capped Echo text and the queue and Library entry points. +- `AnalyticsPolicyTest` covers the default levels, sampling by bucket, the switch payload (base, per version, bad input), the remembered switch and its version check, bucket persistence through `Store` (a saved bucket is reused; a missing or out-of-range one is picked and saved), `AnalyticsEmit` dropping and tagging, and `AnalyticsBeforeSend` tagging (replacing another build's tags), dropping SDK events and keeping a queued event's sample share, the identity events that ignore the switch, and an explicit flag reload applying the switch; a source scan keeps every `PostHog.reloadFeatureFlags` call inside `AnalyticsHelper`. `AnalyticsBudgetTest` checks that every glossary event has a level matching `DEFAULT_LEVELS` and a monthly estimate, and that estimated monthly events per active install stay within the budget (225; about 212 with the listening record and page views; `screen_viewed`'s 20 a month is an estimate to re-measure). The budget counts glossary events only (the survey events included), not SDK identity, flag or exception events. +- `PersonSetOnceTest` covers the install channel and the first setup route going through the set-once slot. `AnalyticsHelperStateTest` checks review prompts never send the old survey trigger; `SurveyAnalyticsTest` covers the survey events' shape and the wording payload; `RecordingAnalyticsTest` covers the entry-detail allowlist, the capped Echo text and the queue and Library entry points. - `GlossaryAllEventsEmissionTest` covers every `emission:` inventory row; `LifecycleSdkMappingTest` / `InstallChannelAttributionTest` assert no dual open/install emits. -- Architecture guards in `:core:testing` enforce CSV ↔ allowlist ↔ inventory parity. +- Architecture guards in `:core:testing` enforce CSV ↔ allowlist ↔ inventory parity, PostHog's spaced names (`survey sent`) included. ```bash ./gradlew :core:analytics:testDebugUnitTest diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt index 6fa865af1..41b4babc2 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt @@ -26,11 +26,6 @@ interface Analytics { /** An engagement prompt (a review prompt) appeared: `engagement_prompt_shown`. */ fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int? = null, npsScore: Int? = null,) - /** The NPS survey's trigger: `feedback_submitted`, which the survey's display condition keys off. */ - fun trackSurveyNpsEligible(completedEpisodes: Int?, triggerContext: String,) - - fun trackSurveyNpsManualTrigger(source: String) - /** The first play on this install, with the episode playing when known. */ fun trackFirstEpisodePlayed(episodeId: String? = null, podcastId: String? = null,) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt index 23c740ac6..ea09c831e 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt @@ -75,8 +75,10 @@ object AnalyticsGlossary { "show_notification_toggled", "share_content", "backup_restore_result", - "feedback_submitted", "engagement_prompt_shown", + "survey shown", + "survey sent", + "survey dismissed", "store_review_opened", "feedback_sent", "library_destination_viewed", diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt index b04b3111c..ef14ab963 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt @@ -246,51 +246,31 @@ object AnalyticsHelper : Analytics { fun rememberEpisodeRecipeIds(episodes: Iterable) = EpisodeRecipeIds.remember(episodes.filter { it.recipeId != null }.associate { it.id to it.recipeId }) // ── Feedback / NPS ────────────────────────────────────────────── - // feedback_submitted is only the NPS survey's trigger (its display condition keys off it); prompts - // that appear are engagement_prompt_shown, hand-offs to Play store_review_opened, and a sent - // feedback form feedback_sent. + // The NPS survey is boxlore's own sheet reporting PostHog's survey events; prompts that appear are + // engagement_prompt_shown, hand-offs to Play store_review_opened, and a sent feedback form + // feedback_sent. - /** Deferred automatic trigger, fired on app open once the user hits the eligibility milestone. */ - override fun trackSurveyNpsEligible(completedEpisodes: Int?, triggerContext: String,) { - AnalyticsEmit.event( - "feedback_submitted", - buildMap { - put("feedback_type", "nps_eligible") - put("source", triggerContext) - put("trigger_type", "automatic") - put("trigger_context", triggerContext) - completedEpisodes?.let { put("completed_episodes", it) } - }, - ) - deliverSurveyTriggerEvent() - } + /** The survey [surveyId] is on screen: `survey shown`. */ + fun trackSurveyShown(surveyId: String) = SurveyAnalyticsTracks.trackSurveyShown(surveyId) - /** Manual trigger from a long-press or a remote console feature flag. */ - override fun trackSurveyNpsManualTrigger(source: String) { - AnalyticsEmit.event( - "feedback_submitted", - mapOf( - "feedback_type" to "nps_manual", - "source" to source, - "trigger_source" to source, - "trigger_type" to "manual", - "trigger_context" to if (source == "remote_flag") "console" else "manual", - ), - ) - deliverSurveyTriggerEvent() - } + /** One send of a submission's answers so far: `survey sent`, partial until [completed]. */ + fun trackSurveySent( + surveyId: String, + submissionId: String, + questions: List, + responses: Map, + completed: Boolean, + ) = SurveyAnalyticsTracks.trackSurveySent(surveyId, submissionId, questions, responses, completed) - /** Flush and refresh flags so the SDK evaluates survey display conditions immediately. */ - private fun deliverSurveyTriggerEvent() { - PostHogStartup.runWhenReady { - PostHog.flush() - reloadFeatureFlags() - } - } + /** The survey closed before any answer: `survey dismissed`. */ + fun trackSurveyDismissed(surveyId: String) = SurveyAnalyticsTracks.trackSurveyDismissed(surveyId) + + /** A survey's wording overrides from [flagKey]'s payload; empty until flags load or with none set. */ + fun surveyCopyOverrides(flagKey: String): Map = SurveyAnalyticsTracks.surveyCopyOverrides(flagKey) /** - * A review prompt appeared (`milestone_review`, or `promoter_review` for an NPS promoter, with - * [npsScore]). Not `feedback_submitted`, which would also make the person eligible for the survey. + * A review prompt appeared (`milestone_review`, with [npsScore] when the listener answered the + * NPS survey). */ override fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int?, npsScore: Int?,) { AnalyticsEmit.event( diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt index 426334f78..3b1f83695 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt @@ -76,27 +76,6 @@ class RecordingAnalytics : Analytics { ) } - override fun trackSurveyNpsEligible(completedEpisodes: Int?, triggerContext: String,) { - capture( - "feedback_submitted", - buildMap { - put("feedback_type", "nps_eligible") - put("source", triggerContext) - completedEpisodes?.let { put("completed_episodes", it) } - }, - ) - } - - override fun trackSurveyNpsManualTrigger(source: String) { - capture( - "feedback_submitted", - mapOf( - "feedback_type" to "nps_manual", - "source" to source, - ), - ) - } - override fun trackFirstEpisodePlayed(episodeId: String?, podcastId: String?) { capture( "first_episode_played", diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTracks.kt new file mode 100644 index 000000000..cde033ab2 --- /dev/null +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTracks.kt @@ -0,0 +1,60 @@ +package cx.aswin.boxlore.core.analytics + +import com.posthog.PostHog + +/** A survey question as PostHog knows it: its ID there and the text the listener saw. */ +data class SurveyQuestionRef(val id: String, val question: String,) + +/** + * PostHog's standard survey events for a survey boxlore draws itself (an `api` survey), so its results + * page and NPS charts work as for PostHog's own. One submission keeps one [submissionId] across its + * partial and final sends; each send carries every answer so far, keyed by question ID. + */ +internal object SurveyAnalyticsTracks { + fun trackSurveyShown(surveyId: String) { + AnalyticsEmit.event(SURVEY_SHOWN, mapOf(SURVEY_ID to surveyId)) + } + + fun trackSurveySent( + surveyId: String, + submissionId: String, + questions: List, + responses: Map, + completed: Boolean, + ) { + AnalyticsEmit.event( + SURVEY_SENT, + buildMap { + put(SURVEY_ID, surveyId) + put("\$survey_submission_id", submissionId) + put("\$survey_completed", completed) + put("\$survey_questions", questions.map { mapOf("id" to it.id, "question" to it.question) }) + responses.forEach { (questionId, answer) -> put("\$survey_response_$questionId", answer) } + }, + ) + } + + /** Closed before any answer; a survey with an answer sent is never also dismissed. */ + fun trackSurveyDismissed(surveyId: String) { + AnalyticsEmit.event(SURVEY_DISMISSED, mapOf(SURVEY_ID to surveyId)) + } + + /** String overrides from [flagKey]'s payload (a JSON object), so wording can change without a release. */ + fun surveyCopyOverrides(flagKey: String): Map = + try { + parseCopyOverrides(if (PostHogStartup.isReady) PostHog.getFeatureFlagPayload(flagKey, null) else null) + } catch (e: Exception) { + emptyMap() + } + + /** The string values of an object [payload]; anything else gives none. */ + internal fun parseCopyOverrides(payload: Any?): Map { + val root = payload as? Map<*, *> ?: return emptyMap() + return buildMap { root.forEach { (key, value) -> if (key is String && value is String) put(key, value) } } + } + + const val SURVEY_SHOWN = "survey shown" + const val SURVEY_SENT = "survey sent" + const val SURVEY_DISMISSED = "survey dismissed" + private const val SURVEY_ID = "\$survey_id" +} diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt index 40ab7a592..b126fc377 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt @@ -271,8 +271,14 @@ class GlossaryAllEventsEmissionTest { "backup_restore_result" to { AnalyticsHelper.trackBackupRestoreResult("export", success = true, itemCount = 3, format = "opml") }, - "feedback_submitted" to { - AnalyticsHelper.trackSurveyNpsManualTrigger(source = "long_press") + "survey shown" to { + AnalyticsHelper.trackSurveyShown("s1") + }, + "survey sent" to { + AnalyticsHelper.trackSurveySent("s1", "sub1", listOf(SurveyQuestionRef("q1", "Score?")), mapOf("q1" to 9), completed = false) + }, + "survey dismissed" to { + AnalyticsHelper.trackSurveyDismissed("s1") }, "engagement_prompt_shown" to { AnalyticsHelper.trackEngagementPromptShown("milestone_review", "episode_milestone", 5) diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt index 73f32f9c4..8b244ef23 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt @@ -19,36 +19,9 @@ class RecordingAnalyticsExtraTest { analytics = RecordingAnalytics() } - @Test - fun trackSurveyNpsEligibleEmitsFeedbackSubmitted() { - analytics.trackSurveyNpsEligible(completedEpisodes = 7, triggerContext = "home") - - val event = analytics.lastEvent!! - assertEquals("feedback_submitted", event.name) - assertEquals("nps_eligible", event.properties["feedback_type"]) - assertEquals("home", event.properties["source"]) - assertEquals(7, event.properties["completed_episodes"]) - } - - @Test - fun trackSurveyNpsEligibleOmitsCompletedWhenNull() { - analytics.trackSurveyNpsEligible(completedEpisodes = null, triggerContext = "settings") - - assertFalse("completed_episodes" in analytics.lastEvent!!.properties) - } - - @Test - fun trackSurveyNpsManualTriggerEmitsFeedbackSubmitted() { - analytics.trackSurveyNpsManualTrigger("about_screen") - - val event = analytics.lastEvent!! - assertEquals("nps_manual", event.properties["feedback_type"]) - assertEquals("about_screen", event.properties["source"]) - } - @Test fun trackEngagementPromptShownCarriesNpsScoreWhenPresent() { - analytics.trackEngagementPromptShown("promoter_review", "nps_handoff", npsScore = 10) + analytics.trackEngagementPromptShown("milestone_review", "episode_milestone", npsScore = 10) val event = analytics.lastEvent!! assertEquals("engagement_prompt_shown", event.name) diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt index 994b37155..dfe0437f2 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt @@ -168,7 +168,7 @@ class AnalyticsGlossaryAllowlistTest { @Test fun `phase A union B has expected cardinality`() { // 40 Phase A (with onboarding_similar_choice, the two listen events and the two screen events) + 1 A/B + 29 Phase B + 5 Support + 2 custom-theme + 1 hero-style + 1 LeaveMeAlone events. - assertEquals(83, AnalyticsGlossary.PHASE_A_UNION_B.size) + assertEquals(85, AnalyticsGlossary.PHASE_A_UNION_B.size) } @Test @@ -194,7 +194,8 @@ class AnalyticsGlossaryAllowlistTest { assertTrue(AnalyticsGlossary.isAllowedEvent("learn_card_action")) assertTrue(AnalyticsGlossary.isAllowedEvent("queue_modified")) assertTrue(AnalyticsGlossary.isAllowedEvent("search_performed")) - assertTrue(AnalyticsGlossary.isAllowedEvent("feedback_submitted")) + assertTrue(AnalyticsGlossary.isAllowedEvent("survey sent")) + assertFalse(AnalyticsGlossary.isAllowedEvent("feedback_submitted")) assertTrue(AnalyticsGlossary.isAllowedEvent("\$set")) } diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTest.kt new file mode 100644 index 000000000..eb3dd09f4 --- /dev/null +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/SurveyAnalyticsTest.kt @@ -0,0 +1,62 @@ +package cx.aswin.boxlore.core.analytics + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** boxlore's own survey sheet sends PostHog's standard survey events, shaped as its results page reads them. */ +class SurveyAnalyticsTest { + private val recorder = mutableListOf>>() + private lateinit var restore: () -> Unit + + @BeforeEach + fun setUp() { + restore = AnalyticsEmit.installRecordingSink(recorder) + } + + @AfterEach + fun tearDown() = restore() + + @Test + fun `a send carries the submission, its questions and every answer so far`() { + AnalyticsHelper.trackSurveySent( + surveyId = "s1", + submissionId = "sub1", + questions = listOf(SurveyQuestionRef("q1", "How likely?"), SurveyQuestionRef("q2", "Why?")), + responses = mapOf("q1" to 7, "q2" to "Faster search"), + completed = true, + ) + + val (name, props) = recorder.single() + assertEquals("survey sent", name) + assertEquals("s1", props["\$survey_id"]) + assertEquals("sub1", props["\$survey_submission_id"]) + assertEquals(true, props["\$survey_completed"]) + assertEquals(7, props["\$survey_response_q1"]) + assertEquals("Faster search", props["\$survey_response_q2"]) + assertEquals( + listOf(mapOf("id" to "q1", "question" to "How likely?"), mapOf("id" to "q2", "question" to "Why?")), + props["\$survey_questions"], + ) + } + + @Test + fun `shown and dismissed name the survey only`() { + AnalyticsHelper.trackSurveyShown("s1") + AnalyticsHelper.trackSurveyDismissed("s1") + + assertEquals(listOf("survey shown" to mapOf("\$survey_id" to "s1"), "survey dismissed" to mapOf("\$survey_id" to "s1")), recorder) + } + + @Test + fun `wording overrides keep only string values of an object payload`() { + assertEquals( + mapOf("ideaTag" to "", "question" to "Would you recommend boxlore?"), + SurveyAnalyticsTracks.parseCopyOverrides(mapOf("ideaTag" to "", "question" to "Would you recommend boxlore?", "n" to 3, 4 to "x")), + ) + assertTrue(SurveyAnalyticsTracks.parseCopyOverrides("{\"ideaTag\":\"\"}").isEmpty()) + assertTrue(SurveyAnalyticsTracks.parseCopyOverrides(null).isEmpty()) + } +} diff --git a/core/catalog/README.md b/core/catalog/README.md index 549979774..deeec014e 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -174,7 +174,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ## Testing notes -- Install referrer: Play's `utm_source=(not set)` placeholder is channel `unknown`, not `utm` (`InstallReferrerManagerTest`). `EngagementPromptCoordinator.shouldShowPromoterReview` waits out the review prompt's quiet days after installing or importing, and `reserveSessionForPrompt` holds the session for a prompt that waits for its screen. `UserSyncCoordinator` calls `onFirstAccountPull` (by default `markLibraryImported`) before a phone's first pull for an account, a sign-in or an account switch, so another phone's synced listening starts the quiet days too (`UserSyncCoordinatorTest`). +- Install referrer: Play's `utm_source=(not set)` placeholder is channel `unknown`, not `utm` (`InstallReferrerManagerTest`). `EngagementPromptCoordinator` allows one prompt per session and only at a finish moment (an episode finished since it was made at startup, `isFinishMoment`): `shouldShowNpsSurvey` applies `NpsSurveySchedule` with nothing playing, `onSurveyDisplayed` uses up the session, the cooldown and the survey's 90 days, and `reserveSessionForPrompt` holds the session for a prompt that waits for its screen. The NPS Play hand-off is gone: 9s and 10s get a Play Store button in the survey instead (`EngagementPromptCoordinatorSurveyTest`). `UserSyncCoordinator` calls `onFirstAccountPull` (by default `markLibraryImported`) before a phone's first pull for an account, a sign-in or an account switch, so another phone's synced listening starts the quiet days too (`UserSyncCoordinatorTest`). - Saved feed addresses: `FeedAddressUpdaterTest` (Robolectric Room: one update writes both addresses, keeps the old one, resets every validator, marks the row dirty, re-registers and reports once; undo; `rss:` and non-HTTPS refused; a changed row left alone; fills, cloud addresses and unfollowed shows aren't corrections and an unfollowed row stays clean; a failed registration request of any kind keeps the address; a registration stays pending until acknowledged and is asked for again, also after the process died; no address written when its request couldn't be saved; a redirect from an address other than the saved one is never adopted; a re-follow keeps a corrected address), `FeedAddressDailyCheckTest` (501 followed shows make two calls, nothing again the same day; only confirmed moves, read before they are saved; an unreadable registry address never saved and tried again the next day; a swapped show stamped by its swap; never back to the address the saved one replaced, whatever replaced it; follows changed on the phone wait; a failed call or an unavailable registry stamps nothing, so the `/podcast` fallback runs once, and never for a show the check reached; the switch), `FeedAddressRecordedAnswersTest` (the API's own `/v2/shows/feeds` and `/v2/feed-report` answers, `fixtures/v2`, through the app's client: a registry move, a dump address, an unknown show, an http-only show, an unavailable registry and a refused report), `FeedReportsTest` and `SharedPreferencesFeedReportStampsTest` (once per show a day, 20 a day in all, limiters sharing one store counting each other's reports, a failed or refused send counting nothing, kept across processes, never offline, for `rss:` shows, without a read or with the switch off; only a readable new address offered back), `PodcastNotificationRegistrarTest` (the same node and topic, the shared node never written without its address, nothing without notifications, retry and refused outcomes, a cloud pull that changes only the address re-registering on the same node and topic, a pulled previous address never undoing a registry correction, and a merge never writing an older copy's address over a newer one), `SubscribedEpisodeCatalogTest` (moves followed on every refresh path, never for `rss:` shows or with the switch off, caller-handled requests left alone), and `DirectFeedRecoveryTest` (every failed read reported, the once-a-day `/podcast` re-check sharing the daily stamp, the report's address tried before the `/podcast` re-check, a passing failure keeping its address, a candidate replacing only the address that failed). - `PodcastRepositoryPlayerPrefsTest` checks the player preferences (and their legacy-file migration) open on first use, not while the repository is constructed on the main thread at startup. - Unit tests live under `core/catalog/src/test`. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt index 94fbe793d..6958b5686 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt @@ -1,7 +1,9 @@ package cx.aswin.boxlore.core.catalog import android.util.Log -import cx.aswin.boxlore.core.prefs.EngagementPromptConstants +import cx.aswin.boxlore.core.prefs.NpsSurveyMoment +import cx.aswin.boxlore.core.prefs.NpsSurveySchedule +import cx.aswin.boxlore.core.prefs.NpsSurveyStore import cx.aswin.boxlore.core.prefs.UserPreferencesRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler @@ -11,11 +13,14 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** - * Coordinates proactive engagement prompts (NPS survey, Play review) so only one - * surfaces per session and promoter handoffs follow the unified strategy. + * Coordinates proactive engagement prompts (NPS survey, Play review) so only one surfaces per + * session, and only at a finish moment: an episode finished since boxlore started + * ([processStartedAtMs], when this coordinator is made at startup), never on an open alone. */ class EngagementPromptCoordinator( private val userPrefs: UserPreferencesRepository, + private val surveyStore: NpsSurveyStore, + private val processStartedAtMs: Long = System.currentTimeMillis(), private val scope: CoroutineScope = CoroutineScope( SupervisorJob() + @@ -41,57 +46,73 @@ class EngagementPromptCoordinator( /** Marks the current session as having shown a proactive prompt and persists cooldown. */ fun recordProactivePromptShown() { sessionProactivePromptShown = true + launchSafely("Failed to record engagement prompt") { userPrefs.recordEngagementPromptShown() } + } + + private fun launchSafely(failure: String, block: suspend () -> Unit) { scope.launch { try { - userPrefs.recordEngagementPromptShown() + block() } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Failed to record engagement prompt", e) + Log.e(TAG, failure, e) } } } - /** Called when the PostHog NPS sheet becomes visible. */ - fun onSurveyDisplayed() { - recordProactivePromptShown() - } + /** An episode finished in this run of boxlore, recently: the only time a prompt may appear. */ + fun isFinishMoment(lastFinishedAtMs: Long?, nowMs: Long = System.currentTimeMillis()): Boolean = + NpsSurveySchedule.isFinishMoment(lastFinishedAtMs, processStartedAtMs, nowMs) /** - * Persists the first NPS rating and queues promoter Play review when [score] is 8+. + * Counts this start of boxlore and, once per install, carries over an older version's survey + * ([NpsSurveyStore.carryOverLegacy]). Called once at startup. */ - fun onNpsRatingSubmitted(score: Int?) { - if (score == null) return - scope.launch { - try { - userPrefs.setNpsLastScore(score) - if (score >= EngagementPromptConstants.PROMOTER_SCORE_THRESHOLD) { - userPrefs.setPromoterReviewPending(true) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Failed to persist NPS score", e) - } - } + suspend fun onAppLaunched(installedAtMs: Long, nowMs: Long = System.currentTimeMillis()) { + surveyStore.recordLaunch() + surveyStore.carryOverLegacy(userPrefs.hasNpsSurveyFired(), installedAtMs, nowMs) + } + + /** Whether the NPS survey should appear now, by [NpsSurveySchedule] with nothing playing and no prompt yet this session. */ + suspend fun shouldShowNpsSurvey( + isPlaying: Boolean, + installedAtMs: Long, + finishedSinceInstall: Int, + lastFinishedAtMs: Long?, + nowMs: Long = System.currentTimeMillis(), + ): Boolean { + if (!canShowProactivePrompt(isPlaying)) return false + if (!isFinishMoment(lastFinishedAtMs, nowMs)) return false + return NpsSurveySchedule.isDue( + NpsSurveyMoment( + nowMs = nowMs, + installedAtMs = installedAtMs, + finishedSinceInstall = finishedSinceInstall, + lastFinishedAtMs = lastFinishedAtMs, + processStartedAtMs = processStartedAtMs, + record = surveyStore.record(), + inQuietDays = userPrefs.isInReviewQuietPeriod(installedAtMs, nowMs), + cooldownElapsed = userPrefs.isEngagementCooldownElapsed(), + ), + ) } /** - * True when a deferred promoter handoff is pending and the user has not reviewed yet. - * Does not apply the 14-day cooldown — that prompt completes the NPS journey — but waits out - * the quiet days after installing ([installedAtMs]) or importing a library. + * The NPS survey is on screen: it uses up this session's prompt, the shared cooldown and its own + * 90 days, and lets the milestone review prompt follow later. */ - suspend fun shouldShowPromoterReview(isPlaying: Boolean, installedAtMs: Long,): Boolean { - if (!canShowProactivePrompt(isPlaying)) return false - if (userPrefs.hasReviewedSync()) return false - if (userPrefs.isInReviewQuietPeriod(installedAtMs)) return false - if (!userPrefs.hasNpsSurveyFired()) return false - return userPrefs.isPromoterReviewPending() + fun onSurveyDisplayed(nowMs: Long = System.currentTimeMillis()) { + recordProactivePromptShown() + launchSafely("Failed to record the NPS survey") { + surveyStore.markShown(nowMs) + userPrefs.markNpsSurveyFired() + } } - /** Clears the one-shot promoter handoff flag after the review sheet is shown or dismissed. */ - suspend fun clearPromoterReviewPending() { - userPrefs.setPromoterReviewPending(false) + /** Keeps the latest score, so the milestone review prompt skips listeners who scored low. */ + fun onNpsRatingSubmitted(score: Int) { + launchSafely("Failed to persist NPS score") { userPrefs.setNpsLastScore(score) } } /** Whether enough time has passed since the last proactive engagement prompt. */ diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt new file mode 100644 index 000000000..f4e42ee42 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt @@ -0,0 +1,73 @@ +package cx.aswin.boxlore.core.catalog + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.NpsSurveyStore +import cx.aswin.boxlore.core.prefs.UserPreferencesRepository +import cx.aswin.boxlore.core.prefs.userPreferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The coordinator asks the NPS survey only at a finish moment in this run, with nothing playing and no + * prompt yet this session; once it was on screen it waits 90 days, and the review prompt may follow. + * (One story in one test: the survey's install-only store lives for the whole test run.) + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class EngagementPromptCoordinatorSurveyTest { + private val day = 24L * 60 * 60 * 1000 + + @Test + fun `the survey appears at a finish moment, once, then waits`(): Unit = runBlocking { + val context = ApplicationProvider.getApplicationContext() + context.userPreferencesDataStore.edit { it.clear() } + val prefs = UserPreferencesRepository(context) + val store = NpsSurveyStore(context) + val now = System.currentTimeMillis() + val processStart = now - 10 * 60 * 1000 + val scope = CoroutineScope(SupervisorJob()) + val coordinator = EngagementPromptCoordinator(prefs, store, processStartedAtMs = processStart, scope = scope) + suspend fun settle() = scope.coroutineContext[Job]!!.children.forEach { it.join() } + suspend fun due(lastFinishedAt: Long?, playing: Boolean = false) = + coordinator.shouldShowNpsSurvey(playing, installedAtMs = now - 30 * day, finishedSinceInstall = 5, lastFinishedAtMs = lastFinishedAt, nowMs = now) + + coordinator.onAppLaunched(installedAtMs = now - 30 * day, nowMs = now) + coordinator.onAppLaunched(installedAtMs = now - 30 * day, nowMs = now) + // A restored or older install that never showed the old survey starts with no history. + assertNull(store.record().lastShownAtMs) + + assertFalse("an open alone is not the moment", due(lastFinishedAt = processStart - 1)) + assertFalse("never during playback", due(lastFinishedAt = now - 60_000, playing = true)) + assertTrue(due(lastFinishedAt = now - 60_000)) + assertTrue(coordinator.isFinishMoment(now - 60_000, now)) + + coordinator.reserveSessionForPrompt() + assertFalse("one prompt per session", due(lastFinishedAt = now - 60_000)) + + coordinator.onSurveyDisplayed(nowMs = now) + coordinator.onNpsRatingSubmitted(9) + settle() + assertEquals(now, store.record().lastShownAtMs) + assertTrue("the review prompt may follow later", prefs.hasNpsSurveyFired()) + assertEquals(9, prefs.npsLastScore()) + + val nextRun = EngagementPromptCoordinator(prefs, store, processStartedAtMs = now, scope = scope) + assertFalse( + "not again within 90 days", + nextRun.shouldShowNpsSurvey(false, now - 30 * day, 6, lastFinishedAtMs = now + 1, nowMs = now + 2), + ) + context.userPreferencesDataStore.edit { it.clear() } + } +} diff --git a/core/designsystem/README.md b/core/designsystem/README.md index b1cc557fe..e84e3add3 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -85,7 +85,7 @@ src/main/assets/echo-character/ # manifest.json, stills (WebP) and clips/ (MP4) - No user data, database files, DataStore names, or SharedPreferences files are owned here. - Resource names are app-internal UI contracts and should be changed with normal Android resource migration care. -- Echo sequence ids in `assets/echo-character/manifest.json` (`welcome`, `onboarding-*`, `account-*`, `import-*`, `story-*`) are called by name from `:feature:onboarding`, `:feature:settings`, `:feature:history` and `:app`; each caller has a test that its ids ship in the manifest. +- Echo sequence ids in `assets/echo-character/manifest.json` (`welcome`, `onboarding-*`, `account-*`, `import-*`, `story-*`, `survey-*`) are called by name from `:feature:onboarding`, `:feature:settings`, `:feature:history`, `:feature:home` and `:app`; each caller has a test that its ids ship in the manifest. The `survey-*` sequences (added 2026-11-01 for the NPS survey) reuse existing clips and stills: ask, notebook, think-chin, magnifier, thumbs-up and the story celebration, each resting on its clip's still, plus a still alone after a low score. - UI typeface: bundled **Google Sans Flex** variable font (`res/font/google_sans_flex_variable.ttf`). Default **ROND = 100 (Round)**; Appearance → Lettering can switch Crisp (0) / Soft (50) / Round (100) via `FontRoundness`, `LocalFontRoundness`, and `BoxLoreTheme(fontRoundness=…)`. `GoogleSansFlexTypeface` caches native faces by weight and axes (`ROND`, `opsz`, optional `wdth`); `Typography.kt` wraps each resolved face in a Compose `FontFamily` so Android skins cannot collapse Material typography weights. `GoogleSansWeight` centrally maps app emphasis to the lighter 400/400/500/600 scale. `rememberGoogleSansFamily()` is the shared helper for explicit weighted UI. Roboto Flex paths (`RobotoFlexFamily`, `LogoFontFamily`, `robotoflex_variable`) are unchanged. `DisplayFonts.kt` adds `UnboundedFamily` (ExtraBold) and `SpaceMonoFamily` (regular, bold) through the same Google Fonts provider (now `internal` in `Typography.kt`), nothing bundled; they are display faces for surfaces that deliberately step outside the app's type (the show deck's Prism card) and never for regular UI text. Shared connected chips: `ConnectedOptionSelector` (Appearance, Library history period/status filters; optional `contentPadding` / `labelStyle` for dense 3-up labels). Content region uses `RegionSegmentedSelector` (trigger plus sheet list of 11 storefronts, onboarding) and `ContentRegionPickerSheet` (the sheet alone, opened from a settings row). `ContentLanguagesSheet` (29 Oct, D9) is the languages sheet: "Include English" first (off only when every pick is in `ContentRegions.ENGLISH_OFF_LANGUAGES`; disabled with the reason otherwise, wording in `includeEnglishSupportingText`, `ContentLanguagesSheetCopyTest`; with English off it says the smaller pool makes recommendations match the listener's taste less closely, then that English fills thin results), then suggested-for-country and other language chips with an "N of 4 picked" count; with English off its slot takes a pick. SIL OFL 1.1 text: [`licenses/GoogleSansFlex-OFL.txt`](licenses/GoogleSansFlex-OFL.txt). - Lettering roundness is mirrored in `boxlore_theme_fast_cache` key `font_roundness` (owned by `:core:prefs`) so share cards / Auto collage can read it without a Compose tree. - `BoxLoreNavigationBar` owns the selectable navigation presentation: a solid Material 3 floating 3+1 shell (Home / Explore / Library pill plus Lore action, with one persistent active indicator tracking all four tabs) or the classic four-tab bar. Its optional `compactProgress: State?` is supplied by the app alongside the mini-player's shared animation: Floating eases labels into icons and moves the same Lore action into a fourth pill slot during contraction, reserving its former 52dp trailing slot before playback docks. A single selection surface sits above the neutral pill/Lore backdrops and below their actions. It travels directly between all four targets in 240ms and morphs from the external Lore circle into the compact fourth-slot capsule. Retargeting starts at its current position and heads toward the latest tab without retaining travel in the previous direction; hiding it never retargets it to Home. Floating actions use ripple-free selectable semantics and subtle icon/label press scaling, so touch feedback cannot paint a second highlight. Solid high-container surfaces, a fine outline, restrained elevation, crossfading filled/outlined icons and press scaling keep both states consistent. Pill width, 16dp edge insets, and `AppFloatingNavigationActionGap` (14dp) stay fixed; the compact Lore target is 48dp. Placement follows layout direction, and selection semantics / spoken labels follow each persistent action. Progress is clamped and read during measurement, placement and graphics layers; neutral Lore elevation follows the shared contraction without per-frame composition. Classic ignores compact progress. Lore uses a subtle icon settling animation after initial content is ready. Lore restores the original blue/pale-blue/violet/red/gold/green multicolour aurora inside that same selection surface, with its original restrained 0.44 colour opacity and a softer broad highlight. Colour strength follows the actual indicator position with an eased blend, including interrupted tab changes, so no separate Lore highlight is painted. Cached diagonal/radial brushes and draw-only phase reads keep its 20-second round trip out of per-frame composition; the original sweep eases smoothly through both turnarounds, pauses while Lore is unselected and resumes from its retained position, and the parent selection shape clips both the external circle and compact capsule. `NavigationChromeMetrics` and `appBottomChromeContentPadding()` keep app and feature overlays clear of the selected chrome and its matching mini-player geometry. @@ -159,7 +159,7 @@ Classic navigation applies its system insets outside the minimum 80dp content he The toolbar action segments use a compact 43 × 33 dp visual size. The available-update segment has a restrained shimmer across its surface. -Expanded tablet modals use solid, inset-aware dialogs up to 640dp within one fold-safe region; phone calls delegate to the unchanged Material sheet. Finite tablet sheet forms can scroll while lazy lists own their viewports. Owners with custom Hidden confirmation supply the matching `centeredDismissAllowed` policy; Engagement defaults to blocking implicit dismissal, and the current ordinary survey/review states opt in explicitly. Shared custom-control feedback adds tablet hover/focus and secondary-click long-press ownership. Artwork upgrades use measured pixel buckets, retain sufficient larger targets and preserve phone keys. Tablet artwork waits while a hidden pane gives it 1px or less, keeps its measurement across phone/tablet changes, and loads at the requested size when its layout has room but the artwork sizes itself. Window-size fallbacks exist only for isolated previews/tests. +Expanded tablet modals use solid, inset-aware dialogs up to 640dp within one fold-safe region; phone calls delegate to the unchanged Material sheet. Finite tablet sheet forms can scroll while lazy lists own their viewports. Owners with custom Hidden confirmation supply the matching `centeredDismissAllowed` policy; Engagement defaults to blocking implicit dismissal, and the current ordinary review states opt in explicitly. `boxloreSheetIsCentered()` tells a custom sheet (Home's NPS survey, which draws its own surface under a transparent sheet so Echo can stand on its edge) whether it is the centered dialog, so it rounds every corner there. Shared custom-control feedback adds tablet hover/focus and secondary-click long-press ownership. Artwork upgrades use measured pixel buckets, retain sufficient larger targets and preserve phone keys. Tablet artwork waits while a hidden pane gives it 1px or less, keeps its measurement across phone/tablet changes, and loads at the requested size when its layout has room but the artwork sizes itself. Window-size fallbacks exist only for isolated previews/tests. The Material BasicAlertDialog override delegates directly to the default host on phones and flat windows. On folded tablets it hosts the unchanged Material content in the shared safe dialog frame, including date pickers. Raw dialog owners use `BoxloreDialog`; their security and dismissal policies remain explicit. All tablet portal frames cap at 640dp; ordinary alerts retain Material's 560dp maximum. diff --git a/core/designsystem/src/main/assets/echo-character/manifest.json b/core/designsystem/src/main/assets/echo-character/manifest.json index 1d75f6947..cfa333f22 100644 --- a/core/designsystem/src/main/assets/echo-character/manifest.json +++ b/core/designsystem/src/main/assets/echo-character/manifest.json @@ -1 +1 @@ -{"schema":"echo-character","version":1,"note":"Echo stills and motion clips with a blank screen (the face is drawn in code). frames: stills, with the screen box as fractions of the frame, and reach (the still's extent). clips: alpha-packed H.264 (colour above, alpha below), one keyframe, with the screen tracked per frame as [centre x, centre y, width, height, tilt degrees], and reach (everything the clip covers); optionally a face script ([share of the clip, face, gaze x, gaze y] from that point on), frames with a blink, and lens frames ([centre x, centre y, glass radius, scale, blink]: one big eye shows through a magnifier, through the glass band). sequences: steps (a still with face and hold in ms, 0 = rest forever, or a clip) played once, then an optional loop.","frames":{"front-rest":{"screen":[0.3516,0.2344,0.6484,0.4688],"reach":[0.2539,0.1523,0.7461,0.8848]},"t1-rest":{"screen":[0.4387,0.3649,0.6566,0.565],"reach":[0.293,0.3008,0.7051,0.9199]},"ta-rest":{"screen":[0.4387,0.3622,0.6566,0.5676],"reach":[0.293,0.2969,0.707,0.9316]},"tb-rest":{"screen":[0.4387,0.3614,0.6566,0.5685],"reach":[0.2949,0.2949,0.709,0.9316]},"account-idle":{"screen":[0.3542,0.2396,0.6458,0.4688],"reach":[0.2552,0.1536,0.7448,0.8828]},"account-cloud":{"screen":[0.4896,0.2188,0.7812,0.4479],"reach":[0.388,0.138,0.8828,0.8672]},"account-cloud-hug":{"screen":[0.3542,0.2188,0.6458,0.4479],"reach":[0.2526,0.1354,0.7448,0.8672]},"account-comedy":{"screen":[0.3646,0.2396,0.6458,0.4583],"reach":[0.2552,0.1484,0.75,0.8802]},"account-truecrime":{"screen":[0.3646,0.2083,0.6562,0.4375],"reach":[0.1745,0.0443,0.7682,0.901]},"account-science":{"screen":[0.375,0.2292,0.6667,0.4479],"reach":[0.237,0.0911,0.7656,0.8698]},"account-history":{"screen":[0.3854,0.2396,0.6771,0.4688],"reach":[0.2839,0.1042,0.7786,0.8828]},"account-business":{"screen":[0.2604,0.2292,0.5417,0.4479],"reach":[0.1536,0.1354,0.6589,0.8672]},"account-wellness":{"screen":[0.375,0.2292,0.6562,0.4479],"reach":[0.1823,0.1224,0.8464,0.8698]},"account-fiction":{"screen":[0.4479,0.25,0.7292,0.4792],"reach":[0.3411,0.0755,0.8359,0.875]},"story-hourglass-rest":{"screen":[0.3315,0.156,0.644,0.4013],"reach":[0.1914,0.041,0.791,0.9199]},"story-business-rest":{"screen":[0.3239,0.171,0.6338,0.415],"reach":[0.1895,0.0605,0.7832,0.9199]},"story-celebrate-rest":{"screen":[0.3872,0.406,0.5851,0.565],"reach":[0.2188,0.252,0.7656,0.9199]},"story-comedy-rest":{"screen":[0.335,0.1823,0.6368,0.423],"reach":[0.2012,0.0742,0.7734,0.9199]},"story-daytime-rest":{"screen":[0.361,0.2119,0.6491,0.4408],"reach":[0.2305,0.041,0.7852,0.918]},"story-detective-rest":{"screen":[0.3488,0.2143,0.6197,0.4334],"reach":[0.1348,0.0352,0.75,0.9199]},"story-fiction-rest":{"screen":[0.3566,0.2356,0.6488,0.4637],"reach":[0.2285,0.0293,0.7812,0.9199]},"story-health-rest":{"screen":[0.3684,0.2718,0.636,0.4811],"reach":[0.1641,0.1602,0.8398,0.9199]},"story-history-rest":{"screen":[0.3485,0.1911,0.6494,0.4278],"reach":[0.2168,0.0312,0.7832,0.9199]},"story-morning-rest":{"screen":[0.3398,0.1511,0.657,0.4009],"reach":[0.2031,0.0371,0.7988,0.9199]},"story-news-rest":{"screen":[0.3609,0.2136,0.6451,0.4297],"reach":[0.2344,0.0312,0.7715,0.9199]},"story-night-rest":{"screen":[0.3056,0.2322,0.5692,0.4399],"reach":[0.1895,0.0312,0.7441,0.916]},"story-science-rest":{"screen":[0.3518,0.1918,0.648,0.428],"reach":[0.1836,0.0312,0.7812,0.918]},"story-tech-rest":{"screen":[0.4099,0.2432,0.6818,0.4576],"reach":[0.1895,0.0586,0.8086,0.9121]},"story-trophy-rest":{"screen":[0.4018,0.2294,0.6842,0.4475],"reach":[0.2734,0.127,0.8379,0.9199]}},"sequences":{"welcome":[{"frame":"front-rest","face":"idle","ms":0}],"onboarding-welcome":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-question":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-thinking":{"steps":[{"clip":"thumbs-up"}],"loop":[{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"notebook"},{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"think-chin"}]},"onboarding-preparing":{"steps":[{"frame":"t1-rest","face":"idle","ms":400}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":1200},{"clip":"magnifier"},{"frame":"t1-rest","face":"idle","ms":1200}]},"onboarding-presenting":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-ready":{"steps":[{"clip":"cards-fan"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-rest":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"account-cloud":{"steps":[{"frame":"account-cloud","face":"idle","ms":0}]},"account-syncing":{"steps":[{"frame":"account-cloud-hug","face":"idle","ms":0}]},"account-outfit-idle":{"steps":[{"frame":"account-idle","face":"idle","ms":0}]},"account-outfit-comedy":{"steps":[{"frame":"account-comedy","face":"idle","ms":0}]},"account-outfit-truecrime":{"steps":[{"frame":"account-truecrime","face":"idle","ms":0}]},"account-outfit-science":{"steps":[{"frame":"account-science","face":"idle","ms":0}]},"account-outfit-history":{"steps":[{"frame":"account-history","face":"idle","ms":0}]},"account-outfit-business":{"steps":[{"frame":"account-business","face":"idle","ms":0}]},"account-outfit-wellness":{"steps":[{"frame":"account-wellness","face":"idle","ms":0}]},"account-outfit-fiction":{"steps":[{"frame":"account-fiction","face":"idle","ms":0}]},"import-pick":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-working":{"steps":[{"frame":"t1-rest","face":"idle","ms":300}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":900}]},"import-done":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-failed":{"steps":[{"frame":"t1-rest","face":"curious","ms":0}]},"story-hello":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"story-hop":{"steps":[{"clip":"hop"},{"frame":"tb-rest","face":"idle","ms":0}]},"story-business":{"steps":[{"clip":"story-business"},{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy":{"steps":[{"clip":"story-comedy"},{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime":{"steps":[{"clip":"story-daytime"},{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective":{"steps":[{"clip":"story-detective"},{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction":{"steps":[{"clip":"story-fiction"},{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health":{"steps":[{"clip":"story-health"},{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history":{"steps":[{"clip":"story-history"},{"frame":"story-history-rest","face":"idle","ms":0}]},"story-hourglass":{"steps":[{"clip":"story-hourglass"},{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-morning":{"steps":[{"clip":"story-morning"},{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news":{"steps":[{"clip":"story-news"},{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night":{"steps":[{"clip":"story-night"},{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science":{"steps":[{"clip":"story-science"},{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech":{"steps":[{"clip":"story-tech"},{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy":{"steps":[{"clip":"story-trophy"},{"frame":"story-trophy-rest","face":"idle","ms":0}]},"story-entry":{"steps":[{"frame":"front-rest","face":"happy","ms":0}]},"story-hello-still":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"story-hourglass-still":{"steps":[{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-business-still":{"steps":[{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate-still":{"steps":[{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy-still":{"steps":[{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime-still":{"steps":[{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective-still":{"steps":[{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction-still":{"steps":[{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health-still":{"steps":[{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history-still":{"steps":[{"frame":"story-history-rest","face":"idle","ms":0}]},"story-morning-still":{"steps":[{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news-still":{"steps":[{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night-still":{"steps":[{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science-still":{"steps":[{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech-still":{"steps":[{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy-still":{"steps":[{"frame":"story-trophy-rest","face":"idle","ms":0}]}},"clips":{"think-chin":{"file":"clips/think-chin.mp4","fps":24,"frames":107,"size":384,"face":"curious","tracks":[[0.5481,0.4664,0.2174,0.2001,-3.16],[0.5479,0.467,0.2175,0.2,-3.12],[0.5477,0.4676,0.2175,0.2,-3.0],[0.5471,0.4687,0.2175,0.1999,-2.86],[0.5462,0.4696,0.2175,0.1999,-2.68],[0.5451,0.4701,0.2174,0.1999,-2.48],[0.5437,0.47,0.2174,0.1997,-2.19],[0.5423,0.4692,0.2172,0.1997,-1.99],[0.5409,0.4681,0.2171,0.1997,-1.74],[0.5399,0.4669,0.2171,0.1995,-1.51],[0.5391,0.4659,0.217,0.1996,-1.4],[0.5387,0.4652,0.217,0.1995,-1.36],[0.5388,0.4651,0.217,0.1995,-1.37],[0.5393,0.4653,0.217,0.1995,-1.52],[0.5403,0.4659,0.2172,0.1997,-1.71],[0.5417,0.4669,0.2174,0.1996,-1.96],[0.5435,0.4681,0.2175,0.1998,-2.34],[0.5454,0.4693,0.2177,0.1999,-2.72],[0.5476,0.4703,0.2179,0.1999,-3.05],[0.5496,0.4706,0.2183,0.2,-3.31],[0.5516,0.4703,0.2186,0.2,-3.49],[0.5533,0.4694,0.219,0.2001,-3.57],[0.555,0.4682,0.2192,0.2,-3.65],[0.5563,0.467,0.2194,0.2001,-3.71],[0.5573,0.466,0.2194,0.2002,-3.77],[0.558,0.4653,0.2194,0.2003,-3.82],[0.5584,0.4651,0.2192,0.2002,-3.82],[0.5583,0.4652,0.2193,0.2003,-3.78],[0.558,0.4658,0.2191,0.2001,-3.74],[0.5574,0.4667,0.219,0.2001,-3.62],[0.5566,0.468,0.2188,0.2,-3.51],[0.5558,0.4697,0.2185,0.1999,-3.41],[0.555,0.4716,0.2182,0.1997,-3.23],[0.5542,0.4733,0.2181,0.1997,-3.05],[0.5536,0.4748,0.2178,0.1995,-3.0],[0.5528,0.4757,0.2177,0.1994,-2.96],[0.5517,0.4759,0.2177,0.1994,-2.96],[0.5502,0.4752,0.2179,0.1996,-3.07],[0.5483,0.4737,0.2182,0.1997,-3.24],[0.546,0.4716,0.2186,0.1998,-3.46],[0.5434,0.4691,0.2191,0.2001,-3.68],[0.5408,0.4665,0.2198,0.2004,-3.95],[0.5385,0.464,0.2207,0.2006,-4.21],[0.5366,0.462,0.2214,0.2008,-4.43],[0.5351,0.4605,0.2219,0.201,-4.64],[0.5341,0.4594,0.222,0.2011,-4.83],[0.5334,0.4587,0.222,0.2011,-4.95],[0.533,0.4584,0.2218,0.2011,-5.04],[0.5327,0.4582,0.2216,0.201,-5.08],[0.5326,0.4581,0.2215,0.201,-5.08],[0.5327,0.4583,0.2215,0.2011,-5.11],[0.5328,0.4588,0.2215,0.201,-5.07],[0.5331,0.4597,0.2215,0.2011,-5.06],[0.5334,0.4608,0.2215,0.2011,-5.06],[0.534,0.4621,0.2213,0.2011,-5.05],[0.5345,0.4633,0.2211,0.201,-5.06],[0.5349,0.4642,0.2209,0.2011,-5.1],[0.5351,0.4646,0.2207,0.201,-5.1],[0.535,0.4645,0.2207,0.2011,-5.09],[0.5347,0.4638,0.2209,0.2011,-5.07],[0.5342,0.4626,0.2211,0.2011,-5.1],[0.5337,0.4613,0.2212,0.2011,-5.13],[0.5331,0.46,0.2215,0.2011,-5.11],[0.5327,0.4589,0.2216,0.201,-5.12],[0.5326,0.4583,0.2215,0.201,-5.17],[0.5326,0.4582,0.2216,0.201,-5.12],[0.5327,0.4587,0.2217,0.2011,-5.11],[0.533,0.4596,0.2215,0.2012,-5.13],[0.5334,0.4607,0.2213,0.2012,-5.16],[0.5337,0.462,0.2214,0.2012,-5.1],[0.534,0.463,0.2215,0.2011,-5.05],[0.5342,0.4636,0.2215,0.201,-5.01],[0.5342,0.4637,0.2215,0.2011,-5.02],[0.5341,0.4633,0.2215,0.201,-4.99],[0.5339,0.4623,0.2214,0.2012,-5.07],[0.5335,0.4611,0.2213,0.2012,-5.14],[0.5331,0.46,0.2215,0.2012,-5.15],[0.5328,0.4591,0.2216,0.2011,-5.13],[0.5326,0.4584,0.2217,0.2011,-5.13],[0.5326,0.4583,0.2217,0.2011,-5.13],[0.5327,0.4585,0.2216,0.2012,-5.15],[0.533,0.4592,0.2215,0.2012,-5.14],[0.5334,0.4601,0.2214,0.2013,-5.1],[0.5338,0.4612,0.2215,0.2013,-5.09],[0.5341,0.4622,0.2215,0.2013,-5.05],[0.5343,0.4629,0.2217,0.2012,-4.99],[0.5344,0.463,0.2217,0.2012,-4.98],[0.5343,0.4627,0.2217,0.2013,-5.06],[0.5341,0.4619,0.2216,0.2013,-5.1],[0.5338,0.4609,0.2215,0.2013,-5.16],[0.5336,0.4599,0.2214,0.2013,-5.21],[0.5335,0.459,0.2212,0.2013,-5.26],[0.5335,0.4585,0.2211,0.2012,-5.21],[0.5338,0.4582,0.221,0.2011,-5.15],[0.5344,0.4584,0.2209,0.2011,-5.04],[0.5354,0.4588,0.2206,0.201,-4.88],[0.5366,0.4595,0.2204,0.201,-4.66],[0.5381,0.4603,0.2201,0.2008,-4.39],[0.5397,0.4613,0.2197,0.2007,-4.13],[0.5413,0.4623,0.2194,0.2005,-3.8],[0.5429,0.4632,0.2192,0.2004,-3.49],[0.5444,0.4641,0.219,0.2002,-3.22],[0.5457,0.4647,0.2185,0.2002,-3.14],[0.5467,0.4652,0.2183,0.2001,-3.03],[0.5475,0.4655,0.2179,0.2001,-3.02],[0.548,0.4656,0.2177,0.2002,-3.06],[0.5483,0.4657,0.2175,0.2002,-3.12]],"rest":"t1-rest","mask":true,"reach":[0.2917,0.2969,0.7109,0.9219]},"wave":{"file":"clips/wave.mp4","fps":24,"frames":61,"size":384,"face":"happy","tracks":[[0.5483,0.4658,0.2174,0.2001,-3.16],[0.5484,0.4661,0.2174,0.2,-3.11],[0.5488,0.4665,0.2173,0.2,-3.03],[0.5494,0.4671,0.2173,0.1998,-2.84],[0.5504,0.4678,0.2175,0.1997,-2.51],[0.5516,0.4683,0.2176,0.1995,-2.09],[0.553,0.4685,0.2177,0.1995,-1.61],[0.5544,0.4681,0.218,0.1995,-1.01],[0.5558,0.4671,0.2182,0.1996,-0.39],[0.5571,0.4657,0.2183,0.1996,0.2],[0.5583,0.4639,0.2184,0.1997,0.79],[0.5595,0.4621,0.2186,0.1995,1.18],[0.5607,0.4604,0.2184,0.1995,1.63],[0.5617,0.459,0.2182,0.1995,1.98],[0.5626,0.458,0.2181,0.1995,2.17],[0.5632,0.4574,0.2179,0.1993,2.26],[0.5636,0.4571,0.2177,0.1994,2.44],[0.5636,0.4569,0.2178,0.1993,2.45],[0.5632,0.4569,0.2182,0.1993,2.43],[0.5628,0.4568,0.2186,0.1993,2.47],[0.5622,0.4568,0.219,0.1993,2.51],[0.5619,0.4568,0.2194,0.1993,2.51],[0.5618,0.4568,0.2194,0.1993,2.51],[0.5621,0.4568,0.2191,0.1993,2.51],[0.5628,0.4568,0.2184,0.1993,2.51],[0.5637,0.4569,0.2176,0.1993,2.5],[0.5645,0.4569,0.2168,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.48],[0.5645,0.4569,0.2168,0.1994,2.46],[0.5637,0.4569,0.2176,0.1993,2.44],[0.5628,0.4568,0.2183,0.1993,2.41],[0.562,0.4568,0.219,0.1993,2.38],[0.5616,0.4567,0.2193,0.1993,2.35],[0.5616,0.4567,0.2193,0.1993,2.35],[0.562,0.4568,0.2189,0.1993,2.35],[0.5626,0.4568,0.2183,0.1994,2.36],[0.5634,0.4569,0.2177,0.1994,2.38],[0.564,0.4569,0.2172,0.1994,2.4],[0.5643,0.457,0.2169,0.1994,2.4],[0.5642,0.457,0.217,0.1994,2.42],[0.5638,0.457,0.2174,0.1994,2.41],[0.5633,0.4569,0.2179,0.1994,2.4],[0.5627,0.457,0.2183,0.1995,2.38],[0.5621,0.4571,0.2187,0.1996,2.34],[0.5616,0.4574,0.2188,0.1995,2.18],[0.5611,0.4578,0.2188,0.1995,1.99],[0.5605,0.4583,0.2186,0.1996,1.72],[0.5597,0.4591,0.2184,0.1995,1.34],[0.5586,0.46,0.2181,0.1995,0.86],[0.5574,0.4608,0.2178,0.1996,0.37],[0.556,0.4618,0.2176,0.1997,-0.21],[0.5546,0.4627,0.2175,0.1997,-0.83],[0.5531,0.4634,0.2173,0.1998,-1.41],[0.5517,0.4641,0.2174,0.1999,-1.89],[0.5505,0.4646,0.2174,0.1999,-2.33],[0.5496,0.465,0.2173,0.2,-2.67],[0.5489,0.4653,0.2173,0.2,-2.91],[0.5485,0.4655,0.2174,0.2001,-3.05],[0.5484,0.4656,0.2173,0.2001,-3.17],[0.5483,0.4656,0.2173,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"ask":{"file":"clips/ask.mp4","fps":24,"frames":56,"size":384,"face":"idle","tracks":[[0.5483,0.4656,0.2173,0.2001,-3.18],[0.5483,0.4657,0.2174,0.2001,-3.17],[0.5485,0.4661,0.2174,0.2,-3.09],[0.5488,0.4667,0.2176,0.1999,-2.92],[0.5493,0.4675,0.2177,0.1998,-2.71],[0.5501,0.4684,0.218,0.1996,-2.41],[0.5508,0.4694,0.2183,0.1994,-2.01],[0.5516,0.47,0.2186,0.1992,-1.58],[0.5523,0.4702,0.2189,0.1992,-1.18],[0.5528,0.4698,0.2194,0.1993,-0.69],[0.5531,0.4692,0.2198,0.1993,-0.24],[0.5534,0.4684,0.2202,0.1993,0.11],[0.5535,0.4676,0.2205,0.1993,0.38],[0.5536,0.4671,0.2208,0.1992,0.59],[0.5536,0.4667,0.2209,0.1991,0.68],[0.5536,0.4665,0.221,0.1992,0.73],[0.5536,0.4664,0.221,0.1993,0.76],[0.5536,0.4662,0.221,0.1995,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.79],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.2209,0.1997,0.76],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5535,0.4661,0.2209,0.1997,0.75],[0.5535,0.4662,0.2209,0.1995,0.71],[0.5534,0.4664,0.2209,0.1994,0.68],[0.5533,0.4665,0.2209,0.1992,0.63],[0.5531,0.4666,0.2208,0.199,0.52],[0.5529,0.4668,0.2207,0.1988,0.34],[0.5526,0.4668,0.2204,0.199,0.1],[0.5522,0.4667,0.2201,0.1992,-0.23],[0.5518,0.4667,0.2197,0.1993,-0.62],[0.5513,0.4666,0.2194,0.1995,-1.02],[0.5508,0.4665,0.219,0.1997,-1.41],[0.5503,0.4664,0.2186,0.1997,-1.75],[0.5499,0.4664,0.2184,0.1996,-2.03],[0.5494,0.4663,0.2181,0.1997,-2.35],[0.549,0.4661,0.2179,0.1997,-2.61],[0.5487,0.466,0.2177,0.1998,-2.83],[0.5484,0.4659,0.2176,0.1999,-3.01],[0.5483,0.4658,0.2176,0.2,-3.15],[0.5482,0.4657,0.2176,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"thumbs-up":{"file":"clips/thumbs-up.mp4","fps":24,"frames":59,"size":384,"face":"happy","tracks":[[0.5483,0.4657,0.2179,0.2001,-3.13],[0.5483,0.4657,0.2178,0.2001,-3.13],[0.5484,0.4659,0.2178,0.2,-3.07],[0.5486,0.4664,0.2178,0.2,-2.97],[0.5492,0.4676,0.2179,0.1999,-2.77],[0.5502,0.4695,0.218,0.1998,-2.47],[0.5515,0.4722,0.2181,0.1996,-2.02],[0.5532,0.4751,0.2184,0.1996,-1.5],[0.5549,0.4778,0.2186,0.1995,-0.9],[0.5564,0.4792,0.2189,0.1993,-0.35],[0.5572,0.4787,0.219,0.199,0.04],[0.5573,0.4757,0.2191,0.1992,0.21],[0.5567,0.4713,0.219,0.1993,0.15],[0.5556,0.4661,0.2189,0.1994,-0.11],[0.5542,0.4611,0.2187,0.1996,-0.47],[0.5529,0.4572,0.2186,0.1998,-0.77],[0.5519,0.4547,0.2185,0.1997,-1.01],[0.5513,0.4534,0.2184,0.1996,-1.13],[0.5511,0.4528,0.2183,0.1995,-1.17],[0.551,0.4527,0.2183,0.1995,-1.15],[0.5515,0.4549,0.2184,0.1994,-1.04],[0.5523,0.4588,0.2185,0.1995,-0.85],[0.5534,0.4638,0.2187,0.1996,-0.65],[0.5548,0.4689,0.2189,0.1998,-0.31],[0.5563,0.4738,0.2192,0.1999,0.1],[0.5575,0.4755,0.2192,0.2001,0.47],[0.5584,0.4747,0.2193,0.2003,0.78],[0.559,0.4723,0.2192,0.2005,1.02],[0.5594,0.4691,0.219,0.2006,1.14],[0.5595,0.4662,0.2188,0.2006,1.17],[0.5597,0.4645,0.2186,0.2005,1.13],[0.5599,0.4641,0.2184,0.2004,1.11],[0.5603,0.4647,0.2182,0.2003,1.15],[0.5608,0.4663,0.2181,0.2002,1.18],[0.5613,0.4679,0.218,0.2001,1.27],[0.5619,0.4692,0.2179,0.2002,1.37],[0.5623,0.47,0.2179,0.2,1.43],[0.5626,0.4701,0.2179,0.1999,1.49],[0.5628,0.4696,0.2178,0.2,1.53],[0.5628,0.469,0.2178,0.1999,1.53],[0.5627,0.4684,0.2178,0.1999,1.52],[0.5627,0.4679,0.2178,0.2,1.52],[0.5626,0.4677,0.2178,0.2,1.48],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.48],[0.5625,0.4676,0.2178,0.1999,1.45],[0.5622,0.4677,0.2178,0.1999,1.36],[0.5616,0.4678,0.2178,0.1999,1.17],[0.5605,0.4679,0.2178,0.2,0.8],[0.5585,0.468,0.218,0.1999,0.26],[0.5557,0.468,0.2184,0.1999,-0.54],[0.5521,0.4678,0.2192,0.1999,-1.46],[0.5476,0.4675,0.2204,0.2,-2.42]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"notebook":{"file":"clips/notebook.mp4","fps":24,"frames":107,"size":384,"face":"idle","tracks":[[0.5478,0.4639,0.2178,0.2054,-3.5],[0.5478,0.4639,0.2179,0.2054,-3.5],[0.5479,0.464,0.2179,0.2055,-3.51],[0.5481,0.4643,0.2179,0.2055,-3.47],[0.5484,0.465,0.218,0.2055,-3.36],[0.5491,0.4659,0.2178,0.2054,-3.3],[0.5506,0.4678,0.2174,0.2051,-3.13],[0.5526,0.4703,0.2168,0.2045,-2.9],[0.555,0.4733,0.2159,0.2035,-2.67],[0.5578,0.4765,0.2148,0.2026,-2.45],[0.5605,0.4798,0.2138,0.2016,-2.13],[0.5625,0.4823,0.213,0.2006,-1.89],[0.5641,0.4844,0.2124,0.1999,-1.66],[0.5651,0.4858,0.2123,0.1994,-1.41],[0.5655,0.4869,0.2129,0.199,-1.37],[0.5655,0.4877,0.2137,0.1988,-1.19],[0.5653,0.4884,0.2148,0.1987,-0.86],[0.5647,0.4893,0.2166,0.1986,-0.73],[0.5642,0.4903,0.2183,0.1985,-0.43],[0.5636,0.4915,0.2202,0.1982,-0.05],[0.563,0.493,0.2221,0.1978,0.22],[0.5626,0.4945,0.2241,0.1973,0.51],[0.5626,0.4958,0.2253,0.1964,1.1],[0.5625,0.4969,0.2266,0.1957,1.5],[0.5627,0.4976,0.2271,0.195,1.96],[0.5629,0.498,0.2274,0.1944,2.35],[0.563,0.4982,0.2276,0.194,2.58],[0.563,0.4983,0.2277,0.1938,2.66],[0.5631,0.4983,0.2278,0.1938,2.65],[0.5634,0.4983,0.2278,0.1937,2.73],[0.5638,0.4983,0.2274,0.1936,2.76],[0.564,0.4983,0.2274,0.1936,2.76],[0.564,0.4984,0.2275,0.1937,2.7],[0.5636,0.4984,0.228,0.1938,2.68],[0.5629,0.4985,0.2285,0.194,2.59],[0.562,0.4985,0.2294,0.194,2.56],[0.5617,0.4985,0.2296,0.1941,2.57],[0.5618,0.4985,0.2294,0.1939,2.69],[0.5627,0.4984,0.2287,0.1938,2.78],[0.5637,0.4984,0.2278,0.1937,2.81],[0.5642,0.4985,0.2272,0.1938,2.76],[0.564,0.4986,0.2274,0.1939,2.71],[0.5635,0.4987,0.2278,0.1941,2.62],[0.5626,0.4987,0.2285,0.1942,2.59],[0.5617,0.4986,0.2293,0.1941,2.63],[0.5613,0.4985,0.2297,0.1939,2.72],[0.5612,0.4985,0.2298,0.1939,2.73],[0.5613,0.4986,0.2298,0.1939,2.69],[0.5616,0.4987,0.2294,0.1941,2.55],[0.562,0.4989,0.2291,0.1944,2.38],[0.5623,0.499,0.2286,0.1947,2.24],[0.5626,0.4991,0.2282,0.1949,2.14],[0.5625,0.4991,0.2282,0.1949,2.12],[0.5617,0.4991,0.2291,0.1948,2.19],[0.5608,0.4991,0.2298,0.1949,2.26],[0.5594,0.4991,0.231,0.195,2.25],[0.5579,0.4992,0.2321,0.1952,2.33],[0.5564,0.4994,0.2332,0.1955,2.27],[0.5553,0.4995,0.2338,0.1957,2.21],[0.5545,0.4995,0.2347,0.1957,2.15],[0.554,0.4995,0.235,0.1957,2.15],[0.5537,0.4995,0.2354,0.1957,2.04],[0.5537,0.4995,0.2354,0.1958,2.0],[0.5537,0.4996,0.2354,0.1959,1.94],[0.5537,0.4996,0.2355,0.1959,1.9],[0.5537,0.4996,0.2355,0.196,1.87],[0.5538,0.4997,0.2356,0.196,1.85],[0.5538,0.4997,0.2356,0.196,1.84],[0.5538,0.4997,0.2356,0.1961,1.82],[0.5538,0.4997,0.2356,0.1962,1.77],[0.5538,0.4998,0.2356,0.1963,1.71],[0.5537,0.4998,0.2356,0.1963,1.67],[0.5537,0.4998,0.2356,0.1964,1.62],[0.5537,0.4996,0.2356,0.1965,1.57],[0.5536,0.4992,0.2356,0.1967,1.49],[0.5536,0.4985,0.2355,0.197,1.41],[0.5535,0.4977,0.2353,0.1974,1.3],[0.5535,0.4966,0.235,0.1978,1.19],[0.5537,0.4954,0.234,0.1981,1.27],[0.5538,0.4943,0.233,0.1985,1.27],[0.554,0.4933,0.2319,0.1989,1.18],[0.5542,0.4924,0.2309,0.1993,1.02],[0.5544,0.4916,0.2299,0.1996,0.79],[0.5546,0.4906,0.229,0.1999,0.59],[0.5548,0.4892,0.2279,0.2002,0.39],[0.5548,0.4873,0.2266,0.2007,0.12],[0.5546,0.4848,0.2251,0.2013,-0.17],[0.5542,0.4817,0.2235,0.202,-0.49],[0.5534,0.4782,0.2223,0.2029,-1.09],[0.5524,0.4749,0.2213,0.2037,-1.69],[0.5514,0.4718,0.2204,0.2044,-2.2],[0.5504,0.4691,0.2198,0.2049,-2.63],[0.5494,0.467,0.2192,0.2052,-2.95],[0.5487,0.4655,0.2188,0.2055,-3.19],[0.5482,0.4645,0.2185,0.2056,-3.38],[0.5479,0.4639,0.2183,0.2057,-3.5],[0.5477,0.4636,0.2182,0.2058,-3.57],[0.5477,0.4635,0.2182,0.2059,-3.62],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4635,0.2182,0.2059,-3.61],[0.5479,0.464,0.2183,0.2058,-3.52]],"rest":"ta-rest","mask":true,"reach":[0.2578,0.2917,0.7474,0.9297]},"magnifier":{"file":"clips/magnifier.mp4","fps":24,"frames":116,"size":384,"face":"curious","tracks":[[0.5479,0.464,0.2183,0.2058,-3.52],[0.5483,0.4648,0.2186,0.2057,-3.34],[0.5489,0.4663,0.2192,0.2053,-3.06],[0.5494,0.4677,0.2197,0.205,-2.78],[0.5501,0.4699,0.2208,0.2046,-2.38],[0.5506,0.4724,0.2219,0.2041,-1.91],[0.5507,0.4748,0.2232,0.2035,-1.49],[0.5503,0.4767,0.2246,0.2032,-1.17],[0.5495,0.4782,0.2262,0.2029,-0.82],[0.5482,0.4782,0.2275,0.203,-0.65],[0.5466,0.4769,0.2286,0.2032,-0.63],[0.5449,0.4734,0.2295,0.2016,-0.74],[0.5433,0.4698,0.2301,0.2015,-0.64],[0.5419,0.4663,0.2304,0.2015,-0.28],[0.5408,0.4631,0.2305,0.2017,-0.54],[0.5399,0.4605,0.2305,0.202,-0.68],[0.5393,0.4597,0.2304,0.2043,-0.74],[0.539,0.459,0.2304,0.2049,-1.0],[0.5388,0.4585,0.2304,0.2055,-1.56],[0.5387,0.4584,0.2304,0.2056,-1.47],[0.5387,0.4584,0.2304,0.2056,-1.46],[0.5387,0.4584,0.2304,0.2056,-1.45],[0.5386,0.4583,0.2304,0.2055,-1.48],[0.5385,0.4583,0.2305,0.2055,-1.51],[0.538,0.4583,0.2309,0.2054,-1.47],[0.5369,0.4582,0.2317,0.2053,-1.33],[0.5349,0.458,0.2329,0.2052,-1.21],[0.533,0.458,0.2339,0.2051,-1.07],[0.5295,0.4578,0.2362,0.205,-0.73],[0.5258,0.4576,0.2387,0.2049,-0.44],[0.5219,0.4575,0.2411,0.2049,-0.2],[0.5189,0.4574,0.2431,0.2049,0.07],[0.5148,0.4573,0.2456,0.2049,0.38],[0.5116,0.4573,0.2467,0.205,0.51],[0.5086,0.4573,0.2471,0.2052,0.68],[0.5063,0.4574,0.2471,0.2054,0.84],[0.5034,0.4575,0.2472,0.2065,0.91],[0.5014,0.4576,0.2467,0.2077,0.89],[0.5,0.4577,0.2465,0.2089,0.84],[0.4991,0.4578,0.2463,0.2099,0.73],[0.4983,0.4578,0.2464,0.2109,0.58],[0.4981,0.4578,0.2464,0.2111,0.52],[0.4981,0.4578,0.2466,0.2111,0.51],[0.4981,0.4578,0.2467,0.211,0.5],[0.4982,0.4578,0.2467,0.211,0.47],[0.4984,0.4578,0.2468,0.211,0.47],[0.4991,0.4578,0.247,0.211,0.43],[0.4998,0.4579,0.2471,0.211,0.39],[0.5018,0.4579,0.2472,0.21,0.41],[0.5045,0.4578,0.2471,0.2088,0.42],[0.5079,0.4576,0.2465,0.2076,0.4],[0.511,0.4575,0.2461,0.2064,0.37],[0.5161,0.4572,0.2446,0.2049,0.14],[0.5209,0.457,0.2427,0.2043,-0.33],[0.5258,0.4571,0.24,0.2041,-0.8],[0.5298,0.4571,0.2376,0.204,-1.11],[0.5353,0.4572,0.2331,0.2043,-1.62],[0.5395,0.4574,0.229,0.2052,-1.94],[0.5429,0.4576,0.2241,0.2061,-1.69],[0.5453,0.4577,0.2202,0.207,-1.41],[0.5484,0.4578,0.2158,0.2078,-1.34],[0.5499,0.4579,0.2132,0.2082,-1.07],[0.5509,0.458,0.2112,0.2084,-0.84],[0.5516,0.458,0.2106,0.2085,-0.96],[0.5526,0.458,0.2097,0.2085,-1.17],[0.5533,0.4579,0.2092,0.2084,-1.43],[0.5541,0.4579,0.2086,0.2083,-1.66],[0.5548,0.4578,0.2079,0.2083,-1.9],[0.5556,0.4578,0.2072,0.2082,-2.18],[0.5563,0.4577,0.2067,0.2081,-2.39],[0.5566,0.4576,0.2063,0.2081,-2.49],[0.5567,0.4576,0.2061,0.2082,-2.57],[0.5568,0.4575,0.206,0.2082,-2.6],[0.5568,0.4574,0.2058,0.2082,-2.63],[0.5569,0.4573,0.2056,0.2083,-2.66],[0.5569,0.4572,0.2055,0.2083,-2.69],[0.557,0.4572,0.2053,0.2083,-2.72],[0.557,0.4571,0.2052,0.2084,-2.75],[0.5571,0.457,0.205,0.2084,-2.78],[0.5571,0.4569,0.2049,0.2084,-2.81],[0.5572,0.4568,0.2047,0.2084,-2.84],[0.5572,0.4568,0.2046,0.2085,-2.87],[0.5573,0.4567,0.2044,0.2085,-2.9],[0.5573,0.4566,0.2043,0.2085,-2.93],[0.5574,0.4565,0.2041,0.2086,-2.96],[0.5574,0.4565,0.2039,0.2086,-2.99],[0.5568,0.4564,0.2047,0.2087,-2.9],[0.5556,0.4564,0.2059,0.2088,-2.65],[0.5543,0.4563,0.2071,0.209,-2.39],[0.5528,0.4563,0.2082,0.2092,-2.11],[0.5514,0.4563,0.2094,0.2094,-1.81],[0.5506,0.4563,0.2097,0.2095,-1.63],[0.5504,0.4563,0.2096,0.2096,-1.58],[0.5504,0.4562,0.2097,0.2096,-1.64],[0.5506,0.4558,0.21,0.2084,-1.68],[0.5509,0.4551,0.2107,0.2066,-1.91],[0.5514,0.4542,0.2116,0.204,-2.58],[0.5518,0.4544,0.2124,0.2032,-2.91],[0.5523,0.455,0.2131,0.2024,-3.07],[0.5528,0.4565,0.2139,0.2028,-3.24],[0.5532,0.4585,0.2146,0.2038,-3.15],[0.5536,0.4613,0.2152,0.2056,-2.56],[0.5539,0.4633,0.2159,0.2053,-2.29],[0.5542,0.4651,0.2165,0.2052,-2.07],[0.5543,0.4666,0.217,0.2049,-1.84],[0.5543,0.4678,0.2175,0.2047,-1.7],[0.5541,0.4683,0.2178,0.2047,-1.69],[0.5537,0.4684,0.2179,0.2047,-1.78],[0.553,0.4681,0.2181,0.2047,-1.94],[0.5521,0.4674,0.2183,0.2049,-2.1],[0.5511,0.4665,0.2185,0.2052,-2.33],[0.5502,0.4657,0.2185,0.2053,-2.56],[0.5493,0.465,0.2186,0.2054,-2.78],[0.5486,0.4644,0.2187,0.2055,-2.97],[0.5482,0.464,0.2185,0.2056,-3.18],[0.548,0.4638,0.2184,0.2056,-3.31]],"rest":"ta-rest","mask":true,"reach":[0.263,0.2943,0.7188,0.9297]},"cards-shuffle":{"file":"clips/cards-shuffle.mp4","fps":24,"frames":119,"size":384,"face":"curious","tracks":[[0.5488,0.4655,0.2176,0.207,-3.34],[0.5493,0.466,0.2175,0.2068,-3.25],[0.5502,0.4672,0.2172,0.2066,-3.17],[0.5517,0.4691,0.2168,0.2061,-3.02],[0.5538,0.4721,0.2161,0.2052,-2.8],[0.5566,0.4758,0.2148,0.2042,-2.59],[0.5597,0.4802,0.2135,0.2028,-2.3],[0.5628,0.4848,0.2122,0.2012,-1.97],[0.5657,0.4893,0.2109,0.1994,-1.71],[0.5682,0.4932,0.2096,0.1979,-1.44],[0.5702,0.4961,0.2084,0.1964,-1.27],[0.5715,0.498,0.2078,0.1954,-1.26],[0.5719,0.4988,0.2077,0.1949,-1.3],[0.5719,0.4987,0.2079,0.1951,-1.22],[0.5714,0.4977,0.2084,0.1957,-1.11],[0.5704,0.4962,0.2096,0.1966,-0.96],[0.569,0.4943,0.2111,0.1977,-0.67],[0.5675,0.4921,0.2126,0.1989,-0.23],[0.5661,0.4899,0.214,0.2001,0.26],[0.5644,0.4878,0.2157,0.2012,0.64],[0.563,0.486,0.2171,0.2022,1.02],[0.5617,0.4845,0.2182,0.203,1.34],[0.5608,0.4834,0.219,0.2035,1.51],[0.56,0.4827,0.2199,0.2039,1.52],[0.5595,0.4822,0.2204,0.2041,1.52],[0.5594,0.482,0.2205,0.2042,1.56],[0.5596,0.482,0.2205,0.2043,1.63],[0.5602,0.482,0.2201,0.2044,1.84],[0.5611,0.4822,0.2198,0.2044,2.02],[0.5624,0.4825,0.219,0.2045,2.32],[0.5638,0.4829,0.2182,0.2046,2.5],[0.5654,0.4832,0.2173,0.2045,2.66],[0.5667,0.4834,0.2166,0.2042,2.73],[0.5679,0.4835,0.2157,0.2039,2.86],[0.5688,0.4835,0.2153,0.2035,2.9],[0.5693,0.4833,0.2149,0.203,3.03],[0.5696,0.4832,0.2148,0.2028,3.11],[0.5696,0.4833,0.2149,0.2029,3.07],[0.5692,0.4835,0.2152,0.2032,3.07],[0.5685,0.4837,0.2156,0.2036,3.17],[0.5673,0.4838,0.2165,0.2038,3.2],[0.5657,0.484,0.2177,0.2041,3.17],[0.5638,0.484,0.2191,0.2041,3.2],[0.5619,0.484,0.2203,0.204,3.16],[0.5601,0.484,0.2217,0.2039,2.95],[0.5586,0.484,0.2228,0.2039,2.75],[0.5574,0.4839,0.2237,0.2039,2.54],[0.5564,0.4839,0.2242,0.2039,2.37],[0.5552,0.4837,0.2249,0.2039,1.98],[0.5535,0.4835,0.2255,0.2038,1.54],[0.551,0.483,0.2261,0.2038,0.97],[0.5476,0.4824,0.2269,0.204,0.4],[0.5433,0.4818,0.2281,0.2047,-0.26],[0.5386,0.4812,0.2293,0.2057,-0.85],[0.5338,0.4805,0.2306,0.2065,-1.45],[0.5293,0.48,0.2319,0.2076,-1.93],[0.5257,0.4796,0.2328,0.2089,-2.36],[0.5227,0.4795,0.2335,0.2103,-2.66],[0.5205,0.4796,0.234,0.2118,-2.82],[0.5191,0.4799,0.2342,0.2133,-2.95],[0.5182,0.4801,0.2344,0.2146,-3.02],[0.5176,0.4803,0.2346,0.2153,-3.06],[0.5174,0.4803,0.2347,0.2154,-3.11],[0.5173,0.4803,0.2347,0.2154,-3.14],[0.5174,0.4804,0.2347,0.2154,-3.07],[0.5174,0.4806,0.2347,0.2154,-3.0],[0.5175,0.4808,0.2347,0.2152,-2.93],[0.5176,0.4811,0.2347,0.215,-2.88],[0.5178,0.4814,0.2347,0.2142,-2.97],[0.5179,0.4813,0.2348,0.2121,-3.39],[0.5179,0.4811,0.2349,0.2094,-4.02],[0.5176,0.4798,0.2351,0.2052,-4.88],[0.5167,0.477,0.2354,0.1996,-5.94],[0.5153,0.473,0.2355,0.1935,-7.04],[0.5133,0.4683,0.2358,0.1885,-7.87],[0.5109,0.4628,0.236,0.184,-8.56],[0.5083,0.4578,0.2362,0.1816,-9.05],[0.506,0.4539,0.2364,0.1811,-9.31],[0.504,0.451,0.2367,0.1818,-9.47],[0.5026,0.4489,0.237,0.1824,-9.55],[0.5017,0.4477,0.2371,0.1833,-9.63],[0.5014,0.4471,0.2371,0.1835,-9.69],[0.5014,0.4468,0.2371,0.1831,-9.76],[0.5016,0.4468,0.2371,0.1824,-9.79],[0.5019,0.447,0.237,0.1819,-9.84],[0.5025,0.4474,0.237,0.1813,-9.77],[0.5033,0.4482,0.237,0.1811,-9.49],[0.5045,0.4495,0.237,0.1817,-8.86],[0.5064,0.452,0.2371,0.184,-7.68],[0.5088,0.4559,0.2373,0.188,-6.15],[0.5116,0.4609,0.2377,0.1931,-4.66],[0.5147,0.4664,0.2383,0.198,-3.12],[0.5176,0.472,0.2392,0.2019,-1.61],[0.52,0.4774,0.2402,0.2041,-0.41],[0.5218,0.4819,0.2412,0.2044,0.57],[0.5229,0.4859,0.2423,0.2035,1.67],[0.5234,0.4896,0.2432,0.2025,2.59],[0.5234,0.4926,0.2439,0.2017,3.28],[0.5233,0.4949,0.2445,0.2011,3.76],[0.5232,0.4965,0.2448,0.2006,4.09],[0.5231,0.4974,0.245,0.2004,4.28],[0.5231,0.4978,0.2451,0.2004,4.34],[0.5233,0.4978,0.2452,0.2004,4.31],[0.5238,0.4974,0.2451,0.2005,4.22],[0.5248,0.4966,0.2447,0.2007,3.98],[0.5262,0.4952,0.244,0.201,3.56],[0.5283,0.4933,0.2428,0.2015,3.08],[0.5309,0.4907,0.2411,0.2021,2.44],[0.5338,0.4876,0.2388,0.2028,1.63],[0.5368,0.4841,0.236,0.2036,0.77],[0.5397,0.4805,0.2327,0.2044,-0.07],[0.5423,0.4768,0.2293,0.205,-0.92],[0.5445,0.4733,0.2259,0.2056,-1.74],[0.5462,0.4704,0.2229,0.2061,-2.38],[0.5474,0.4682,0.2206,0.2064,-2.87],[0.5482,0.4667,0.2189,0.2066,-3.2],[0.5486,0.4658,0.2179,0.2068,-3.41],[0.5487,0.4654,0.2175,0.2069,-3.48],[0.5488,0.4653,0.2173,0.207,-3.52]],"rest":"tb-rest","mask":true,"reach":[0.276,0.2891,0.7865,0.9323]},"cards-fan":{"file":"clips/cards-fan.mp4","fps":24,"frames":137,"size":384,"face":"happy","tracks":[[0.5487,0.4654,0.2173,0.207,-3.52],[0.5488,0.4656,0.2174,0.207,-3.5],[0.549,0.4663,0.2175,0.2067,-3.46],[0.5495,0.4675,0.2177,0.2066,-3.38],[0.55,0.4695,0.218,0.2062,-3.25],[0.5506,0.4724,0.2184,0.2057,-3.08],[0.5508,0.4757,0.2189,0.205,-2.89],[0.5505,0.4792,0.2197,0.2043,-2.74],[0.5494,0.4826,0.2207,0.2034,-2.7],[0.5473,0.4857,0.222,0.2027,-2.77],[0.5443,0.4879,0.2237,0.2022,-2.97],[0.5405,0.4894,0.2256,0.2019,-3.15],[0.5363,0.4902,0.2276,0.2019,-3.5],[0.5319,0.4902,0.2298,0.2021,-3.82],[0.5276,0.4895,0.2319,0.2025,-4.06],[0.5235,0.4884,0.2339,0.2031,-4.16],[0.5199,0.4868,0.2356,0.2036,-4.3],[0.5165,0.4849,0.2373,0.2043,-4.2],[0.5133,0.4826,0.2388,0.205,-4.03],[0.5105,0.4801,0.2401,0.2057,-3.79],[0.5079,0.4775,0.2412,0.2063,-3.54],[0.5055,0.4745,0.2422,0.2063,-3.34],[0.5035,0.4711,0.243,0.205,-3.04],[0.5019,0.4677,0.2436,0.2027,-2.72],[0.5006,0.4646,0.2439,0.2,-2.45],[0.4996,0.4619,0.2442,0.1972,-2.22],[0.499,0.4599,0.2443,0.195,-1.94],[0.4986,0.4588,0.2443,0.1939,-1.82],[0.4983,0.4584,0.2443,0.1936,-1.74],[0.4982,0.4582,0.2443,0.1935,-1.7],[0.4981,0.4582,0.2442,0.1935,-1.65],[0.498,0.4579,0.2442,0.1931,-1.58],[0.4979,0.4575,0.2443,0.1924,-1.55],[0.4978,0.4569,0.2443,0.1915,-1.61],[0.4976,0.4562,0.2443,0.1905,-1.76],[0.4974,0.4556,0.2443,0.1895,-1.98],[0.4972,0.4551,0.2443,0.1889,-2.26],[0.4971,0.4548,0.2443,0.1887,-2.51],[0.4969,0.4546,0.2443,0.1887,-2.68],[0.4968,0.4546,0.2443,0.1887,-2.75],[0.4968,0.4546,0.2443,0.1887,-2.72],[0.4967,0.4545,0.2443,0.1887,-2.57],[0.4967,0.4545,0.2443,0.1887,-2.29],[0.4967,0.4546,0.2443,0.1889,-1.92],[0.4967,0.4547,0.2443,0.1891,-1.52],[0.4967,0.4549,0.2443,0.1894,-1.16],[0.4968,0.4552,0.2443,0.1896,-0.91],[0.4969,0.4555,0.2443,0.1895,-0.77],[0.497,0.456,0.2443,0.1889,-0.67],[0.4973,0.4563,0.2444,0.1878,-0.6],[0.4976,0.4564,0.2444,0.1859,-0.51],[0.4979,0.4565,0.2445,0.1842,-0.4],[0.4982,0.4564,0.2446,0.1826,-0.29],[0.4985,0.4561,0.2446,0.1815,-0.22],[0.4987,0.4559,0.2446,0.1809,-0.15],[0.4988,0.4558,0.2446,0.181,-0.11],[0.4989,0.4557,0.2446,0.1811,-0.07],[0.4989,0.4557,0.2446,0.1812,-0.02],[0.499,0.4556,0.2446,0.1813,0.02],[0.4991,0.4555,0.2446,0.1814,0.06],[0.4992,0.4555,0.2446,0.1815,0.1],[0.4993,0.4554,0.2446,0.1816,0.15],[0.4994,0.4554,0.2446,0.1817,0.19],[0.4995,0.4553,0.2446,0.1818,0.23],[0.4996,0.4552,0.2446,0.1819,0.28],[0.4997,0.4552,0.2446,0.182,0.32],[0.4998,0.4551,0.2446,0.1821,0.36],[0.4998,0.455,0.2446,0.1822,0.4],[0.4999,0.455,0.2446,0.1823,0.45],[0.5,0.4549,0.2446,0.1824,0.49],[0.5001,0.4549,0.2446,0.1825,0.53],[0.5002,0.4548,0.2446,0.1826,0.58],[0.5003,0.4547,0.2446,0.1827,0.62],[0.5004,0.4547,0.2446,0.1828,0.66],[0.5005,0.4546,0.2446,0.1829,0.7],[0.5006,0.4545,0.2446,0.183,0.75],[0.5007,0.4545,0.2447,0.1831,0.79],[0.5007,0.4544,0.2447,0.1832,0.83],[0.5008,0.4544,0.2447,0.1833,0.88],[0.5009,0.4543,0.2447,0.1834,0.92],[0.501,0.4542,0.2447,0.1835,0.96],[0.5011,0.4542,0.2447,0.1836,1.01],[0.5012,0.4541,0.2447,0.1837,1.05],[0.5013,0.454,0.2447,0.1838,1.09],[0.5014,0.454,0.2447,0.1839,1.13],[0.5015,0.4539,0.2447,0.184,1.18],[0.5015,0.4538,0.2447,0.1841,1.22],[0.5016,0.4538,0.2447,0.1842,1.26],[0.5017,0.4537,0.2447,0.1843,1.31],[0.5018,0.4537,0.2447,0.1844,1.35],[0.5019,0.4536,0.2447,0.1845,1.39],[0.502,0.4535,0.2447,0.1846,1.43],[0.5021,0.4535,0.2447,0.1847,1.48],[0.5022,0.4534,0.2447,0.1848,1.52],[0.5023,0.4533,0.2447,0.1849,1.56],[0.5024,0.4533,0.2447,0.185,1.61],[0.5024,0.4532,0.2447,0.1851,1.65],[0.5025,0.4532,0.2447,0.1852,1.69],[0.5026,0.4531,0.2448,0.1853,1.73],[0.5027,0.453,0.2448,0.1854,1.78],[0.5028,0.453,0.2448,0.1855,1.82],[0.5029,0.4529,0.2448,0.1856,1.86],[0.503,0.4528,0.2448,0.1857,1.91],[0.5031,0.4528,0.2448,0.1858,1.95],[0.503,0.4543,0.2448,0.1888,1.89],[0.5025,0.4571,0.2449,0.1929,1.78],[0.5016,0.4604,0.245,0.1973,1.58],[0.5001,0.4644,0.2452,0.2018,1.4],[0.498,0.4691,0.2453,0.206,1.23],[0.4955,0.473,0.2453,0.2071,1.17],[0.4928,0.4764,0.2451,0.2069,1.2],[0.4901,0.4798,0.2447,0.2062,1.35],[0.4877,0.4829,0.2442,0.2054,1.53],[0.4857,0.4855,0.2436,0.2047,1.71],[0.4842,0.4875,0.2431,0.204,1.89],[0.4834,0.489,0.2428,0.2036,2.07],[0.4831,0.49,0.2426,0.2032,2.23],[0.4832,0.4908,0.2425,0.203,2.35],[0.4838,0.4915,0.2427,0.2028,2.49],[0.485,0.492,0.243,0.2027,2.63],[0.4868,0.4922,0.2434,0.2026,2.68],[0.4894,0.4922,0.2439,0.2025,2.64],[0.4931,0.4918,0.2445,0.2027,2.5],[0.4979,0.491,0.2452,0.2028,2.38],[0.5034,0.4897,0.2452,0.2032,1.97],[0.5094,0.4879,0.2447,0.2037,1.47],[0.5157,0.4857,0.2435,0.2041,0.9],[0.5219,0.4831,0.2414,0.2045,0.27],[0.5275,0.4803,0.2381,0.2052,-0.53],[0.5328,0.4773,0.2345,0.2057,-1.18],[0.5374,0.4743,0.2306,0.2061,-1.79],[0.5414,0.4715,0.2267,0.2066,-2.33],[0.5443,0.4691,0.2234,0.2069,-2.73],[0.5464,0.4673,0.2208,0.207,-2.99],[0.5476,0.4662,0.2191,0.207,-3.16],[0.5483,0.4656,0.2182,0.2071,-3.22],[0.5485,0.4653,0.2178,0.2071,-3.22]],"rest":"tb-rest","mask":true,"reach":[0.2682,0.2917,0.7578,0.9323]},"hop":{"file":"clips/hop.mp4","fps":24,"frames":40,"size":384,"face":"happy","tracks":[[0.5489,0.4667,0.2178,0.2067,-3.27],[0.5499,0.4699,0.218,0.2066,-3.11],[0.5516,0.4754,0.2182,0.206,-2.87],[0.5538,0.4831,0.2185,0.2049,-2.57],[0.5563,0.4925,0.2189,0.2037,-2.17],[0.5585,0.5025,0.2195,0.2014,-1.68],[0.5599,0.5111,0.2203,0.1987,-1.5],[0.56,0.5169,0.2215,0.1963,-1.43],[0.5587,0.5176,0.2225,0.1941,-1.36],[0.5544,0.5053,0.2231,0.1938,-1.44],[0.548,0.4848,0.2236,0.1953,-1.77],[0.54,0.459,0.2237,0.198,-2.21],[0.5311,0.4315,0.2238,0.2004,-2.87],[0.5219,0.4055,0.224,0.2028,-3.76],[0.5151,0.3902,0.2246,0.2036,-4.66],[0.51,0.3812,0.2252,0.2034,-5.45],[0.5068,0.3769,0.2257,0.2024,-6.0],[0.5051,0.3751,0.2259,0.2018,-6.29],[0.5046,0.3746,0.2259,0.2013,-6.43],[0.5046,0.3748,0.2258,0.2011,-6.53],[0.5047,0.3756,0.2258,0.201,-6.56],[0.505,0.3777,0.2258,0.2006,-6.54],[0.5057,0.3838,0.2256,0.2005,-6.58],[0.5071,0.3949,0.2254,0.2004,-6.59],[0.5105,0.4089,0.2251,0.2004,-6.43],[0.5159,0.4252,0.2248,0.2014,-6.09],[0.5226,0.4428,0.2243,0.2029,-5.66],[0.5302,0.4582,0.2236,0.2042,-4.95],[0.538,0.4696,0.2227,0.2054,-4.13],[0.5444,0.4785,0.2219,0.2062,-3.34],[0.5491,0.4843,0.2208,0.2057,-2.7],[0.5522,0.4865,0.2199,0.2052,-2.22],[0.5536,0.4857,0.2193,0.2049,-2.03],[0.5538,0.4826,0.2188,0.2051,-2.08],[0.5531,0.4783,0.2183,0.2055,-2.32],[0.5519,0.4739,0.2181,0.2062,-2.64],[0.5507,0.4703,0.2178,0.2067,-2.95],[0.5498,0.4677,0.2176,0.207,-3.2],[0.5492,0.4662,0.2175,0.2071,-3.35],[0.5489,0.4655,0.2174,0.2072,-3.42]],"rest":"tb-rest","mask":true,"reach":[0.2604,0.224,0.737,0.9323]},"story-hourglass":{"file":"clips/story-hourglass.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-hourglass-rest","mask":true,"tracks":[[0.4878,0.2786,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.45],[0.4878,0.2791,0.3125,0.2452,-0.42],[0.4877,0.2799,0.3125,0.2452,-0.37],[0.4877,0.2807,0.3125,0.2452,-0.3],[0.4877,0.2814,0.3125,0.2452,-0.2],[0.4878,0.2814,0.3125,0.2452,-0.07],[0.4879,0.2802,0.3125,0.2452,0.08],[0.4881,0.2776,0.3125,0.2452,0.25],[0.4883,0.2739,0.3125,0.2452,0.44],[0.4884,0.2694,0.3125,0.2452,0.64],[0.4886,0.2648,0.3125,0.2452,0.83],[0.4889,0.2602,0.3125,0.2452,1.02],[0.4892,0.2558,0.3125,0.2452,1.18],[0.4895,0.2522,0.3125,0.2452,1.3],[0.4896,0.249,0.3125,0.2452,1.44],[0.4893,0.2463,0.3125,0.2452,1.76],[0.4883,0.2448,0.3125,0.2452,2.09],[0.4876,0.244,0.3125,0.2452,2.17],[0.4869,0.2433,0.3125,0.2452,1.86],[0.484,0.2449,0.3125,0.2452,1.13],[0.4788,0.2493,0.3125,0.2452,-0.16],[0.4726,0.2554,0.3125,0.2452,-1.89],[0.4658,0.263,0.3125,0.2452,-3.94],[0.4588,0.2721,0.3125,0.2452,-5.9],[0.4542,0.2805,0.3125,0.2452,-7.34],[0.4535,0.2889,0.3125,0.2452,-8.06],[0.4558,0.2969,0.3125,0.2452,-7.97],[0.4604,0.3049,0.3125,0.2452,-6.9],[0.4671,0.3116,0.3125,0.2452,-5.06],[0.4753,0.3167,0.3125,0.2452,-2.88],[0.4838,0.3187,0.3125,0.2452,-0.63],[0.4921,0.3184,0.3125,0.2452,1.55],[0.4998,0.3163,0.3125,0.2452,3.62],[0.5066,0.3134,0.3125,0.2452,5.48],[0.5126,0.3107,0.3125,0.2452,7.07],[0.5187,0.3085,0.3125,0.2452,8.37],[0.524,0.3066,0.3125,0.2452,9.48],[0.5288,0.3051,0.3125,0.2452,10.38],[0.5333,0.3039,0.3125,0.2452,11.07],[0.5376,0.3029,0.3125,0.2452,11.53],[0.5404,0.3021,0.3125,0.2452,11.81],[0.5429,0.3014,0.3125,0.2452,11.95],[0.5448,0.3008,0.3125,0.2452,12.0],[0.5459,0.3004,0.3125,0.2452,12.0],[0.5464,0.3001,0.3125,0.2452,12.0],[0.5469,0.2998,0.3125,0.2452,12.0],[0.5471,0.2995,0.3125,0.2452,12.0],[0.5471,0.2993,0.3125,0.2452,12.0],[0.5471,0.299,0.3125,0.2452,12.0],[0.5471,0.2988,0.3125,0.2452,12.0],[0.547,0.2985,0.3125,0.2452,12.0],[0.5469,0.2982,0.3125,0.2452,12.0],[0.5469,0.298,0.3125,0.2452,12.0],[0.5468,0.2978,0.3125,0.2452,12.0],[0.5468,0.2977,0.3125,0.2452,12.0],[0.5467,0.2976,0.3125,0.2452,12.0],[0.5467,0.2975,0.3125,0.2452,12.0],[0.5466,0.2974,0.3125,0.2452,12.0],[0.5466,0.2973,0.3125,0.2452,12.0],[0.5465,0.2972,0.3125,0.2452,12.0],[0.5465,0.2971,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2968,0.3125,0.2452,12.0],[0.5465,0.2967,0.3125,0.2452,12.0],[0.5465,0.2966,0.3125,0.2452,12.0],[0.5465,0.2965,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5458,0.2966,0.3125,0.2452,12.0],[0.5445,0.297,0.3125,0.2452,11.96],[0.5423,0.2976,0.3125,0.2452,11.85],[0.5393,0.2979,0.3125,0.2452,11.6],[0.5351,0.2977,0.3125,0.2452,11.16],[0.53,0.2967,0.3125,0.2452,10.48],[0.5245,0.295,0.3125,0.2452,9.56],[0.5186,0.2927,0.3125,0.2452,8.4],[0.5123,0.2901,0.3125,0.2452,7.09],[0.5064,0.2877,0.3125,0.2452,5.73],[0.5015,0.2856,0.3125,0.2452,4.38],[0.4979,0.2839,0.3125,0.2452,3.16],[0.4953,0.2829,0.3125,0.2452,2.13],[0.4932,0.2822,0.3125,0.2452,1.31],[0.4917,0.2817,0.3125,0.2452,0.7],[0.4906,0.2813,0.3125,0.2452,0.26],[0.4894,0.2809,0.3125,0.2452,-0.04],[0.4885,0.2802,0.3125,0.2452,-0.22],[0.488,0.2796,0.3125,0.2452,-0.34],[0.4879,0.2792,0.3125,0.2452,-0.42],[0.4878,0.2788,0.3125,0.2452,-0.46],[0.4878,0.2785,0.3125,0.2452,-0.47],[0.4878,0.2784,0.3125,0.2452,-0.48],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.5],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5]],"reach":[0.1634,0.03,0.8366,0.92],"script":[[0,"idle",0,1],[0.35,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-business":{"file":"clips/story-business.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-business-rest","mask":true,"tracks":[[0.4789,0.293,0.3099,0.244,-0.19],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.22],[0.4787,0.293,0.3099,0.244,-0.23],[0.4787,0.293,0.3099,0.244,-0.26],[0.4786,0.293,0.3099,0.244,-0.3],[0.4783,0.293,0.3099,0.244,-0.35],[0.4778,0.293,0.3099,0.244,-0.43],[0.4772,0.293,0.3099,0.244,-0.52],[0.4764,0.2931,0.3099,0.244,-0.61],[0.4759,0.2933,0.3099,0.244,-0.68],[0.4763,0.2938,0.3099,0.244,-0.72],[0.4781,0.2945,0.3099,0.244,-0.72],[0.4812,0.2955,0.3099,0.244,-0.66],[0.4852,0.2966,0.3099,0.244,-0.56],[0.4893,0.2978,0.3099,0.244,-0.44],[0.4932,0.2989,0.3099,0.244,-0.31],[0.4961,0.2997,0.3099,0.244,-0.17],[0.4981,0.3002,0.3099,0.244,-0.05],[0.4994,0.3005,0.3099,0.244,0.05],[0.5005,0.3006,0.3099,0.244,0.14],[0.5012,0.3006,0.3099,0.244,0.22],[0.5017,0.3006,0.3099,0.244,0.29],[0.5019,0.3006,0.3099,0.244,0.36],[0.502,0.3006,0.3099,0.244,0.41],[0.502,0.3006,0.3099,0.244,0.44],[0.502,0.3006,0.3099,0.244,0.47],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.5],[0.5021,0.3006,0.3099,0.244,0.53],[0.5022,0.3007,0.3099,0.244,0.58],[0.5026,0.301,0.3099,0.244,0.66],[0.5032,0.3014,0.3099,0.244,0.77],[0.5044,0.3021,0.3099,0.244,0.91],[0.506,0.3031,0.3099,0.244,1.08],[0.5081,0.3043,0.3099,0.244,1.28],[0.5108,0.3054,0.3099,0.244,1.5],[0.5137,0.3066,0.3099,0.244,1.71],[0.5162,0.3075,0.3099,0.244,1.9],[0.5183,0.3082,0.3099,0.244,2.04],[0.5196,0.3085,0.3099,0.244,2.11],[0.5197,0.3084,0.3099,0.244,2.09],[0.5183,0.3078,0.3099,0.244,1.98],[0.5161,0.3067,0.3099,0.244,1.79],[0.5134,0.3052,0.3099,0.244,1.53],[0.5104,0.3036,0.3099,0.244,1.24],[0.5077,0.302,0.3099,0.244,0.95],[0.5059,0.3006,0.3099,0.244,0.7],[0.5048,0.2996,0.3099,0.244,0.5],[0.5042,0.2992,0.3099,0.244,0.39],[0.5043,0.2992,0.3099,0.244,0.32],[0.5049,0.2998,0.3099,0.244,0.31],[0.5056,0.3007,0.3099,0.244,0.34],[0.5063,0.3018,0.3099,0.244,0.39],[0.5068,0.3028,0.3099,0.244,0.44],[0.5071,0.3036,0.3099,0.244,0.47],[0.5067,0.3039,0.3099,0.244,0.47],[0.5062,0.3034,0.3099,0.244,0.44],[0.5055,0.3021,0.3099,0.244,0.37],[0.5044,0.3003,0.3099,0.244,0.27],[0.5032,0.2983,0.3099,0.244,0.15],[0.5021,0.2968,0.3099,0.244,0.03],[0.5013,0.2969,0.3099,0.244,-0.09],[0.501,0.2998,0.3099,0.244,-0.19],[0.5011,0.3056,0.3099,0.244,-0.26],[0.5009,0.3143,0.3099,0.244,-0.32],[0.5003,0.3251,0.3099,0.244,-0.35],[0.4994,0.3363,0.3099,0.244,-0.37],[0.4984,0.3464,0.3099,0.244,-0.37],[0.4976,0.3549,0.3099,0.244,-0.32],[0.4983,0.3609,0.3099,0.244,-0.2],[0.5009,0.3637,0.3099,0.244,0.03],[0.5051,0.3636,0.3099,0.244,0.43],[0.5107,0.3605,0.3099,0.244,1.02],[0.5173,0.3542,0.3099,0.244,1.77],[0.5242,0.3452,0.3099,0.244,2.67],[0.5307,0.3342,0.3099,0.244,3.62],[0.5363,0.3226,0.3099,0.244,4.55],[0.5404,0.3117,0.3099,0.244,5.37],[0.543,0.303,0.3099,0.244,6.0],[0.5438,0.2971,0.3099,0.244,6.39],[0.5428,0.2943,0.3099,0.244,6.52],[0.54,0.2944,0.3099,0.244,6.41],[0.5357,0.2968,0.3099,0.244,6.06],[0.5298,0.3009,0.3099,0.244,5.52],[0.5223,0.306,0.3099,0.244,4.83],[0.5136,0.3115,0.3099,0.244,4.03],[0.5039,0.3165,0.3099,0.244,3.16],[0.4938,0.3197,0.3099,0.244,2.25],[0.4839,0.3208,0.3099,0.244,1.35],[0.4748,0.3198,0.3099,0.244,0.48],[0.4668,0.317,0.3099,0.244,-0.31],[0.4604,0.3129,0.3099,0.244,-1.0],[0.4559,0.3086,0.3099,0.244,-1.58],[0.453,0.3042,0.3099,0.244,-1.99],[0.4519,0.2998,0.3099,0.244,-2.25],[0.4522,0.2959,0.3099,0.244,-2.37],[0.4536,0.2928,0.3099,0.244,-2.35],[0.4558,0.2906,0.3099,0.244,-2.22],[0.4586,0.2893,0.3099,0.244,-2.02],[0.4618,0.2888,0.3099,0.244,-1.77],[0.4652,0.289,0.3099,0.244,-1.52],[0.4686,0.2897,0.3099,0.244,-1.28],[0.4717,0.2906,0.3099,0.244,-1.06],[0.4742,0.2914,0.3099,0.244,-0.86],[0.476,0.2921,0.3099,0.244,-0.7],[0.4771,0.2925,0.3099,0.244,-0.56],[0.4777,0.2927,0.3099,0.244,-0.44],[0.478,0.2927,0.3099,0.244,-0.34],[0.4782,0.2928,0.3099,0.244,-0.28],[0.4783,0.2928,0.3099,0.244,-0.24],[0.4783,0.2928,0.3099,0.244,-0.23],[0.4784,0.2928,0.3099,0.244,-0.24],[0.4784,0.2928,0.3099,0.244,-0.25],[0.4784,0.2928,0.3099,0.244,-0.27],[0.4784,0.2929,0.3099,0.244,-0.29],[0.4784,0.2929,0.3099,0.244,-0.3],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32]],"reach":[0.1642,0.03,0.8358,0.92],"script":[[0,"idle",0,1],[0.5,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-celebrate":{"file":"clips/story-celebrate.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-celebrate-rest","mask":true,"tracks":[[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.0],[0.4861,0.4855,0.1979,0.1589,-0.99],[0.4861,0.4855,0.1979,0.1589,-0.98],[0.4861,0.4855,0.1979,0.1589,-0.96],[0.4861,0.4855,0.1979,0.1589,-0.94],[0.4861,0.4855,0.1979,0.1589,-0.92],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.93],[0.4862,0.4857,0.1979,0.1589,-0.95],[0.4862,0.4863,0.1979,0.1589,-0.98],[0.4861,0.4873,0.1979,0.1589,-1.01],[0.4861,0.4889,0.1979,0.1589,-1.03],[0.486,0.491,0.1979,0.1589,-1.06],[0.486,0.4935,0.1979,0.1589,-1.08],[0.4859,0.4964,0.1979,0.1589,-1.09],[0.4859,0.4994,0.1979,0.1589,-1.1],[0.486,0.5027,0.1979,0.1589,-1.09],[0.4859,0.506,0.1979,0.1589,-1.08],[0.4858,0.5095,0.1979,0.1589,-1.07],[0.4857,0.5132,0.1979,0.1589,-1.07],[0.4857,0.5169,0.1979,0.1589,-1.06],[0.4856,0.5207,0.1979,0.1589,-1.04],[0.4855,0.5245,0.1979,0.1589,-1.0],[0.4854,0.5283,0.1979,0.1589,-0.95],[0.4854,0.532,0.1979,0.1589,-0.88],[0.4853,0.5355,0.1979,0.1589,-0.8],[0.4852,0.5389,0.1979,0.1589,-0.73],[0.4852,0.5424,0.1979,0.1589,-0.66],[0.4852,0.5458,0.1979,0.1589,-0.61],[0.4852,0.5486,0.1979,0.1589,-0.58],[0.4852,0.5515,0.1979,0.1589,-0.57],[0.4852,0.5544,0.1979,0.1589,-0.57],[0.4852,0.5569,0.1979,0.1589,-0.59],[0.4852,0.5586,0.1979,0.1589,-0.56],[0.4852,0.5587,0.1979,0.1589,-0.51],[0.4851,0.5518,0.1979,0.1589,-0.43],[0.4853,0.532,0.1979,0.1589,-0.31],[0.4854,0.5028,0.1979,0.1589,-0.16],[0.4854,0.4673,0.1979,0.1589,-0.02],[0.4857,0.428,0.1979,0.1589,0.16],[0.4861,0.3911,0.1979,0.1589,0.31],[0.4862,0.3637,0.1979,0.1589,0.45],[0.4864,0.3427,0.1979,0.1589,0.58],[0.4867,0.3262,0.1979,0.1589,0.66],[0.4867,0.3141,0.1979,0.1589,0.72],[0.4868,0.3052,0.1979,0.1589,0.76],[0.4868,0.2987,0.1979,0.1589,0.78],[0.4868,0.2942,0.1979,0.1589,0.76],[0.4867,0.2916,0.1979,0.1589,0.72],[0.4866,0.2895,0.1979,0.1589,0.66],[0.4865,0.2878,0.1979,0.1589,0.58],[0.4864,0.2865,0.1979,0.1589,0.47],[0.4861,0.2855,0.1979,0.1589,0.35],[0.4858,0.2847,0.1979,0.1589,0.22],[0.4853,0.2842,0.1979,0.1589,0.06],[0.4848,0.2839,0.1979,0.1589,-0.13],[0.4843,0.2838,0.1979,0.1589,-0.32],[0.4838,0.2839,0.1979,0.1589,-0.54],[0.4834,0.2842,0.1979,0.1589,-0.76],[0.4829,0.2847,0.1979,0.1589,-0.99],[0.4823,0.2855,0.1979,0.1589,-1.23],[0.4817,0.2866,0.1979,0.1589,-1.47],[0.4811,0.2879,0.1979,0.1589,-1.7],[0.4805,0.2896,0.1979,0.1589,-1.93],[0.48,0.2916,0.1979,0.1589,-2.15],[0.4795,0.2937,0.1979,0.1589,-2.37],[0.479,0.296,0.1979,0.1589,-2.59],[0.4784,0.2991,0.1979,0.1589,-2.8],[0.4779,0.3024,0.1979,0.1589,-3.0],[0.4773,0.3062,0.1979,0.1589,-3.19],[0.4769,0.3106,0.1979,0.1589,-3.27],[0.4764,0.3159,0.1979,0.1589,-3.25],[0.4762,0.3231,0.1979,0.1589,-3.15],[0.4761,0.3379,0.1979,0.1589,-2.96],[0.4767,0.3615,0.1979,0.1589,-2.67],[0.4775,0.3898,0.1979,0.1589,-2.38],[0.4787,0.4211,0.1979,0.1589,-2.04],[0.4801,0.4528,0.1979,0.1589,-1.73],[0.4816,0.4795,0.1979,0.1589,-1.42],[0.4827,0.5,0.1979,0.1589,-1.11],[0.4836,0.5172,0.1979,0.1589,-0.8],[0.4842,0.5313,0.1979,0.1589,-0.55],[0.4846,0.5413,0.1979,0.1589,-0.3],[0.485,0.5462,0.1979,0.1589,-0.12],[0.4851,0.5453,0.1979,0.1589,-0.01],[0.4852,0.54,0.1979,0.1589,-0.01],[0.4853,0.5318,0.1979,0.1589,-0.07],[0.4853,0.523,0.1979,0.1589,-0.19],[0.4854,0.5153,0.1979,0.1589,-0.32],[0.4855,0.5093,0.1979,0.1589,-0.43],[0.4856,0.5046,0.1979,0.1589,-0.54],[0.4857,0.5007,0.1979,0.1589,-0.62],[0.4858,0.4977,0.1979,0.1589,-0.67],[0.4859,0.495,0.1979,0.1589,-0.71],[0.486,0.4926,0.1979,0.1589,-0.75],[0.4861,0.4904,0.1979,0.1589,-0.78],[0.4861,0.4888,0.1979,0.1589,-0.8],[0.4861,0.4874,0.1979,0.1589,-0.82],[0.4861,0.4864,0.1979,0.1589,-0.82],[0.4861,0.4859,0.1979,0.1589,-0.82],[0.4861,0.4856,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.83],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.85],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89]],"reach":[0.185,0.03,0.815,0.92],"script":[[0,"happy",0,0],[0.7,"blink",0,0],[0.74,"happy",0,0]]},"story-comedy":{"file":"clips/story-comedy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-comedy-rest","mask":true,"tracks":[[0.4859,0.3027,0.3018,0.2407,-0.35],[0.4859,0.3026,0.3018,0.2407,-0.36],[0.486,0.3026,0.3018,0.2407,-0.36],[0.4859,0.3026,0.3018,0.2407,-0.37],[0.486,0.3027,0.3018,0.2407,-0.39],[0.486,0.303,0.3018,0.2407,-0.39],[0.4861,0.3077,0.3018,0.2407,-0.41],[0.4861,0.3144,0.3018,0.2407,-0.42],[0.4861,0.3237,0.3018,0.2407,-0.44],[0.4861,0.3333,0.3018,0.2407,-0.45],[0.4861,0.3429,0.3018,0.2407,-0.46],[0.4859,0.348,0.3018,0.2407,-0.47],[0.4859,0.3512,0.3018,0.2407,-0.49],[0.4859,0.3516,0.3018,0.2407,-0.51],[0.4858,0.3517,0.3018,0.2407,-0.53],[0.4858,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.55],[0.4857,0.3518,0.3018,0.2407,-0.53],[0.4858,0.3538,0.3018,0.2407,-0.44],[0.4856,0.3556,0.3018,0.2407,-0.23],[0.4857,0.343,0.3018,0.2407,0.12],[0.4878,0.3222,0.3018,0.2407,0.68],[0.4914,0.3012,0.3018,0.2407,1.39],[0.4968,0.2825,0.3018,0.2407,2.34],[0.505,0.2683,0.3018,0.2407,3.48],[0.5151,0.2711,0.3018,0.2407,4.73],[0.5255,0.2782,0.3018,0.2407,6.02],[0.5357,0.2813,0.3018,0.2407,7.28],[0.5448,0.2772,0.3018,0.2407,8.32],[0.5515,0.2704,0.3018,0.2407,9.17],[0.5554,0.2645,0.3018,0.2407,9.76],[0.5568,0.268,0.3018,0.2407,10.16],[0.5567,0.2793,0.3018,0.2407,10.39],[0.5554,0.2933,0.3018,0.2407,10.52],[0.554,0.3022,0.3018,0.2407,10.58],[0.5535,0.3035,0.3018,0.2407,10.62],[0.5534,0.2956,0.3018,0.2407,10.66],[0.5534,0.2832,0.3018,0.2407,10.71],[0.5539,0.2727,0.3018,0.2407,10.71],[0.5547,0.269,0.3018,0.2407,10.68],[0.5553,0.2722,0.3018,0.2407,10.6],[0.556,0.2817,0.3018,0.2407,10.48],[0.5565,0.2917,0.3018,0.2407,10.41],[0.5564,0.2983,0.3018,0.2407,10.38],[0.5555,0.3019,0.3018,0.2407,10.4],[0.5544,0.2997,0.3018,0.2407,10.43],[0.5537,0.2924,0.3018,0.2407,10.52],[0.5534,0.2839,0.3018,0.2407,10.58],[0.5536,0.279,0.3018,0.2407,10.68],[0.5546,0.2778,0.3018,0.2407,10.79],[0.5554,0.2795,0.3018,0.2407,10.9],[0.5557,0.2817,0.3018,0.2407,10.98],[0.5553,0.2854,0.3018,0.2407,11.07],[0.555,0.2867,0.3018,0.2407,11.14],[0.5553,0.2864,0.3018,0.2407,11.2],[0.5561,0.2864,0.3018,0.2407,11.27],[0.5569,0.287,0.3018,0.2407,11.32],[0.5576,0.2844,0.3018,0.2407,11.38],[0.558,0.2825,0.3018,0.2407,11.45],[0.5575,0.2808,0.3018,0.2407,11.53],[0.5569,0.282,0.3018,0.2407,11.6],[0.556,0.2854,0.3018,0.2407,11.6],[0.5551,0.2905,0.3018,0.2407,11.29],[0.5525,0.2961,0.3018,0.2407,10.57],[0.5468,0.3018,0.3018,0.2407,9.37],[0.5361,0.3072,0.3018,0.2407,7.64],[0.5215,0.313,0.3018,0.2407,5.42],[0.5049,0.3201,0.3018,0.2407,2.88],[0.4883,0.3253,0.3018,0.2407,0.2],[0.4734,0.3263,0.3018,0.2407,-2.33],[0.4629,0.3234,0.3018,0.2407,-4.54],[0.4563,0.3184,0.3018,0.2407,-6.27],[0.4524,0.3135,0.3018,0.2407,-7.48],[0.4508,0.3101,0.3018,0.2407,-8.16],[0.4511,0.3089,0.3018,0.2407,-8.51],[0.452,0.3095,0.3018,0.2407,-8.61],[0.453,0.3119,0.3018,0.2407,-8.59],[0.4536,0.3151,0.3018,0.2407,-8.54],[0.4536,0.318,0.3018,0.2407,-8.53],[0.453,0.3195,0.3018,0.2407,-8.57],[0.4521,0.3195,0.3018,0.2407,-8.67],[0.451,0.3178,0.3018,0.2407,-8.8],[0.45,0.3146,0.3018,0.2407,-8.93],[0.4495,0.311,0.3018,0.2407,-9.02],[0.4496,0.308,0.3018,0.2407,-9.05],[0.4502,0.3062,0.3018,0.2407,-9.02],[0.4512,0.3062,0.3018,0.2407,-8.94],[0.4524,0.3078,0.3018,0.2407,-8.82],[0.4534,0.3107,0.3018,0.2407,-8.71],[0.4542,0.3142,0.3018,0.2407,-8.62],[0.4545,0.3175,0.3018,0.2407,-8.57],[0.4546,0.3198,0.3018,0.2407,-8.57],[0.4545,0.321,0.3018,0.2407,-8.57],[0.4543,0.3211,0.3018,0.2407,-8.57],[0.4542,0.3207,0.3018,0.2407,-8.54],[0.4542,0.3201,0.3018,0.2407,-8.38],[0.4548,0.3194,0.3018,0.2407,-8.02],[0.4563,0.3186,0.3018,0.2407,-7.46],[0.4592,0.3175,0.3018,0.2407,-6.71],[0.4629,0.3159,0.3018,0.2407,-5.78],[0.4676,0.314,0.3018,0.2407,-4.75],[0.4726,0.3116,0.3018,0.2407,-3.71],[0.4772,0.309,0.3018,0.2407,-2.76],[0.4808,0.3068,0.3018,0.2407,-1.95],[0.4834,0.3049,0.3018,0.2407,-1.31],[0.485,0.3034,0.3018,0.2407,-0.86],[0.4857,0.3026,0.3018,0.2407,-0.58],[0.4859,0.3023,0.3018,0.2407,-0.41],[0.4859,0.3023,0.3018,0.2407,-0.31],[0.4859,0.3022,0.3018,0.2407,-0.24],[0.4859,0.3021,0.3018,0.2407,-0.21],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.2],[0.4859,0.3022,0.3018,0.2407,-0.21],[0.486,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22]],"reach":[0.1647,0.03,0.8353,0.92],"script":[[0,"happy",0,0],[0.85,"blink",0,0],[0.89,"happy",0,0]]},"story-daytime":{"file":"clips/story-daytime.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-daytime-rest","mask":true,"tracks":[[0.5051,0.3264,0.2881,0.2289,-0.83],[0.505,0.3264,0.2881,0.2289,-0.81],[0.505,0.3265,0.2881,0.2289,-0.75],[0.5048,0.3265,0.2881,0.2289,-0.55],[0.504,0.3268,0.2881,0.2289,-0.23],[0.5023,0.3273,0.2881,0.2289,0.22],[0.5003,0.3278,0.2881,0.2289,0.81],[0.497,0.3286,0.2881,0.2289,1.5],[0.4933,0.3295,0.2881,0.2289,2.2],[0.4902,0.3303,0.2881,0.2289,2.9],[0.4871,0.3308,0.2881,0.2289,3.53],[0.4836,0.331,0.2881,0.2289,4.04],[0.4812,0.3305,0.2881,0.2289,4.42],[0.4787,0.3292,0.2881,0.2289,4.67],[0.4759,0.3272,0.2881,0.2289,4.79],[0.4741,0.3249,0.2881,0.2289,4.81],[0.4729,0.3222,0.2881,0.2289,4.77],[0.472,0.3198,0.2881,0.2289,4.63],[0.4714,0.3172,0.2881,0.2289,4.45],[0.4711,0.3152,0.2881,0.2289,4.2],[0.4709,0.3136,0.2881,0.2289,3.92],[0.4707,0.3121,0.2881,0.2289,3.63],[0.4705,0.3107,0.2881,0.2289,3.35],[0.4705,0.31,0.2881,0.2289,3.08],[0.4705,0.3092,0.2881,0.2289,2.83],[0.4706,0.3083,0.2881,0.2289,2.63],[0.4706,0.3077,0.2881,0.2289,2.46],[0.4707,0.3073,0.2881,0.2289,2.34],[0.4708,0.307,0.2881,0.2289,2.25],[0.4709,0.3067,0.2881,0.2289,2.18],[0.4709,0.3065,0.2881,0.2289,2.14],[0.4709,0.3065,0.2881,0.2289,2.11],[0.4709,0.3063,0.2881,0.2289,2.08],[0.4709,0.3063,0.2881,0.2289,2.04],[0.4709,0.3062,0.2881,0.2289,2.0],[0.4708,0.3062,0.2881,0.2289,1.97],[0.4708,0.3061,0.2881,0.2289,1.96],[0.4708,0.3062,0.2881,0.2289,1.95],[0.4709,0.3062,0.2881,0.2289,1.97],[0.4709,0.3063,0.2881,0.2289,2.0],[0.471,0.3063,0.2881,0.2289,2.04],[0.471,0.3064,0.2881,0.2289,2.08],[0.471,0.3065,0.2881,0.2289,2.12],[0.471,0.3066,0.2881,0.2289,2.17],[0.471,0.3067,0.2881,0.2289,2.23],[0.471,0.3069,0.2881,0.2289,2.31],[0.471,0.3071,0.2881,0.2289,2.44],[0.471,0.3075,0.2881,0.2289,2.64],[0.471,0.308,0.2881,0.2289,2.9],[0.4713,0.3088,0.2881,0.2289,3.16],[0.4719,0.3105,0.2881,0.2289,3.42],[0.4728,0.3126,0.2881,0.2289,3.61],[0.474,0.315,0.2881,0.2289,3.66],[0.4755,0.3176,0.2881,0.2289,3.51],[0.4774,0.3203,0.2881,0.2289,3.14],[0.4794,0.3222,0.2881,0.2289,2.61],[0.4817,0.3239,0.2881,0.2289,1.98],[0.4848,0.3252,0.2881,0.2289,1.29],[0.489,0.326,0.2881,0.2289,0.64],[0.4933,0.3265,0.2881,0.2289,0.1],[0.4973,0.3267,0.2881,0.2289,-0.3],[0.5008,0.3267,0.2881,0.2289,-0.57],[0.5034,0.3266,0.2881,0.2289,-0.73],[0.5046,0.3265,0.2881,0.2289,-0.8],[0.505,0.3264,0.2881,0.2289,-0.82],[0.5051,0.3263,0.2881,0.2289,-0.86],[0.5052,0.3262,0.2881,0.2289,-0.96],[0.5042,0.3267,0.2881,0.2289,-1.17],[0.5014,0.3278,0.2881,0.2289,-1.52],[0.4942,0.3299,0.2881,0.2289,-2.05],[0.4824,0.332,0.2881,0.2289,-2.79],[0.4677,0.3332,0.2881,0.2289,-3.72],[0.4524,0.3336,0.2881,0.2289,-4.82],[0.4365,0.333,0.2881,0.2289,-6.02],[0.4225,0.3312,0.2881,0.2289,-7.25],[0.4122,0.3295,0.2881,0.2289,-8.41],[0.4043,0.3285,0.2881,0.2289,-9.45],[0.398,0.3279,0.2881,0.2289,-10.3],[0.3945,0.3276,0.2881,0.2289,-10.91],[0.3946,0.3276,0.2881,0.2289,-11.29],[0.3973,0.3277,0.2881,0.2289,-11.38],[0.403,0.3279,0.2881,0.2289,-11.15],[0.4125,0.3286,0.2881,0.2289,-10.58],[0.4249,0.3299,0.2881,0.2289,-9.6],[0.4437,0.3315,0.2881,0.2289,-8.23],[0.4653,0.3328,0.2881,0.2289,-6.47],[0.4914,0.3335,0.2881,0.2289,-4.38],[0.5172,0.3333,0.2881,0.2289,-2.07],[0.5439,0.3323,0.2881,0.2289,0.29],[0.5652,0.331,0.2881,0.2289,2.55],[0.5834,0.3299,0.2881,0.2289,4.54],[0.5954,0.3294,0.2881,0.2289,6.19],[0.6037,0.3292,0.2881,0.2289,7.4],[0.6077,0.3294,0.2881,0.2289,8.22],[0.6091,0.3297,0.2881,0.2289,8.67],[0.6079,0.33,0.2881,0.2289,8.82],[0.6047,0.3304,0.2881,0.2289,8.67],[0.5998,0.3309,0.2881,0.2289,8.3],[0.5919,0.3318,0.2881,0.2289,7.65],[0.582,0.3328,0.2881,0.2289,6.79],[0.5698,0.3333,0.2881,0.2289,5.76],[0.5545,0.3327,0.2881,0.2289,4.6],[0.5393,0.3316,0.2881,0.2289,3.4],[0.5268,0.3299,0.2881,0.2289,2.3],[0.5166,0.3281,0.2881,0.2289,1.32],[0.5094,0.3266,0.2881,0.2289,0.58],[0.5069,0.3262,0.2881,0.2289,0.06],[0.5058,0.3261,0.2881,0.2289,-0.28],[0.5053,0.3262,0.2881,0.2289,-0.46],[0.5051,0.3262,0.2881,0.2289,-0.52],[0.5051,0.3261,0.2881,0.2289,-0.55],[0.5051,0.326,0.2881,0.2289,-0.57],[0.5051,0.326,0.2881,0.2289,-0.58],[0.5051,0.326,0.2881,0.2289,-0.59],[0.5051,0.326,0.2881,0.2289,-0.6],[0.5051,0.3261,0.2881,0.2289,-0.62],[0.5051,0.3262,0.2881,0.2289,-0.63],[0.5051,0.3262,0.2881,0.2289,-0.64],[0.5051,0.3262,0.2881,0.2289,-0.65],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67]],"reach":[0.1211,0.03,0.8789,0.92],"script":[[0,"happy",0,0],[0.45,"blink",0,0],[0.5,"idle",1,0],[0.75,"happy",0,0]]},"story-detective":{"file":"clips/story-detective.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-detective-rest","mask":true,"tracks":[[0.4842,0.3239,0.2709,0.2191,-0.26],[0.4842,0.3238,0.2709,0.2191,-0.26],[0.4834,0.3238,0.2709,0.2191,-0.27],[0.4812,0.3238,0.2709,0.2191,-0.28],[0.4788,0.3237,0.2709,0.2191,-0.28],[0.4765,0.3237,0.2709,0.2191,-0.27],[0.4731,0.3236,0.2709,0.2191,-0.25],[0.4698,0.3235,0.2709,0.2191,-0.22],[0.4677,0.3235,0.2709,0.2191,-0.2],[0.4649,0.3235,0.2709,0.2191,-0.2],[0.4621,0.3235,0.2709,0.2191,-0.26],[0.4588,0.3237,0.2709,0.2191,-0.37],[0.4559,0.324,0.2709,0.2191,-0.54],[0.4527,0.3244,0.2709,0.2191,-0.75],[0.4503,0.3249,0.2709,0.2191,-1.0],[0.4478,0.3254,0.2709,0.2191,-1.24],[0.4478,0.3266,0.2709,0.2191,-1.47],[0.4479,0.3277,0.2709,0.2191,-1.65],[0.4503,0.3295,0.2709,0.2191,-1.76],[0.4527,0.3313,0.2709,0.2191,-1.75],[0.4575,0.3337,0.2709,0.2191,-1.65],[0.4614,0.3352,0.2709,0.2191,-1.45],[0.47,0.337,0.2709,0.2191,-1.23],[0.477,0.338,0.2709,0.2191,-0.62],[0.4883,0.3391,0.2709,0.2191,0.32],[0.4972,0.3395,0.2709,0.2191,1.58],[0.5101,0.3393,0.2709,0.2191,3.11],[0.5182,0.3388,0.2709,0.2191,4.83],[0.5283,0.3376,0.2709,0.2191,6.37],[0.5347,0.3361,0.2709,0.2191,7.77],[0.5362,0.3344,0.2709,0.2191,8.79],[0.5338,0.3332,0.2709,0.2191,9.46],[0.5316,0.3319,0.2709,0.2191,9.91],[0.5308,0.3314,0.2709,0.2191,10.15],[0.5296,0.331,0.2709,0.2191,10.25],[0.5364,0.331,0.2709,0.2191,10.48],[0.543,0.3311,0.2709,0.2191,10.84],[0.5526,0.3316,0.2709,0.2191,11.2],[0.5586,0.3322,0.2709,0.2191,11.58],[0.5674,0.3331,0.2709,0.2191,11.84],[0.5732,0.334,0.2709,0.2191,11.98],[0.5808,0.3352,0.2709,0.2191,11.99],[0.5854,0.336,0.2709,0.2191,12.0],[0.5907,0.3369,0.2709,0.2191,12.0],[0.5933,0.3375,0.2709,0.2191,12.0],[0.5962,0.3381,0.2709,0.2191,12.0],[0.5975,0.3383,0.2709,0.2191,12.0],[0.599,0.3386,0.2709,0.2191,12.0],[0.5998,0.3388,0.2709,0.2191,12.0],[0.6008,0.3389,0.2709,0.2191,12.0],[0.6015,0.3391,0.2709,0.2191,12.0],[0.6021,0.3393,0.2709,0.2191,12.0],[0.6028,0.3394,0.2709,0.2191,12.0],[0.6034,0.3396,0.2709,0.2191,12.0],[0.6041,0.3397,0.2709,0.2191,12.0],[0.6047,0.3399,0.2709,0.2191,12.0],[0.6056,0.34,0.2709,0.2191,12.0],[0.6063,0.3402,0.2709,0.2191,12.0],[0.6073,0.3403,0.2709,0.2191,12.0],[0.608,0.3405,0.2709,0.2191,12.0],[0.6093,0.3409,0.2709,0.2191,12.0],[0.6102,0.3412,0.2709,0.2191,12.0],[0.6117,0.3419,0.2709,0.2191,12.0],[0.6129,0.3425,0.2709,0.2191,12.0],[0.6149,0.3434,0.2709,0.2191,12.0],[0.6162,0.344,0.2709,0.2191,12.0],[0.6179,0.3448,0.2709,0.2191,12.0],[0.619,0.3454,0.2709,0.2191,12.0],[0.6202,0.3459,0.2709,0.2191,12.0],[0.6208,0.3462,0.2709,0.2191,12.0],[0.6213,0.3465,0.2709,0.2191,12.0],[0.6216,0.3467,0.2709,0.2191,12.0],[0.6218,0.3469,0.2709,0.2191,12.0],[0.622,0.3471,0.2709,0.2191,12.0],[0.6218,0.3473,0.2709,0.2191,12.0],[0.621,0.3472,0.2709,0.2191,12.0],[0.6181,0.3464,0.2709,0.2191,12.0],[0.6152,0.3455,0.2709,0.2191,12.0],[0.6101,0.3437,0.2709,0.2191,12.0],[0.6034,0.3415,0.2709,0.2191,12.0],[0.6008,0.3402,0.2709,0.2191,12.0],[0.6003,0.3395,0.2709,0.2191,11.6],[0.595,0.3396,0.2709,0.2191,11.01],[0.592,0.3405,0.2709,0.2191,10.22],[0.5837,0.3425,0.2709,0.2191,9.21],[0.5725,0.3437,0.2709,0.2191,7.94],[0.5572,0.3447,0.2709,0.2191,6.78],[0.5442,0.3451,0.2709,0.2191,5.5],[0.5297,0.345,0.2709,0.2191,4.46],[0.5223,0.3443,0.2709,0.2191,3.5],[0.5116,0.3422,0.2709,0.2191,2.64],[0.5051,0.3404,0.2709,0.2191,1.93],[0.499,0.337,0.2709,0.2191,1.46],[0.4945,0.334,0.2709,0.2191,0.86],[0.4896,0.3302,0.2709,0.2191,0.39],[0.4874,0.3278,0.2709,0.2191,0.06],[0.4851,0.3255,0.2709,0.2191,-0.12],[0.4847,0.3246,0.2709,0.2191,-0.23],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.27],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.23],[0.4842,0.3237,0.2709,0.2191,-0.21],[0.4842,0.3237,0.2709,0.2191,-0.19],[0.4842,0.3237,0.2709,0.2191,-0.17],[0.4842,0.3237,0.2709,0.2191,-0.15],[0.4842,0.3237,0.2709,0.2191,-0.13],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.1],[0.4842,0.3237,0.2709,0.2191,-0.09],[0.4842,0.3237,0.2709,0.2191,-0.08],[0.4842,0.3238,0.2709,0.2191,-0.07],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05]],"reach":[0.0995,0.03,0.9005,0.92],"script":[[0,"idle",-1,0],[0.8,"blink",-1,0],[0.84,"idle",-1,0]],"lens":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,[0.3399,0.3458,0.0567,0.7,0],[0.3582,0.3438,0.0567,0.85,0],[0.3825,0.3413,0.0569,1.0,0],[0.3973,0.3399,0.0571,1.0,0],[0.4163,0.3382,0.0572,1.0,0],[0.4272,0.3373,0.0574,1.0,0],[0.4381,0.3363,0.0577,1.0,0],[0.4499,0.3362,0.0578,1.0,0],[0.4611,0.3363,0.058,1.0,0],[0.4734,0.3374,0.058,1.0,0],[0.4858,0.3384,0.0581,1.0,0],[0.5031,0.3398,0.0579,1.0,0],[0.5136,0.341,0.0577,1.0,0],[0.5281,0.3429,0.0575,1.0,0],[0.5373,0.3442,0.0574,1.0,0],[0.5491,0.3459,0.0573,1.0,0],[0.5559,0.3471,0.0573,1.0,0],[0.5636,0.3485,0.0574,1.0,0],[0.5672,0.349,0.0574,1.0,0],[0.5711,0.3497,0.0574,1.0,0],[0.5727,0.3499,0.0574,1.0,0],[0.5745,0.3502,0.0574,1.0,0],[0.5754,0.3503,0.0574,1.0,0],[0.5764,0.3505,0.0574,1.0,0],[0.5772,0.3505,0.0574,1.0,0],[0.5779,0.3506,0.0574,1.0,0],[0.5788,0.3507,0.0574,1.0,0],[0.5796,0.3508,0.0574,1.0,0],[0.5803,0.3509,0.0574,1.0,1],[0.581,0.351,0.0574,1.0,1],[0.5819,0.3511,0.0574,1.0,1],[0.5826,0.3513,0.0574,1.0,0],[0.5835,0.3515,0.0574,1.0,0],[0.5843,0.3518,0.0574,1.0,0],[0.5856,0.3523,0.0574,1.0,0],[0.5866,0.3527,0.0574,1.0,0],[0.5882,0.3535,0.0573,1.0,0],[0.5896,0.3542,0.0573,1.0,0],[0.5917,0.3552,0.0573,1.0,0],[0.5931,0.356,0.0573,1.0,0],[0.5949,0.357,0.0573,1.0,0],[0.596,0.3576,0.0573,1.0,0],[0.5972,0.3582,0.0573,1.0,0],[0.5979,0.3587,0.0574,1.0,0],[0.5985,0.3591,0.0574,1.0,0],[0.5988,0.3594,0.0574,1.0,0],[0.599,0.3598,0.0573,1.0,0],[0.5991,0.3601,0.0573,1.0,0],[0.5988,0.3602,0.0574,1.0,0],[0.5975,0.36,0.0573,1.0,0],[0.5927,0.358,0.0572,1.0,0],[0.5879,0.3559,0.0572,1.0,0],[0.5783,0.3521,0.0571,1.0,0],[0.5646,0.3468,0.0571,1.0,0],[0.5456,0.3403,0.0569,1.0,0],[0.5301,0.3355,0.0567,1.0,0],[0.5014,0.3292,0.0567,1.0,0],[0.4775,0.3245,0.0566,1.0,0],[0.4441,0.322,0.0566,0.85,0],[0.4171,0.321,0.0567,0.7,0],null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"blinks":[21,22,90,91]},"story-fiction":{"file":"clips/story-fiction.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-fiction-rest","mask":true,"tracks":[[0.5027,0.3497,0.2922,0.2282,-0.39],[0.5027,0.3498,0.2922,0.2282,-0.4],[0.5028,0.3499,0.2922,0.2282,-0.4],[0.5028,0.3501,0.2922,0.2282,-0.41],[0.5028,0.3505,0.2922,0.2282,-0.39],[0.5029,0.3512,0.2922,0.2282,-0.38],[0.503,0.3525,0.2922,0.2282,-0.34],[0.5032,0.3546,0.2922,0.2282,-0.29],[0.5036,0.358,0.2922,0.2282,-0.22],[0.5042,0.3627,0.2922,0.2282,-0.15],[0.505,0.3686,0.2922,0.2282,-0.06],[0.506,0.3749,0.2922,0.2282,0.03],[0.5074,0.3815,0.2922,0.2282,0.13],[0.5092,0.3877,0.2922,0.2282,0.22],[0.5112,0.3931,0.2922,0.2282,0.31],[0.5135,0.3974,0.2922,0.2282,0.4],[0.5159,0.4011,0.2922,0.2282,0.5],[0.5182,0.4039,0.2922,0.2282,0.58],[0.5201,0.4058,0.2922,0.2282,0.67],[0.522,0.4073,0.2922,0.2282,0.75],[0.5235,0.4083,0.2922,0.2282,0.82],[0.5248,0.409,0.2922,0.2282,0.89],[0.526,0.4094,0.2922,0.2282,0.95],[0.527,0.4095,0.2922,0.2282,1.01],[0.528,0.4095,0.2922,0.2282,1.07],[0.5291,0.4093,0.2922,0.2282,1.12],[0.5304,0.409,0.2922,0.2282,1.16],[0.5317,0.4086,0.2922,0.2282,1.18],[0.533,0.4081,0.2922,0.2282,1.18],[0.5341,0.4074,0.2922,0.2282,1.16],[0.5347,0.4065,0.2922,0.2282,1.11],[0.5347,0.4056,0.2922,0.2282,1.03],[0.534,0.4046,0.2922,0.2282,0.9],[0.5325,0.4034,0.2922,0.2282,0.72],[0.5303,0.402,0.2922,0.2282,0.45],[0.5273,0.4004,0.2922,0.2282,0.1],[0.5235,0.3984,0.2922,0.2282,-0.39],[0.519,0.3962,0.2922,0.2282,-1.03],[0.5139,0.3938,0.2922,0.2282,-1.78],[0.5074,0.391,0.2922,0.2282,-2.64],[0.5007,0.389,0.2922,0.2282,-3.56],[0.4942,0.3878,0.2922,0.2282,-4.45],[0.4885,0.3876,0.2922,0.2282,-5.29],[0.4836,0.3884,0.2922,0.2282,-6.0],[0.4807,0.3902,0.2922,0.2282,-6.59],[0.4787,0.3923,0.2922,0.2282,-7.05],[0.4775,0.3943,0.2922,0.2282,-7.38],[0.4767,0.3959,0.2922,0.2282,-7.61],[0.4762,0.3971,0.2922,0.2282,-7.79],[0.4759,0.3979,0.2922,0.2282,-7.93],[0.4759,0.3984,0.2922,0.2282,-8.03],[0.4758,0.3988,0.2922,0.2282,-8.11],[0.4758,0.399,0.2922,0.2282,-8.17],[0.4758,0.3992,0.2922,0.2282,-8.21],[0.4757,0.3994,0.2922,0.2282,-8.22],[0.4756,0.3995,0.2922,0.2282,-8.2],[0.4755,0.3996,0.2922,0.2282,-8.13],[0.4752,0.3997,0.2922,0.2282,-8.0],[0.4745,0.3999,0.2922,0.2282,-7.81],[0.4734,0.4002,0.2922,0.2282,-7.54],[0.4722,0.4004,0.2922,0.2282,-7.24],[0.4709,0.4008,0.2922,0.2282,-6.91],[0.4695,0.4011,0.2922,0.2282,-6.59],[0.4686,0.4014,0.2922,0.2282,-6.28],[0.4681,0.4017,0.2922,0.2282,-6.0],[0.4678,0.4019,0.2922,0.2282,-5.78],[0.4678,0.4021,0.2922,0.2282,-5.61],[0.4682,0.4023,0.2922,0.2282,-5.45],[0.4689,0.4024,0.2922,0.2282,-5.31],[0.4699,0.4024,0.2922,0.2282,-5.19],[0.4714,0.4022,0.2922,0.2282,-5.05],[0.4732,0.402,0.2922,0.2282,-4.9],[0.4754,0.4016,0.2922,0.2282,-4.71],[0.478,0.4011,0.2922,0.2282,-4.49],[0.4812,0.4004,0.2922,0.2282,-4.25],[0.4844,0.3994,0.2922,0.2282,-3.98],[0.4875,0.3983,0.2922,0.2282,-3.68],[0.4905,0.3969,0.2922,0.2282,-3.39],[0.4931,0.3954,0.2922,0.2282,-3.09],[0.4947,0.3938,0.2922,0.2282,-2.81],[0.4961,0.392,0.2922,0.2282,-2.55],[0.4973,0.3903,0.2922,0.2282,-2.31],[0.4982,0.3882,0.2922,0.2282,-2.09],[0.4988,0.3863,0.2922,0.2282,-1.88],[0.4995,0.3844,0.2922,0.2282,-1.69],[0.4998,0.3825,0.2922,0.2282,-1.51],[0.5001,0.3801,0.2922,0.2282,-1.36],[0.5003,0.3779,0.2922,0.2282,-1.23],[0.5007,0.3756,0.2922,0.2282,-1.11],[0.501,0.3729,0.2922,0.2282,-1.02],[0.5014,0.3701,0.2922,0.2282,-0.94],[0.5017,0.3677,0.2922,0.2282,-0.87],[0.5019,0.3652,0.2922,0.2282,-0.81],[0.502,0.3627,0.2922,0.2282,-0.77],[0.5021,0.3605,0.2922,0.2282,-0.72],[0.5023,0.3581,0.2922,0.2282,-0.68],[0.5024,0.3559,0.2922,0.2282,-0.63],[0.5025,0.3541,0.2922,0.2282,-0.59],[0.5025,0.3527,0.2922,0.2282,-0.55],[0.5026,0.3516,0.2922,0.2282,-0.52],[0.5026,0.3511,0.2922,0.2282,-0.48],[0.5026,0.3508,0.2922,0.2282,-0.46],[0.5026,0.3506,0.2922,0.2282,-0.44],[0.5026,0.3505,0.2922,0.2282,-0.43],[0.5026,0.3504,0.2922,0.2282,-0.44],[0.5026,0.3504,0.2922,0.2282,-0.45],[0.5026,0.3504,0.2922,0.2282,-0.46],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.47],[0.5026,0.3503,0.2922,0.2282,-0.46],[0.5026,0.3503,0.2922,0.2282,-0.45],[0.5026,0.3503,0.2922,0.2282,-0.44],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43]],"reach":[0.1998,0.03,0.8002,0.92],"script":[[0,"idle",0,1],[0.55,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-health":{"file":"clips/story-health.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-health-rest","mask":true,"tracks":[[0.5022,0.3764,0.2677,0.2093,-0.33],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.37],[0.5022,0.3764,0.2677,0.2093,-0.43],[0.502,0.3764,0.2677,0.2093,-0.56],[0.5012,0.3764,0.2677,0.2093,-0.84],[0.4989,0.3764,0.2677,0.2093,-1.29],[0.4948,0.3763,0.2677,0.2093,-1.96],[0.4886,0.3764,0.2677,0.2093,-2.87],[0.4802,0.3767,0.2677,0.2093,-4.03],[0.4697,0.3773,0.2677,0.2093,-5.34],[0.4582,0.3782,0.2677,0.2093,-6.73],[0.4463,0.3796,0.2677,0.2093,-8.08],[0.4343,0.3814,0.2677,0.2093,-9.3],[0.4231,0.3834,0.2677,0.2093,-10.29],[0.4129,0.3858,0.2677,0.2093,-11.04],[0.4036,0.3881,0.2677,0.2093,-11.53],[0.3951,0.3906,0.2677,0.2093,-11.82],[0.3875,0.393,0.2677,0.2093,-11.96],[0.3806,0.3952,0.2677,0.2093,-12.0],[0.3748,0.3971,0.2677,0.2093,-12.0],[0.3698,0.3989,0.2677,0.2093,-12.0],[0.3656,0.4004,0.2677,0.2093,-12.0],[0.3625,0.4016,0.2677,0.2093,-12.0],[0.3598,0.4027,0.2677,0.2093,-12.0],[0.3576,0.4035,0.2677,0.2093,-12.0],[0.356,0.4041,0.2677,0.2093,-12.0],[0.3549,0.4045,0.2677,0.2093,-12.0],[0.3542,0.4048,0.2677,0.2093,-12.0],[0.3541,0.4049,0.2677,0.2093,-12.0],[0.3545,0.4048,0.2677,0.2093,-12.0],[0.3556,0.4044,0.2677,0.2093,-12.0],[0.3573,0.4038,0.2677,0.2093,-12.0],[0.3598,0.4029,0.2677,0.2093,-12.0],[0.3634,0.4016,0.2677,0.2093,-12.0],[0.368,0.3999,0.2677,0.2093,-12.0],[0.3737,0.398,0.2677,0.2093,-12.0],[0.3812,0.3955,0.2677,0.2093,-11.93],[0.3921,0.3924,0.2677,0.2093,-11.7],[0.4044,0.3892,0.2677,0.2093,-11.26],[0.4184,0.386,0.2677,0.2093,-10.54],[0.4333,0.383,0.2677,0.2093,-9.45],[0.4488,0.3803,0.2677,0.2093,-8.01],[0.4629,0.3786,0.2677,0.2093,-6.22],[0.4773,0.3773,0.2677,0.2093,-4.17],[0.4915,0.3765,0.2677,0.2093,-1.91],[0.506,0.3761,0.2677,0.2093,0.44],[0.5203,0.3763,0.2677,0.2093,2.77],[0.5344,0.377,0.2677,0.2093,4.93],[0.5503,0.3786,0.2677,0.2093,6.85],[0.5656,0.3807,0.2677,0.2093,8.49],[0.5801,0.3832,0.2677,0.2093,9.8],[0.5937,0.386,0.2677,0.2093,10.75],[0.6056,0.3888,0.2677,0.2093,11.38],[0.6138,0.391,0.2677,0.2093,11.76],[0.6206,0.393,0.2677,0.2093,11.94],[0.6256,0.3947,0.2677,0.2093,12.0],[0.6293,0.3959,0.2677,0.2093,12.0],[0.6324,0.397,0.2677,0.2093,12.0],[0.635,0.3979,0.2677,0.2093,12.0],[0.6371,0.3988,0.2677,0.2093,12.0],[0.6389,0.3995,0.2677,0.2093,12.0],[0.6405,0.4001,0.2677,0.2093,12.0],[0.6417,0.4006,0.2677,0.2093,12.0],[0.6425,0.4009,0.2677,0.2093,12.0],[0.643,0.4011,0.2677,0.2093,12.0],[0.6432,0.4012,0.2677,0.2093,12.0],[0.643,0.4012,0.2677,0.2093,12.0],[0.6421,0.4009,0.2677,0.2093,12.0],[0.6404,0.4003,0.2677,0.2093,12.0],[0.637,0.3991,0.2677,0.2093,12.0],[0.6315,0.3973,0.2677,0.2093,12.0],[0.6237,0.395,0.2677,0.2093,11.97],[0.6138,0.3921,0.2677,0.2093,11.85],[0.6019,0.389,0.2677,0.2093,11.62],[0.5894,0.386,0.2677,0.2093,11.19],[0.5766,0.3833,0.2677,0.2093,10.5],[0.5637,0.381,0.2677,0.2093,9.56],[0.5511,0.3792,0.2677,0.2093,8.34],[0.5393,0.3778,0.2677,0.2093,6.92],[0.5284,0.3768,0.2677,0.2093,5.45],[0.5187,0.3764,0.2677,0.2093,4.02],[0.5114,0.3762,0.2677,0.2093,2.77],[0.5065,0.3751,0.2677,0.2093,1.8],[0.5035,0.3733,0.2677,0.2093,1.13],[0.502,0.3705,0.2677,0.2093,0.76],[0.502,0.3671,0.2677,0.2093,0.62],[0.5019,0.3634,0.2677,0.2093,0.63],[0.5019,0.3604,0.2677,0.2093,0.72],[0.5019,0.3582,0.2677,0.2093,0.83],[0.5019,0.357,0.2677,0.2093,0.9],[0.5019,0.3564,0.2677,0.2093,0.92],[0.5019,0.3564,0.2677,0.2093,0.92],[0.502,0.3569,0.2677,0.2093,0.89],[0.502,0.3576,0.2677,0.2093,0.85],[0.502,0.3585,0.2677,0.2093,0.78],[0.5021,0.3596,0.2677,0.2093,0.72],[0.5021,0.3607,0.2677,0.2093,0.65],[0.5021,0.3619,0.2677,0.2093,0.58],[0.5021,0.3632,0.2677,0.2093,0.51],[0.5021,0.3647,0.2677,0.2093,0.46],[0.5021,0.366,0.2677,0.2093,0.41],[0.5021,0.3673,0.2677,0.2093,0.36],[0.5022,0.3684,0.2677,0.2093,0.3],[0.5022,0.3695,0.2677,0.2093,0.25],[0.5022,0.3707,0.2677,0.2093,0.19],[0.5022,0.3718,0.2677,0.2093,0.12],[0.5022,0.3726,0.2677,0.2093,0.04],[0.5023,0.3736,0.2677,0.2093,-0.03],[0.5023,0.3744,0.2677,0.2093,-0.1],[0.5023,0.3749,0.2677,0.2093,-0.16],[0.5023,0.3754,0.2677,0.2093,-0.22],[0.5023,0.3757,0.2677,0.2093,-0.26],[0.5023,0.376,0.2677,0.2093,-0.3],[0.5023,0.3761,0.2677,0.2093,-0.32],[0.5022,0.3762,0.2677,0.2093,-0.33],[0.5023,0.3762,0.2677,0.2093,-0.34],[0.5022,0.3763,0.2677,0.2093,-0.33],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.31],[0.5022,0.3763,0.2677,0.2093,-0.3],[0.5022,0.3763,0.2677,0.2093,-0.3]],"reach":[0.03,0.157,0.97,0.92],"script":[[0,"happy",0,0],[0.3,"blink",0,0],[0.6,"happy",0,0]]},"story-history":{"file":"clips/story-history.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-history-rest","mask":true,"tracks":[[0.4989,0.3094,0.3009,0.2367,-0.51],[0.4989,0.3094,0.3009,0.2367,-0.52],[0.4989,0.3094,0.3009,0.2367,-0.53],[0.4989,0.3094,0.3009,0.2367,-0.54],[0.4989,0.3094,0.3009,0.2367,-0.55],[0.4989,0.3094,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3096,0.3009,0.2367,-0.55],[0.4989,0.3097,0.3009,0.2367,-0.52],[0.4989,0.3098,0.3009,0.2367,-0.48],[0.4989,0.3101,0.3009,0.2367,-0.45],[0.4988,0.3105,0.3009,0.2367,-0.42],[0.4988,0.3111,0.3009,0.2367,-0.38],[0.4987,0.3119,0.3009,0.2367,-0.36],[0.4987,0.3131,0.3009,0.2367,-0.34],[0.4986,0.3145,0.3009,0.2367,-0.32],[0.4986,0.3158,0.3009,0.2367,-0.32],[0.4986,0.3176,0.3009,0.2367,-0.31],[0.4986,0.3193,0.3009,0.2367,-0.32],[0.4986,0.3213,0.3009,0.2367,-0.33],[0.4986,0.3231,0.3009,0.2367,-0.35],[0.4985,0.3253,0.3009,0.2367,-0.38],[0.4985,0.3273,0.3009,0.2367,-0.42],[0.4985,0.3295,0.3009,0.2367,-0.47],[0.4985,0.3315,0.3009,0.2367,-0.54],[0.4985,0.3334,0.3009,0.2367,-0.59],[0.4984,0.3352,0.3009,0.2367,-0.64],[0.4984,0.3367,0.3009,0.2367,-0.67],[0.4984,0.3381,0.3009,0.2367,-0.7],[0.4984,0.3391,0.3009,0.2367,-0.71],[0.4984,0.3399,0.3009,0.2367,-0.72],[0.4983,0.3404,0.3009,0.2367,-0.71],[0.4983,0.3407,0.3009,0.2367,-0.72],[0.4983,0.3408,0.3009,0.2367,-0.72],[0.4983,0.3409,0.3009,0.2367,-0.73],[0.4983,0.3409,0.3009,0.2367,-0.74],[0.4984,0.3409,0.3009,0.2367,-0.76],[0.4984,0.3409,0.3009,0.2367,-0.78],[0.4984,0.3409,0.3009,0.2367,-0.79],[0.4984,0.3409,0.3009,0.2367,-0.81],[0.4984,0.3409,0.3009,0.2367,-0.84],[0.4985,0.3411,0.3009,0.2367,-0.88],[0.4986,0.3415,0.3009,0.2367,-0.92],[0.4991,0.3423,0.3009,0.2367,-0.97],[0.5,0.3436,0.3009,0.2367,-1.04],[0.5016,0.3453,0.3009,0.2367,-1.14],[0.504,0.3476,0.3009,0.2367,-1.28],[0.5072,0.35,0.3009,0.2367,-1.44],[0.5107,0.3529,0.3009,0.2367,-1.67],[0.5145,0.3558,0.3009,0.2367,-1.95],[0.5182,0.3588,0.3009,0.2367,-2.26],[0.5214,0.3619,0.3009,0.2367,-2.61],[0.5241,0.3651,0.3009,0.2367,-2.95],[0.5262,0.3673,0.3009,0.2367,-3.26],[0.5276,0.3697,0.3009,0.2367,-3.55],[0.5282,0.3732,0.3009,0.2367,-3.77],[0.5284,0.3749,0.3009,0.2367,-3.89],[0.5284,0.3746,0.3009,0.2367,-3.96],[0.5284,0.3768,0.3009,0.2367,-4.0],[0.5283,0.3797,0.3009,0.2367,-4.01],[0.5283,0.3795,0.3009,0.2367,-3.99],[0.5283,0.3785,0.3009,0.2367,-3.96],[0.5282,0.3801,0.3009,0.2367,-3.93],[0.5282,0.3816,0.3009,0.2367,-3.9],[0.5282,0.38,0.3009,0.2367,-3.85],[0.5282,0.3779,0.3009,0.2367,-3.78],[0.5282,0.3782,0.3009,0.2367,-3.72],[0.5282,0.3791,0.3009,0.2367,-3.67],[0.5282,0.3769,0.3009,0.2367,-3.61],[0.5282,0.3749,0.3009,0.2367,-3.58],[0.5282,0.3769,0.3009,0.2367,-3.59],[0.5281,0.3782,0.3009,0.2367,-3.62],[0.5282,0.376,0.3009,0.2367,-3.64],[0.5283,0.3758,0.3009,0.2367,-3.66],[0.5283,0.3777,0.3009,0.2367,-3.67],[0.5284,0.3771,0.3009,0.2367,-3.67],[0.5286,0.376,0.3009,0.2367,-3.63],[0.5286,0.3757,0.3009,0.2367,-3.57],[0.5288,0.3742,0.3009,0.2367,-3.48],[0.529,0.3708,0.3009,0.2367,-3.33],[0.5288,0.3673,0.3009,0.2367,-3.12],[0.5279,0.3636,0.3009,0.2367,-2.86],[0.5262,0.3594,0.3009,0.2367,-2.54],[0.5238,0.3547,0.3009,0.2367,-2.18],[0.5204,0.3493,0.3009,0.2367,-1.79],[0.5164,0.3437,0.3009,0.2367,-1.42],[0.5122,0.3379,0.3009,0.2367,-1.09],[0.5082,0.3324,0.3009,0.2367,-0.82],[0.5047,0.3273,0.3009,0.2367,-0.62],[0.502,0.3231,0.3009,0.2367,-0.48],[0.5002,0.3197,0.3009,0.2367,-0.41],[0.4991,0.3169,0.3009,0.2367,-0.38],[0.4987,0.3147,0.3009,0.2367,-0.37],[0.4986,0.3132,0.3009,0.2367,-0.38],[0.4985,0.3121,0.3009,0.2367,-0.41],[0.4985,0.3113,0.3009,0.2367,-0.43],[0.4986,0.3108,0.3009,0.2367,-0.46],[0.4986,0.3104,0.3009,0.2367,-0.48],[0.4986,0.3101,0.3009,0.2367,-0.5],[0.4986,0.31,0.3009,0.2367,-0.53],[0.4987,0.3098,0.3009,0.2367,-0.54],[0.4987,0.3097,0.3009,0.2367,-0.55],[0.4987,0.3097,0.3009,0.2367,-0.54],[0.4987,0.3096,0.3009,0.2367,-0.52],[0.4987,0.3095,0.3009,0.2367,-0.51],[0.4987,0.3095,0.3009,0.2367,-0.48],[0.4987,0.3095,0.3009,0.2367,-0.47],[0.4987,0.3095,0.3009,0.2367,-0.45],[0.4986,0.3094,0.3009,0.2367,-0.44],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3095,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41]],"reach":[0.1788,0.03,0.8212,0.92],"script":[[0,"idle",0,1],[0.6,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-morning":{"file":"clips/story-morning.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-morning-rest","mask":true,"tracks":[[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.3],[0.4983,0.276,0.3172,0.2498,-0.31],[0.4983,0.2759,0.3172,0.2498,-0.32],[0.4983,0.2758,0.3172,0.2498,-0.31],[0.4984,0.2759,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.25],[0.4983,0.2765,0.3172,0.2498,-0.2],[0.4983,0.2776,0.3172,0.2498,-0.13],[0.4983,0.279,0.3172,0.2498,-0.07],[0.4984,0.281,0.3172,0.2498,-0.01],[0.4984,0.2838,0.3172,0.2498,0.03],[0.4984,0.2872,0.3172,0.2498,0.05],[0.4985,0.2908,0.3172,0.2498,0.04],[0.4985,0.2954,0.3172,0.2498,0.01],[0.4985,0.3003,0.3172,0.2498,-0.02],[0.4985,0.3051,0.3172,0.2498,-0.06],[0.4985,0.3098,0.3172,0.2498,-0.11],[0.4984,0.3141,0.3172,0.2498,-0.17],[0.4983,0.3175,0.3172,0.2498,-0.23],[0.4981,0.3201,0.3172,0.2498,-0.31],[0.498,0.3219,0.3172,0.2498,-0.39],[0.4979,0.323,0.3172,0.2498,-0.49],[0.4978,0.3235,0.3172,0.2498,-0.59],[0.4977,0.3237,0.3172,0.2498,-0.7],[0.4976,0.3235,0.3172,0.2498,-0.79],[0.4975,0.323,0.3172,0.2498,-0.88],[0.4974,0.3221,0.3172,0.2498,-0.96],[0.4973,0.3209,0.3172,0.2498,-1.03],[0.4973,0.3192,0.3172,0.2498,-1.09],[0.4972,0.3173,0.3172,0.2498,-1.14],[0.4971,0.3151,0.3172,0.2498,-1.15],[0.4969,0.313,0.3172,0.2498,-1.14],[0.4968,0.3107,0.3172,0.2498,-1.07],[0.4968,0.3086,0.3172,0.2498,-0.95],[0.497,0.3065,0.3172,0.2498,-0.79],[0.4975,0.3045,0.3172,0.2498,-0.6],[0.4981,0.3027,0.3172,0.2498,-0.39],[0.4988,0.301,0.3172,0.2498,-0.21],[0.4994,0.2995,0.3172,0.2498,-0.11],[0.4997,0.2983,0.3172,0.2498,-0.1],[0.4996,0.2974,0.3172,0.2498,-0.15],[0.4992,0.2967,0.3172,0.2498,-0.28],[0.4987,0.2958,0.3172,0.2498,-0.43],[0.4983,0.2948,0.3172,0.2498,-0.58],[0.4981,0.2933,0.3172,0.2498,-0.69],[0.4984,0.2919,0.3172,0.2498,-0.76],[0.4988,0.2906,0.3172,0.2498,-0.8],[0.499,0.29,0.3172,0.2498,-0.84],[0.4992,0.2904,0.3172,0.2498,-0.87],[0.499,0.2922,0.3172,0.2498,-0.93],[0.4986,0.2948,0.3172,0.2498,-1.04],[0.4981,0.2982,0.3172,0.2498,-1.19],[0.498,0.302,0.3172,0.2498,-1.37],[0.4976,0.3057,0.3172,0.2498,-1.55],[0.4973,0.3092,0.3172,0.2498,-1.69],[0.4971,0.3126,0.3172,0.2498,-1.8],[0.497,0.3157,0.3172,0.2498,-1.89],[0.497,0.3185,0.3172,0.2498,-1.93],[0.497,0.3205,0.3172,0.2498,-1.92],[0.4971,0.3218,0.3172,0.2498,-1.91],[0.4973,0.322,0.3172,0.2498,-1.87],[0.4974,0.3212,0.3172,0.2498,-1.8],[0.4974,0.319,0.3172,0.2498,-1.69],[0.4976,0.3165,0.3172,0.2498,-1.55],[0.4978,0.3127,0.3172,0.2498,-1.37],[0.498,0.3088,0.3172,0.2498,-1.16],[0.4983,0.3051,0.3172,0.2498,-0.91],[0.4985,0.3014,0.3172,0.2498,-0.67],[0.4985,0.2973,0.3172,0.2498,-0.46],[0.4986,0.2941,0.3172,0.2498,-0.3],[0.4986,0.291,0.3172,0.2498,-0.19],[0.4986,0.2882,0.3172,0.2498,-0.13],[0.4985,0.2856,0.3172,0.2498,-0.11],[0.4985,0.2838,0.3172,0.2498,-0.11],[0.4985,0.282,0.3172,0.2498,-0.11],[0.4985,0.2804,0.3172,0.2498,-0.12],[0.4985,0.2791,0.3172,0.2498,-0.12],[0.4984,0.2781,0.3172,0.2498,-0.1],[0.4984,0.277,0.3172,0.2498,-0.1],[0.4984,0.2762,0.3172,0.2498,-0.09],[0.4984,0.2756,0.3172,0.2498,-0.08],[0.4984,0.275,0.3172,0.2498,-0.08],[0.4985,0.2745,0.3172,0.2498,-0.09],[0.4985,0.2743,0.3172,0.2498,-0.09],[0.4985,0.2742,0.3172,0.2498,-0.1],[0.4985,0.2742,0.3172,0.2498,-0.12],[0.4985,0.2743,0.3172,0.2498,-0.14],[0.4985,0.2744,0.3172,0.2498,-0.16],[0.4985,0.2746,0.3172,0.2498,-0.17],[0.4985,0.2749,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.21],[0.4984,0.2754,0.3172,0.2498,-0.22],[0.4984,0.2757,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2759,0.3172,0.2498,-0.25],[0.4984,0.2759,0.3172,0.2498,-0.27],[0.4984,0.276,0.3172,0.2498,-0.28],[0.4983,0.276,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.276,0.3172,0.2498,-0.28],[0.4983,0.2759,0.3172,0.2498,-0.26],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2756,0.3172,0.2498,-0.22],[0.4984,0.2755,0.3172,0.2498,-0.2],[0.4984,0.2753,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.19],[0.4984,0.2751,0.3172,0.2498,-0.19],[0.4984,0.275,0.3172,0.2498,-0.2],[0.4984,0.2748,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.22],[0.4984,0.2746,0.3172,0.2498,-0.22],[0.4984,0.2745,0.3172,0.2498,-0.22]],"reach":[0.1974,0.03,0.8026,0.92],"script":[[0,"idle",0,0],[0.25,"blink",0,0],[0.55,"happy",0,0]]},"story-news":{"file":"clips/story-news.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-news-rest","mask":true,"tracks":[[0.503,0.3217,0.2842,0.2162,-0.52],[0.503,0.3216,0.2842,0.2162,-0.53],[0.503,0.3216,0.2842,0.2162,-0.53],[0.5029,0.3215,0.2842,0.2162,-0.55],[0.5029,0.3215,0.2842,0.2162,-0.57],[0.5029,0.3215,0.2842,0.2162,-0.58],[0.5029,0.3215,0.2842,0.2162,-0.6],[0.5029,0.3215,0.2842,0.2162,-0.62],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.61],[0.5029,0.3216,0.2842,0.2162,-0.59],[0.5029,0.3216,0.2842,0.2162,-0.55],[0.503,0.3217,0.2842,0.2162,-0.51],[0.503,0.3217,0.2842,0.2162,-0.47],[0.503,0.3216,0.2842,0.2162,-0.43],[0.5031,0.3216,0.2842,0.2162,-0.4],[0.5031,0.3215,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.4],[0.5031,0.3214,0.2842,0.2162,-0.41],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3216,0.2842,0.2162,-0.42],[0.5031,0.3217,0.2842,0.2162,-0.43],[0.5031,0.3219,0.2842,0.2162,-0.45],[0.5031,0.3223,0.2842,0.2162,-0.48],[0.5031,0.3227,0.2842,0.2162,-0.54],[0.5031,0.3232,0.2842,0.2162,-0.65],[0.5031,0.324,0.2842,0.2162,-0.81],[0.5031,0.3251,0.2842,0.2162,-1.05],[0.5029,0.3263,0.2842,0.2162,-1.38],[0.5024,0.3278,0.2842,0.2162,-1.76],[0.5013,0.3297,0.2842,0.2162,-2.16],[0.4997,0.3316,0.2842,0.2162,-2.62],[0.4979,0.3333,0.2842,0.2162,-3.13],[0.4962,0.3344,0.2842,0.2162,-3.66],[0.4929,0.3342,0.2842,0.2162,-4.23],[0.4892,0.3333,0.2842,0.2162,-4.83],[0.4854,0.3319,0.2842,0.2162,-5.43],[0.4815,0.3303,0.2842,0.2162,-6.03],[0.4775,0.329,0.2842,0.2162,-6.56],[0.4753,0.3286,0.2842,0.2162,-7.01],[0.4741,0.3284,0.2842,0.2162,-7.39],[0.4735,0.3283,0.2842,0.2162,-7.64],[0.4733,0.3283,0.2842,0.2162,-7.77],[0.4733,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.87],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.85],[0.4734,0.3283,0.2842,0.2162,-7.84],[0.4734,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.82],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4735,0.3284,0.2842,0.2162,-7.81],[0.4735,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.78],[0.4734,0.3282,0.2842,0.2162,-7.77],[0.4738,0.3284,0.2842,0.2162,-7.71],[0.4748,0.3292,0.2842,0.2162,-7.57],[0.4765,0.3301,0.2842,0.2162,-7.34],[0.4793,0.3313,0.2842,0.2162,-7.0],[0.4825,0.3321,0.2842,0.2162,-6.52],[0.4864,0.3323,0.2842,0.2162,-5.89],[0.4901,0.3315,0.2842,0.2162,-5.15],[0.4933,0.3301,0.2842,0.2162,-4.34],[0.4961,0.3281,0.2842,0.2162,-3.54],[0.4984,0.3262,0.2842,0.2162,-2.76],[0.5,0.3247,0.2842,0.2162,-2.08],[0.5012,0.3236,0.2842,0.2162,-1.54],[0.5021,0.3228,0.2842,0.2162,-1.14],[0.5025,0.3223,0.2842,0.2162,-0.84],[0.5027,0.3221,0.2842,0.2162,-0.63],[0.5028,0.3219,0.2842,0.2162,-0.48],[0.5029,0.3218,0.2842,0.2162,-0.32],[0.503,0.3216,0.2842,0.2162,-0.18],[0.5031,0.3214,0.2842,0.2162,-0.04],[0.5034,0.3211,0.2842,0.2162,0.06],[0.5035,0.3211,0.2842,0.2162,0.12],[0.5035,0.3211,0.2842,0.2162,0.09],[0.5035,0.3212,0.2842,0.2162,0.02],[0.5034,0.3213,0.2842,0.2162,-0.12],[0.5031,0.3217,0.2842,0.2162,-0.27],[0.5031,0.3218,0.2842,0.2162,-0.42],[0.503,0.3218,0.2842,0.2162,-0.54],[0.503,0.3218,0.2842,0.2162,-0.64],[0.5029,0.3217,0.2842,0.2162,-0.67],[0.5029,0.3216,0.2842,0.2162,-0.69],[0.503,0.3216,0.2842,0.2162,-0.71],[0.503,0.3216,0.2842,0.2162,-0.72],[0.503,0.3216,0.2842,0.2162,-0.73],[0.503,0.3216,0.2842,0.2162,-0.74],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75]],"reach":[0.1966,0.03,0.8034,0.92],"script":[[0,"idle",0,0],[0.25,"idle",0,1],[0.7,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-night":{"file":"clips/story-night.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-night-rest","mask":true,"tracks":[[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.3361,0.2636,0.2077,0.64],[0.4376,0.3368,0.2636,0.2077,0.63],[0.4377,0.3389,0.2636,0.2077,0.62],[0.4378,0.3428,0.2636,0.2077,0.6],[0.438,0.3484,0.2636,0.2077,0.61],[0.4383,0.3555,0.2636,0.2077,0.65],[0.4386,0.3631,0.2636,0.2077,0.76],[0.439,0.3696,0.2636,0.2077,1.0],[0.4395,0.3738,0.2636,0.2077,1.38],[0.4399,0.3747,0.2636,0.2077,1.91],[0.4401,0.3722,0.2636,0.2077,2.59],[0.4401,0.3664,0.2636,0.2077,3.37],[0.4399,0.3581,0.2636,0.2077,4.21],[0.4396,0.3489,0.2636,0.2077,5.06],[0.4392,0.3392,0.2636,0.2077,5.9],[0.4389,0.3302,0.2636,0.2077,6.68],[0.4389,0.3227,0.2636,0.2077,7.41],[0.4391,0.3165,0.2636,0.2077,8.05],[0.4394,0.3114,0.2636,0.2077,8.62],[0.4399,0.3081,0.2636,0.2077,9.11],[0.4404,0.306,0.2636,0.2077,9.52],[0.4407,0.3048,0.2636,0.2077,9.84],[0.4409,0.3041,0.2636,0.2077,10.09],[0.441,0.3039,0.2636,0.2077,10.26],[0.4412,0.3037,0.2636,0.2077,10.38],[0.4413,0.3036,0.2636,0.2077,10.46],[0.4415,0.3034,0.2636,0.2077,10.55],[0.4416,0.3032,0.2636,0.2077,10.63],[0.4418,0.303,0.2636,0.2077,10.71],[0.4419,0.3028,0.2636,0.2077,10.79],[0.442,0.3027,0.2636,0.2077,10.87],[0.4422,0.3025,0.2636,0.2077,10.95],[0.4423,0.3023,0.2636,0.2077,11.03],[0.4425,0.3021,0.2636,0.2077,11.11],[0.4426,0.3019,0.2636,0.2077,11.19],[0.4427,0.3018,0.2636,0.2077,11.27],[0.4429,0.3016,0.2636,0.2077,11.35],[0.443,0.3014,0.2636,0.2077,11.43],[0.4432,0.3012,0.2636,0.2077,11.52],[0.4433,0.301,0.2636,0.2077,11.6],[0.4435,0.3009,0.2636,0.2077,11.67],[0.4436,0.3007,0.2636,0.2077,11.7],[0.4437,0.3005,0.2636,0.2077,11.69],[0.4437,0.3012,0.2636,0.2077,11.65],[0.4438,0.3031,0.2636,0.2077,11.58],[0.4437,0.3072,0.2636,0.2077,11.48],[0.4434,0.3134,0.2636,0.2077,11.37],[0.4417,0.3236,0.2636,0.2077,11.25],[0.4389,0.3342,0.2636,0.2077,11.09],[0.4343,0.3442,0.2636,0.2077,10.87],[0.4281,0.3529,0.2636,0.2077,10.55],[0.4197,0.3597,0.2636,0.2077,10.08],[0.4105,0.3626,0.2636,0.2077,9.47],[0.4001,0.3642,0.2636,0.2077,8.68],[0.3892,0.3649,0.2636,0.2077,7.74],[0.3782,0.3647,0.2636,0.2077,6.67],[0.3679,0.3639,0.2636,0.2077,5.54],[0.3585,0.3629,0.2636,0.2077,4.37],[0.3508,0.3618,0.2636,0.2077,3.24],[0.3452,0.3604,0.2636,0.2077,2.18],[0.3417,0.3589,0.2636,0.2077,1.25],[0.3408,0.3571,0.2636,0.2077,0.51],[0.3425,0.3551,0.2636,0.2077,0.04],[0.3472,0.3525,0.2636,0.2077,-0.14],[0.3547,0.3497,0.2636,0.2077,0.07],[0.3651,0.3467,0.2636,0.2077,0.68],[0.3785,0.3435,0.2636,0.2077,1.73],[0.3948,0.3406,0.2636,0.2077,3.1],[0.4133,0.3383,0.2636,0.2077,4.7],[0.4335,0.3368,0.2636,0.2077,6.37],[0.4548,0.3363,0.2636,0.2077,7.98],[0.4758,0.3368,0.2636,0.2077,9.37],[0.4968,0.3385,0.2636,0.2077,10.47],[0.5153,0.3406,0.2636,0.2077,11.22],[0.5309,0.343,0.2636,0.2077,11.69],[0.5434,0.3454,0.2636,0.2077,11.92],[0.5527,0.3476,0.2636,0.2077,12.0],[0.5577,0.349,0.2636,0.2077,12.0],[0.5603,0.35,0.2636,0.2077,12.0],[0.5603,0.3505,0.2636,0.2077,12.0],[0.5579,0.3505,0.2636,0.2077,12.0],[0.5536,0.3501,0.2636,0.2077,12.0],[0.5477,0.3493,0.2636,0.2077,12.0],[0.5403,0.3485,0.2636,0.2077,12.0],[0.5316,0.3476,0.2636,0.2077,12.0],[0.522,0.3468,0.2636,0.2077,12.0],[0.5114,0.3461,0.2636,0.2077,11.98],[0.5002,0.3456,0.2636,0.2077,11.87],[0.487,0.3455,0.2636,0.2077,11.58],[0.4743,0.3458,0.2636,0.2077,11.05],[0.4623,0.3459,0.2636,0.2077,10.23],[0.4523,0.3458,0.2636,0.2077,9.11],[0.4442,0.3453,0.2636,0.2077,7.75],[0.4401,0.344,0.2636,0.2077,6.25],[0.4378,0.3423,0.2636,0.2077,4.76],[0.437,0.3406,0.2636,0.2077,3.4],[0.4368,0.3389,0.2636,0.2077,2.27],[0.4369,0.3377,0.2636,0.2077,1.44],[0.4371,0.3369,0.2636,0.2077,0.91],[0.4372,0.3363,0.2636,0.2077,0.61],[0.4373,0.3361,0.2636,0.2077,0.48],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.42],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.45],[0.4372,0.3361,0.2636,0.2077,0.46],[0.4372,0.3361,0.2636,0.2077,0.47],[0.4372,0.3361,0.2636,0.2077,0.49],[0.4372,0.3361,0.2636,0.2077,0.5],[0.4372,0.3361,0.2636,0.2077,0.52],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53]],"reach":[0.089,0.03,0.911,0.92],"script":[[0,"blink",0,0],[0.3,"idle",0,0],[0.38,"blink",0,0],[0.7,"happy",0,0]]},"story-science":{"file":"clips/story-science.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-science-rest","mask":true,"tracks":[[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.57],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.5],[0.4999,0.31,0.2962,0.2362,-0.45],[0.4993,0.3103,0.2962,0.2362,-0.4],[0.4985,0.3109,0.2962,0.2362,-0.34],[0.4965,0.3123,0.2962,0.2362,-0.25],[0.4935,0.3138,0.2962,0.2362,-0.15],[0.4904,0.3154,0.2962,0.2362,-0.01],[0.4864,0.3172,0.2962,0.2362,0.18],[0.4828,0.3187,0.2962,0.2362,0.4],[0.4794,0.3197,0.2962,0.2362,0.64],[0.4766,0.3204,0.2962,0.2362,0.91],[0.4738,0.3211,0.2962,0.2362,1.16],[0.4721,0.3211,0.2962,0.2362,1.39],[0.4704,0.3211,0.2962,0.2362,1.61],[0.4699,0.3207,0.2962,0.2362,1.86],[0.4703,0.3202,0.2962,0.2362,2.13],[0.4713,0.3196,0.2962,0.2362,2.55],[0.4736,0.3189,0.2962,0.2362,3.22],[0.4761,0.3183,0.2962,0.2362,4.13],[0.4807,0.3179,0.2962,0.2362,5.25],[0.4873,0.3182,0.2962,0.2362,6.55],[0.4933,0.3186,0.2962,0.2362,7.87],[0.5001,0.3197,0.2962,0.2362,9.13],[0.5066,0.3209,0.2962,0.2362,10.22],[0.5115,0.3222,0.2962,0.2362,11.02],[0.5144,0.3233,0.2962,0.2362,11.56],[0.5178,0.3246,0.2962,0.2362,11.89],[0.5194,0.3253,0.2962,0.2362,12.0],[0.5214,0.326,0.2962,0.2362,12.0],[0.5226,0.3265,0.2962,0.2362,12.0],[0.5236,0.3268,0.2962,0.2362,12.0],[0.5243,0.3269,0.2962,0.2362,12.0],[0.525,0.327,0.2962,0.2362,12.0],[0.5258,0.3271,0.2962,0.2362,12.0],[0.5266,0.3272,0.2962,0.2362,12.0],[0.5276,0.3274,0.2962,0.2362,12.0],[0.5284,0.3276,0.2962,0.2362,12.0],[0.5292,0.3277,0.2962,0.2362,12.0],[0.5297,0.3278,0.2962,0.2362,12.0],[0.5302,0.328,0.2962,0.2362,12.0],[0.5304,0.328,0.2962,0.2362,12.0],[0.5306,0.328,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5309,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5299,0.3277,0.2962,0.2362,12.0],[0.5284,0.3271,0.2962,0.2362,12.0],[0.5261,0.326,0.2962,0.2362,12.0],[0.5228,0.3246,0.2962,0.2362,11.94],[0.5187,0.3227,0.2962,0.2362,11.75],[0.5141,0.3207,0.2962,0.2362,11.37],[0.5093,0.3185,0.2962,0.2362,10.77],[0.5046,0.3163,0.2962,0.2362,9.9],[0.5,0.3143,0.2962,0.2362,8.81],[0.4954,0.3124,0.2962,0.2362,7.55],[0.4912,0.3107,0.2962,0.2362,6.22],[0.4877,0.3095,0.2962,0.2362,4.94],[0.4846,0.3085,0.2962,0.2362,3.8],[0.4823,0.308,0.2962,0.2362,2.84],[0.4811,0.3077,0.2962,0.2362,2.12],[0.4804,0.3077,0.2962,0.2362,1.59],[0.48,0.3077,0.2962,0.2362,1.21],[0.4802,0.3078,0.2962,0.2362,0.93],[0.4806,0.3079,0.2962,0.2362,0.71],[0.4816,0.3081,0.2962,0.2362,0.51],[0.4833,0.3083,0.2962,0.2362,0.35],[0.4858,0.3085,0.2962,0.2362,0.19],[0.4883,0.3088,0.2962,0.2362,0.06],[0.4915,0.3091,0.2962,0.2362,-0.06],[0.4944,0.3093,0.2962,0.2362,-0.16],[0.4967,0.3094,0.2962,0.2362,-0.24],[0.4982,0.3096,0.2962,0.2362,-0.3],[0.4995,0.3097,0.2962,0.2362,-0.36],[0.4998,0.3098,0.2962,0.2362,-0.4],[0.4999,0.3098,0.2962,0.2362,-0.43],[0.4999,0.3098,0.2962,0.2362,-0.45],[0.4998,0.3098,0.2962,0.2362,-0.47],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.49],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55]],"reach":[0.1678,0.03,0.8322,0.92],"script":[[0,"idle",0,0],[0.25,"idle",-1,-1],[0.65,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-tech":{"file":"clips/story-tech.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-tech-rest","mask":true,"tracks":[[0.5459,0.3504,0.2718,0.2144,-0.65],[0.5459,0.3503,0.2718,0.2144,-0.63],[0.5456,0.3505,0.2718,0.2144,-0.59],[0.544,0.3509,0.2718,0.2144,-0.3],[0.5403,0.3521,0.2718,0.2144,0.35],[0.5339,0.354,0.2718,0.2144,1.37],[0.5215,0.3577,0.2718,0.2144,2.73],[0.5073,0.3615,0.2718,0.2144,4.43],[0.4932,0.3653,0.2718,0.2144,6.23],[0.4807,0.3685,0.2718,0.2144,7.99],[0.4704,0.3709,0.2718,0.2144,9.5],[0.4658,0.3717,0.2718,0.2144,10.65],[0.4631,0.3722,0.2718,0.2144,11.44],[0.4612,0.3727,0.2718,0.2144,11.89],[0.4594,0.3736,0.2718,0.2144,12.0],[0.4583,0.3744,0.2718,0.2144,12.0],[0.4576,0.375,0.2718,0.2144,12.0],[0.4577,0.3752,0.2718,0.2144,12.0],[0.4597,0.3745,0.2718,0.2144,12.0],[0.4623,0.3733,0.2718,0.2144,12.0],[0.4645,0.3724,0.2718,0.2144,12.0],[0.4659,0.3716,0.2718,0.2144,12.0],[0.4659,0.3715,0.2718,0.2144,12.0],[0.464,0.3724,0.2718,0.2144,12.0],[0.4618,0.3733,0.2718,0.2144,12.0],[0.4599,0.374,0.2718,0.2144,12.0],[0.4587,0.3744,0.2718,0.2144,12.0],[0.4587,0.3743,0.2718,0.2144,12.0],[0.4594,0.3738,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4616,0.3727,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4606,0.3731,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.4605,0.3733,0.2718,0.2144,12.0],[0.4616,0.3729,0.2718,0.2144,12.0],[0.4629,0.3725,0.2718,0.2144,12.0],[0.4636,0.3721,0.2718,0.2144,12.0],[0.4635,0.3721,0.2718,0.2144,12.0],[0.4627,0.3724,0.2718,0.2144,12.0],[0.4611,0.373,0.2718,0.2144,12.0],[0.4594,0.3735,0.2718,0.2144,12.0],[0.4585,0.374,0.2718,0.2144,12.0],[0.4583,0.3741,0.2718,0.2144,12.0],[0.4589,0.3738,0.2718,0.2144,12.0],[0.46,0.3733,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4595,0.3736,0.2718,0.2144,12.0],[0.4554,0.376,0.2718,0.2144,12.0],[0.4503,0.38,0.2718,0.2144,12.0],[0.445,0.3855,0.2718,0.2144,12.0],[0.4406,0.3914,0.2718,0.2144,12.0],[0.4387,0.3956,0.2718,0.2144,12.0],[0.4407,0.3962,0.2718,0.2144,12.0],[0.4446,0.3934,0.2718,0.2144,12.0],[0.4498,0.3876,0.2718,0.2144,12.0],[0.4584,0.3796,0.2718,0.2144,12.0],[0.4666,0.3726,0.2718,0.2144,12.0],[0.4744,0.3675,0.2718,0.2144,12.0],[0.4813,0.3645,0.2718,0.2144,12.0],[0.4871,0.3632,0.2718,0.2144,12.0],[0.4892,0.3633,0.2718,0.2144,12.0],[0.4903,0.3635,0.2718,0.2144,12.0],[0.4909,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4898,0.3636,0.2718,0.2144,12.0],[0.4872,0.364,0.2718,0.2144,11.94],[0.482,0.3673,0.2718,0.2144,11.75],[0.4769,0.3746,0.2718,0.2144,11.33],[0.4764,0.3882,0.2718,0.2144,10.59],[0.4803,0.403,0.2718,0.2144,9.5],[0.4887,0.4135,0.2718,0.2144,8.11],[0.5019,0.4165,0.2718,0.2144,6.48],[0.517,0.4107,0.2718,0.2144,4.79],[0.528,0.3959,0.2718,0.2144,3.18],[0.5364,0.3783,0.2718,0.2144,1.8],[0.5416,0.3649,0.2718,0.2144,0.74],[0.5444,0.3561,0.2718,0.2144,0.04],[0.5455,0.352,0.2718,0.2144,-0.36],[0.5458,0.3506,0.2718,0.2144,-0.52],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.55],[0.5458,0.3506,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57]],"reach":[0.1894,0.03,0.8106,0.92],"script":[[0,"idle",0,1],[0.4,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-trophy":{"file":"clips/story-trophy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-trophy-rest","mask":true,"tracks":[[0.543,0.3385,0.2824,0.2181,-0.62],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.64],[0.543,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.68],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.71],[0.543,0.3385,0.2824,0.2181,-0.73],[0.5431,0.3385,0.2824,0.2181,-0.74],[0.5431,0.3385,0.2824,0.2181,-0.76],[0.5431,0.3385,0.2824,0.2181,-0.79],[0.5431,0.3385,0.2824,0.2181,-0.84],[0.5431,0.3385,0.2824,0.2181,-0.9],[0.543,0.3398,0.2824,0.2181,-0.98],[0.5431,0.3432,0.2824,0.2181,-1.06],[0.5432,0.348,0.2824,0.2181,-0.75],[0.543,0.3534,0.2824,0.2181,-0.5],[0.542,0.3595,0.2824,0.2181,-0.32],[0.5425,0.3614,0.2824,0.2181,-0.19],[0.5398,0.3589,0.2824,0.2181,-0.14],[0.5362,0.3494,0.2824,0.2181,-0.55],[0.5318,0.3349,0.2824,0.2181,-0.96],[0.5278,0.3181,0.2824,0.2181,-1.78],[0.5222,0.304,0.2824,0.2181,-2.55],[0.5198,0.2933,0.2824,0.2181,-3.23],[0.5183,0.2894,0.2824,0.2181,-3.84],[0.5179,0.2907,0.2824,0.2181,-4.35],[0.518,0.2937,0.2824,0.2181,-4.39],[0.5182,0.2962,0.2824,0.2181,-4.43],[0.5183,0.2972,0.2824,0.2181,-4.45],[0.5182,0.2969,0.2824,0.2181,-4.46],[0.518,0.2957,0.2824,0.2181,-4.49],[0.5179,0.2943,0.2824,0.2181,-4.51],[0.5178,0.2937,0.2824,0.2181,-4.53],[0.5178,0.2935,0.2824,0.2181,-4.56],[0.5177,0.2934,0.2824,0.2181,-4.59],[0.5177,0.2934,0.2824,0.2181,-4.61],[0.5177,0.2934,0.2824,0.2181,-4.64],[0.5177,0.2934,0.2824,0.2181,-4.63],[0.5177,0.2935,0.2824,0.2181,-4.6],[0.5177,0.2935,0.2824,0.2181,-4.54],[0.5183,0.285,0.2824,0.2181,-4.44],[0.5189,0.272,0.2824,0.2181,-4.31],[0.5193,0.256,0.2824,0.2181,-4.19],[0.5197,0.2462,0.2824,0.2181,-4.06],[0.5198,0.2506,0.2824,0.2181,-3.97],[0.5194,0.2677,0.2824,0.2181,-3.94],[0.5191,0.2887,0.2824,0.2181,-3.94],[0.5189,0.3113,0.2824,0.2181,-3.99],[0.5188,0.3255,0.2824,0.2181,-4.07],[0.5188,0.324,0.2824,0.2181,-4.16],[0.5185,0.3164,0.2824,0.2181,-4.23],[0.5181,0.3087,0.2824,0.2181,-4.3],[0.5176,0.3021,0.2824,0.2181,-4.35],[0.5171,0.2974,0.2824,0.2181,-4.39],[0.5168,0.2945,0.2824,0.2181,-4.44],[0.5168,0.2936,0.2824,0.2181,-4.47],[0.5168,0.2934,0.2824,0.2181,-4.43],[0.5171,0.2938,0.2824,0.2181,-4.29],[0.5177,0.2993,0.2824,0.2181,-4.04],[0.5184,0.3079,0.2824,0.2181,-3.68],[0.5197,0.3182,0.2824,0.2181,-3.22],[0.5216,0.3293,0.2824,0.2181,-2.73],[0.5241,0.3399,0.2824,0.2181,-2.22],[0.527,0.3453,0.2824,0.2181,-1.78],[0.5304,0.347,0.2824,0.2181,-1.41],[0.5338,0.3465,0.2824,0.2181,-1.13],[0.5371,0.3445,0.2824,0.2181,-0.93],[0.5396,0.3425,0.2824,0.2181,-0.84],[0.5414,0.3406,0.2824,0.2181,-0.76],[0.5425,0.3393,0.2824,0.2181,-0.7],[0.5431,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.61],[0.543,0.3385,0.2824,0.2181,-0.6],[0.5429,0.3385,0.2824,0.2181,-0.59],[0.5429,0.3385,0.2824,0.2181,-0.58],[0.5429,0.3385,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.56],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.58],[0.543,0.3384,0.2824,0.2181,-0.59],[0.543,0.3384,0.2824,0.2181,-0.6],[0.543,0.3384,0.2824,0.2181,-0.61],[0.543,0.3384,0.2824,0.2181,-0.62],[0.543,0.3384,0.2824,0.2181,-0.63],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64]],"reach":[0.1216,0.03,0.8784,0.92],"script":[[0,"happy",0,0],[0.5,"blink",0,0],[0.54,"happy",0,0]]}}} \ No newline at end of file +{"schema":"echo-character","version":1,"note":"Echo stills and motion clips with a blank screen (the face is drawn in code). frames: stills, with the screen box as fractions of the frame, and reach (the still's extent). clips: alpha-packed H.264 (colour above, alpha below), one keyframe, with the screen tracked per frame as [centre x, centre y, width, height, tilt degrees], and reach (everything the clip covers); optionally a face script ([share of the clip, face, gaze x, gaze y] from that point on), frames with a blink, and lens frames ([centre x, centre y, glass radius, scale, blink]: one big eye shows through a magnifier, through the glass band). sequences: steps (a still with face and hold in ms, 0 = rest forever, or a clip) played once, then an optional loop.","frames":{"front-rest":{"screen":[0.3516,0.2344,0.6484,0.4688],"reach":[0.2539,0.1523,0.7461,0.8848]},"t1-rest":{"screen":[0.4387,0.3649,0.6566,0.565],"reach":[0.293,0.3008,0.7051,0.9199]},"ta-rest":{"screen":[0.4387,0.3622,0.6566,0.5676],"reach":[0.293,0.2969,0.707,0.9316]},"tb-rest":{"screen":[0.4387,0.3614,0.6566,0.5685],"reach":[0.2949,0.2949,0.709,0.9316]},"account-idle":{"screen":[0.3542,0.2396,0.6458,0.4688],"reach":[0.2552,0.1536,0.7448,0.8828]},"account-cloud":{"screen":[0.4896,0.2188,0.7812,0.4479],"reach":[0.388,0.138,0.8828,0.8672]},"account-cloud-hug":{"screen":[0.3542,0.2188,0.6458,0.4479],"reach":[0.2526,0.1354,0.7448,0.8672]},"account-comedy":{"screen":[0.3646,0.2396,0.6458,0.4583],"reach":[0.2552,0.1484,0.75,0.8802]},"account-truecrime":{"screen":[0.3646,0.2083,0.6562,0.4375],"reach":[0.1745,0.0443,0.7682,0.901]},"account-science":{"screen":[0.375,0.2292,0.6667,0.4479],"reach":[0.237,0.0911,0.7656,0.8698]},"account-history":{"screen":[0.3854,0.2396,0.6771,0.4688],"reach":[0.2839,0.1042,0.7786,0.8828]},"account-business":{"screen":[0.2604,0.2292,0.5417,0.4479],"reach":[0.1536,0.1354,0.6589,0.8672]},"account-wellness":{"screen":[0.375,0.2292,0.6562,0.4479],"reach":[0.1823,0.1224,0.8464,0.8698]},"account-fiction":{"screen":[0.4479,0.25,0.7292,0.4792],"reach":[0.3411,0.0755,0.8359,0.875]},"story-hourglass-rest":{"screen":[0.3315,0.156,0.644,0.4013],"reach":[0.1914,0.041,0.791,0.9199]},"story-business-rest":{"screen":[0.3239,0.171,0.6338,0.415],"reach":[0.1895,0.0605,0.7832,0.9199]},"story-celebrate-rest":{"screen":[0.3872,0.406,0.5851,0.565],"reach":[0.2188,0.252,0.7656,0.9199]},"story-comedy-rest":{"screen":[0.335,0.1823,0.6368,0.423],"reach":[0.2012,0.0742,0.7734,0.9199]},"story-daytime-rest":{"screen":[0.361,0.2119,0.6491,0.4408],"reach":[0.2305,0.041,0.7852,0.918]},"story-detective-rest":{"screen":[0.3488,0.2143,0.6197,0.4334],"reach":[0.1348,0.0352,0.75,0.9199]},"story-fiction-rest":{"screen":[0.3566,0.2356,0.6488,0.4637],"reach":[0.2285,0.0293,0.7812,0.9199]},"story-health-rest":{"screen":[0.3684,0.2718,0.636,0.4811],"reach":[0.1641,0.1602,0.8398,0.9199]},"story-history-rest":{"screen":[0.3485,0.1911,0.6494,0.4278],"reach":[0.2168,0.0312,0.7832,0.9199]},"story-morning-rest":{"screen":[0.3398,0.1511,0.657,0.4009],"reach":[0.2031,0.0371,0.7988,0.9199]},"story-news-rest":{"screen":[0.3609,0.2136,0.6451,0.4297],"reach":[0.2344,0.0312,0.7715,0.9199]},"story-night-rest":{"screen":[0.3056,0.2322,0.5692,0.4399],"reach":[0.1895,0.0312,0.7441,0.916]},"story-science-rest":{"screen":[0.3518,0.1918,0.648,0.428],"reach":[0.1836,0.0312,0.7812,0.918]},"story-tech-rest":{"screen":[0.4099,0.2432,0.6818,0.4576],"reach":[0.1895,0.0586,0.8086,0.9121]},"story-trophy-rest":{"screen":[0.4018,0.2294,0.6842,0.4475],"reach":[0.2734,0.127,0.8379,0.9199]}},"sequences":{"welcome":[{"frame":"front-rest","face":"idle","ms":0}],"onboarding-welcome":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"survey-ask":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"survey-notes":{"steps":[{"clip":"notebook"},{"frame":"ta-rest","face":"idle","ms":0}]},"survey-think":{"steps":[{"clip":"think-chin"},{"frame":"t1-rest","face":"curious","ms":0}]},"survey-idea":{"steps":[{"clip":"magnifier"},{"frame":"ta-rest","face":"curious","ms":0}]},"survey-thanks-calm":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"survey-thanks":{"steps":[{"clip":"thumbs-up"},{"frame":"t1-rest","face":"happy","ms":0}]},"survey-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"happy","ms":0}]},"onboarding-question":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-thinking":{"steps":[{"clip":"thumbs-up"}],"loop":[{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"notebook"},{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"think-chin"}]},"onboarding-preparing":{"steps":[{"frame":"t1-rest","face":"idle","ms":400}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":1200},{"clip":"magnifier"},{"frame":"t1-rest","face":"idle","ms":1200}]},"onboarding-presenting":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-ready":{"steps":[{"clip":"cards-fan"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-rest":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"account-cloud":{"steps":[{"frame":"account-cloud","face":"idle","ms":0}]},"account-syncing":{"steps":[{"frame":"account-cloud-hug","face":"idle","ms":0}]},"account-outfit-idle":{"steps":[{"frame":"account-idle","face":"idle","ms":0}]},"account-outfit-comedy":{"steps":[{"frame":"account-comedy","face":"idle","ms":0}]},"account-outfit-truecrime":{"steps":[{"frame":"account-truecrime","face":"idle","ms":0}]},"account-outfit-science":{"steps":[{"frame":"account-science","face":"idle","ms":0}]},"account-outfit-history":{"steps":[{"frame":"account-history","face":"idle","ms":0}]},"account-outfit-business":{"steps":[{"frame":"account-business","face":"idle","ms":0}]},"account-outfit-wellness":{"steps":[{"frame":"account-wellness","face":"idle","ms":0}]},"account-outfit-fiction":{"steps":[{"frame":"account-fiction","face":"idle","ms":0}]},"import-pick":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-working":{"steps":[{"frame":"t1-rest","face":"idle","ms":300}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":900}]},"import-done":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-failed":{"steps":[{"frame":"t1-rest","face":"curious","ms":0}]},"story-hello":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"story-hop":{"steps":[{"clip":"hop"},{"frame":"tb-rest","face":"idle","ms":0}]},"story-business":{"steps":[{"clip":"story-business"},{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy":{"steps":[{"clip":"story-comedy"},{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime":{"steps":[{"clip":"story-daytime"},{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective":{"steps":[{"clip":"story-detective"},{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction":{"steps":[{"clip":"story-fiction"},{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health":{"steps":[{"clip":"story-health"},{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history":{"steps":[{"clip":"story-history"},{"frame":"story-history-rest","face":"idle","ms":0}]},"story-hourglass":{"steps":[{"clip":"story-hourglass"},{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-morning":{"steps":[{"clip":"story-morning"},{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news":{"steps":[{"clip":"story-news"},{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night":{"steps":[{"clip":"story-night"},{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science":{"steps":[{"clip":"story-science"},{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech":{"steps":[{"clip":"story-tech"},{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy":{"steps":[{"clip":"story-trophy"},{"frame":"story-trophy-rest","face":"idle","ms":0}]},"story-entry":{"steps":[{"frame":"front-rest","face":"happy","ms":0}]},"story-hello-still":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"story-hourglass-still":{"steps":[{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-business-still":{"steps":[{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate-still":{"steps":[{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy-still":{"steps":[{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime-still":{"steps":[{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective-still":{"steps":[{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction-still":{"steps":[{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health-still":{"steps":[{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history-still":{"steps":[{"frame":"story-history-rest","face":"idle","ms":0}]},"story-morning-still":{"steps":[{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news-still":{"steps":[{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night-still":{"steps":[{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science-still":{"steps":[{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech-still":{"steps":[{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy-still":{"steps":[{"frame":"story-trophy-rest","face":"idle","ms":0}]}},"clips":{"think-chin":{"file":"clips/think-chin.mp4","fps":24,"frames":107,"size":384,"face":"curious","tracks":[[0.5481,0.4664,0.2174,0.2001,-3.16],[0.5479,0.467,0.2175,0.2,-3.12],[0.5477,0.4676,0.2175,0.2,-3.0],[0.5471,0.4687,0.2175,0.1999,-2.86],[0.5462,0.4696,0.2175,0.1999,-2.68],[0.5451,0.4701,0.2174,0.1999,-2.48],[0.5437,0.47,0.2174,0.1997,-2.19],[0.5423,0.4692,0.2172,0.1997,-1.99],[0.5409,0.4681,0.2171,0.1997,-1.74],[0.5399,0.4669,0.2171,0.1995,-1.51],[0.5391,0.4659,0.217,0.1996,-1.4],[0.5387,0.4652,0.217,0.1995,-1.36],[0.5388,0.4651,0.217,0.1995,-1.37],[0.5393,0.4653,0.217,0.1995,-1.52],[0.5403,0.4659,0.2172,0.1997,-1.71],[0.5417,0.4669,0.2174,0.1996,-1.96],[0.5435,0.4681,0.2175,0.1998,-2.34],[0.5454,0.4693,0.2177,0.1999,-2.72],[0.5476,0.4703,0.2179,0.1999,-3.05],[0.5496,0.4706,0.2183,0.2,-3.31],[0.5516,0.4703,0.2186,0.2,-3.49],[0.5533,0.4694,0.219,0.2001,-3.57],[0.555,0.4682,0.2192,0.2,-3.65],[0.5563,0.467,0.2194,0.2001,-3.71],[0.5573,0.466,0.2194,0.2002,-3.77],[0.558,0.4653,0.2194,0.2003,-3.82],[0.5584,0.4651,0.2192,0.2002,-3.82],[0.5583,0.4652,0.2193,0.2003,-3.78],[0.558,0.4658,0.2191,0.2001,-3.74],[0.5574,0.4667,0.219,0.2001,-3.62],[0.5566,0.468,0.2188,0.2,-3.51],[0.5558,0.4697,0.2185,0.1999,-3.41],[0.555,0.4716,0.2182,0.1997,-3.23],[0.5542,0.4733,0.2181,0.1997,-3.05],[0.5536,0.4748,0.2178,0.1995,-3.0],[0.5528,0.4757,0.2177,0.1994,-2.96],[0.5517,0.4759,0.2177,0.1994,-2.96],[0.5502,0.4752,0.2179,0.1996,-3.07],[0.5483,0.4737,0.2182,0.1997,-3.24],[0.546,0.4716,0.2186,0.1998,-3.46],[0.5434,0.4691,0.2191,0.2001,-3.68],[0.5408,0.4665,0.2198,0.2004,-3.95],[0.5385,0.464,0.2207,0.2006,-4.21],[0.5366,0.462,0.2214,0.2008,-4.43],[0.5351,0.4605,0.2219,0.201,-4.64],[0.5341,0.4594,0.222,0.2011,-4.83],[0.5334,0.4587,0.222,0.2011,-4.95],[0.533,0.4584,0.2218,0.2011,-5.04],[0.5327,0.4582,0.2216,0.201,-5.08],[0.5326,0.4581,0.2215,0.201,-5.08],[0.5327,0.4583,0.2215,0.2011,-5.11],[0.5328,0.4588,0.2215,0.201,-5.07],[0.5331,0.4597,0.2215,0.2011,-5.06],[0.5334,0.4608,0.2215,0.2011,-5.06],[0.534,0.4621,0.2213,0.2011,-5.05],[0.5345,0.4633,0.2211,0.201,-5.06],[0.5349,0.4642,0.2209,0.2011,-5.1],[0.5351,0.4646,0.2207,0.201,-5.1],[0.535,0.4645,0.2207,0.2011,-5.09],[0.5347,0.4638,0.2209,0.2011,-5.07],[0.5342,0.4626,0.2211,0.2011,-5.1],[0.5337,0.4613,0.2212,0.2011,-5.13],[0.5331,0.46,0.2215,0.2011,-5.11],[0.5327,0.4589,0.2216,0.201,-5.12],[0.5326,0.4583,0.2215,0.201,-5.17],[0.5326,0.4582,0.2216,0.201,-5.12],[0.5327,0.4587,0.2217,0.2011,-5.11],[0.533,0.4596,0.2215,0.2012,-5.13],[0.5334,0.4607,0.2213,0.2012,-5.16],[0.5337,0.462,0.2214,0.2012,-5.1],[0.534,0.463,0.2215,0.2011,-5.05],[0.5342,0.4636,0.2215,0.201,-5.01],[0.5342,0.4637,0.2215,0.2011,-5.02],[0.5341,0.4633,0.2215,0.201,-4.99],[0.5339,0.4623,0.2214,0.2012,-5.07],[0.5335,0.4611,0.2213,0.2012,-5.14],[0.5331,0.46,0.2215,0.2012,-5.15],[0.5328,0.4591,0.2216,0.2011,-5.13],[0.5326,0.4584,0.2217,0.2011,-5.13],[0.5326,0.4583,0.2217,0.2011,-5.13],[0.5327,0.4585,0.2216,0.2012,-5.15],[0.533,0.4592,0.2215,0.2012,-5.14],[0.5334,0.4601,0.2214,0.2013,-5.1],[0.5338,0.4612,0.2215,0.2013,-5.09],[0.5341,0.4622,0.2215,0.2013,-5.05],[0.5343,0.4629,0.2217,0.2012,-4.99],[0.5344,0.463,0.2217,0.2012,-4.98],[0.5343,0.4627,0.2217,0.2013,-5.06],[0.5341,0.4619,0.2216,0.2013,-5.1],[0.5338,0.4609,0.2215,0.2013,-5.16],[0.5336,0.4599,0.2214,0.2013,-5.21],[0.5335,0.459,0.2212,0.2013,-5.26],[0.5335,0.4585,0.2211,0.2012,-5.21],[0.5338,0.4582,0.221,0.2011,-5.15],[0.5344,0.4584,0.2209,0.2011,-5.04],[0.5354,0.4588,0.2206,0.201,-4.88],[0.5366,0.4595,0.2204,0.201,-4.66],[0.5381,0.4603,0.2201,0.2008,-4.39],[0.5397,0.4613,0.2197,0.2007,-4.13],[0.5413,0.4623,0.2194,0.2005,-3.8],[0.5429,0.4632,0.2192,0.2004,-3.49],[0.5444,0.4641,0.219,0.2002,-3.22],[0.5457,0.4647,0.2185,0.2002,-3.14],[0.5467,0.4652,0.2183,0.2001,-3.03],[0.5475,0.4655,0.2179,0.2001,-3.02],[0.548,0.4656,0.2177,0.2002,-3.06],[0.5483,0.4657,0.2175,0.2002,-3.12]],"rest":"t1-rest","mask":true,"reach":[0.2917,0.2969,0.7109,0.9219]},"wave":{"file":"clips/wave.mp4","fps":24,"frames":61,"size":384,"face":"happy","tracks":[[0.5483,0.4658,0.2174,0.2001,-3.16],[0.5484,0.4661,0.2174,0.2,-3.11],[0.5488,0.4665,0.2173,0.2,-3.03],[0.5494,0.4671,0.2173,0.1998,-2.84],[0.5504,0.4678,0.2175,0.1997,-2.51],[0.5516,0.4683,0.2176,0.1995,-2.09],[0.553,0.4685,0.2177,0.1995,-1.61],[0.5544,0.4681,0.218,0.1995,-1.01],[0.5558,0.4671,0.2182,0.1996,-0.39],[0.5571,0.4657,0.2183,0.1996,0.2],[0.5583,0.4639,0.2184,0.1997,0.79],[0.5595,0.4621,0.2186,0.1995,1.18],[0.5607,0.4604,0.2184,0.1995,1.63],[0.5617,0.459,0.2182,0.1995,1.98],[0.5626,0.458,0.2181,0.1995,2.17],[0.5632,0.4574,0.2179,0.1993,2.26],[0.5636,0.4571,0.2177,0.1994,2.44],[0.5636,0.4569,0.2178,0.1993,2.45],[0.5632,0.4569,0.2182,0.1993,2.43],[0.5628,0.4568,0.2186,0.1993,2.47],[0.5622,0.4568,0.219,0.1993,2.51],[0.5619,0.4568,0.2194,0.1993,2.51],[0.5618,0.4568,0.2194,0.1993,2.51],[0.5621,0.4568,0.2191,0.1993,2.51],[0.5628,0.4568,0.2184,0.1993,2.51],[0.5637,0.4569,0.2176,0.1993,2.5],[0.5645,0.4569,0.2168,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.48],[0.5645,0.4569,0.2168,0.1994,2.46],[0.5637,0.4569,0.2176,0.1993,2.44],[0.5628,0.4568,0.2183,0.1993,2.41],[0.562,0.4568,0.219,0.1993,2.38],[0.5616,0.4567,0.2193,0.1993,2.35],[0.5616,0.4567,0.2193,0.1993,2.35],[0.562,0.4568,0.2189,0.1993,2.35],[0.5626,0.4568,0.2183,0.1994,2.36],[0.5634,0.4569,0.2177,0.1994,2.38],[0.564,0.4569,0.2172,0.1994,2.4],[0.5643,0.457,0.2169,0.1994,2.4],[0.5642,0.457,0.217,0.1994,2.42],[0.5638,0.457,0.2174,0.1994,2.41],[0.5633,0.4569,0.2179,0.1994,2.4],[0.5627,0.457,0.2183,0.1995,2.38],[0.5621,0.4571,0.2187,0.1996,2.34],[0.5616,0.4574,0.2188,0.1995,2.18],[0.5611,0.4578,0.2188,0.1995,1.99],[0.5605,0.4583,0.2186,0.1996,1.72],[0.5597,0.4591,0.2184,0.1995,1.34],[0.5586,0.46,0.2181,0.1995,0.86],[0.5574,0.4608,0.2178,0.1996,0.37],[0.556,0.4618,0.2176,0.1997,-0.21],[0.5546,0.4627,0.2175,0.1997,-0.83],[0.5531,0.4634,0.2173,0.1998,-1.41],[0.5517,0.4641,0.2174,0.1999,-1.89],[0.5505,0.4646,0.2174,0.1999,-2.33],[0.5496,0.465,0.2173,0.2,-2.67],[0.5489,0.4653,0.2173,0.2,-2.91],[0.5485,0.4655,0.2174,0.2001,-3.05],[0.5484,0.4656,0.2173,0.2001,-3.17],[0.5483,0.4656,0.2173,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"ask":{"file":"clips/ask.mp4","fps":24,"frames":56,"size":384,"face":"idle","tracks":[[0.5483,0.4656,0.2173,0.2001,-3.18],[0.5483,0.4657,0.2174,0.2001,-3.17],[0.5485,0.4661,0.2174,0.2,-3.09],[0.5488,0.4667,0.2176,0.1999,-2.92],[0.5493,0.4675,0.2177,0.1998,-2.71],[0.5501,0.4684,0.218,0.1996,-2.41],[0.5508,0.4694,0.2183,0.1994,-2.01],[0.5516,0.47,0.2186,0.1992,-1.58],[0.5523,0.4702,0.2189,0.1992,-1.18],[0.5528,0.4698,0.2194,0.1993,-0.69],[0.5531,0.4692,0.2198,0.1993,-0.24],[0.5534,0.4684,0.2202,0.1993,0.11],[0.5535,0.4676,0.2205,0.1993,0.38],[0.5536,0.4671,0.2208,0.1992,0.59],[0.5536,0.4667,0.2209,0.1991,0.68],[0.5536,0.4665,0.221,0.1992,0.73],[0.5536,0.4664,0.221,0.1993,0.76],[0.5536,0.4662,0.221,0.1995,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.79],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.2209,0.1997,0.76],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5535,0.4661,0.2209,0.1997,0.75],[0.5535,0.4662,0.2209,0.1995,0.71],[0.5534,0.4664,0.2209,0.1994,0.68],[0.5533,0.4665,0.2209,0.1992,0.63],[0.5531,0.4666,0.2208,0.199,0.52],[0.5529,0.4668,0.2207,0.1988,0.34],[0.5526,0.4668,0.2204,0.199,0.1],[0.5522,0.4667,0.2201,0.1992,-0.23],[0.5518,0.4667,0.2197,0.1993,-0.62],[0.5513,0.4666,0.2194,0.1995,-1.02],[0.5508,0.4665,0.219,0.1997,-1.41],[0.5503,0.4664,0.2186,0.1997,-1.75],[0.5499,0.4664,0.2184,0.1996,-2.03],[0.5494,0.4663,0.2181,0.1997,-2.35],[0.549,0.4661,0.2179,0.1997,-2.61],[0.5487,0.466,0.2177,0.1998,-2.83],[0.5484,0.4659,0.2176,0.1999,-3.01],[0.5483,0.4658,0.2176,0.2,-3.15],[0.5482,0.4657,0.2176,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"thumbs-up":{"file":"clips/thumbs-up.mp4","fps":24,"frames":59,"size":384,"face":"happy","tracks":[[0.5483,0.4657,0.2179,0.2001,-3.13],[0.5483,0.4657,0.2178,0.2001,-3.13],[0.5484,0.4659,0.2178,0.2,-3.07],[0.5486,0.4664,0.2178,0.2,-2.97],[0.5492,0.4676,0.2179,0.1999,-2.77],[0.5502,0.4695,0.218,0.1998,-2.47],[0.5515,0.4722,0.2181,0.1996,-2.02],[0.5532,0.4751,0.2184,0.1996,-1.5],[0.5549,0.4778,0.2186,0.1995,-0.9],[0.5564,0.4792,0.2189,0.1993,-0.35],[0.5572,0.4787,0.219,0.199,0.04],[0.5573,0.4757,0.2191,0.1992,0.21],[0.5567,0.4713,0.219,0.1993,0.15],[0.5556,0.4661,0.2189,0.1994,-0.11],[0.5542,0.4611,0.2187,0.1996,-0.47],[0.5529,0.4572,0.2186,0.1998,-0.77],[0.5519,0.4547,0.2185,0.1997,-1.01],[0.5513,0.4534,0.2184,0.1996,-1.13],[0.5511,0.4528,0.2183,0.1995,-1.17],[0.551,0.4527,0.2183,0.1995,-1.15],[0.5515,0.4549,0.2184,0.1994,-1.04],[0.5523,0.4588,0.2185,0.1995,-0.85],[0.5534,0.4638,0.2187,0.1996,-0.65],[0.5548,0.4689,0.2189,0.1998,-0.31],[0.5563,0.4738,0.2192,0.1999,0.1],[0.5575,0.4755,0.2192,0.2001,0.47],[0.5584,0.4747,0.2193,0.2003,0.78],[0.559,0.4723,0.2192,0.2005,1.02],[0.5594,0.4691,0.219,0.2006,1.14],[0.5595,0.4662,0.2188,0.2006,1.17],[0.5597,0.4645,0.2186,0.2005,1.13],[0.5599,0.4641,0.2184,0.2004,1.11],[0.5603,0.4647,0.2182,0.2003,1.15],[0.5608,0.4663,0.2181,0.2002,1.18],[0.5613,0.4679,0.218,0.2001,1.27],[0.5619,0.4692,0.2179,0.2002,1.37],[0.5623,0.47,0.2179,0.2,1.43],[0.5626,0.4701,0.2179,0.1999,1.49],[0.5628,0.4696,0.2178,0.2,1.53],[0.5628,0.469,0.2178,0.1999,1.53],[0.5627,0.4684,0.2178,0.1999,1.52],[0.5627,0.4679,0.2178,0.2,1.52],[0.5626,0.4677,0.2178,0.2,1.48],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.48],[0.5625,0.4676,0.2178,0.1999,1.45],[0.5622,0.4677,0.2178,0.1999,1.36],[0.5616,0.4678,0.2178,0.1999,1.17],[0.5605,0.4679,0.2178,0.2,0.8],[0.5585,0.468,0.218,0.1999,0.26],[0.5557,0.468,0.2184,0.1999,-0.54],[0.5521,0.4678,0.2192,0.1999,-1.46],[0.5476,0.4675,0.2204,0.2,-2.42]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"notebook":{"file":"clips/notebook.mp4","fps":24,"frames":107,"size":384,"face":"idle","tracks":[[0.5478,0.4639,0.2178,0.2054,-3.5],[0.5478,0.4639,0.2179,0.2054,-3.5],[0.5479,0.464,0.2179,0.2055,-3.51],[0.5481,0.4643,0.2179,0.2055,-3.47],[0.5484,0.465,0.218,0.2055,-3.36],[0.5491,0.4659,0.2178,0.2054,-3.3],[0.5506,0.4678,0.2174,0.2051,-3.13],[0.5526,0.4703,0.2168,0.2045,-2.9],[0.555,0.4733,0.2159,0.2035,-2.67],[0.5578,0.4765,0.2148,0.2026,-2.45],[0.5605,0.4798,0.2138,0.2016,-2.13],[0.5625,0.4823,0.213,0.2006,-1.89],[0.5641,0.4844,0.2124,0.1999,-1.66],[0.5651,0.4858,0.2123,0.1994,-1.41],[0.5655,0.4869,0.2129,0.199,-1.37],[0.5655,0.4877,0.2137,0.1988,-1.19],[0.5653,0.4884,0.2148,0.1987,-0.86],[0.5647,0.4893,0.2166,0.1986,-0.73],[0.5642,0.4903,0.2183,0.1985,-0.43],[0.5636,0.4915,0.2202,0.1982,-0.05],[0.563,0.493,0.2221,0.1978,0.22],[0.5626,0.4945,0.2241,0.1973,0.51],[0.5626,0.4958,0.2253,0.1964,1.1],[0.5625,0.4969,0.2266,0.1957,1.5],[0.5627,0.4976,0.2271,0.195,1.96],[0.5629,0.498,0.2274,0.1944,2.35],[0.563,0.4982,0.2276,0.194,2.58],[0.563,0.4983,0.2277,0.1938,2.66],[0.5631,0.4983,0.2278,0.1938,2.65],[0.5634,0.4983,0.2278,0.1937,2.73],[0.5638,0.4983,0.2274,0.1936,2.76],[0.564,0.4983,0.2274,0.1936,2.76],[0.564,0.4984,0.2275,0.1937,2.7],[0.5636,0.4984,0.228,0.1938,2.68],[0.5629,0.4985,0.2285,0.194,2.59],[0.562,0.4985,0.2294,0.194,2.56],[0.5617,0.4985,0.2296,0.1941,2.57],[0.5618,0.4985,0.2294,0.1939,2.69],[0.5627,0.4984,0.2287,0.1938,2.78],[0.5637,0.4984,0.2278,0.1937,2.81],[0.5642,0.4985,0.2272,0.1938,2.76],[0.564,0.4986,0.2274,0.1939,2.71],[0.5635,0.4987,0.2278,0.1941,2.62],[0.5626,0.4987,0.2285,0.1942,2.59],[0.5617,0.4986,0.2293,0.1941,2.63],[0.5613,0.4985,0.2297,0.1939,2.72],[0.5612,0.4985,0.2298,0.1939,2.73],[0.5613,0.4986,0.2298,0.1939,2.69],[0.5616,0.4987,0.2294,0.1941,2.55],[0.562,0.4989,0.2291,0.1944,2.38],[0.5623,0.499,0.2286,0.1947,2.24],[0.5626,0.4991,0.2282,0.1949,2.14],[0.5625,0.4991,0.2282,0.1949,2.12],[0.5617,0.4991,0.2291,0.1948,2.19],[0.5608,0.4991,0.2298,0.1949,2.26],[0.5594,0.4991,0.231,0.195,2.25],[0.5579,0.4992,0.2321,0.1952,2.33],[0.5564,0.4994,0.2332,0.1955,2.27],[0.5553,0.4995,0.2338,0.1957,2.21],[0.5545,0.4995,0.2347,0.1957,2.15],[0.554,0.4995,0.235,0.1957,2.15],[0.5537,0.4995,0.2354,0.1957,2.04],[0.5537,0.4995,0.2354,0.1958,2.0],[0.5537,0.4996,0.2354,0.1959,1.94],[0.5537,0.4996,0.2355,0.1959,1.9],[0.5537,0.4996,0.2355,0.196,1.87],[0.5538,0.4997,0.2356,0.196,1.85],[0.5538,0.4997,0.2356,0.196,1.84],[0.5538,0.4997,0.2356,0.1961,1.82],[0.5538,0.4997,0.2356,0.1962,1.77],[0.5538,0.4998,0.2356,0.1963,1.71],[0.5537,0.4998,0.2356,0.1963,1.67],[0.5537,0.4998,0.2356,0.1964,1.62],[0.5537,0.4996,0.2356,0.1965,1.57],[0.5536,0.4992,0.2356,0.1967,1.49],[0.5536,0.4985,0.2355,0.197,1.41],[0.5535,0.4977,0.2353,0.1974,1.3],[0.5535,0.4966,0.235,0.1978,1.19],[0.5537,0.4954,0.234,0.1981,1.27],[0.5538,0.4943,0.233,0.1985,1.27],[0.554,0.4933,0.2319,0.1989,1.18],[0.5542,0.4924,0.2309,0.1993,1.02],[0.5544,0.4916,0.2299,0.1996,0.79],[0.5546,0.4906,0.229,0.1999,0.59],[0.5548,0.4892,0.2279,0.2002,0.39],[0.5548,0.4873,0.2266,0.2007,0.12],[0.5546,0.4848,0.2251,0.2013,-0.17],[0.5542,0.4817,0.2235,0.202,-0.49],[0.5534,0.4782,0.2223,0.2029,-1.09],[0.5524,0.4749,0.2213,0.2037,-1.69],[0.5514,0.4718,0.2204,0.2044,-2.2],[0.5504,0.4691,0.2198,0.2049,-2.63],[0.5494,0.467,0.2192,0.2052,-2.95],[0.5487,0.4655,0.2188,0.2055,-3.19],[0.5482,0.4645,0.2185,0.2056,-3.38],[0.5479,0.4639,0.2183,0.2057,-3.5],[0.5477,0.4636,0.2182,0.2058,-3.57],[0.5477,0.4635,0.2182,0.2059,-3.62],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4635,0.2182,0.2059,-3.61],[0.5479,0.464,0.2183,0.2058,-3.52]],"rest":"ta-rest","mask":true,"reach":[0.2578,0.2917,0.7474,0.9297]},"magnifier":{"file":"clips/magnifier.mp4","fps":24,"frames":116,"size":384,"face":"curious","tracks":[[0.5479,0.464,0.2183,0.2058,-3.52],[0.5483,0.4648,0.2186,0.2057,-3.34],[0.5489,0.4663,0.2192,0.2053,-3.06],[0.5494,0.4677,0.2197,0.205,-2.78],[0.5501,0.4699,0.2208,0.2046,-2.38],[0.5506,0.4724,0.2219,0.2041,-1.91],[0.5507,0.4748,0.2232,0.2035,-1.49],[0.5503,0.4767,0.2246,0.2032,-1.17],[0.5495,0.4782,0.2262,0.2029,-0.82],[0.5482,0.4782,0.2275,0.203,-0.65],[0.5466,0.4769,0.2286,0.2032,-0.63],[0.5449,0.4734,0.2295,0.2016,-0.74],[0.5433,0.4698,0.2301,0.2015,-0.64],[0.5419,0.4663,0.2304,0.2015,-0.28],[0.5408,0.4631,0.2305,0.2017,-0.54],[0.5399,0.4605,0.2305,0.202,-0.68],[0.5393,0.4597,0.2304,0.2043,-0.74],[0.539,0.459,0.2304,0.2049,-1.0],[0.5388,0.4585,0.2304,0.2055,-1.56],[0.5387,0.4584,0.2304,0.2056,-1.47],[0.5387,0.4584,0.2304,0.2056,-1.46],[0.5387,0.4584,0.2304,0.2056,-1.45],[0.5386,0.4583,0.2304,0.2055,-1.48],[0.5385,0.4583,0.2305,0.2055,-1.51],[0.538,0.4583,0.2309,0.2054,-1.47],[0.5369,0.4582,0.2317,0.2053,-1.33],[0.5349,0.458,0.2329,0.2052,-1.21],[0.533,0.458,0.2339,0.2051,-1.07],[0.5295,0.4578,0.2362,0.205,-0.73],[0.5258,0.4576,0.2387,0.2049,-0.44],[0.5219,0.4575,0.2411,0.2049,-0.2],[0.5189,0.4574,0.2431,0.2049,0.07],[0.5148,0.4573,0.2456,0.2049,0.38],[0.5116,0.4573,0.2467,0.205,0.51],[0.5086,0.4573,0.2471,0.2052,0.68],[0.5063,0.4574,0.2471,0.2054,0.84],[0.5034,0.4575,0.2472,0.2065,0.91],[0.5014,0.4576,0.2467,0.2077,0.89],[0.5,0.4577,0.2465,0.2089,0.84],[0.4991,0.4578,0.2463,0.2099,0.73],[0.4983,0.4578,0.2464,0.2109,0.58],[0.4981,0.4578,0.2464,0.2111,0.52],[0.4981,0.4578,0.2466,0.2111,0.51],[0.4981,0.4578,0.2467,0.211,0.5],[0.4982,0.4578,0.2467,0.211,0.47],[0.4984,0.4578,0.2468,0.211,0.47],[0.4991,0.4578,0.247,0.211,0.43],[0.4998,0.4579,0.2471,0.211,0.39],[0.5018,0.4579,0.2472,0.21,0.41],[0.5045,0.4578,0.2471,0.2088,0.42],[0.5079,0.4576,0.2465,0.2076,0.4],[0.511,0.4575,0.2461,0.2064,0.37],[0.5161,0.4572,0.2446,0.2049,0.14],[0.5209,0.457,0.2427,0.2043,-0.33],[0.5258,0.4571,0.24,0.2041,-0.8],[0.5298,0.4571,0.2376,0.204,-1.11],[0.5353,0.4572,0.2331,0.2043,-1.62],[0.5395,0.4574,0.229,0.2052,-1.94],[0.5429,0.4576,0.2241,0.2061,-1.69],[0.5453,0.4577,0.2202,0.207,-1.41],[0.5484,0.4578,0.2158,0.2078,-1.34],[0.5499,0.4579,0.2132,0.2082,-1.07],[0.5509,0.458,0.2112,0.2084,-0.84],[0.5516,0.458,0.2106,0.2085,-0.96],[0.5526,0.458,0.2097,0.2085,-1.17],[0.5533,0.4579,0.2092,0.2084,-1.43],[0.5541,0.4579,0.2086,0.2083,-1.66],[0.5548,0.4578,0.2079,0.2083,-1.9],[0.5556,0.4578,0.2072,0.2082,-2.18],[0.5563,0.4577,0.2067,0.2081,-2.39],[0.5566,0.4576,0.2063,0.2081,-2.49],[0.5567,0.4576,0.2061,0.2082,-2.57],[0.5568,0.4575,0.206,0.2082,-2.6],[0.5568,0.4574,0.2058,0.2082,-2.63],[0.5569,0.4573,0.2056,0.2083,-2.66],[0.5569,0.4572,0.2055,0.2083,-2.69],[0.557,0.4572,0.2053,0.2083,-2.72],[0.557,0.4571,0.2052,0.2084,-2.75],[0.5571,0.457,0.205,0.2084,-2.78],[0.5571,0.4569,0.2049,0.2084,-2.81],[0.5572,0.4568,0.2047,0.2084,-2.84],[0.5572,0.4568,0.2046,0.2085,-2.87],[0.5573,0.4567,0.2044,0.2085,-2.9],[0.5573,0.4566,0.2043,0.2085,-2.93],[0.5574,0.4565,0.2041,0.2086,-2.96],[0.5574,0.4565,0.2039,0.2086,-2.99],[0.5568,0.4564,0.2047,0.2087,-2.9],[0.5556,0.4564,0.2059,0.2088,-2.65],[0.5543,0.4563,0.2071,0.209,-2.39],[0.5528,0.4563,0.2082,0.2092,-2.11],[0.5514,0.4563,0.2094,0.2094,-1.81],[0.5506,0.4563,0.2097,0.2095,-1.63],[0.5504,0.4563,0.2096,0.2096,-1.58],[0.5504,0.4562,0.2097,0.2096,-1.64],[0.5506,0.4558,0.21,0.2084,-1.68],[0.5509,0.4551,0.2107,0.2066,-1.91],[0.5514,0.4542,0.2116,0.204,-2.58],[0.5518,0.4544,0.2124,0.2032,-2.91],[0.5523,0.455,0.2131,0.2024,-3.07],[0.5528,0.4565,0.2139,0.2028,-3.24],[0.5532,0.4585,0.2146,0.2038,-3.15],[0.5536,0.4613,0.2152,0.2056,-2.56],[0.5539,0.4633,0.2159,0.2053,-2.29],[0.5542,0.4651,0.2165,0.2052,-2.07],[0.5543,0.4666,0.217,0.2049,-1.84],[0.5543,0.4678,0.2175,0.2047,-1.7],[0.5541,0.4683,0.2178,0.2047,-1.69],[0.5537,0.4684,0.2179,0.2047,-1.78],[0.553,0.4681,0.2181,0.2047,-1.94],[0.5521,0.4674,0.2183,0.2049,-2.1],[0.5511,0.4665,0.2185,0.2052,-2.33],[0.5502,0.4657,0.2185,0.2053,-2.56],[0.5493,0.465,0.2186,0.2054,-2.78],[0.5486,0.4644,0.2187,0.2055,-2.97],[0.5482,0.464,0.2185,0.2056,-3.18],[0.548,0.4638,0.2184,0.2056,-3.31]],"rest":"ta-rest","mask":true,"reach":[0.263,0.2943,0.7188,0.9297]},"cards-shuffle":{"file":"clips/cards-shuffle.mp4","fps":24,"frames":119,"size":384,"face":"curious","tracks":[[0.5488,0.4655,0.2176,0.207,-3.34],[0.5493,0.466,0.2175,0.2068,-3.25],[0.5502,0.4672,0.2172,0.2066,-3.17],[0.5517,0.4691,0.2168,0.2061,-3.02],[0.5538,0.4721,0.2161,0.2052,-2.8],[0.5566,0.4758,0.2148,0.2042,-2.59],[0.5597,0.4802,0.2135,0.2028,-2.3],[0.5628,0.4848,0.2122,0.2012,-1.97],[0.5657,0.4893,0.2109,0.1994,-1.71],[0.5682,0.4932,0.2096,0.1979,-1.44],[0.5702,0.4961,0.2084,0.1964,-1.27],[0.5715,0.498,0.2078,0.1954,-1.26],[0.5719,0.4988,0.2077,0.1949,-1.3],[0.5719,0.4987,0.2079,0.1951,-1.22],[0.5714,0.4977,0.2084,0.1957,-1.11],[0.5704,0.4962,0.2096,0.1966,-0.96],[0.569,0.4943,0.2111,0.1977,-0.67],[0.5675,0.4921,0.2126,0.1989,-0.23],[0.5661,0.4899,0.214,0.2001,0.26],[0.5644,0.4878,0.2157,0.2012,0.64],[0.563,0.486,0.2171,0.2022,1.02],[0.5617,0.4845,0.2182,0.203,1.34],[0.5608,0.4834,0.219,0.2035,1.51],[0.56,0.4827,0.2199,0.2039,1.52],[0.5595,0.4822,0.2204,0.2041,1.52],[0.5594,0.482,0.2205,0.2042,1.56],[0.5596,0.482,0.2205,0.2043,1.63],[0.5602,0.482,0.2201,0.2044,1.84],[0.5611,0.4822,0.2198,0.2044,2.02],[0.5624,0.4825,0.219,0.2045,2.32],[0.5638,0.4829,0.2182,0.2046,2.5],[0.5654,0.4832,0.2173,0.2045,2.66],[0.5667,0.4834,0.2166,0.2042,2.73],[0.5679,0.4835,0.2157,0.2039,2.86],[0.5688,0.4835,0.2153,0.2035,2.9],[0.5693,0.4833,0.2149,0.203,3.03],[0.5696,0.4832,0.2148,0.2028,3.11],[0.5696,0.4833,0.2149,0.2029,3.07],[0.5692,0.4835,0.2152,0.2032,3.07],[0.5685,0.4837,0.2156,0.2036,3.17],[0.5673,0.4838,0.2165,0.2038,3.2],[0.5657,0.484,0.2177,0.2041,3.17],[0.5638,0.484,0.2191,0.2041,3.2],[0.5619,0.484,0.2203,0.204,3.16],[0.5601,0.484,0.2217,0.2039,2.95],[0.5586,0.484,0.2228,0.2039,2.75],[0.5574,0.4839,0.2237,0.2039,2.54],[0.5564,0.4839,0.2242,0.2039,2.37],[0.5552,0.4837,0.2249,0.2039,1.98],[0.5535,0.4835,0.2255,0.2038,1.54],[0.551,0.483,0.2261,0.2038,0.97],[0.5476,0.4824,0.2269,0.204,0.4],[0.5433,0.4818,0.2281,0.2047,-0.26],[0.5386,0.4812,0.2293,0.2057,-0.85],[0.5338,0.4805,0.2306,0.2065,-1.45],[0.5293,0.48,0.2319,0.2076,-1.93],[0.5257,0.4796,0.2328,0.2089,-2.36],[0.5227,0.4795,0.2335,0.2103,-2.66],[0.5205,0.4796,0.234,0.2118,-2.82],[0.5191,0.4799,0.2342,0.2133,-2.95],[0.5182,0.4801,0.2344,0.2146,-3.02],[0.5176,0.4803,0.2346,0.2153,-3.06],[0.5174,0.4803,0.2347,0.2154,-3.11],[0.5173,0.4803,0.2347,0.2154,-3.14],[0.5174,0.4804,0.2347,0.2154,-3.07],[0.5174,0.4806,0.2347,0.2154,-3.0],[0.5175,0.4808,0.2347,0.2152,-2.93],[0.5176,0.4811,0.2347,0.215,-2.88],[0.5178,0.4814,0.2347,0.2142,-2.97],[0.5179,0.4813,0.2348,0.2121,-3.39],[0.5179,0.4811,0.2349,0.2094,-4.02],[0.5176,0.4798,0.2351,0.2052,-4.88],[0.5167,0.477,0.2354,0.1996,-5.94],[0.5153,0.473,0.2355,0.1935,-7.04],[0.5133,0.4683,0.2358,0.1885,-7.87],[0.5109,0.4628,0.236,0.184,-8.56],[0.5083,0.4578,0.2362,0.1816,-9.05],[0.506,0.4539,0.2364,0.1811,-9.31],[0.504,0.451,0.2367,0.1818,-9.47],[0.5026,0.4489,0.237,0.1824,-9.55],[0.5017,0.4477,0.2371,0.1833,-9.63],[0.5014,0.4471,0.2371,0.1835,-9.69],[0.5014,0.4468,0.2371,0.1831,-9.76],[0.5016,0.4468,0.2371,0.1824,-9.79],[0.5019,0.447,0.237,0.1819,-9.84],[0.5025,0.4474,0.237,0.1813,-9.77],[0.5033,0.4482,0.237,0.1811,-9.49],[0.5045,0.4495,0.237,0.1817,-8.86],[0.5064,0.452,0.2371,0.184,-7.68],[0.5088,0.4559,0.2373,0.188,-6.15],[0.5116,0.4609,0.2377,0.1931,-4.66],[0.5147,0.4664,0.2383,0.198,-3.12],[0.5176,0.472,0.2392,0.2019,-1.61],[0.52,0.4774,0.2402,0.2041,-0.41],[0.5218,0.4819,0.2412,0.2044,0.57],[0.5229,0.4859,0.2423,0.2035,1.67],[0.5234,0.4896,0.2432,0.2025,2.59],[0.5234,0.4926,0.2439,0.2017,3.28],[0.5233,0.4949,0.2445,0.2011,3.76],[0.5232,0.4965,0.2448,0.2006,4.09],[0.5231,0.4974,0.245,0.2004,4.28],[0.5231,0.4978,0.2451,0.2004,4.34],[0.5233,0.4978,0.2452,0.2004,4.31],[0.5238,0.4974,0.2451,0.2005,4.22],[0.5248,0.4966,0.2447,0.2007,3.98],[0.5262,0.4952,0.244,0.201,3.56],[0.5283,0.4933,0.2428,0.2015,3.08],[0.5309,0.4907,0.2411,0.2021,2.44],[0.5338,0.4876,0.2388,0.2028,1.63],[0.5368,0.4841,0.236,0.2036,0.77],[0.5397,0.4805,0.2327,0.2044,-0.07],[0.5423,0.4768,0.2293,0.205,-0.92],[0.5445,0.4733,0.2259,0.2056,-1.74],[0.5462,0.4704,0.2229,0.2061,-2.38],[0.5474,0.4682,0.2206,0.2064,-2.87],[0.5482,0.4667,0.2189,0.2066,-3.2],[0.5486,0.4658,0.2179,0.2068,-3.41],[0.5487,0.4654,0.2175,0.2069,-3.48],[0.5488,0.4653,0.2173,0.207,-3.52]],"rest":"tb-rest","mask":true,"reach":[0.276,0.2891,0.7865,0.9323]},"cards-fan":{"file":"clips/cards-fan.mp4","fps":24,"frames":137,"size":384,"face":"happy","tracks":[[0.5487,0.4654,0.2173,0.207,-3.52],[0.5488,0.4656,0.2174,0.207,-3.5],[0.549,0.4663,0.2175,0.2067,-3.46],[0.5495,0.4675,0.2177,0.2066,-3.38],[0.55,0.4695,0.218,0.2062,-3.25],[0.5506,0.4724,0.2184,0.2057,-3.08],[0.5508,0.4757,0.2189,0.205,-2.89],[0.5505,0.4792,0.2197,0.2043,-2.74],[0.5494,0.4826,0.2207,0.2034,-2.7],[0.5473,0.4857,0.222,0.2027,-2.77],[0.5443,0.4879,0.2237,0.2022,-2.97],[0.5405,0.4894,0.2256,0.2019,-3.15],[0.5363,0.4902,0.2276,0.2019,-3.5],[0.5319,0.4902,0.2298,0.2021,-3.82],[0.5276,0.4895,0.2319,0.2025,-4.06],[0.5235,0.4884,0.2339,0.2031,-4.16],[0.5199,0.4868,0.2356,0.2036,-4.3],[0.5165,0.4849,0.2373,0.2043,-4.2],[0.5133,0.4826,0.2388,0.205,-4.03],[0.5105,0.4801,0.2401,0.2057,-3.79],[0.5079,0.4775,0.2412,0.2063,-3.54],[0.5055,0.4745,0.2422,0.2063,-3.34],[0.5035,0.4711,0.243,0.205,-3.04],[0.5019,0.4677,0.2436,0.2027,-2.72],[0.5006,0.4646,0.2439,0.2,-2.45],[0.4996,0.4619,0.2442,0.1972,-2.22],[0.499,0.4599,0.2443,0.195,-1.94],[0.4986,0.4588,0.2443,0.1939,-1.82],[0.4983,0.4584,0.2443,0.1936,-1.74],[0.4982,0.4582,0.2443,0.1935,-1.7],[0.4981,0.4582,0.2442,0.1935,-1.65],[0.498,0.4579,0.2442,0.1931,-1.58],[0.4979,0.4575,0.2443,0.1924,-1.55],[0.4978,0.4569,0.2443,0.1915,-1.61],[0.4976,0.4562,0.2443,0.1905,-1.76],[0.4974,0.4556,0.2443,0.1895,-1.98],[0.4972,0.4551,0.2443,0.1889,-2.26],[0.4971,0.4548,0.2443,0.1887,-2.51],[0.4969,0.4546,0.2443,0.1887,-2.68],[0.4968,0.4546,0.2443,0.1887,-2.75],[0.4968,0.4546,0.2443,0.1887,-2.72],[0.4967,0.4545,0.2443,0.1887,-2.57],[0.4967,0.4545,0.2443,0.1887,-2.29],[0.4967,0.4546,0.2443,0.1889,-1.92],[0.4967,0.4547,0.2443,0.1891,-1.52],[0.4967,0.4549,0.2443,0.1894,-1.16],[0.4968,0.4552,0.2443,0.1896,-0.91],[0.4969,0.4555,0.2443,0.1895,-0.77],[0.497,0.456,0.2443,0.1889,-0.67],[0.4973,0.4563,0.2444,0.1878,-0.6],[0.4976,0.4564,0.2444,0.1859,-0.51],[0.4979,0.4565,0.2445,0.1842,-0.4],[0.4982,0.4564,0.2446,0.1826,-0.29],[0.4985,0.4561,0.2446,0.1815,-0.22],[0.4987,0.4559,0.2446,0.1809,-0.15],[0.4988,0.4558,0.2446,0.181,-0.11],[0.4989,0.4557,0.2446,0.1811,-0.07],[0.4989,0.4557,0.2446,0.1812,-0.02],[0.499,0.4556,0.2446,0.1813,0.02],[0.4991,0.4555,0.2446,0.1814,0.06],[0.4992,0.4555,0.2446,0.1815,0.1],[0.4993,0.4554,0.2446,0.1816,0.15],[0.4994,0.4554,0.2446,0.1817,0.19],[0.4995,0.4553,0.2446,0.1818,0.23],[0.4996,0.4552,0.2446,0.1819,0.28],[0.4997,0.4552,0.2446,0.182,0.32],[0.4998,0.4551,0.2446,0.1821,0.36],[0.4998,0.455,0.2446,0.1822,0.4],[0.4999,0.455,0.2446,0.1823,0.45],[0.5,0.4549,0.2446,0.1824,0.49],[0.5001,0.4549,0.2446,0.1825,0.53],[0.5002,0.4548,0.2446,0.1826,0.58],[0.5003,0.4547,0.2446,0.1827,0.62],[0.5004,0.4547,0.2446,0.1828,0.66],[0.5005,0.4546,0.2446,0.1829,0.7],[0.5006,0.4545,0.2446,0.183,0.75],[0.5007,0.4545,0.2447,0.1831,0.79],[0.5007,0.4544,0.2447,0.1832,0.83],[0.5008,0.4544,0.2447,0.1833,0.88],[0.5009,0.4543,0.2447,0.1834,0.92],[0.501,0.4542,0.2447,0.1835,0.96],[0.5011,0.4542,0.2447,0.1836,1.01],[0.5012,0.4541,0.2447,0.1837,1.05],[0.5013,0.454,0.2447,0.1838,1.09],[0.5014,0.454,0.2447,0.1839,1.13],[0.5015,0.4539,0.2447,0.184,1.18],[0.5015,0.4538,0.2447,0.1841,1.22],[0.5016,0.4538,0.2447,0.1842,1.26],[0.5017,0.4537,0.2447,0.1843,1.31],[0.5018,0.4537,0.2447,0.1844,1.35],[0.5019,0.4536,0.2447,0.1845,1.39],[0.502,0.4535,0.2447,0.1846,1.43],[0.5021,0.4535,0.2447,0.1847,1.48],[0.5022,0.4534,0.2447,0.1848,1.52],[0.5023,0.4533,0.2447,0.1849,1.56],[0.5024,0.4533,0.2447,0.185,1.61],[0.5024,0.4532,0.2447,0.1851,1.65],[0.5025,0.4532,0.2447,0.1852,1.69],[0.5026,0.4531,0.2448,0.1853,1.73],[0.5027,0.453,0.2448,0.1854,1.78],[0.5028,0.453,0.2448,0.1855,1.82],[0.5029,0.4529,0.2448,0.1856,1.86],[0.503,0.4528,0.2448,0.1857,1.91],[0.5031,0.4528,0.2448,0.1858,1.95],[0.503,0.4543,0.2448,0.1888,1.89],[0.5025,0.4571,0.2449,0.1929,1.78],[0.5016,0.4604,0.245,0.1973,1.58],[0.5001,0.4644,0.2452,0.2018,1.4],[0.498,0.4691,0.2453,0.206,1.23],[0.4955,0.473,0.2453,0.2071,1.17],[0.4928,0.4764,0.2451,0.2069,1.2],[0.4901,0.4798,0.2447,0.2062,1.35],[0.4877,0.4829,0.2442,0.2054,1.53],[0.4857,0.4855,0.2436,0.2047,1.71],[0.4842,0.4875,0.2431,0.204,1.89],[0.4834,0.489,0.2428,0.2036,2.07],[0.4831,0.49,0.2426,0.2032,2.23],[0.4832,0.4908,0.2425,0.203,2.35],[0.4838,0.4915,0.2427,0.2028,2.49],[0.485,0.492,0.243,0.2027,2.63],[0.4868,0.4922,0.2434,0.2026,2.68],[0.4894,0.4922,0.2439,0.2025,2.64],[0.4931,0.4918,0.2445,0.2027,2.5],[0.4979,0.491,0.2452,0.2028,2.38],[0.5034,0.4897,0.2452,0.2032,1.97],[0.5094,0.4879,0.2447,0.2037,1.47],[0.5157,0.4857,0.2435,0.2041,0.9],[0.5219,0.4831,0.2414,0.2045,0.27],[0.5275,0.4803,0.2381,0.2052,-0.53],[0.5328,0.4773,0.2345,0.2057,-1.18],[0.5374,0.4743,0.2306,0.2061,-1.79],[0.5414,0.4715,0.2267,0.2066,-2.33],[0.5443,0.4691,0.2234,0.2069,-2.73],[0.5464,0.4673,0.2208,0.207,-2.99],[0.5476,0.4662,0.2191,0.207,-3.16],[0.5483,0.4656,0.2182,0.2071,-3.22],[0.5485,0.4653,0.2178,0.2071,-3.22]],"rest":"tb-rest","mask":true,"reach":[0.2682,0.2917,0.7578,0.9323]},"hop":{"file":"clips/hop.mp4","fps":24,"frames":40,"size":384,"face":"happy","tracks":[[0.5489,0.4667,0.2178,0.2067,-3.27],[0.5499,0.4699,0.218,0.2066,-3.11],[0.5516,0.4754,0.2182,0.206,-2.87],[0.5538,0.4831,0.2185,0.2049,-2.57],[0.5563,0.4925,0.2189,0.2037,-2.17],[0.5585,0.5025,0.2195,0.2014,-1.68],[0.5599,0.5111,0.2203,0.1987,-1.5],[0.56,0.5169,0.2215,0.1963,-1.43],[0.5587,0.5176,0.2225,0.1941,-1.36],[0.5544,0.5053,0.2231,0.1938,-1.44],[0.548,0.4848,0.2236,0.1953,-1.77],[0.54,0.459,0.2237,0.198,-2.21],[0.5311,0.4315,0.2238,0.2004,-2.87],[0.5219,0.4055,0.224,0.2028,-3.76],[0.5151,0.3902,0.2246,0.2036,-4.66],[0.51,0.3812,0.2252,0.2034,-5.45],[0.5068,0.3769,0.2257,0.2024,-6.0],[0.5051,0.3751,0.2259,0.2018,-6.29],[0.5046,0.3746,0.2259,0.2013,-6.43],[0.5046,0.3748,0.2258,0.2011,-6.53],[0.5047,0.3756,0.2258,0.201,-6.56],[0.505,0.3777,0.2258,0.2006,-6.54],[0.5057,0.3838,0.2256,0.2005,-6.58],[0.5071,0.3949,0.2254,0.2004,-6.59],[0.5105,0.4089,0.2251,0.2004,-6.43],[0.5159,0.4252,0.2248,0.2014,-6.09],[0.5226,0.4428,0.2243,0.2029,-5.66],[0.5302,0.4582,0.2236,0.2042,-4.95],[0.538,0.4696,0.2227,0.2054,-4.13],[0.5444,0.4785,0.2219,0.2062,-3.34],[0.5491,0.4843,0.2208,0.2057,-2.7],[0.5522,0.4865,0.2199,0.2052,-2.22],[0.5536,0.4857,0.2193,0.2049,-2.03],[0.5538,0.4826,0.2188,0.2051,-2.08],[0.5531,0.4783,0.2183,0.2055,-2.32],[0.5519,0.4739,0.2181,0.2062,-2.64],[0.5507,0.4703,0.2178,0.2067,-2.95],[0.5498,0.4677,0.2176,0.207,-3.2],[0.5492,0.4662,0.2175,0.2071,-3.35],[0.5489,0.4655,0.2174,0.2072,-3.42]],"rest":"tb-rest","mask":true,"reach":[0.2604,0.224,0.737,0.9323]},"story-hourglass":{"file":"clips/story-hourglass.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-hourglass-rest","mask":true,"tracks":[[0.4878,0.2786,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.45],[0.4878,0.2791,0.3125,0.2452,-0.42],[0.4877,0.2799,0.3125,0.2452,-0.37],[0.4877,0.2807,0.3125,0.2452,-0.3],[0.4877,0.2814,0.3125,0.2452,-0.2],[0.4878,0.2814,0.3125,0.2452,-0.07],[0.4879,0.2802,0.3125,0.2452,0.08],[0.4881,0.2776,0.3125,0.2452,0.25],[0.4883,0.2739,0.3125,0.2452,0.44],[0.4884,0.2694,0.3125,0.2452,0.64],[0.4886,0.2648,0.3125,0.2452,0.83],[0.4889,0.2602,0.3125,0.2452,1.02],[0.4892,0.2558,0.3125,0.2452,1.18],[0.4895,0.2522,0.3125,0.2452,1.3],[0.4896,0.249,0.3125,0.2452,1.44],[0.4893,0.2463,0.3125,0.2452,1.76],[0.4883,0.2448,0.3125,0.2452,2.09],[0.4876,0.244,0.3125,0.2452,2.17],[0.4869,0.2433,0.3125,0.2452,1.86],[0.484,0.2449,0.3125,0.2452,1.13],[0.4788,0.2493,0.3125,0.2452,-0.16],[0.4726,0.2554,0.3125,0.2452,-1.89],[0.4658,0.263,0.3125,0.2452,-3.94],[0.4588,0.2721,0.3125,0.2452,-5.9],[0.4542,0.2805,0.3125,0.2452,-7.34],[0.4535,0.2889,0.3125,0.2452,-8.06],[0.4558,0.2969,0.3125,0.2452,-7.97],[0.4604,0.3049,0.3125,0.2452,-6.9],[0.4671,0.3116,0.3125,0.2452,-5.06],[0.4753,0.3167,0.3125,0.2452,-2.88],[0.4838,0.3187,0.3125,0.2452,-0.63],[0.4921,0.3184,0.3125,0.2452,1.55],[0.4998,0.3163,0.3125,0.2452,3.62],[0.5066,0.3134,0.3125,0.2452,5.48],[0.5126,0.3107,0.3125,0.2452,7.07],[0.5187,0.3085,0.3125,0.2452,8.37],[0.524,0.3066,0.3125,0.2452,9.48],[0.5288,0.3051,0.3125,0.2452,10.38],[0.5333,0.3039,0.3125,0.2452,11.07],[0.5376,0.3029,0.3125,0.2452,11.53],[0.5404,0.3021,0.3125,0.2452,11.81],[0.5429,0.3014,0.3125,0.2452,11.95],[0.5448,0.3008,0.3125,0.2452,12.0],[0.5459,0.3004,0.3125,0.2452,12.0],[0.5464,0.3001,0.3125,0.2452,12.0],[0.5469,0.2998,0.3125,0.2452,12.0],[0.5471,0.2995,0.3125,0.2452,12.0],[0.5471,0.2993,0.3125,0.2452,12.0],[0.5471,0.299,0.3125,0.2452,12.0],[0.5471,0.2988,0.3125,0.2452,12.0],[0.547,0.2985,0.3125,0.2452,12.0],[0.5469,0.2982,0.3125,0.2452,12.0],[0.5469,0.298,0.3125,0.2452,12.0],[0.5468,0.2978,0.3125,0.2452,12.0],[0.5468,0.2977,0.3125,0.2452,12.0],[0.5467,0.2976,0.3125,0.2452,12.0],[0.5467,0.2975,0.3125,0.2452,12.0],[0.5466,0.2974,0.3125,0.2452,12.0],[0.5466,0.2973,0.3125,0.2452,12.0],[0.5465,0.2972,0.3125,0.2452,12.0],[0.5465,0.2971,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2968,0.3125,0.2452,12.0],[0.5465,0.2967,0.3125,0.2452,12.0],[0.5465,0.2966,0.3125,0.2452,12.0],[0.5465,0.2965,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5458,0.2966,0.3125,0.2452,12.0],[0.5445,0.297,0.3125,0.2452,11.96],[0.5423,0.2976,0.3125,0.2452,11.85],[0.5393,0.2979,0.3125,0.2452,11.6],[0.5351,0.2977,0.3125,0.2452,11.16],[0.53,0.2967,0.3125,0.2452,10.48],[0.5245,0.295,0.3125,0.2452,9.56],[0.5186,0.2927,0.3125,0.2452,8.4],[0.5123,0.2901,0.3125,0.2452,7.09],[0.5064,0.2877,0.3125,0.2452,5.73],[0.5015,0.2856,0.3125,0.2452,4.38],[0.4979,0.2839,0.3125,0.2452,3.16],[0.4953,0.2829,0.3125,0.2452,2.13],[0.4932,0.2822,0.3125,0.2452,1.31],[0.4917,0.2817,0.3125,0.2452,0.7],[0.4906,0.2813,0.3125,0.2452,0.26],[0.4894,0.2809,0.3125,0.2452,-0.04],[0.4885,0.2802,0.3125,0.2452,-0.22],[0.488,0.2796,0.3125,0.2452,-0.34],[0.4879,0.2792,0.3125,0.2452,-0.42],[0.4878,0.2788,0.3125,0.2452,-0.46],[0.4878,0.2785,0.3125,0.2452,-0.47],[0.4878,0.2784,0.3125,0.2452,-0.48],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.5],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5]],"reach":[0.1634,0.03,0.8366,0.92],"script":[[0,"idle",0,1],[0.35,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-business":{"file":"clips/story-business.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-business-rest","mask":true,"tracks":[[0.4789,0.293,0.3099,0.244,-0.19],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.22],[0.4787,0.293,0.3099,0.244,-0.23],[0.4787,0.293,0.3099,0.244,-0.26],[0.4786,0.293,0.3099,0.244,-0.3],[0.4783,0.293,0.3099,0.244,-0.35],[0.4778,0.293,0.3099,0.244,-0.43],[0.4772,0.293,0.3099,0.244,-0.52],[0.4764,0.2931,0.3099,0.244,-0.61],[0.4759,0.2933,0.3099,0.244,-0.68],[0.4763,0.2938,0.3099,0.244,-0.72],[0.4781,0.2945,0.3099,0.244,-0.72],[0.4812,0.2955,0.3099,0.244,-0.66],[0.4852,0.2966,0.3099,0.244,-0.56],[0.4893,0.2978,0.3099,0.244,-0.44],[0.4932,0.2989,0.3099,0.244,-0.31],[0.4961,0.2997,0.3099,0.244,-0.17],[0.4981,0.3002,0.3099,0.244,-0.05],[0.4994,0.3005,0.3099,0.244,0.05],[0.5005,0.3006,0.3099,0.244,0.14],[0.5012,0.3006,0.3099,0.244,0.22],[0.5017,0.3006,0.3099,0.244,0.29],[0.5019,0.3006,0.3099,0.244,0.36],[0.502,0.3006,0.3099,0.244,0.41],[0.502,0.3006,0.3099,0.244,0.44],[0.502,0.3006,0.3099,0.244,0.47],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.5],[0.5021,0.3006,0.3099,0.244,0.53],[0.5022,0.3007,0.3099,0.244,0.58],[0.5026,0.301,0.3099,0.244,0.66],[0.5032,0.3014,0.3099,0.244,0.77],[0.5044,0.3021,0.3099,0.244,0.91],[0.506,0.3031,0.3099,0.244,1.08],[0.5081,0.3043,0.3099,0.244,1.28],[0.5108,0.3054,0.3099,0.244,1.5],[0.5137,0.3066,0.3099,0.244,1.71],[0.5162,0.3075,0.3099,0.244,1.9],[0.5183,0.3082,0.3099,0.244,2.04],[0.5196,0.3085,0.3099,0.244,2.11],[0.5197,0.3084,0.3099,0.244,2.09],[0.5183,0.3078,0.3099,0.244,1.98],[0.5161,0.3067,0.3099,0.244,1.79],[0.5134,0.3052,0.3099,0.244,1.53],[0.5104,0.3036,0.3099,0.244,1.24],[0.5077,0.302,0.3099,0.244,0.95],[0.5059,0.3006,0.3099,0.244,0.7],[0.5048,0.2996,0.3099,0.244,0.5],[0.5042,0.2992,0.3099,0.244,0.39],[0.5043,0.2992,0.3099,0.244,0.32],[0.5049,0.2998,0.3099,0.244,0.31],[0.5056,0.3007,0.3099,0.244,0.34],[0.5063,0.3018,0.3099,0.244,0.39],[0.5068,0.3028,0.3099,0.244,0.44],[0.5071,0.3036,0.3099,0.244,0.47],[0.5067,0.3039,0.3099,0.244,0.47],[0.5062,0.3034,0.3099,0.244,0.44],[0.5055,0.3021,0.3099,0.244,0.37],[0.5044,0.3003,0.3099,0.244,0.27],[0.5032,0.2983,0.3099,0.244,0.15],[0.5021,0.2968,0.3099,0.244,0.03],[0.5013,0.2969,0.3099,0.244,-0.09],[0.501,0.2998,0.3099,0.244,-0.19],[0.5011,0.3056,0.3099,0.244,-0.26],[0.5009,0.3143,0.3099,0.244,-0.32],[0.5003,0.3251,0.3099,0.244,-0.35],[0.4994,0.3363,0.3099,0.244,-0.37],[0.4984,0.3464,0.3099,0.244,-0.37],[0.4976,0.3549,0.3099,0.244,-0.32],[0.4983,0.3609,0.3099,0.244,-0.2],[0.5009,0.3637,0.3099,0.244,0.03],[0.5051,0.3636,0.3099,0.244,0.43],[0.5107,0.3605,0.3099,0.244,1.02],[0.5173,0.3542,0.3099,0.244,1.77],[0.5242,0.3452,0.3099,0.244,2.67],[0.5307,0.3342,0.3099,0.244,3.62],[0.5363,0.3226,0.3099,0.244,4.55],[0.5404,0.3117,0.3099,0.244,5.37],[0.543,0.303,0.3099,0.244,6.0],[0.5438,0.2971,0.3099,0.244,6.39],[0.5428,0.2943,0.3099,0.244,6.52],[0.54,0.2944,0.3099,0.244,6.41],[0.5357,0.2968,0.3099,0.244,6.06],[0.5298,0.3009,0.3099,0.244,5.52],[0.5223,0.306,0.3099,0.244,4.83],[0.5136,0.3115,0.3099,0.244,4.03],[0.5039,0.3165,0.3099,0.244,3.16],[0.4938,0.3197,0.3099,0.244,2.25],[0.4839,0.3208,0.3099,0.244,1.35],[0.4748,0.3198,0.3099,0.244,0.48],[0.4668,0.317,0.3099,0.244,-0.31],[0.4604,0.3129,0.3099,0.244,-1.0],[0.4559,0.3086,0.3099,0.244,-1.58],[0.453,0.3042,0.3099,0.244,-1.99],[0.4519,0.2998,0.3099,0.244,-2.25],[0.4522,0.2959,0.3099,0.244,-2.37],[0.4536,0.2928,0.3099,0.244,-2.35],[0.4558,0.2906,0.3099,0.244,-2.22],[0.4586,0.2893,0.3099,0.244,-2.02],[0.4618,0.2888,0.3099,0.244,-1.77],[0.4652,0.289,0.3099,0.244,-1.52],[0.4686,0.2897,0.3099,0.244,-1.28],[0.4717,0.2906,0.3099,0.244,-1.06],[0.4742,0.2914,0.3099,0.244,-0.86],[0.476,0.2921,0.3099,0.244,-0.7],[0.4771,0.2925,0.3099,0.244,-0.56],[0.4777,0.2927,0.3099,0.244,-0.44],[0.478,0.2927,0.3099,0.244,-0.34],[0.4782,0.2928,0.3099,0.244,-0.28],[0.4783,0.2928,0.3099,0.244,-0.24],[0.4783,0.2928,0.3099,0.244,-0.23],[0.4784,0.2928,0.3099,0.244,-0.24],[0.4784,0.2928,0.3099,0.244,-0.25],[0.4784,0.2928,0.3099,0.244,-0.27],[0.4784,0.2929,0.3099,0.244,-0.29],[0.4784,0.2929,0.3099,0.244,-0.3],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32]],"reach":[0.1642,0.03,0.8358,0.92],"script":[[0,"idle",0,1],[0.5,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-celebrate":{"file":"clips/story-celebrate.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-celebrate-rest","mask":true,"tracks":[[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.0],[0.4861,0.4855,0.1979,0.1589,-0.99],[0.4861,0.4855,0.1979,0.1589,-0.98],[0.4861,0.4855,0.1979,0.1589,-0.96],[0.4861,0.4855,0.1979,0.1589,-0.94],[0.4861,0.4855,0.1979,0.1589,-0.92],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.93],[0.4862,0.4857,0.1979,0.1589,-0.95],[0.4862,0.4863,0.1979,0.1589,-0.98],[0.4861,0.4873,0.1979,0.1589,-1.01],[0.4861,0.4889,0.1979,0.1589,-1.03],[0.486,0.491,0.1979,0.1589,-1.06],[0.486,0.4935,0.1979,0.1589,-1.08],[0.4859,0.4964,0.1979,0.1589,-1.09],[0.4859,0.4994,0.1979,0.1589,-1.1],[0.486,0.5027,0.1979,0.1589,-1.09],[0.4859,0.506,0.1979,0.1589,-1.08],[0.4858,0.5095,0.1979,0.1589,-1.07],[0.4857,0.5132,0.1979,0.1589,-1.07],[0.4857,0.5169,0.1979,0.1589,-1.06],[0.4856,0.5207,0.1979,0.1589,-1.04],[0.4855,0.5245,0.1979,0.1589,-1.0],[0.4854,0.5283,0.1979,0.1589,-0.95],[0.4854,0.532,0.1979,0.1589,-0.88],[0.4853,0.5355,0.1979,0.1589,-0.8],[0.4852,0.5389,0.1979,0.1589,-0.73],[0.4852,0.5424,0.1979,0.1589,-0.66],[0.4852,0.5458,0.1979,0.1589,-0.61],[0.4852,0.5486,0.1979,0.1589,-0.58],[0.4852,0.5515,0.1979,0.1589,-0.57],[0.4852,0.5544,0.1979,0.1589,-0.57],[0.4852,0.5569,0.1979,0.1589,-0.59],[0.4852,0.5586,0.1979,0.1589,-0.56],[0.4852,0.5587,0.1979,0.1589,-0.51],[0.4851,0.5518,0.1979,0.1589,-0.43],[0.4853,0.532,0.1979,0.1589,-0.31],[0.4854,0.5028,0.1979,0.1589,-0.16],[0.4854,0.4673,0.1979,0.1589,-0.02],[0.4857,0.428,0.1979,0.1589,0.16],[0.4861,0.3911,0.1979,0.1589,0.31],[0.4862,0.3637,0.1979,0.1589,0.45],[0.4864,0.3427,0.1979,0.1589,0.58],[0.4867,0.3262,0.1979,0.1589,0.66],[0.4867,0.3141,0.1979,0.1589,0.72],[0.4868,0.3052,0.1979,0.1589,0.76],[0.4868,0.2987,0.1979,0.1589,0.78],[0.4868,0.2942,0.1979,0.1589,0.76],[0.4867,0.2916,0.1979,0.1589,0.72],[0.4866,0.2895,0.1979,0.1589,0.66],[0.4865,0.2878,0.1979,0.1589,0.58],[0.4864,0.2865,0.1979,0.1589,0.47],[0.4861,0.2855,0.1979,0.1589,0.35],[0.4858,0.2847,0.1979,0.1589,0.22],[0.4853,0.2842,0.1979,0.1589,0.06],[0.4848,0.2839,0.1979,0.1589,-0.13],[0.4843,0.2838,0.1979,0.1589,-0.32],[0.4838,0.2839,0.1979,0.1589,-0.54],[0.4834,0.2842,0.1979,0.1589,-0.76],[0.4829,0.2847,0.1979,0.1589,-0.99],[0.4823,0.2855,0.1979,0.1589,-1.23],[0.4817,0.2866,0.1979,0.1589,-1.47],[0.4811,0.2879,0.1979,0.1589,-1.7],[0.4805,0.2896,0.1979,0.1589,-1.93],[0.48,0.2916,0.1979,0.1589,-2.15],[0.4795,0.2937,0.1979,0.1589,-2.37],[0.479,0.296,0.1979,0.1589,-2.59],[0.4784,0.2991,0.1979,0.1589,-2.8],[0.4779,0.3024,0.1979,0.1589,-3.0],[0.4773,0.3062,0.1979,0.1589,-3.19],[0.4769,0.3106,0.1979,0.1589,-3.27],[0.4764,0.3159,0.1979,0.1589,-3.25],[0.4762,0.3231,0.1979,0.1589,-3.15],[0.4761,0.3379,0.1979,0.1589,-2.96],[0.4767,0.3615,0.1979,0.1589,-2.67],[0.4775,0.3898,0.1979,0.1589,-2.38],[0.4787,0.4211,0.1979,0.1589,-2.04],[0.4801,0.4528,0.1979,0.1589,-1.73],[0.4816,0.4795,0.1979,0.1589,-1.42],[0.4827,0.5,0.1979,0.1589,-1.11],[0.4836,0.5172,0.1979,0.1589,-0.8],[0.4842,0.5313,0.1979,0.1589,-0.55],[0.4846,0.5413,0.1979,0.1589,-0.3],[0.485,0.5462,0.1979,0.1589,-0.12],[0.4851,0.5453,0.1979,0.1589,-0.01],[0.4852,0.54,0.1979,0.1589,-0.01],[0.4853,0.5318,0.1979,0.1589,-0.07],[0.4853,0.523,0.1979,0.1589,-0.19],[0.4854,0.5153,0.1979,0.1589,-0.32],[0.4855,0.5093,0.1979,0.1589,-0.43],[0.4856,0.5046,0.1979,0.1589,-0.54],[0.4857,0.5007,0.1979,0.1589,-0.62],[0.4858,0.4977,0.1979,0.1589,-0.67],[0.4859,0.495,0.1979,0.1589,-0.71],[0.486,0.4926,0.1979,0.1589,-0.75],[0.4861,0.4904,0.1979,0.1589,-0.78],[0.4861,0.4888,0.1979,0.1589,-0.8],[0.4861,0.4874,0.1979,0.1589,-0.82],[0.4861,0.4864,0.1979,0.1589,-0.82],[0.4861,0.4859,0.1979,0.1589,-0.82],[0.4861,0.4856,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.83],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.85],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89]],"reach":[0.185,0.03,0.815,0.92],"script":[[0,"happy",0,0],[0.7,"blink",0,0],[0.74,"happy",0,0]]},"story-comedy":{"file":"clips/story-comedy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-comedy-rest","mask":true,"tracks":[[0.4859,0.3027,0.3018,0.2407,-0.35],[0.4859,0.3026,0.3018,0.2407,-0.36],[0.486,0.3026,0.3018,0.2407,-0.36],[0.4859,0.3026,0.3018,0.2407,-0.37],[0.486,0.3027,0.3018,0.2407,-0.39],[0.486,0.303,0.3018,0.2407,-0.39],[0.4861,0.3077,0.3018,0.2407,-0.41],[0.4861,0.3144,0.3018,0.2407,-0.42],[0.4861,0.3237,0.3018,0.2407,-0.44],[0.4861,0.3333,0.3018,0.2407,-0.45],[0.4861,0.3429,0.3018,0.2407,-0.46],[0.4859,0.348,0.3018,0.2407,-0.47],[0.4859,0.3512,0.3018,0.2407,-0.49],[0.4859,0.3516,0.3018,0.2407,-0.51],[0.4858,0.3517,0.3018,0.2407,-0.53],[0.4858,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.55],[0.4857,0.3518,0.3018,0.2407,-0.53],[0.4858,0.3538,0.3018,0.2407,-0.44],[0.4856,0.3556,0.3018,0.2407,-0.23],[0.4857,0.343,0.3018,0.2407,0.12],[0.4878,0.3222,0.3018,0.2407,0.68],[0.4914,0.3012,0.3018,0.2407,1.39],[0.4968,0.2825,0.3018,0.2407,2.34],[0.505,0.2683,0.3018,0.2407,3.48],[0.5151,0.2711,0.3018,0.2407,4.73],[0.5255,0.2782,0.3018,0.2407,6.02],[0.5357,0.2813,0.3018,0.2407,7.28],[0.5448,0.2772,0.3018,0.2407,8.32],[0.5515,0.2704,0.3018,0.2407,9.17],[0.5554,0.2645,0.3018,0.2407,9.76],[0.5568,0.268,0.3018,0.2407,10.16],[0.5567,0.2793,0.3018,0.2407,10.39],[0.5554,0.2933,0.3018,0.2407,10.52],[0.554,0.3022,0.3018,0.2407,10.58],[0.5535,0.3035,0.3018,0.2407,10.62],[0.5534,0.2956,0.3018,0.2407,10.66],[0.5534,0.2832,0.3018,0.2407,10.71],[0.5539,0.2727,0.3018,0.2407,10.71],[0.5547,0.269,0.3018,0.2407,10.68],[0.5553,0.2722,0.3018,0.2407,10.6],[0.556,0.2817,0.3018,0.2407,10.48],[0.5565,0.2917,0.3018,0.2407,10.41],[0.5564,0.2983,0.3018,0.2407,10.38],[0.5555,0.3019,0.3018,0.2407,10.4],[0.5544,0.2997,0.3018,0.2407,10.43],[0.5537,0.2924,0.3018,0.2407,10.52],[0.5534,0.2839,0.3018,0.2407,10.58],[0.5536,0.279,0.3018,0.2407,10.68],[0.5546,0.2778,0.3018,0.2407,10.79],[0.5554,0.2795,0.3018,0.2407,10.9],[0.5557,0.2817,0.3018,0.2407,10.98],[0.5553,0.2854,0.3018,0.2407,11.07],[0.555,0.2867,0.3018,0.2407,11.14],[0.5553,0.2864,0.3018,0.2407,11.2],[0.5561,0.2864,0.3018,0.2407,11.27],[0.5569,0.287,0.3018,0.2407,11.32],[0.5576,0.2844,0.3018,0.2407,11.38],[0.558,0.2825,0.3018,0.2407,11.45],[0.5575,0.2808,0.3018,0.2407,11.53],[0.5569,0.282,0.3018,0.2407,11.6],[0.556,0.2854,0.3018,0.2407,11.6],[0.5551,0.2905,0.3018,0.2407,11.29],[0.5525,0.2961,0.3018,0.2407,10.57],[0.5468,0.3018,0.3018,0.2407,9.37],[0.5361,0.3072,0.3018,0.2407,7.64],[0.5215,0.313,0.3018,0.2407,5.42],[0.5049,0.3201,0.3018,0.2407,2.88],[0.4883,0.3253,0.3018,0.2407,0.2],[0.4734,0.3263,0.3018,0.2407,-2.33],[0.4629,0.3234,0.3018,0.2407,-4.54],[0.4563,0.3184,0.3018,0.2407,-6.27],[0.4524,0.3135,0.3018,0.2407,-7.48],[0.4508,0.3101,0.3018,0.2407,-8.16],[0.4511,0.3089,0.3018,0.2407,-8.51],[0.452,0.3095,0.3018,0.2407,-8.61],[0.453,0.3119,0.3018,0.2407,-8.59],[0.4536,0.3151,0.3018,0.2407,-8.54],[0.4536,0.318,0.3018,0.2407,-8.53],[0.453,0.3195,0.3018,0.2407,-8.57],[0.4521,0.3195,0.3018,0.2407,-8.67],[0.451,0.3178,0.3018,0.2407,-8.8],[0.45,0.3146,0.3018,0.2407,-8.93],[0.4495,0.311,0.3018,0.2407,-9.02],[0.4496,0.308,0.3018,0.2407,-9.05],[0.4502,0.3062,0.3018,0.2407,-9.02],[0.4512,0.3062,0.3018,0.2407,-8.94],[0.4524,0.3078,0.3018,0.2407,-8.82],[0.4534,0.3107,0.3018,0.2407,-8.71],[0.4542,0.3142,0.3018,0.2407,-8.62],[0.4545,0.3175,0.3018,0.2407,-8.57],[0.4546,0.3198,0.3018,0.2407,-8.57],[0.4545,0.321,0.3018,0.2407,-8.57],[0.4543,0.3211,0.3018,0.2407,-8.57],[0.4542,0.3207,0.3018,0.2407,-8.54],[0.4542,0.3201,0.3018,0.2407,-8.38],[0.4548,0.3194,0.3018,0.2407,-8.02],[0.4563,0.3186,0.3018,0.2407,-7.46],[0.4592,0.3175,0.3018,0.2407,-6.71],[0.4629,0.3159,0.3018,0.2407,-5.78],[0.4676,0.314,0.3018,0.2407,-4.75],[0.4726,0.3116,0.3018,0.2407,-3.71],[0.4772,0.309,0.3018,0.2407,-2.76],[0.4808,0.3068,0.3018,0.2407,-1.95],[0.4834,0.3049,0.3018,0.2407,-1.31],[0.485,0.3034,0.3018,0.2407,-0.86],[0.4857,0.3026,0.3018,0.2407,-0.58],[0.4859,0.3023,0.3018,0.2407,-0.41],[0.4859,0.3023,0.3018,0.2407,-0.31],[0.4859,0.3022,0.3018,0.2407,-0.24],[0.4859,0.3021,0.3018,0.2407,-0.21],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.2],[0.4859,0.3022,0.3018,0.2407,-0.21],[0.486,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22]],"reach":[0.1647,0.03,0.8353,0.92],"script":[[0,"happy",0,0],[0.85,"blink",0,0],[0.89,"happy",0,0]]},"story-daytime":{"file":"clips/story-daytime.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-daytime-rest","mask":true,"tracks":[[0.5051,0.3264,0.2881,0.2289,-0.83],[0.505,0.3264,0.2881,0.2289,-0.81],[0.505,0.3265,0.2881,0.2289,-0.75],[0.5048,0.3265,0.2881,0.2289,-0.55],[0.504,0.3268,0.2881,0.2289,-0.23],[0.5023,0.3273,0.2881,0.2289,0.22],[0.5003,0.3278,0.2881,0.2289,0.81],[0.497,0.3286,0.2881,0.2289,1.5],[0.4933,0.3295,0.2881,0.2289,2.2],[0.4902,0.3303,0.2881,0.2289,2.9],[0.4871,0.3308,0.2881,0.2289,3.53],[0.4836,0.331,0.2881,0.2289,4.04],[0.4812,0.3305,0.2881,0.2289,4.42],[0.4787,0.3292,0.2881,0.2289,4.67],[0.4759,0.3272,0.2881,0.2289,4.79],[0.4741,0.3249,0.2881,0.2289,4.81],[0.4729,0.3222,0.2881,0.2289,4.77],[0.472,0.3198,0.2881,0.2289,4.63],[0.4714,0.3172,0.2881,0.2289,4.45],[0.4711,0.3152,0.2881,0.2289,4.2],[0.4709,0.3136,0.2881,0.2289,3.92],[0.4707,0.3121,0.2881,0.2289,3.63],[0.4705,0.3107,0.2881,0.2289,3.35],[0.4705,0.31,0.2881,0.2289,3.08],[0.4705,0.3092,0.2881,0.2289,2.83],[0.4706,0.3083,0.2881,0.2289,2.63],[0.4706,0.3077,0.2881,0.2289,2.46],[0.4707,0.3073,0.2881,0.2289,2.34],[0.4708,0.307,0.2881,0.2289,2.25],[0.4709,0.3067,0.2881,0.2289,2.18],[0.4709,0.3065,0.2881,0.2289,2.14],[0.4709,0.3065,0.2881,0.2289,2.11],[0.4709,0.3063,0.2881,0.2289,2.08],[0.4709,0.3063,0.2881,0.2289,2.04],[0.4709,0.3062,0.2881,0.2289,2.0],[0.4708,0.3062,0.2881,0.2289,1.97],[0.4708,0.3061,0.2881,0.2289,1.96],[0.4708,0.3062,0.2881,0.2289,1.95],[0.4709,0.3062,0.2881,0.2289,1.97],[0.4709,0.3063,0.2881,0.2289,2.0],[0.471,0.3063,0.2881,0.2289,2.04],[0.471,0.3064,0.2881,0.2289,2.08],[0.471,0.3065,0.2881,0.2289,2.12],[0.471,0.3066,0.2881,0.2289,2.17],[0.471,0.3067,0.2881,0.2289,2.23],[0.471,0.3069,0.2881,0.2289,2.31],[0.471,0.3071,0.2881,0.2289,2.44],[0.471,0.3075,0.2881,0.2289,2.64],[0.471,0.308,0.2881,0.2289,2.9],[0.4713,0.3088,0.2881,0.2289,3.16],[0.4719,0.3105,0.2881,0.2289,3.42],[0.4728,0.3126,0.2881,0.2289,3.61],[0.474,0.315,0.2881,0.2289,3.66],[0.4755,0.3176,0.2881,0.2289,3.51],[0.4774,0.3203,0.2881,0.2289,3.14],[0.4794,0.3222,0.2881,0.2289,2.61],[0.4817,0.3239,0.2881,0.2289,1.98],[0.4848,0.3252,0.2881,0.2289,1.29],[0.489,0.326,0.2881,0.2289,0.64],[0.4933,0.3265,0.2881,0.2289,0.1],[0.4973,0.3267,0.2881,0.2289,-0.3],[0.5008,0.3267,0.2881,0.2289,-0.57],[0.5034,0.3266,0.2881,0.2289,-0.73],[0.5046,0.3265,0.2881,0.2289,-0.8],[0.505,0.3264,0.2881,0.2289,-0.82],[0.5051,0.3263,0.2881,0.2289,-0.86],[0.5052,0.3262,0.2881,0.2289,-0.96],[0.5042,0.3267,0.2881,0.2289,-1.17],[0.5014,0.3278,0.2881,0.2289,-1.52],[0.4942,0.3299,0.2881,0.2289,-2.05],[0.4824,0.332,0.2881,0.2289,-2.79],[0.4677,0.3332,0.2881,0.2289,-3.72],[0.4524,0.3336,0.2881,0.2289,-4.82],[0.4365,0.333,0.2881,0.2289,-6.02],[0.4225,0.3312,0.2881,0.2289,-7.25],[0.4122,0.3295,0.2881,0.2289,-8.41],[0.4043,0.3285,0.2881,0.2289,-9.45],[0.398,0.3279,0.2881,0.2289,-10.3],[0.3945,0.3276,0.2881,0.2289,-10.91],[0.3946,0.3276,0.2881,0.2289,-11.29],[0.3973,0.3277,0.2881,0.2289,-11.38],[0.403,0.3279,0.2881,0.2289,-11.15],[0.4125,0.3286,0.2881,0.2289,-10.58],[0.4249,0.3299,0.2881,0.2289,-9.6],[0.4437,0.3315,0.2881,0.2289,-8.23],[0.4653,0.3328,0.2881,0.2289,-6.47],[0.4914,0.3335,0.2881,0.2289,-4.38],[0.5172,0.3333,0.2881,0.2289,-2.07],[0.5439,0.3323,0.2881,0.2289,0.29],[0.5652,0.331,0.2881,0.2289,2.55],[0.5834,0.3299,0.2881,0.2289,4.54],[0.5954,0.3294,0.2881,0.2289,6.19],[0.6037,0.3292,0.2881,0.2289,7.4],[0.6077,0.3294,0.2881,0.2289,8.22],[0.6091,0.3297,0.2881,0.2289,8.67],[0.6079,0.33,0.2881,0.2289,8.82],[0.6047,0.3304,0.2881,0.2289,8.67],[0.5998,0.3309,0.2881,0.2289,8.3],[0.5919,0.3318,0.2881,0.2289,7.65],[0.582,0.3328,0.2881,0.2289,6.79],[0.5698,0.3333,0.2881,0.2289,5.76],[0.5545,0.3327,0.2881,0.2289,4.6],[0.5393,0.3316,0.2881,0.2289,3.4],[0.5268,0.3299,0.2881,0.2289,2.3],[0.5166,0.3281,0.2881,0.2289,1.32],[0.5094,0.3266,0.2881,0.2289,0.58],[0.5069,0.3262,0.2881,0.2289,0.06],[0.5058,0.3261,0.2881,0.2289,-0.28],[0.5053,0.3262,0.2881,0.2289,-0.46],[0.5051,0.3262,0.2881,0.2289,-0.52],[0.5051,0.3261,0.2881,0.2289,-0.55],[0.5051,0.326,0.2881,0.2289,-0.57],[0.5051,0.326,0.2881,0.2289,-0.58],[0.5051,0.326,0.2881,0.2289,-0.59],[0.5051,0.326,0.2881,0.2289,-0.6],[0.5051,0.3261,0.2881,0.2289,-0.62],[0.5051,0.3262,0.2881,0.2289,-0.63],[0.5051,0.3262,0.2881,0.2289,-0.64],[0.5051,0.3262,0.2881,0.2289,-0.65],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67]],"reach":[0.1211,0.03,0.8789,0.92],"script":[[0,"happy",0,0],[0.45,"blink",0,0],[0.5,"idle",1,0],[0.75,"happy",0,0]]},"story-detective":{"file":"clips/story-detective.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-detective-rest","mask":true,"tracks":[[0.4842,0.3239,0.2709,0.2191,-0.26],[0.4842,0.3238,0.2709,0.2191,-0.26],[0.4834,0.3238,0.2709,0.2191,-0.27],[0.4812,0.3238,0.2709,0.2191,-0.28],[0.4788,0.3237,0.2709,0.2191,-0.28],[0.4765,0.3237,0.2709,0.2191,-0.27],[0.4731,0.3236,0.2709,0.2191,-0.25],[0.4698,0.3235,0.2709,0.2191,-0.22],[0.4677,0.3235,0.2709,0.2191,-0.2],[0.4649,0.3235,0.2709,0.2191,-0.2],[0.4621,0.3235,0.2709,0.2191,-0.26],[0.4588,0.3237,0.2709,0.2191,-0.37],[0.4559,0.324,0.2709,0.2191,-0.54],[0.4527,0.3244,0.2709,0.2191,-0.75],[0.4503,0.3249,0.2709,0.2191,-1.0],[0.4478,0.3254,0.2709,0.2191,-1.24],[0.4478,0.3266,0.2709,0.2191,-1.47],[0.4479,0.3277,0.2709,0.2191,-1.65],[0.4503,0.3295,0.2709,0.2191,-1.76],[0.4527,0.3313,0.2709,0.2191,-1.75],[0.4575,0.3337,0.2709,0.2191,-1.65],[0.4614,0.3352,0.2709,0.2191,-1.45],[0.47,0.337,0.2709,0.2191,-1.23],[0.477,0.338,0.2709,0.2191,-0.62],[0.4883,0.3391,0.2709,0.2191,0.32],[0.4972,0.3395,0.2709,0.2191,1.58],[0.5101,0.3393,0.2709,0.2191,3.11],[0.5182,0.3388,0.2709,0.2191,4.83],[0.5283,0.3376,0.2709,0.2191,6.37],[0.5347,0.3361,0.2709,0.2191,7.77],[0.5362,0.3344,0.2709,0.2191,8.79],[0.5338,0.3332,0.2709,0.2191,9.46],[0.5316,0.3319,0.2709,0.2191,9.91],[0.5308,0.3314,0.2709,0.2191,10.15],[0.5296,0.331,0.2709,0.2191,10.25],[0.5364,0.331,0.2709,0.2191,10.48],[0.543,0.3311,0.2709,0.2191,10.84],[0.5526,0.3316,0.2709,0.2191,11.2],[0.5586,0.3322,0.2709,0.2191,11.58],[0.5674,0.3331,0.2709,0.2191,11.84],[0.5732,0.334,0.2709,0.2191,11.98],[0.5808,0.3352,0.2709,0.2191,11.99],[0.5854,0.336,0.2709,0.2191,12.0],[0.5907,0.3369,0.2709,0.2191,12.0],[0.5933,0.3375,0.2709,0.2191,12.0],[0.5962,0.3381,0.2709,0.2191,12.0],[0.5975,0.3383,0.2709,0.2191,12.0],[0.599,0.3386,0.2709,0.2191,12.0],[0.5998,0.3388,0.2709,0.2191,12.0],[0.6008,0.3389,0.2709,0.2191,12.0],[0.6015,0.3391,0.2709,0.2191,12.0],[0.6021,0.3393,0.2709,0.2191,12.0],[0.6028,0.3394,0.2709,0.2191,12.0],[0.6034,0.3396,0.2709,0.2191,12.0],[0.6041,0.3397,0.2709,0.2191,12.0],[0.6047,0.3399,0.2709,0.2191,12.0],[0.6056,0.34,0.2709,0.2191,12.0],[0.6063,0.3402,0.2709,0.2191,12.0],[0.6073,0.3403,0.2709,0.2191,12.0],[0.608,0.3405,0.2709,0.2191,12.0],[0.6093,0.3409,0.2709,0.2191,12.0],[0.6102,0.3412,0.2709,0.2191,12.0],[0.6117,0.3419,0.2709,0.2191,12.0],[0.6129,0.3425,0.2709,0.2191,12.0],[0.6149,0.3434,0.2709,0.2191,12.0],[0.6162,0.344,0.2709,0.2191,12.0],[0.6179,0.3448,0.2709,0.2191,12.0],[0.619,0.3454,0.2709,0.2191,12.0],[0.6202,0.3459,0.2709,0.2191,12.0],[0.6208,0.3462,0.2709,0.2191,12.0],[0.6213,0.3465,0.2709,0.2191,12.0],[0.6216,0.3467,0.2709,0.2191,12.0],[0.6218,0.3469,0.2709,0.2191,12.0],[0.622,0.3471,0.2709,0.2191,12.0],[0.6218,0.3473,0.2709,0.2191,12.0],[0.621,0.3472,0.2709,0.2191,12.0],[0.6181,0.3464,0.2709,0.2191,12.0],[0.6152,0.3455,0.2709,0.2191,12.0],[0.6101,0.3437,0.2709,0.2191,12.0],[0.6034,0.3415,0.2709,0.2191,12.0],[0.6008,0.3402,0.2709,0.2191,12.0],[0.6003,0.3395,0.2709,0.2191,11.6],[0.595,0.3396,0.2709,0.2191,11.01],[0.592,0.3405,0.2709,0.2191,10.22],[0.5837,0.3425,0.2709,0.2191,9.21],[0.5725,0.3437,0.2709,0.2191,7.94],[0.5572,0.3447,0.2709,0.2191,6.78],[0.5442,0.3451,0.2709,0.2191,5.5],[0.5297,0.345,0.2709,0.2191,4.46],[0.5223,0.3443,0.2709,0.2191,3.5],[0.5116,0.3422,0.2709,0.2191,2.64],[0.5051,0.3404,0.2709,0.2191,1.93],[0.499,0.337,0.2709,0.2191,1.46],[0.4945,0.334,0.2709,0.2191,0.86],[0.4896,0.3302,0.2709,0.2191,0.39],[0.4874,0.3278,0.2709,0.2191,0.06],[0.4851,0.3255,0.2709,0.2191,-0.12],[0.4847,0.3246,0.2709,0.2191,-0.23],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.27],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.23],[0.4842,0.3237,0.2709,0.2191,-0.21],[0.4842,0.3237,0.2709,0.2191,-0.19],[0.4842,0.3237,0.2709,0.2191,-0.17],[0.4842,0.3237,0.2709,0.2191,-0.15],[0.4842,0.3237,0.2709,0.2191,-0.13],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.1],[0.4842,0.3237,0.2709,0.2191,-0.09],[0.4842,0.3237,0.2709,0.2191,-0.08],[0.4842,0.3238,0.2709,0.2191,-0.07],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05]],"reach":[0.0995,0.03,0.9005,0.92],"script":[[0,"idle",-1,0],[0.8,"blink",-1,0],[0.84,"idle",-1,0]],"lens":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,[0.3399,0.3458,0.0567,0.7,0],[0.3582,0.3438,0.0567,0.85,0],[0.3825,0.3413,0.0569,1.0,0],[0.3973,0.3399,0.0571,1.0,0],[0.4163,0.3382,0.0572,1.0,0],[0.4272,0.3373,0.0574,1.0,0],[0.4381,0.3363,0.0577,1.0,0],[0.4499,0.3362,0.0578,1.0,0],[0.4611,0.3363,0.058,1.0,0],[0.4734,0.3374,0.058,1.0,0],[0.4858,0.3384,0.0581,1.0,0],[0.5031,0.3398,0.0579,1.0,0],[0.5136,0.341,0.0577,1.0,0],[0.5281,0.3429,0.0575,1.0,0],[0.5373,0.3442,0.0574,1.0,0],[0.5491,0.3459,0.0573,1.0,0],[0.5559,0.3471,0.0573,1.0,0],[0.5636,0.3485,0.0574,1.0,0],[0.5672,0.349,0.0574,1.0,0],[0.5711,0.3497,0.0574,1.0,0],[0.5727,0.3499,0.0574,1.0,0],[0.5745,0.3502,0.0574,1.0,0],[0.5754,0.3503,0.0574,1.0,0],[0.5764,0.3505,0.0574,1.0,0],[0.5772,0.3505,0.0574,1.0,0],[0.5779,0.3506,0.0574,1.0,0],[0.5788,0.3507,0.0574,1.0,0],[0.5796,0.3508,0.0574,1.0,0],[0.5803,0.3509,0.0574,1.0,1],[0.581,0.351,0.0574,1.0,1],[0.5819,0.3511,0.0574,1.0,1],[0.5826,0.3513,0.0574,1.0,0],[0.5835,0.3515,0.0574,1.0,0],[0.5843,0.3518,0.0574,1.0,0],[0.5856,0.3523,0.0574,1.0,0],[0.5866,0.3527,0.0574,1.0,0],[0.5882,0.3535,0.0573,1.0,0],[0.5896,0.3542,0.0573,1.0,0],[0.5917,0.3552,0.0573,1.0,0],[0.5931,0.356,0.0573,1.0,0],[0.5949,0.357,0.0573,1.0,0],[0.596,0.3576,0.0573,1.0,0],[0.5972,0.3582,0.0573,1.0,0],[0.5979,0.3587,0.0574,1.0,0],[0.5985,0.3591,0.0574,1.0,0],[0.5988,0.3594,0.0574,1.0,0],[0.599,0.3598,0.0573,1.0,0],[0.5991,0.3601,0.0573,1.0,0],[0.5988,0.3602,0.0574,1.0,0],[0.5975,0.36,0.0573,1.0,0],[0.5927,0.358,0.0572,1.0,0],[0.5879,0.3559,0.0572,1.0,0],[0.5783,0.3521,0.0571,1.0,0],[0.5646,0.3468,0.0571,1.0,0],[0.5456,0.3403,0.0569,1.0,0],[0.5301,0.3355,0.0567,1.0,0],[0.5014,0.3292,0.0567,1.0,0],[0.4775,0.3245,0.0566,1.0,0],[0.4441,0.322,0.0566,0.85,0],[0.4171,0.321,0.0567,0.7,0],null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"blinks":[21,22,90,91]},"story-fiction":{"file":"clips/story-fiction.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-fiction-rest","mask":true,"tracks":[[0.5027,0.3497,0.2922,0.2282,-0.39],[0.5027,0.3498,0.2922,0.2282,-0.4],[0.5028,0.3499,0.2922,0.2282,-0.4],[0.5028,0.3501,0.2922,0.2282,-0.41],[0.5028,0.3505,0.2922,0.2282,-0.39],[0.5029,0.3512,0.2922,0.2282,-0.38],[0.503,0.3525,0.2922,0.2282,-0.34],[0.5032,0.3546,0.2922,0.2282,-0.29],[0.5036,0.358,0.2922,0.2282,-0.22],[0.5042,0.3627,0.2922,0.2282,-0.15],[0.505,0.3686,0.2922,0.2282,-0.06],[0.506,0.3749,0.2922,0.2282,0.03],[0.5074,0.3815,0.2922,0.2282,0.13],[0.5092,0.3877,0.2922,0.2282,0.22],[0.5112,0.3931,0.2922,0.2282,0.31],[0.5135,0.3974,0.2922,0.2282,0.4],[0.5159,0.4011,0.2922,0.2282,0.5],[0.5182,0.4039,0.2922,0.2282,0.58],[0.5201,0.4058,0.2922,0.2282,0.67],[0.522,0.4073,0.2922,0.2282,0.75],[0.5235,0.4083,0.2922,0.2282,0.82],[0.5248,0.409,0.2922,0.2282,0.89],[0.526,0.4094,0.2922,0.2282,0.95],[0.527,0.4095,0.2922,0.2282,1.01],[0.528,0.4095,0.2922,0.2282,1.07],[0.5291,0.4093,0.2922,0.2282,1.12],[0.5304,0.409,0.2922,0.2282,1.16],[0.5317,0.4086,0.2922,0.2282,1.18],[0.533,0.4081,0.2922,0.2282,1.18],[0.5341,0.4074,0.2922,0.2282,1.16],[0.5347,0.4065,0.2922,0.2282,1.11],[0.5347,0.4056,0.2922,0.2282,1.03],[0.534,0.4046,0.2922,0.2282,0.9],[0.5325,0.4034,0.2922,0.2282,0.72],[0.5303,0.402,0.2922,0.2282,0.45],[0.5273,0.4004,0.2922,0.2282,0.1],[0.5235,0.3984,0.2922,0.2282,-0.39],[0.519,0.3962,0.2922,0.2282,-1.03],[0.5139,0.3938,0.2922,0.2282,-1.78],[0.5074,0.391,0.2922,0.2282,-2.64],[0.5007,0.389,0.2922,0.2282,-3.56],[0.4942,0.3878,0.2922,0.2282,-4.45],[0.4885,0.3876,0.2922,0.2282,-5.29],[0.4836,0.3884,0.2922,0.2282,-6.0],[0.4807,0.3902,0.2922,0.2282,-6.59],[0.4787,0.3923,0.2922,0.2282,-7.05],[0.4775,0.3943,0.2922,0.2282,-7.38],[0.4767,0.3959,0.2922,0.2282,-7.61],[0.4762,0.3971,0.2922,0.2282,-7.79],[0.4759,0.3979,0.2922,0.2282,-7.93],[0.4759,0.3984,0.2922,0.2282,-8.03],[0.4758,0.3988,0.2922,0.2282,-8.11],[0.4758,0.399,0.2922,0.2282,-8.17],[0.4758,0.3992,0.2922,0.2282,-8.21],[0.4757,0.3994,0.2922,0.2282,-8.22],[0.4756,0.3995,0.2922,0.2282,-8.2],[0.4755,0.3996,0.2922,0.2282,-8.13],[0.4752,0.3997,0.2922,0.2282,-8.0],[0.4745,0.3999,0.2922,0.2282,-7.81],[0.4734,0.4002,0.2922,0.2282,-7.54],[0.4722,0.4004,0.2922,0.2282,-7.24],[0.4709,0.4008,0.2922,0.2282,-6.91],[0.4695,0.4011,0.2922,0.2282,-6.59],[0.4686,0.4014,0.2922,0.2282,-6.28],[0.4681,0.4017,0.2922,0.2282,-6.0],[0.4678,0.4019,0.2922,0.2282,-5.78],[0.4678,0.4021,0.2922,0.2282,-5.61],[0.4682,0.4023,0.2922,0.2282,-5.45],[0.4689,0.4024,0.2922,0.2282,-5.31],[0.4699,0.4024,0.2922,0.2282,-5.19],[0.4714,0.4022,0.2922,0.2282,-5.05],[0.4732,0.402,0.2922,0.2282,-4.9],[0.4754,0.4016,0.2922,0.2282,-4.71],[0.478,0.4011,0.2922,0.2282,-4.49],[0.4812,0.4004,0.2922,0.2282,-4.25],[0.4844,0.3994,0.2922,0.2282,-3.98],[0.4875,0.3983,0.2922,0.2282,-3.68],[0.4905,0.3969,0.2922,0.2282,-3.39],[0.4931,0.3954,0.2922,0.2282,-3.09],[0.4947,0.3938,0.2922,0.2282,-2.81],[0.4961,0.392,0.2922,0.2282,-2.55],[0.4973,0.3903,0.2922,0.2282,-2.31],[0.4982,0.3882,0.2922,0.2282,-2.09],[0.4988,0.3863,0.2922,0.2282,-1.88],[0.4995,0.3844,0.2922,0.2282,-1.69],[0.4998,0.3825,0.2922,0.2282,-1.51],[0.5001,0.3801,0.2922,0.2282,-1.36],[0.5003,0.3779,0.2922,0.2282,-1.23],[0.5007,0.3756,0.2922,0.2282,-1.11],[0.501,0.3729,0.2922,0.2282,-1.02],[0.5014,0.3701,0.2922,0.2282,-0.94],[0.5017,0.3677,0.2922,0.2282,-0.87],[0.5019,0.3652,0.2922,0.2282,-0.81],[0.502,0.3627,0.2922,0.2282,-0.77],[0.5021,0.3605,0.2922,0.2282,-0.72],[0.5023,0.3581,0.2922,0.2282,-0.68],[0.5024,0.3559,0.2922,0.2282,-0.63],[0.5025,0.3541,0.2922,0.2282,-0.59],[0.5025,0.3527,0.2922,0.2282,-0.55],[0.5026,0.3516,0.2922,0.2282,-0.52],[0.5026,0.3511,0.2922,0.2282,-0.48],[0.5026,0.3508,0.2922,0.2282,-0.46],[0.5026,0.3506,0.2922,0.2282,-0.44],[0.5026,0.3505,0.2922,0.2282,-0.43],[0.5026,0.3504,0.2922,0.2282,-0.44],[0.5026,0.3504,0.2922,0.2282,-0.45],[0.5026,0.3504,0.2922,0.2282,-0.46],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.47],[0.5026,0.3503,0.2922,0.2282,-0.46],[0.5026,0.3503,0.2922,0.2282,-0.45],[0.5026,0.3503,0.2922,0.2282,-0.44],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43]],"reach":[0.1998,0.03,0.8002,0.92],"script":[[0,"idle",0,1],[0.55,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-health":{"file":"clips/story-health.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-health-rest","mask":true,"tracks":[[0.5022,0.3764,0.2677,0.2093,-0.33],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.37],[0.5022,0.3764,0.2677,0.2093,-0.43],[0.502,0.3764,0.2677,0.2093,-0.56],[0.5012,0.3764,0.2677,0.2093,-0.84],[0.4989,0.3764,0.2677,0.2093,-1.29],[0.4948,0.3763,0.2677,0.2093,-1.96],[0.4886,0.3764,0.2677,0.2093,-2.87],[0.4802,0.3767,0.2677,0.2093,-4.03],[0.4697,0.3773,0.2677,0.2093,-5.34],[0.4582,0.3782,0.2677,0.2093,-6.73],[0.4463,0.3796,0.2677,0.2093,-8.08],[0.4343,0.3814,0.2677,0.2093,-9.3],[0.4231,0.3834,0.2677,0.2093,-10.29],[0.4129,0.3858,0.2677,0.2093,-11.04],[0.4036,0.3881,0.2677,0.2093,-11.53],[0.3951,0.3906,0.2677,0.2093,-11.82],[0.3875,0.393,0.2677,0.2093,-11.96],[0.3806,0.3952,0.2677,0.2093,-12.0],[0.3748,0.3971,0.2677,0.2093,-12.0],[0.3698,0.3989,0.2677,0.2093,-12.0],[0.3656,0.4004,0.2677,0.2093,-12.0],[0.3625,0.4016,0.2677,0.2093,-12.0],[0.3598,0.4027,0.2677,0.2093,-12.0],[0.3576,0.4035,0.2677,0.2093,-12.0],[0.356,0.4041,0.2677,0.2093,-12.0],[0.3549,0.4045,0.2677,0.2093,-12.0],[0.3542,0.4048,0.2677,0.2093,-12.0],[0.3541,0.4049,0.2677,0.2093,-12.0],[0.3545,0.4048,0.2677,0.2093,-12.0],[0.3556,0.4044,0.2677,0.2093,-12.0],[0.3573,0.4038,0.2677,0.2093,-12.0],[0.3598,0.4029,0.2677,0.2093,-12.0],[0.3634,0.4016,0.2677,0.2093,-12.0],[0.368,0.3999,0.2677,0.2093,-12.0],[0.3737,0.398,0.2677,0.2093,-12.0],[0.3812,0.3955,0.2677,0.2093,-11.93],[0.3921,0.3924,0.2677,0.2093,-11.7],[0.4044,0.3892,0.2677,0.2093,-11.26],[0.4184,0.386,0.2677,0.2093,-10.54],[0.4333,0.383,0.2677,0.2093,-9.45],[0.4488,0.3803,0.2677,0.2093,-8.01],[0.4629,0.3786,0.2677,0.2093,-6.22],[0.4773,0.3773,0.2677,0.2093,-4.17],[0.4915,0.3765,0.2677,0.2093,-1.91],[0.506,0.3761,0.2677,0.2093,0.44],[0.5203,0.3763,0.2677,0.2093,2.77],[0.5344,0.377,0.2677,0.2093,4.93],[0.5503,0.3786,0.2677,0.2093,6.85],[0.5656,0.3807,0.2677,0.2093,8.49],[0.5801,0.3832,0.2677,0.2093,9.8],[0.5937,0.386,0.2677,0.2093,10.75],[0.6056,0.3888,0.2677,0.2093,11.38],[0.6138,0.391,0.2677,0.2093,11.76],[0.6206,0.393,0.2677,0.2093,11.94],[0.6256,0.3947,0.2677,0.2093,12.0],[0.6293,0.3959,0.2677,0.2093,12.0],[0.6324,0.397,0.2677,0.2093,12.0],[0.635,0.3979,0.2677,0.2093,12.0],[0.6371,0.3988,0.2677,0.2093,12.0],[0.6389,0.3995,0.2677,0.2093,12.0],[0.6405,0.4001,0.2677,0.2093,12.0],[0.6417,0.4006,0.2677,0.2093,12.0],[0.6425,0.4009,0.2677,0.2093,12.0],[0.643,0.4011,0.2677,0.2093,12.0],[0.6432,0.4012,0.2677,0.2093,12.0],[0.643,0.4012,0.2677,0.2093,12.0],[0.6421,0.4009,0.2677,0.2093,12.0],[0.6404,0.4003,0.2677,0.2093,12.0],[0.637,0.3991,0.2677,0.2093,12.0],[0.6315,0.3973,0.2677,0.2093,12.0],[0.6237,0.395,0.2677,0.2093,11.97],[0.6138,0.3921,0.2677,0.2093,11.85],[0.6019,0.389,0.2677,0.2093,11.62],[0.5894,0.386,0.2677,0.2093,11.19],[0.5766,0.3833,0.2677,0.2093,10.5],[0.5637,0.381,0.2677,0.2093,9.56],[0.5511,0.3792,0.2677,0.2093,8.34],[0.5393,0.3778,0.2677,0.2093,6.92],[0.5284,0.3768,0.2677,0.2093,5.45],[0.5187,0.3764,0.2677,0.2093,4.02],[0.5114,0.3762,0.2677,0.2093,2.77],[0.5065,0.3751,0.2677,0.2093,1.8],[0.5035,0.3733,0.2677,0.2093,1.13],[0.502,0.3705,0.2677,0.2093,0.76],[0.502,0.3671,0.2677,0.2093,0.62],[0.5019,0.3634,0.2677,0.2093,0.63],[0.5019,0.3604,0.2677,0.2093,0.72],[0.5019,0.3582,0.2677,0.2093,0.83],[0.5019,0.357,0.2677,0.2093,0.9],[0.5019,0.3564,0.2677,0.2093,0.92],[0.5019,0.3564,0.2677,0.2093,0.92],[0.502,0.3569,0.2677,0.2093,0.89],[0.502,0.3576,0.2677,0.2093,0.85],[0.502,0.3585,0.2677,0.2093,0.78],[0.5021,0.3596,0.2677,0.2093,0.72],[0.5021,0.3607,0.2677,0.2093,0.65],[0.5021,0.3619,0.2677,0.2093,0.58],[0.5021,0.3632,0.2677,0.2093,0.51],[0.5021,0.3647,0.2677,0.2093,0.46],[0.5021,0.366,0.2677,0.2093,0.41],[0.5021,0.3673,0.2677,0.2093,0.36],[0.5022,0.3684,0.2677,0.2093,0.3],[0.5022,0.3695,0.2677,0.2093,0.25],[0.5022,0.3707,0.2677,0.2093,0.19],[0.5022,0.3718,0.2677,0.2093,0.12],[0.5022,0.3726,0.2677,0.2093,0.04],[0.5023,0.3736,0.2677,0.2093,-0.03],[0.5023,0.3744,0.2677,0.2093,-0.1],[0.5023,0.3749,0.2677,0.2093,-0.16],[0.5023,0.3754,0.2677,0.2093,-0.22],[0.5023,0.3757,0.2677,0.2093,-0.26],[0.5023,0.376,0.2677,0.2093,-0.3],[0.5023,0.3761,0.2677,0.2093,-0.32],[0.5022,0.3762,0.2677,0.2093,-0.33],[0.5023,0.3762,0.2677,0.2093,-0.34],[0.5022,0.3763,0.2677,0.2093,-0.33],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.31],[0.5022,0.3763,0.2677,0.2093,-0.3],[0.5022,0.3763,0.2677,0.2093,-0.3]],"reach":[0.03,0.157,0.97,0.92],"script":[[0,"happy",0,0],[0.3,"blink",0,0],[0.6,"happy",0,0]]},"story-history":{"file":"clips/story-history.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-history-rest","mask":true,"tracks":[[0.4989,0.3094,0.3009,0.2367,-0.51],[0.4989,0.3094,0.3009,0.2367,-0.52],[0.4989,0.3094,0.3009,0.2367,-0.53],[0.4989,0.3094,0.3009,0.2367,-0.54],[0.4989,0.3094,0.3009,0.2367,-0.55],[0.4989,0.3094,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3096,0.3009,0.2367,-0.55],[0.4989,0.3097,0.3009,0.2367,-0.52],[0.4989,0.3098,0.3009,0.2367,-0.48],[0.4989,0.3101,0.3009,0.2367,-0.45],[0.4988,0.3105,0.3009,0.2367,-0.42],[0.4988,0.3111,0.3009,0.2367,-0.38],[0.4987,0.3119,0.3009,0.2367,-0.36],[0.4987,0.3131,0.3009,0.2367,-0.34],[0.4986,0.3145,0.3009,0.2367,-0.32],[0.4986,0.3158,0.3009,0.2367,-0.32],[0.4986,0.3176,0.3009,0.2367,-0.31],[0.4986,0.3193,0.3009,0.2367,-0.32],[0.4986,0.3213,0.3009,0.2367,-0.33],[0.4986,0.3231,0.3009,0.2367,-0.35],[0.4985,0.3253,0.3009,0.2367,-0.38],[0.4985,0.3273,0.3009,0.2367,-0.42],[0.4985,0.3295,0.3009,0.2367,-0.47],[0.4985,0.3315,0.3009,0.2367,-0.54],[0.4985,0.3334,0.3009,0.2367,-0.59],[0.4984,0.3352,0.3009,0.2367,-0.64],[0.4984,0.3367,0.3009,0.2367,-0.67],[0.4984,0.3381,0.3009,0.2367,-0.7],[0.4984,0.3391,0.3009,0.2367,-0.71],[0.4984,0.3399,0.3009,0.2367,-0.72],[0.4983,0.3404,0.3009,0.2367,-0.71],[0.4983,0.3407,0.3009,0.2367,-0.72],[0.4983,0.3408,0.3009,0.2367,-0.72],[0.4983,0.3409,0.3009,0.2367,-0.73],[0.4983,0.3409,0.3009,0.2367,-0.74],[0.4984,0.3409,0.3009,0.2367,-0.76],[0.4984,0.3409,0.3009,0.2367,-0.78],[0.4984,0.3409,0.3009,0.2367,-0.79],[0.4984,0.3409,0.3009,0.2367,-0.81],[0.4984,0.3409,0.3009,0.2367,-0.84],[0.4985,0.3411,0.3009,0.2367,-0.88],[0.4986,0.3415,0.3009,0.2367,-0.92],[0.4991,0.3423,0.3009,0.2367,-0.97],[0.5,0.3436,0.3009,0.2367,-1.04],[0.5016,0.3453,0.3009,0.2367,-1.14],[0.504,0.3476,0.3009,0.2367,-1.28],[0.5072,0.35,0.3009,0.2367,-1.44],[0.5107,0.3529,0.3009,0.2367,-1.67],[0.5145,0.3558,0.3009,0.2367,-1.95],[0.5182,0.3588,0.3009,0.2367,-2.26],[0.5214,0.3619,0.3009,0.2367,-2.61],[0.5241,0.3651,0.3009,0.2367,-2.95],[0.5262,0.3673,0.3009,0.2367,-3.26],[0.5276,0.3697,0.3009,0.2367,-3.55],[0.5282,0.3732,0.3009,0.2367,-3.77],[0.5284,0.3749,0.3009,0.2367,-3.89],[0.5284,0.3746,0.3009,0.2367,-3.96],[0.5284,0.3768,0.3009,0.2367,-4.0],[0.5283,0.3797,0.3009,0.2367,-4.01],[0.5283,0.3795,0.3009,0.2367,-3.99],[0.5283,0.3785,0.3009,0.2367,-3.96],[0.5282,0.3801,0.3009,0.2367,-3.93],[0.5282,0.3816,0.3009,0.2367,-3.9],[0.5282,0.38,0.3009,0.2367,-3.85],[0.5282,0.3779,0.3009,0.2367,-3.78],[0.5282,0.3782,0.3009,0.2367,-3.72],[0.5282,0.3791,0.3009,0.2367,-3.67],[0.5282,0.3769,0.3009,0.2367,-3.61],[0.5282,0.3749,0.3009,0.2367,-3.58],[0.5282,0.3769,0.3009,0.2367,-3.59],[0.5281,0.3782,0.3009,0.2367,-3.62],[0.5282,0.376,0.3009,0.2367,-3.64],[0.5283,0.3758,0.3009,0.2367,-3.66],[0.5283,0.3777,0.3009,0.2367,-3.67],[0.5284,0.3771,0.3009,0.2367,-3.67],[0.5286,0.376,0.3009,0.2367,-3.63],[0.5286,0.3757,0.3009,0.2367,-3.57],[0.5288,0.3742,0.3009,0.2367,-3.48],[0.529,0.3708,0.3009,0.2367,-3.33],[0.5288,0.3673,0.3009,0.2367,-3.12],[0.5279,0.3636,0.3009,0.2367,-2.86],[0.5262,0.3594,0.3009,0.2367,-2.54],[0.5238,0.3547,0.3009,0.2367,-2.18],[0.5204,0.3493,0.3009,0.2367,-1.79],[0.5164,0.3437,0.3009,0.2367,-1.42],[0.5122,0.3379,0.3009,0.2367,-1.09],[0.5082,0.3324,0.3009,0.2367,-0.82],[0.5047,0.3273,0.3009,0.2367,-0.62],[0.502,0.3231,0.3009,0.2367,-0.48],[0.5002,0.3197,0.3009,0.2367,-0.41],[0.4991,0.3169,0.3009,0.2367,-0.38],[0.4987,0.3147,0.3009,0.2367,-0.37],[0.4986,0.3132,0.3009,0.2367,-0.38],[0.4985,0.3121,0.3009,0.2367,-0.41],[0.4985,0.3113,0.3009,0.2367,-0.43],[0.4986,0.3108,0.3009,0.2367,-0.46],[0.4986,0.3104,0.3009,0.2367,-0.48],[0.4986,0.3101,0.3009,0.2367,-0.5],[0.4986,0.31,0.3009,0.2367,-0.53],[0.4987,0.3098,0.3009,0.2367,-0.54],[0.4987,0.3097,0.3009,0.2367,-0.55],[0.4987,0.3097,0.3009,0.2367,-0.54],[0.4987,0.3096,0.3009,0.2367,-0.52],[0.4987,0.3095,0.3009,0.2367,-0.51],[0.4987,0.3095,0.3009,0.2367,-0.48],[0.4987,0.3095,0.3009,0.2367,-0.47],[0.4987,0.3095,0.3009,0.2367,-0.45],[0.4986,0.3094,0.3009,0.2367,-0.44],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3095,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41]],"reach":[0.1788,0.03,0.8212,0.92],"script":[[0,"idle",0,1],[0.6,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-morning":{"file":"clips/story-morning.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-morning-rest","mask":true,"tracks":[[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.3],[0.4983,0.276,0.3172,0.2498,-0.31],[0.4983,0.2759,0.3172,0.2498,-0.32],[0.4983,0.2758,0.3172,0.2498,-0.31],[0.4984,0.2759,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.25],[0.4983,0.2765,0.3172,0.2498,-0.2],[0.4983,0.2776,0.3172,0.2498,-0.13],[0.4983,0.279,0.3172,0.2498,-0.07],[0.4984,0.281,0.3172,0.2498,-0.01],[0.4984,0.2838,0.3172,0.2498,0.03],[0.4984,0.2872,0.3172,0.2498,0.05],[0.4985,0.2908,0.3172,0.2498,0.04],[0.4985,0.2954,0.3172,0.2498,0.01],[0.4985,0.3003,0.3172,0.2498,-0.02],[0.4985,0.3051,0.3172,0.2498,-0.06],[0.4985,0.3098,0.3172,0.2498,-0.11],[0.4984,0.3141,0.3172,0.2498,-0.17],[0.4983,0.3175,0.3172,0.2498,-0.23],[0.4981,0.3201,0.3172,0.2498,-0.31],[0.498,0.3219,0.3172,0.2498,-0.39],[0.4979,0.323,0.3172,0.2498,-0.49],[0.4978,0.3235,0.3172,0.2498,-0.59],[0.4977,0.3237,0.3172,0.2498,-0.7],[0.4976,0.3235,0.3172,0.2498,-0.79],[0.4975,0.323,0.3172,0.2498,-0.88],[0.4974,0.3221,0.3172,0.2498,-0.96],[0.4973,0.3209,0.3172,0.2498,-1.03],[0.4973,0.3192,0.3172,0.2498,-1.09],[0.4972,0.3173,0.3172,0.2498,-1.14],[0.4971,0.3151,0.3172,0.2498,-1.15],[0.4969,0.313,0.3172,0.2498,-1.14],[0.4968,0.3107,0.3172,0.2498,-1.07],[0.4968,0.3086,0.3172,0.2498,-0.95],[0.497,0.3065,0.3172,0.2498,-0.79],[0.4975,0.3045,0.3172,0.2498,-0.6],[0.4981,0.3027,0.3172,0.2498,-0.39],[0.4988,0.301,0.3172,0.2498,-0.21],[0.4994,0.2995,0.3172,0.2498,-0.11],[0.4997,0.2983,0.3172,0.2498,-0.1],[0.4996,0.2974,0.3172,0.2498,-0.15],[0.4992,0.2967,0.3172,0.2498,-0.28],[0.4987,0.2958,0.3172,0.2498,-0.43],[0.4983,0.2948,0.3172,0.2498,-0.58],[0.4981,0.2933,0.3172,0.2498,-0.69],[0.4984,0.2919,0.3172,0.2498,-0.76],[0.4988,0.2906,0.3172,0.2498,-0.8],[0.499,0.29,0.3172,0.2498,-0.84],[0.4992,0.2904,0.3172,0.2498,-0.87],[0.499,0.2922,0.3172,0.2498,-0.93],[0.4986,0.2948,0.3172,0.2498,-1.04],[0.4981,0.2982,0.3172,0.2498,-1.19],[0.498,0.302,0.3172,0.2498,-1.37],[0.4976,0.3057,0.3172,0.2498,-1.55],[0.4973,0.3092,0.3172,0.2498,-1.69],[0.4971,0.3126,0.3172,0.2498,-1.8],[0.497,0.3157,0.3172,0.2498,-1.89],[0.497,0.3185,0.3172,0.2498,-1.93],[0.497,0.3205,0.3172,0.2498,-1.92],[0.4971,0.3218,0.3172,0.2498,-1.91],[0.4973,0.322,0.3172,0.2498,-1.87],[0.4974,0.3212,0.3172,0.2498,-1.8],[0.4974,0.319,0.3172,0.2498,-1.69],[0.4976,0.3165,0.3172,0.2498,-1.55],[0.4978,0.3127,0.3172,0.2498,-1.37],[0.498,0.3088,0.3172,0.2498,-1.16],[0.4983,0.3051,0.3172,0.2498,-0.91],[0.4985,0.3014,0.3172,0.2498,-0.67],[0.4985,0.2973,0.3172,0.2498,-0.46],[0.4986,0.2941,0.3172,0.2498,-0.3],[0.4986,0.291,0.3172,0.2498,-0.19],[0.4986,0.2882,0.3172,0.2498,-0.13],[0.4985,0.2856,0.3172,0.2498,-0.11],[0.4985,0.2838,0.3172,0.2498,-0.11],[0.4985,0.282,0.3172,0.2498,-0.11],[0.4985,0.2804,0.3172,0.2498,-0.12],[0.4985,0.2791,0.3172,0.2498,-0.12],[0.4984,0.2781,0.3172,0.2498,-0.1],[0.4984,0.277,0.3172,0.2498,-0.1],[0.4984,0.2762,0.3172,0.2498,-0.09],[0.4984,0.2756,0.3172,0.2498,-0.08],[0.4984,0.275,0.3172,0.2498,-0.08],[0.4985,0.2745,0.3172,0.2498,-0.09],[0.4985,0.2743,0.3172,0.2498,-0.09],[0.4985,0.2742,0.3172,0.2498,-0.1],[0.4985,0.2742,0.3172,0.2498,-0.12],[0.4985,0.2743,0.3172,0.2498,-0.14],[0.4985,0.2744,0.3172,0.2498,-0.16],[0.4985,0.2746,0.3172,0.2498,-0.17],[0.4985,0.2749,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.21],[0.4984,0.2754,0.3172,0.2498,-0.22],[0.4984,0.2757,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2759,0.3172,0.2498,-0.25],[0.4984,0.2759,0.3172,0.2498,-0.27],[0.4984,0.276,0.3172,0.2498,-0.28],[0.4983,0.276,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.276,0.3172,0.2498,-0.28],[0.4983,0.2759,0.3172,0.2498,-0.26],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2756,0.3172,0.2498,-0.22],[0.4984,0.2755,0.3172,0.2498,-0.2],[0.4984,0.2753,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.19],[0.4984,0.2751,0.3172,0.2498,-0.19],[0.4984,0.275,0.3172,0.2498,-0.2],[0.4984,0.2748,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.22],[0.4984,0.2746,0.3172,0.2498,-0.22],[0.4984,0.2745,0.3172,0.2498,-0.22]],"reach":[0.1974,0.03,0.8026,0.92],"script":[[0,"idle",0,0],[0.25,"blink",0,0],[0.55,"happy",0,0]]},"story-news":{"file":"clips/story-news.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-news-rest","mask":true,"tracks":[[0.503,0.3217,0.2842,0.2162,-0.52],[0.503,0.3216,0.2842,0.2162,-0.53],[0.503,0.3216,0.2842,0.2162,-0.53],[0.5029,0.3215,0.2842,0.2162,-0.55],[0.5029,0.3215,0.2842,0.2162,-0.57],[0.5029,0.3215,0.2842,0.2162,-0.58],[0.5029,0.3215,0.2842,0.2162,-0.6],[0.5029,0.3215,0.2842,0.2162,-0.62],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.61],[0.5029,0.3216,0.2842,0.2162,-0.59],[0.5029,0.3216,0.2842,0.2162,-0.55],[0.503,0.3217,0.2842,0.2162,-0.51],[0.503,0.3217,0.2842,0.2162,-0.47],[0.503,0.3216,0.2842,0.2162,-0.43],[0.5031,0.3216,0.2842,0.2162,-0.4],[0.5031,0.3215,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.4],[0.5031,0.3214,0.2842,0.2162,-0.41],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3216,0.2842,0.2162,-0.42],[0.5031,0.3217,0.2842,0.2162,-0.43],[0.5031,0.3219,0.2842,0.2162,-0.45],[0.5031,0.3223,0.2842,0.2162,-0.48],[0.5031,0.3227,0.2842,0.2162,-0.54],[0.5031,0.3232,0.2842,0.2162,-0.65],[0.5031,0.324,0.2842,0.2162,-0.81],[0.5031,0.3251,0.2842,0.2162,-1.05],[0.5029,0.3263,0.2842,0.2162,-1.38],[0.5024,0.3278,0.2842,0.2162,-1.76],[0.5013,0.3297,0.2842,0.2162,-2.16],[0.4997,0.3316,0.2842,0.2162,-2.62],[0.4979,0.3333,0.2842,0.2162,-3.13],[0.4962,0.3344,0.2842,0.2162,-3.66],[0.4929,0.3342,0.2842,0.2162,-4.23],[0.4892,0.3333,0.2842,0.2162,-4.83],[0.4854,0.3319,0.2842,0.2162,-5.43],[0.4815,0.3303,0.2842,0.2162,-6.03],[0.4775,0.329,0.2842,0.2162,-6.56],[0.4753,0.3286,0.2842,0.2162,-7.01],[0.4741,0.3284,0.2842,0.2162,-7.39],[0.4735,0.3283,0.2842,0.2162,-7.64],[0.4733,0.3283,0.2842,0.2162,-7.77],[0.4733,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.87],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.85],[0.4734,0.3283,0.2842,0.2162,-7.84],[0.4734,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.82],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4735,0.3284,0.2842,0.2162,-7.81],[0.4735,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.78],[0.4734,0.3282,0.2842,0.2162,-7.77],[0.4738,0.3284,0.2842,0.2162,-7.71],[0.4748,0.3292,0.2842,0.2162,-7.57],[0.4765,0.3301,0.2842,0.2162,-7.34],[0.4793,0.3313,0.2842,0.2162,-7.0],[0.4825,0.3321,0.2842,0.2162,-6.52],[0.4864,0.3323,0.2842,0.2162,-5.89],[0.4901,0.3315,0.2842,0.2162,-5.15],[0.4933,0.3301,0.2842,0.2162,-4.34],[0.4961,0.3281,0.2842,0.2162,-3.54],[0.4984,0.3262,0.2842,0.2162,-2.76],[0.5,0.3247,0.2842,0.2162,-2.08],[0.5012,0.3236,0.2842,0.2162,-1.54],[0.5021,0.3228,0.2842,0.2162,-1.14],[0.5025,0.3223,0.2842,0.2162,-0.84],[0.5027,0.3221,0.2842,0.2162,-0.63],[0.5028,0.3219,0.2842,0.2162,-0.48],[0.5029,0.3218,0.2842,0.2162,-0.32],[0.503,0.3216,0.2842,0.2162,-0.18],[0.5031,0.3214,0.2842,0.2162,-0.04],[0.5034,0.3211,0.2842,0.2162,0.06],[0.5035,0.3211,0.2842,0.2162,0.12],[0.5035,0.3211,0.2842,0.2162,0.09],[0.5035,0.3212,0.2842,0.2162,0.02],[0.5034,0.3213,0.2842,0.2162,-0.12],[0.5031,0.3217,0.2842,0.2162,-0.27],[0.5031,0.3218,0.2842,0.2162,-0.42],[0.503,0.3218,0.2842,0.2162,-0.54],[0.503,0.3218,0.2842,0.2162,-0.64],[0.5029,0.3217,0.2842,0.2162,-0.67],[0.5029,0.3216,0.2842,0.2162,-0.69],[0.503,0.3216,0.2842,0.2162,-0.71],[0.503,0.3216,0.2842,0.2162,-0.72],[0.503,0.3216,0.2842,0.2162,-0.73],[0.503,0.3216,0.2842,0.2162,-0.74],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75]],"reach":[0.1966,0.03,0.8034,0.92],"script":[[0,"idle",0,0],[0.25,"idle",0,1],[0.7,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-night":{"file":"clips/story-night.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-night-rest","mask":true,"tracks":[[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.3361,0.2636,0.2077,0.64],[0.4376,0.3368,0.2636,0.2077,0.63],[0.4377,0.3389,0.2636,0.2077,0.62],[0.4378,0.3428,0.2636,0.2077,0.6],[0.438,0.3484,0.2636,0.2077,0.61],[0.4383,0.3555,0.2636,0.2077,0.65],[0.4386,0.3631,0.2636,0.2077,0.76],[0.439,0.3696,0.2636,0.2077,1.0],[0.4395,0.3738,0.2636,0.2077,1.38],[0.4399,0.3747,0.2636,0.2077,1.91],[0.4401,0.3722,0.2636,0.2077,2.59],[0.4401,0.3664,0.2636,0.2077,3.37],[0.4399,0.3581,0.2636,0.2077,4.21],[0.4396,0.3489,0.2636,0.2077,5.06],[0.4392,0.3392,0.2636,0.2077,5.9],[0.4389,0.3302,0.2636,0.2077,6.68],[0.4389,0.3227,0.2636,0.2077,7.41],[0.4391,0.3165,0.2636,0.2077,8.05],[0.4394,0.3114,0.2636,0.2077,8.62],[0.4399,0.3081,0.2636,0.2077,9.11],[0.4404,0.306,0.2636,0.2077,9.52],[0.4407,0.3048,0.2636,0.2077,9.84],[0.4409,0.3041,0.2636,0.2077,10.09],[0.441,0.3039,0.2636,0.2077,10.26],[0.4412,0.3037,0.2636,0.2077,10.38],[0.4413,0.3036,0.2636,0.2077,10.46],[0.4415,0.3034,0.2636,0.2077,10.55],[0.4416,0.3032,0.2636,0.2077,10.63],[0.4418,0.303,0.2636,0.2077,10.71],[0.4419,0.3028,0.2636,0.2077,10.79],[0.442,0.3027,0.2636,0.2077,10.87],[0.4422,0.3025,0.2636,0.2077,10.95],[0.4423,0.3023,0.2636,0.2077,11.03],[0.4425,0.3021,0.2636,0.2077,11.11],[0.4426,0.3019,0.2636,0.2077,11.19],[0.4427,0.3018,0.2636,0.2077,11.27],[0.4429,0.3016,0.2636,0.2077,11.35],[0.443,0.3014,0.2636,0.2077,11.43],[0.4432,0.3012,0.2636,0.2077,11.52],[0.4433,0.301,0.2636,0.2077,11.6],[0.4435,0.3009,0.2636,0.2077,11.67],[0.4436,0.3007,0.2636,0.2077,11.7],[0.4437,0.3005,0.2636,0.2077,11.69],[0.4437,0.3012,0.2636,0.2077,11.65],[0.4438,0.3031,0.2636,0.2077,11.58],[0.4437,0.3072,0.2636,0.2077,11.48],[0.4434,0.3134,0.2636,0.2077,11.37],[0.4417,0.3236,0.2636,0.2077,11.25],[0.4389,0.3342,0.2636,0.2077,11.09],[0.4343,0.3442,0.2636,0.2077,10.87],[0.4281,0.3529,0.2636,0.2077,10.55],[0.4197,0.3597,0.2636,0.2077,10.08],[0.4105,0.3626,0.2636,0.2077,9.47],[0.4001,0.3642,0.2636,0.2077,8.68],[0.3892,0.3649,0.2636,0.2077,7.74],[0.3782,0.3647,0.2636,0.2077,6.67],[0.3679,0.3639,0.2636,0.2077,5.54],[0.3585,0.3629,0.2636,0.2077,4.37],[0.3508,0.3618,0.2636,0.2077,3.24],[0.3452,0.3604,0.2636,0.2077,2.18],[0.3417,0.3589,0.2636,0.2077,1.25],[0.3408,0.3571,0.2636,0.2077,0.51],[0.3425,0.3551,0.2636,0.2077,0.04],[0.3472,0.3525,0.2636,0.2077,-0.14],[0.3547,0.3497,0.2636,0.2077,0.07],[0.3651,0.3467,0.2636,0.2077,0.68],[0.3785,0.3435,0.2636,0.2077,1.73],[0.3948,0.3406,0.2636,0.2077,3.1],[0.4133,0.3383,0.2636,0.2077,4.7],[0.4335,0.3368,0.2636,0.2077,6.37],[0.4548,0.3363,0.2636,0.2077,7.98],[0.4758,0.3368,0.2636,0.2077,9.37],[0.4968,0.3385,0.2636,0.2077,10.47],[0.5153,0.3406,0.2636,0.2077,11.22],[0.5309,0.343,0.2636,0.2077,11.69],[0.5434,0.3454,0.2636,0.2077,11.92],[0.5527,0.3476,0.2636,0.2077,12.0],[0.5577,0.349,0.2636,0.2077,12.0],[0.5603,0.35,0.2636,0.2077,12.0],[0.5603,0.3505,0.2636,0.2077,12.0],[0.5579,0.3505,0.2636,0.2077,12.0],[0.5536,0.3501,0.2636,0.2077,12.0],[0.5477,0.3493,0.2636,0.2077,12.0],[0.5403,0.3485,0.2636,0.2077,12.0],[0.5316,0.3476,0.2636,0.2077,12.0],[0.522,0.3468,0.2636,0.2077,12.0],[0.5114,0.3461,0.2636,0.2077,11.98],[0.5002,0.3456,0.2636,0.2077,11.87],[0.487,0.3455,0.2636,0.2077,11.58],[0.4743,0.3458,0.2636,0.2077,11.05],[0.4623,0.3459,0.2636,0.2077,10.23],[0.4523,0.3458,0.2636,0.2077,9.11],[0.4442,0.3453,0.2636,0.2077,7.75],[0.4401,0.344,0.2636,0.2077,6.25],[0.4378,0.3423,0.2636,0.2077,4.76],[0.437,0.3406,0.2636,0.2077,3.4],[0.4368,0.3389,0.2636,0.2077,2.27],[0.4369,0.3377,0.2636,0.2077,1.44],[0.4371,0.3369,0.2636,0.2077,0.91],[0.4372,0.3363,0.2636,0.2077,0.61],[0.4373,0.3361,0.2636,0.2077,0.48],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.42],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.45],[0.4372,0.3361,0.2636,0.2077,0.46],[0.4372,0.3361,0.2636,0.2077,0.47],[0.4372,0.3361,0.2636,0.2077,0.49],[0.4372,0.3361,0.2636,0.2077,0.5],[0.4372,0.3361,0.2636,0.2077,0.52],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53]],"reach":[0.089,0.03,0.911,0.92],"script":[[0,"blink",0,0],[0.3,"idle",0,0],[0.38,"blink",0,0],[0.7,"happy",0,0]]},"story-science":{"file":"clips/story-science.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-science-rest","mask":true,"tracks":[[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.57],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.5],[0.4999,0.31,0.2962,0.2362,-0.45],[0.4993,0.3103,0.2962,0.2362,-0.4],[0.4985,0.3109,0.2962,0.2362,-0.34],[0.4965,0.3123,0.2962,0.2362,-0.25],[0.4935,0.3138,0.2962,0.2362,-0.15],[0.4904,0.3154,0.2962,0.2362,-0.01],[0.4864,0.3172,0.2962,0.2362,0.18],[0.4828,0.3187,0.2962,0.2362,0.4],[0.4794,0.3197,0.2962,0.2362,0.64],[0.4766,0.3204,0.2962,0.2362,0.91],[0.4738,0.3211,0.2962,0.2362,1.16],[0.4721,0.3211,0.2962,0.2362,1.39],[0.4704,0.3211,0.2962,0.2362,1.61],[0.4699,0.3207,0.2962,0.2362,1.86],[0.4703,0.3202,0.2962,0.2362,2.13],[0.4713,0.3196,0.2962,0.2362,2.55],[0.4736,0.3189,0.2962,0.2362,3.22],[0.4761,0.3183,0.2962,0.2362,4.13],[0.4807,0.3179,0.2962,0.2362,5.25],[0.4873,0.3182,0.2962,0.2362,6.55],[0.4933,0.3186,0.2962,0.2362,7.87],[0.5001,0.3197,0.2962,0.2362,9.13],[0.5066,0.3209,0.2962,0.2362,10.22],[0.5115,0.3222,0.2962,0.2362,11.02],[0.5144,0.3233,0.2962,0.2362,11.56],[0.5178,0.3246,0.2962,0.2362,11.89],[0.5194,0.3253,0.2962,0.2362,12.0],[0.5214,0.326,0.2962,0.2362,12.0],[0.5226,0.3265,0.2962,0.2362,12.0],[0.5236,0.3268,0.2962,0.2362,12.0],[0.5243,0.3269,0.2962,0.2362,12.0],[0.525,0.327,0.2962,0.2362,12.0],[0.5258,0.3271,0.2962,0.2362,12.0],[0.5266,0.3272,0.2962,0.2362,12.0],[0.5276,0.3274,0.2962,0.2362,12.0],[0.5284,0.3276,0.2962,0.2362,12.0],[0.5292,0.3277,0.2962,0.2362,12.0],[0.5297,0.3278,0.2962,0.2362,12.0],[0.5302,0.328,0.2962,0.2362,12.0],[0.5304,0.328,0.2962,0.2362,12.0],[0.5306,0.328,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5309,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5299,0.3277,0.2962,0.2362,12.0],[0.5284,0.3271,0.2962,0.2362,12.0],[0.5261,0.326,0.2962,0.2362,12.0],[0.5228,0.3246,0.2962,0.2362,11.94],[0.5187,0.3227,0.2962,0.2362,11.75],[0.5141,0.3207,0.2962,0.2362,11.37],[0.5093,0.3185,0.2962,0.2362,10.77],[0.5046,0.3163,0.2962,0.2362,9.9],[0.5,0.3143,0.2962,0.2362,8.81],[0.4954,0.3124,0.2962,0.2362,7.55],[0.4912,0.3107,0.2962,0.2362,6.22],[0.4877,0.3095,0.2962,0.2362,4.94],[0.4846,0.3085,0.2962,0.2362,3.8],[0.4823,0.308,0.2962,0.2362,2.84],[0.4811,0.3077,0.2962,0.2362,2.12],[0.4804,0.3077,0.2962,0.2362,1.59],[0.48,0.3077,0.2962,0.2362,1.21],[0.4802,0.3078,0.2962,0.2362,0.93],[0.4806,0.3079,0.2962,0.2362,0.71],[0.4816,0.3081,0.2962,0.2362,0.51],[0.4833,0.3083,0.2962,0.2362,0.35],[0.4858,0.3085,0.2962,0.2362,0.19],[0.4883,0.3088,0.2962,0.2362,0.06],[0.4915,0.3091,0.2962,0.2362,-0.06],[0.4944,0.3093,0.2962,0.2362,-0.16],[0.4967,0.3094,0.2962,0.2362,-0.24],[0.4982,0.3096,0.2962,0.2362,-0.3],[0.4995,0.3097,0.2962,0.2362,-0.36],[0.4998,0.3098,0.2962,0.2362,-0.4],[0.4999,0.3098,0.2962,0.2362,-0.43],[0.4999,0.3098,0.2962,0.2362,-0.45],[0.4998,0.3098,0.2962,0.2362,-0.47],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.49],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55]],"reach":[0.1678,0.03,0.8322,0.92],"script":[[0,"idle",0,0],[0.25,"idle",-1,-1],[0.65,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-tech":{"file":"clips/story-tech.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-tech-rest","mask":true,"tracks":[[0.5459,0.3504,0.2718,0.2144,-0.65],[0.5459,0.3503,0.2718,0.2144,-0.63],[0.5456,0.3505,0.2718,0.2144,-0.59],[0.544,0.3509,0.2718,0.2144,-0.3],[0.5403,0.3521,0.2718,0.2144,0.35],[0.5339,0.354,0.2718,0.2144,1.37],[0.5215,0.3577,0.2718,0.2144,2.73],[0.5073,0.3615,0.2718,0.2144,4.43],[0.4932,0.3653,0.2718,0.2144,6.23],[0.4807,0.3685,0.2718,0.2144,7.99],[0.4704,0.3709,0.2718,0.2144,9.5],[0.4658,0.3717,0.2718,0.2144,10.65],[0.4631,0.3722,0.2718,0.2144,11.44],[0.4612,0.3727,0.2718,0.2144,11.89],[0.4594,0.3736,0.2718,0.2144,12.0],[0.4583,0.3744,0.2718,0.2144,12.0],[0.4576,0.375,0.2718,0.2144,12.0],[0.4577,0.3752,0.2718,0.2144,12.0],[0.4597,0.3745,0.2718,0.2144,12.0],[0.4623,0.3733,0.2718,0.2144,12.0],[0.4645,0.3724,0.2718,0.2144,12.0],[0.4659,0.3716,0.2718,0.2144,12.0],[0.4659,0.3715,0.2718,0.2144,12.0],[0.464,0.3724,0.2718,0.2144,12.0],[0.4618,0.3733,0.2718,0.2144,12.0],[0.4599,0.374,0.2718,0.2144,12.0],[0.4587,0.3744,0.2718,0.2144,12.0],[0.4587,0.3743,0.2718,0.2144,12.0],[0.4594,0.3738,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4616,0.3727,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4606,0.3731,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.4605,0.3733,0.2718,0.2144,12.0],[0.4616,0.3729,0.2718,0.2144,12.0],[0.4629,0.3725,0.2718,0.2144,12.0],[0.4636,0.3721,0.2718,0.2144,12.0],[0.4635,0.3721,0.2718,0.2144,12.0],[0.4627,0.3724,0.2718,0.2144,12.0],[0.4611,0.373,0.2718,0.2144,12.0],[0.4594,0.3735,0.2718,0.2144,12.0],[0.4585,0.374,0.2718,0.2144,12.0],[0.4583,0.3741,0.2718,0.2144,12.0],[0.4589,0.3738,0.2718,0.2144,12.0],[0.46,0.3733,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4595,0.3736,0.2718,0.2144,12.0],[0.4554,0.376,0.2718,0.2144,12.0],[0.4503,0.38,0.2718,0.2144,12.0],[0.445,0.3855,0.2718,0.2144,12.0],[0.4406,0.3914,0.2718,0.2144,12.0],[0.4387,0.3956,0.2718,0.2144,12.0],[0.4407,0.3962,0.2718,0.2144,12.0],[0.4446,0.3934,0.2718,0.2144,12.0],[0.4498,0.3876,0.2718,0.2144,12.0],[0.4584,0.3796,0.2718,0.2144,12.0],[0.4666,0.3726,0.2718,0.2144,12.0],[0.4744,0.3675,0.2718,0.2144,12.0],[0.4813,0.3645,0.2718,0.2144,12.0],[0.4871,0.3632,0.2718,0.2144,12.0],[0.4892,0.3633,0.2718,0.2144,12.0],[0.4903,0.3635,0.2718,0.2144,12.0],[0.4909,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4898,0.3636,0.2718,0.2144,12.0],[0.4872,0.364,0.2718,0.2144,11.94],[0.482,0.3673,0.2718,0.2144,11.75],[0.4769,0.3746,0.2718,0.2144,11.33],[0.4764,0.3882,0.2718,0.2144,10.59],[0.4803,0.403,0.2718,0.2144,9.5],[0.4887,0.4135,0.2718,0.2144,8.11],[0.5019,0.4165,0.2718,0.2144,6.48],[0.517,0.4107,0.2718,0.2144,4.79],[0.528,0.3959,0.2718,0.2144,3.18],[0.5364,0.3783,0.2718,0.2144,1.8],[0.5416,0.3649,0.2718,0.2144,0.74],[0.5444,0.3561,0.2718,0.2144,0.04],[0.5455,0.352,0.2718,0.2144,-0.36],[0.5458,0.3506,0.2718,0.2144,-0.52],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.55],[0.5458,0.3506,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57]],"reach":[0.1894,0.03,0.8106,0.92],"script":[[0,"idle",0,1],[0.4,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-trophy":{"file":"clips/story-trophy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-trophy-rest","mask":true,"tracks":[[0.543,0.3385,0.2824,0.2181,-0.62],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.64],[0.543,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.68],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.71],[0.543,0.3385,0.2824,0.2181,-0.73],[0.5431,0.3385,0.2824,0.2181,-0.74],[0.5431,0.3385,0.2824,0.2181,-0.76],[0.5431,0.3385,0.2824,0.2181,-0.79],[0.5431,0.3385,0.2824,0.2181,-0.84],[0.5431,0.3385,0.2824,0.2181,-0.9],[0.543,0.3398,0.2824,0.2181,-0.98],[0.5431,0.3432,0.2824,0.2181,-1.06],[0.5432,0.348,0.2824,0.2181,-0.75],[0.543,0.3534,0.2824,0.2181,-0.5],[0.542,0.3595,0.2824,0.2181,-0.32],[0.5425,0.3614,0.2824,0.2181,-0.19],[0.5398,0.3589,0.2824,0.2181,-0.14],[0.5362,0.3494,0.2824,0.2181,-0.55],[0.5318,0.3349,0.2824,0.2181,-0.96],[0.5278,0.3181,0.2824,0.2181,-1.78],[0.5222,0.304,0.2824,0.2181,-2.55],[0.5198,0.2933,0.2824,0.2181,-3.23],[0.5183,0.2894,0.2824,0.2181,-3.84],[0.5179,0.2907,0.2824,0.2181,-4.35],[0.518,0.2937,0.2824,0.2181,-4.39],[0.5182,0.2962,0.2824,0.2181,-4.43],[0.5183,0.2972,0.2824,0.2181,-4.45],[0.5182,0.2969,0.2824,0.2181,-4.46],[0.518,0.2957,0.2824,0.2181,-4.49],[0.5179,0.2943,0.2824,0.2181,-4.51],[0.5178,0.2937,0.2824,0.2181,-4.53],[0.5178,0.2935,0.2824,0.2181,-4.56],[0.5177,0.2934,0.2824,0.2181,-4.59],[0.5177,0.2934,0.2824,0.2181,-4.61],[0.5177,0.2934,0.2824,0.2181,-4.64],[0.5177,0.2934,0.2824,0.2181,-4.63],[0.5177,0.2935,0.2824,0.2181,-4.6],[0.5177,0.2935,0.2824,0.2181,-4.54],[0.5183,0.285,0.2824,0.2181,-4.44],[0.5189,0.272,0.2824,0.2181,-4.31],[0.5193,0.256,0.2824,0.2181,-4.19],[0.5197,0.2462,0.2824,0.2181,-4.06],[0.5198,0.2506,0.2824,0.2181,-3.97],[0.5194,0.2677,0.2824,0.2181,-3.94],[0.5191,0.2887,0.2824,0.2181,-3.94],[0.5189,0.3113,0.2824,0.2181,-3.99],[0.5188,0.3255,0.2824,0.2181,-4.07],[0.5188,0.324,0.2824,0.2181,-4.16],[0.5185,0.3164,0.2824,0.2181,-4.23],[0.5181,0.3087,0.2824,0.2181,-4.3],[0.5176,0.3021,0.2824,0.2181,-4.35],[0.5171,0.2974,0.2824,0.2181,-4.39],[0.5168,0.2945,0.2824,0.2181,-4.44],[0.5168,0.2936,0.2824,0.2181,-4.47],[0.5168,0.2934,0.2824,0.2181,-4.43],[0.5171,0.2938,0.2824,0.2181,-4.29],[0.5177,0.2993,0.2824,0.2181,-4.04],[0.5184,0.3079,0.2824,0.2181,-3.68],[0.5197,0.3182,0.2824,0.2181,-3.22],[0.5216,0.3293,0.2824,0.2181,-2.73],[0.5241,0.3399,0.2824,0.2181,-2.22],[0.527,0.3453,0.2824,0.2181,-1.78],[0.5304,0.347,0.2824,0.2181,-1.41],[0.5338,0.3465,0.2824,0.2181,-1.13],[0.5371,0.3445,0.2824,0.2181,-0.93],[0.5396,0.3425,0.2824,0.2181,-0.84],[0.5414,0.3406,0.2824,0.2181,-0.76],[0.5425,0.3393,0.2824,0.2181,-0.7],[0.5431,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.61],[0.543,0.3385,0.2824,0.2181,-0.6],[0.5429,0.3385,0.2824,0.2181,-0.59],[0.5429,0.3385,0.2824,0.2181,-0.58],[0.5429,0.3385,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.56],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.58],[0.543,0.3384,0.2824,0.2181,-0.59],[0.543,0.3384,0.2824,0.2181,-0.6],[0.543,0.3384,0.2824,0.2181,-0.61],[0.543,0.3384,0.2824,0.2181,-0.62],[0.543,0.3384,0.2824,0.2181,-0.63],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64]],"reach":[0.1216,0.03,0.8784,0.92],"script":[[0,"happy",0,0],[0.5,"blink",0,0],[0.54,"happy",0,0]]}}} \ No newline at end of file diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxloreModalBottomSheet.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxloreModalBottomSheet.kt index ae9d4752d..37077d3cf 100644 --- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxloreModalBottomSheet.kt +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxloreModalBottomSheet.kt @@ -24,6 +24,13 @@ import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowWidth import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout import cx.aswin.boxlore.core.designsystem.layout.foldSafeRegions +/** Whether [BoxloreModalBottomSheet] shows as a centered dialog here (expanded or folded tablet windows) rather than a sheet. */ +@Composable +fun boxloreSheetIsCentered(): Boolean { + val window = LocalBoxloreWindowLayout.current + return window.usesTabletLayout && (window.widthClass >= BoxloreWindowWidth.Expanded || window.foldSafeRegions().size > 1) +} + /** Native phone sheet; a bounded, inset-aware dialog on expanded tablet windows. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -45,9 +52,7 @@ fun BoxloreModalBottomSheet( centeredDismissAllowed: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { - val window = LocalBoxloreWindowLayout.current - val centered = window.usesTabletLayout && (window.widthClass >= BoxloreWindowWidth.Expanded || window.foldSafeRegions().size > 1) - if (!centered) { + if (!boxloreSheetIsCentered()) { ModalBottomSheet( onDismissRequest, modifier, sheetState, sheetMaxWidth, sheetGesturesEnabled, shape, containerColor, contentColor, tonalElevation, scrimColor, dragHandle, contentWindowInsets, properties, content, diff --git a/core/prefs/README.md b/core/prefs/README.md index f1b5d9331..a9e970fca 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -50,6 +50,8 @@ src/main/java/cx/aswin/boxlore/core/prefs/ FontRoundnessAxis.kt HomePinnedShows.kt LibraryPreferences.kt + NpsSurveySchedule.kt + NpsSurveyStore.kt PlaybackSkipBounds.kt PreferenceIdList.kt PrefsFileMigrator.kt @@ -82,6 +84,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/ - `subscription_manual_order` and `home_pinned_podcast_ids` live in the same `user_preferences` DataStore (do not rename the file). Unsubscribe drops that id from both lists. - `seen_listening_story` (`seenListeningStoryStream` / `setSeenListeningStory`, added 2026-10-31): the last monthly listening story opened, as `yyyy-MM`, so Listening history stops calling it New. Local to the phone. - Review prompt (`EngagementPreferences`): a milestone (5, 15 or 30 episodes finished in boxlore on this install; Home counts them) is stored as `review_milestone_pending` and, once a prompt shows, as `review_milestone_consumed`, so the same milestone never asks again. `shouldShowReviewPrompt` and `isInReviewQuietPeriod` take Android's install time (a backup doesn't carry it) and keep a 2-day quiet period (`REVIEW_QUIET_DAYS`) after it and after `library_imported_at` (`markLibraryImported`). `review_first_launch_at` (the old lazily set install day) is no longer read. The pending milestone follows the count, so one a backup brought onto a phone with no listening behind it is dropped; on the first sync after updating, an install already prompted counts milestone 5 (or the one it has reached) as used (`ReviewPromptPreferencesTest`). `has_logged_first_play` is read only so an updated install doesn't send `first_episode_played` again; the player claims it now. `UserPreferencesRepositoryTest` covers the quiet days after install and import and a used milestone never coming back. +- NPS survey: `NpsSurveySchedule` decides when it may appear: right after an episode finishes in this run of boxlore (within 2 hours; never on an open alone), with 3 episodes finished here, a week since install, not in the first run, outside the quiet days and the shared 14-day cooldown, and 90 days after it last appeared. `NpsSurveyStore` keeps when it last appeared and how many times boxlore has started in its own file, `boxlore_nps_survey`, which backups leave out, so a restored phone is asked afresh. Once per install `carryOverLegacy` makes an updated install that showed the old popover survey (`nps_survey_fired`) wait 90 days from the update; a phone installed within a day is treated as restored and ignores the copied flag. `nps_survey_fired` and `nps_last_score` stay in `user_preferences`, because the milestone review prompt waits for the survey and skips low scores; the old pending and Play hand-off keys are no longer used. `NpsSurveyScheduleTest` and `NpsSurveyStoreTest` cover each rule. - `legacy_rss_repair_*` keys are a crash-recovery contract; keep them until every shipping build that can begin the repair has aged out. ## Testing notes diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt index e62b5449f..208542480 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt @@ -44,26 +44,16 @@ private object AnalyticsKeys { androidx.datastore.preferences.core .booleanPreferencesKey("review_has_reviewed") - // NPS survey: milestone marks eligibility (pending); the event fires on - // the next app open so it never surfaces during background playback. - val NPS_SURVEY_PENDING = - androidx.datastore.preferences.core - .booleanPreferencesKey("nps_survey_pending") + // NPS survey: whether it has appeared, which the milestone review prompt waits for. val NPS_SURVEY_FIRED = androidx.datastore.preferences.core .booleanPreferencesKey("nps_survey_fired") - val NPS_SURVEY_COMPLETED_COUNT = - androidx.datastore.preferences.core - .intPreferencesKey("nps_survey_completed_count") val ENGAGEMENT_LAST_PROMPT_AT = androidx.datastore.preferences.core .longPreferencesKey("engagement_last_prompt_at") val NPS_LAST_SCORE = androidx.datastore.preferences.core .intPreferencesKey("nps_last_score") - val PROMOTER_REVIEW_PENDING = - androidx.datastore.preferences.core - .booleanPreferencesKey("promoter_review_pending") val REVIEW_MILESTONE_PENDING = androidx.datastore.preferences.core .intPreferencesKey("review_milestone_pending") @@ -286,49 +276,19 @@ internal class EngagementPreferences(private val dataStore: DataStore - if (pref[AnalyticsKeys.NPS_SURVEY_FIRED] == true) return@edit - pref[AnalyticsKeys.NPS_SURVEY_PENDING] = true - pref[AnalyticsKeys.NPS_SURVEY_COMPLETED_COUNT] = completedCount - } - } - - suspend fun isNpsSurveyPending(): Boolean = dataStore.data.first()[AnalyticsKeys.NPS_SURVEY_PENDING] ?: false - - /** Whether the NPS trigger event has already fired for this install. */ + /** Whether the NPS survey has appeared for this listener; the milestone review prompt waits for it. */ suspend fun hasNpsSurveyFired(): Boolean = dataStore.data.first()[AnalyticsKeys.NPS_SURVEY_FIRED] ?: false - /** Completed-episode count captured when the survey became pending. */ - suspend fun npsSurveyCompletedCount(): Int? = dataStore.data.first()[AnalyticsKeys.NPS_SURVEY_COMPLETED_COUNT] - - /** Mark the NPS survey as fired and clear the pending flag. */ + /** The NPS survey appeared. Its own schedule lives in [NpsSurveyStore], which backups leave out. */ suspend fun markNpsSurveyFired() { - dataStore.edit { pref -> - pref[AnalyticsKeys.NPS_SURVEY_FIRED] = true - pref[AnalyticsKeys.NPS_SURVEY_PENDING] = false - } + dataStore.edit { pref -> pref[AnalyticsKeys.NPS_SURVEY_FIRED] = true } } } diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt index d66df2190..2c779e5b6 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt @@ -4,9 +4,6 @@ package cx.aswin.boxlore.core.prefs * Shared thresholds for NPS scoring and proactive engagement cooldowns. */ object EngagementPromptConstants { - /** Minimum NPS score (inclusive) that qualifies for Play Store promoter handoff. */ - const val PROMOTER_SCORE_THRESHOLD = 8 - /** Minimum days between unrelated proactive prompts (NPS, milestone review). */ const val ENGAGEMENT_COOLDOWN_DAYS = 14 diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt new file mode 100644 index 000000000..27995e8bb --- /dev/null +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt @@ -0,0 +1,51 @@ +package cx.aswin.boxlore.core.prefs + +/** One moment Home could show the NPS survey: the listening done on this install and what else is going on. */ +data class NpsSurveyMoment( + val nowMs: Long, + val installedAtMs: Long, + /** Episodes played to the end in boxlore since it was installed here. */ + val finishedSinceInstall: Int, + /** When the latest of them finished; null when none has. */ + val lastFinishedAtMs: Long?, + val processStartedAtMs: Long, + val record: NpsSurveyRecord, + /** The quiet days after installing, onboarding, an import or a first sign-in. */ + val inQuietDays: Boolean, + /** The gap shared with the review prompt has passed. */ + val cooldownElapsed: Boolean, +) + +/** + * When the NPS survey may appear: right after an episode finishes in this run of boxlore (never on an + * open alone), once 3 episodes have finished here and boxlore has been installed for a week, never in + * the first run, never in the quiet days, and again only 90 days after it last appeared. Playback and + * other sheets are checked by the caller. + */ +object NpsSurveySchedule { + const val MIN_FINISHED_EPISODES = 3 + const val MIN_DAYS_INSTALLED = 7 + const val ASK_AGAIN_DAYS = 90 + + /** How long after an episode finishes Home still counts it as the moment to ask. */ + const val FINISH_MOMENT_WINDOW_MS = 2L * 60 * 60 * 1000 + + /** An episode finished since this run of boxlore started, and recently. */ + fun isFinishMoment(lastFinishedAtMs: Long?, processStartedAtMs: Long, nowMs: Long,): Boolean = + lastFinishedAtMs != null && lastFinishedAtMs >= processStartedAtMs && nowMs - lastFinishedAtMs <= FINISH_MOMENT_WINDOW_MS + + fun isDue(moment: NpsSurveyMoment): Boolean { + val now = moment.nowMs + val lastShown = moment.record.lastShownAtMs + return isFinishMoment(moment.lastFinishedAtMs, moment.processStartedAtMs, now) && + moment.finishedSinceInstall >= MIN_FINISHED_EPISODES && + moment.installedAtMs > 0L && + now - moment.installedAtMs >= MIN_DAYS_INSTALLED * DAY_MS && + moment.record.launches >= 2 && + !moment.inQuietDays && + moment.cooldownElapsed && + (lastShown == null || now - lastShown >= ASK_AGAIN_DAYS * DAY_MS) + } + + private const val DAY_MS = 24L * 60 * 60 * 1000 +} diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt new file mode 100644 index 000000000..258fa4bb4 --- /dev/null +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt @@ -0,0 +1,62 @@ +package cx.aswin.boxlore.core.prefs + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import java.io.IOException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first + +/** The NPS survey's history on this install. The backup rules leave the file out, so a restored phone is asked afresh. */ +internal val Context.npsSurveyDataStore: DataStore by preferencesDataStore(name = NpsSurveyStore.FILE_NAME) + +/** What the NPS survey's schedule reads: when it last appeared here, and how many times boxlore has started here. */ +data class NpsSurveyRecord(val lastShownAtMs: Long?, val launches: Int,) + +class NpsSurveyStore internal constructor(private val dataStore: DataStore,) { + constructor(context: Context) : this(context.applicationContext.npsSurveyDataStore) + + suspend fun record(): NpsSurveyRecord { + val prefs = dataStore.data.catch { if (it is IOException) emit(emptyPreferences()) else throw it }.first() + return NpsSurveyRecord(lastShownAtMs = prefs[LAST_SHOWN_AT], launches = prefs[LAUNCHES] ?: 0) + } + + /** Counts one start of boxlore on this install, so the survey never appears in the first one. */ + suspend fun recordLaunch() { + dataStore.edit { it[LAUNCHES] = (it[LAUNCHES] ?: 0) + 1 } + } + + /** The survey was on screen at [nowMs]; it waits [NpsSurveySchedule.ASK_AGAIN_DAYS] before asking again. */ + suspend fun markShown(nowMs: Long) { + dataStore.edit { it[LAST_SHOWN_AT] = nowMs } + } + + /** + * Runs once per install. An install that already showed the survey from an older version + * ([legacyShown], the backed-up `nps_survey_fired`) waits the full gap from the update. A phone + * restored from a backup is new here (installed within a day of [nowMs]), so the copied flag is + * ignored and it is asked like any new install. + */ + suspend fun carryOverLegacy(legacyShown: Boolean, installedAtMs: Long, nowMs: Long,) { + dataStore.edit { prefs -> + if (prefs[LEGACY_CHECKED] == true) return@edit + prefs[LEGACY_CHECKED] = true + val updatedInstall = installedAtMs > 0L && nowMs - installedAtMs >= RESTORED_INSTALL_WINDOW_MS + if (legacyShown && updatedInstall && prefs[LAST_SHOWN_AT] == null) prefs[LAST_SHOWN_AT] = nowMs + } + } + + companion object { + const val FILE_NAME = "boxlore_nps_survey" + private const val RESTORED_INSTALL_WINDOW_MS = 24L * 60 * 60 * 1000 + private val LAST_SHOWN_AT = longPreferencesKey("last_shown_at") + private val LAUNCHES = intPreferencesKey("launches") + private val LEGACY_CHECKED = booleanPreferencesKey("legacy_checked") + } +} diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt index bb3a89918..4afcf8891 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt @@ -648,18 +648,8 @@ class UserPreferencesRepository(context: Context,) { suspend fun npsLastScore(): Int? = engagement.npsLastScore() - suspend fun setPromoterReviewPending(pending: Boolean) = engagement.setPromoterReviewPending(pending) - - suspend fun isPromoterReviewPending(): Boolean = engagement.isPromoterReviewPending() - - suspend fun markNpsSurveyPending(completedCount: Int) = engagement.markNpsSurveyPending(completedCount) - - suspend fun isNpsSurveyPending(): Boolean = engagement.isNpsSurveyPending() - suspend fun hasNpsSurveyFired(): Boolean = engagement.hasNpsSurveyFired() - suspend fun npsSurveyCompletedCount(): Int? = engagement.npsSurveyCompletedCount() - suspend fun markNpsSurveyFired() = engagement.markNpsSurveyFired() val hideCompletedInFeedsStream: Flow = diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt new file mode 100644 index 000000000..d24624827 --- /dev/null +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt @@ -0,0 +1,78 @@ +package cx.aswin.boxlore.core.prefs + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** When the NPS survey may appear: only at a finish moment, after real listening here, and every 90 days. */ +class NpsSurveyScheduleTest { + private val day = 24L * 60 * 60 * 1000 + private val now = 1_800_000_000_000L + private val processStart = now - 30 * 60 * 1000 + + private fun moment( + finished: Int = 3, + lastFinishedAt: Long? = now - 60_000, + installedAt: Long = now - 8 * day, + launches: Int = 2, + lastShownAt: Long? = null, + quiet: Boolean = false, + cooldown: Boolean = true, + ) = NpsSurveyMoment( + nowMs = now, + installedAtMs = installedAt, + finishedSinceInstall = finished, + lastFinishedAtMs = lastFinishedAt, + processStartedAtMs = processStart, + record = NpsSurveyRecord(lastShownAtMs = lastShownAt, launches = launches), + inQuietDays = quiet, + cooldownElapsed = cooldown, + ) + + @Test + fun dueRightAfterTheThirdFinishAWeekIn() { + assertTrue(NpsSurveySchedule.isDue(moment())) + } + + @Test + fun neverOnAnOpenWithoutAFinishInThisRun() { + // Finished before boxlore started this time: opening the app is not the moment. + assertFalse(NpsSurveySchedule.isDue(moment(lastFinishedAt = processStart - 1))) + assertFalse(NpsSurveySchedule.isDue(moment(lastFinishedAt = null))) + } + + @Test + fun aFinishLongAgoInThisRunIsNotTheMoment() { + val longRun = moment(lastFinishedAt = now - NpsSurveySchedule.FINISH_MOMENT_WINDOW_MS - 1) + assertFalse(NpsSurveySchedule.isDue(longRun.copy(processStartedAtMs = now - 10 * day))) + } + + @Test + fun needsThreeFinishedEpisodesHere() { + assertFalse(NpsSurveySchedule.isDue(moment(finished = 2))) + } + + @Test + fun needsAWeekSinceInstallAndAKnownInstallTime() { + assertFalse(NpsSurveySchedule.isDue(moment(installedAt = now - 6 * day))) + assertFalse(NpsSurveySchedule.isDue(moment(installedAt = 0L))) + } + + @Test + fun neverInTheFirstRun() { + assertFalse(NpsSurveySchedule.isDue(moment(launches = 1))) + assertFalse(NpsSurveySchedule.isDue(moment(launches = 0))) + } + + @Test + fun waitsOutTheQuietDaysAndTheSharedCooldown() { + assertFalse(NpsSurveySchedule.isDue(moment(quiet = true))) + assertFalse(NpsSurveySchedule.isDue(moment(cooldown = false))) + } + + @Test + fun asksAgainOnlyAfterNinetyDays() { + assertFalse(NpsSurveySchedule.isDue(moment(lastShownAt = now - 89 * day))) + assertTrue(NpsSurveySchedule.isDue(moment(lastShownAt = now - 90 * day))) + } +} diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt new file mode 100644 index 000000000..98881cc80 --- /dev/null +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt @@ -0,0 +1,73 @@ +package cx.aswin.boxlore.core.prefs + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The NPS survey's install-only history: launches, when it last appeared, and the one-time carry-over. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NpsSurveyStoreTest { + private val day = 24L * 60 * 60 * 1000 + private val now = 1_800_000_000_000L + private lateinit var context: Context + private lateinit var store: NpsSurveyStore + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + runBlocking { context.npsSurveyDataStore.edit { it.clear() } } + store = NpsSurveyStore(context) + } + + @After + fun tearDown() { + runBlocking { context.npsSurveyDataStore.edit { it.clear() } } + } + + @Test + fun countsLaunchesAndRemembersWhenShown() = runTest { + assertEquals(NpsSurveyRecord(lastShownAtMs = null, launches = 0), store.record()) + store.recordLaunch() + store.recordLaunch() + store.markShown(now) + assertEquals(NpsSurveyRecord(lastShownAtMs = now, launches = 2), store.record()) + } + + @Test + fun anUpdatedInstallThatShowedTheOldSurveyWaitsFromTheUpdate() = runTest { + store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now) + assertEquals(now, store.record().lastShownAtMs) + } + + @Test + fun aRestoredPhoneIgnoresTheBackedUpFlag() = runTest { + // Installed an hour ago: the old flag came in with a backup, so this phone is asked afresh. + store.carryOverLegacy(legacyShown = true, installedAtMs = now - 60 * 60 * 1000, nowMs = now) + assertNull(store.record().lastShownAtMs) + } + + @Test + fun theCarryOverRunsOnce() = runTest { + store.carryOverLegacy(legacyShown = false, installedAtMs = now - 40 * day, nowMs = now) + store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now + day) + assertNull(store.record().lastShownAtMs) + } + + @Test + fun theCarryOverNeverMovesARealShowing() = runTest { + store.markShown(now - 10 * day) + store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now) + assertEquals(now - 10 * day, store.record().lastShownAtMs) + } +} diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt index 98f830d1f..31b0414ac 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt @@ -538,35 +538,13 @@ class UserPreferencesRepositoryTest { assertEquals(9, repository.npsLastScore()) } - @Test - fun promoterReviewPendingRoundTrips() = runTest { - assertFalse(repository.isPromoterReviewPending()) - repository.setPromoterReviewPending(true) - assertTrue(repository.isPromoterReviewPending()) - } - - // ---- NPS survey lifecycle ---- + // ---- NPS survey ---- @Test - fun npsSurveyPendingThenFiredLifecycle() = runTest { - assertFalse(repository.isNpsSurveyPending()) + fun npsSurveyFiredLifecycle() = runTest { assertFalse(repository.hasNpsSurveyFired()) - assertNull(repository.npsSurveyCompletedCount()) - - repository.markNpsSurveyPending(3) - assertTrue(repository.isNpsSurveyPending()) - assertEquals(3, repository.npsSurveyCompletedCount()) - repository.markNpsSurveyFired() assertTrue(repository.hasNpsSurveyFired()) - assertFalse(repository.isNpsSurveyPending()) - } - - @Test - fun markNpsSurveyPendingNoOpAfterFired() = runTest { - repository.markNpsSurveyFired() - repository.markNpsSurveyPending(10) - assertFalse(repository.isNpsSurveyPending()) } // ---- Hide-completed toggles ---- diff --git a/core/testing/README.md b/core/testing/README.md index d49f9bf17..5fefadeca 100644 --- a/core/testing/README.md +++ b/core/testing/README.md @@ -50,7 +50,7 @@ src/test/java/cx/aswin/boxlore/core/testing/architecture/ - Run this module to execute architecture guards. - Guards cover feature isolation, selected singleton allowlists (including Android framework entry points such as `MediaRouter`), catalog-to-designsystem boundaries, direct database access restrictions, and README presence for included app/core/feature modules. -- `GlossaryCoverageGuardTest` asserts every `docs/analytics/event_glossary.csv` name is in `AnalyticsGlossary`, and every name has a row in `docs/analytics/glossary_emission_coverage.csv` with mode `emission:*`, `sdk_backed:*`, or `person_props_only` (opens/installs must not be dual-counted as custom emits). +- `GlossaryCoverageGuardTest` asserts every `docs/analytics/event_glossary.csv` name (snake case, or PostHog's spaced survey names such as `survey sent`) is in `AnalyticsGlossary`, and every name has a row in `docs/analytics/glossary_emission_coverage.csv` with mode `emission:*`, `sdk_backed:*`, or `person_props_only` (opens/installs must not be dual-counted as custom emits). - `BrandNameGuardTest` scans `src/main` string resources, manifest labels and Kotlin string literals in `app`, `core` and `feature` for `BoxLore`, `Boxlore`, `BOXLORE` and the retired BoxCast name. Word boundaries skip identifiers, log tags and constants; lowercase `boxcast` deep links and legacy migration names stay allowed in Kotlin. HTTP User-Agent product tokens are allowlisted by file because podcast hosts identify the app by them. - Do **not** add `api(projects.core.analytics)` here — `:core:analytics` already `testImplementation`s this module (cycle risk). Emission suites live in `:core:analytics`. diff --git a/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/GlossaryCoverageGuardTest.kt b/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/GlossaryCoverageGuardTest.kt index d87522261..3a76ef6e5 100644 --- a/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/GlossaryCoverageGuardTest.kt +++ b/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/GlossaryCoverageGuardTest.kt @@ -117,7 +117,8 @@ class GlossaryCoverageGuardTest { i++ } val body = text.substring(start, i - 1) - return Regex(""""([a-z][a-z0-9_]*)"""") + // Snake case, or PostHog's own spaced names such as "survey sent". + return Regex(""""([a-z][a-z0-9_]*(?: [a-z][a-z0-9_]*)*)"""") .findAll(body) .map { it.groupValues[1] } .toSet() diff --git a/docs/ANALYTICS_EVENT_GLOSSARY.md b/docs/ANALYTICS_EVENT_GLOSSARY.md index 68f908218..6a9495d5a 100644 --- a/docs/ANALYTICS_EVENT_GLOSSARY.md +++ b/docs/ANALYTICS_EVENT_GLOSSARY.md @@ -150,7 +150,9 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `show_notification_toggled` | Per-show notification preference changed | podcast_id:string; enabled:bool | — | none | | `share_content` | User shared podcast/episode/app | content_type:enum | podcast_id:string; episode_id:string; recipe_id:string; channel:string; surface:string | none | | `backup_restore_result` | Library backup or restore finished | action:string; success:bool | item_count:int; format:string; error_message:string | none | -| `feedback_submitted` | NPS survey trigger (eligible or manual); the survey shows on any of these | feedback_type:string | score:int; source:string | none | +| `survey shown` | The NPS survey sheet was on screen (PostHog's standard survey event) | $survey_id:string | — | none | +| `survey sent` | NPS answers so far: the score at once (partial), then the follow-up (completed); answers as `$survey_response_` | $survey_id:string; $survey_submission_id:string; $survey_completed:bool; $survey_questions:list | — | logged_by_policy | +| `survey dismissed` | The NPS survey closed before any answer | $survey_id:string | — | none | | `engagement_prompt_shown` | A Home review prompt was shown | prompt_type:string; source:string | completed_episodes:int; nps_score:int | none | | `store_review_opened` | boxlore launched Play's review flow (Play may still not show the sheet) or opened the store page | method:string; source:string | — | none | | `feedback_sent` | In-app feedback form accepted by the server | category:string; with_email:bool; with_diagnostics:bool | — | none | diff --git a/docs/analytics/event_glossary.csv b/docs/analytics/event_glossary.csv index ab1029dbe..502ccdfe7 100644 --- a/docs/analytics/event_glossary.csv +++ b/docs/analytics/event_glossary.csv @@ -59,7 +59,9 @@ smart_download_sync,Smart download sync,Smart Downloads sync ran,B,Smart Downloa show_notification_toggled,Show notification toggled,Per-show notification preference changed,B,Notif % among subs,podcast_id:string:Y;enabled:bool:Y,,notifications;library,,none,New,always,0.09 share_content,Share content,User shared podcast/episode/app,B,Share/deeplink growth,content_type:enum:Y;podcast_id:string:N;episode_id:string:N;recipe_id:string:N;channel:string:N;surface:string:N,,growth;share,share,none,New consolidated share_*,always,0.02 backup_restore_result,Backup/restore result,Library backup or restore finished,B,Import/migrate retention,action:string:Y;success:bool:Y;item_count:int:N;format:string:N;error_message:string:N,,library,,none,action: backup|restore|opml_export|opml_import,always,0.08 -feedback_submitted,Feedback submitted,NPS survey trigger (eligible or manual),B,Promoters; delight,feedback_type:string:Y;score:int:N;source:string:N,survey_nps_eligible;survey_nps_manual_trigger;promoter_review_handoff;feedback_submitted,company_health;delight,,none,Only nps_eligible and nps_manual: the NPS survey shows on any feedback_submitted,always,0.05 +survey shown,Survey shown,The NPS survey sheet was on screen (PostHog's standard event for an api survey),B,NPS reach,$survey_id:string:Y,feedback_submitted,company_health;delight,survey_start,none,Sent by boxlore's own sheet; at most once per 90 days per install,always,0.03 +survey sent,Survey sent,NPS answers so far: the score at once (partial) then the follow-up (completed),B,NPS score and reasons; promoters,$survey_id:string:Y;$survey_submission_id:string:Y;$survey_completed:bool:Y;$survey_questions:list:Y,,company_health;delight,survey_answer,logged_by_policy,Answers as $survey_response_: the 0–10 score and the listener's typed text (up to 500 characters) when given,always,0.03 +survey dismissed,Survey dismissed,The NPS survey closed before any answer,B,NPS reach,$survey_id:string:Y,,company_health;delight,,none,Never after an answer was sent,always,0.02 engagement_prompt_shown,Engagement prompt shown,A Home review prompt was shown,B,Review funnel reach,prompt_type:string:Y;source:string:Y;completed_episodes:int:N;nps_score:int:N,,company_health;delight,,none,promoter_review or milestone_review; completed_episodes counts episodes finished on this install,always,0.05 store_review_opened,Store review opened,Play review flow launched (Play may not show it) or store page opened,B,Review funnel,method:string:Y;source:string:Y,,company_health;delight,,none,method in_app_review or store_page,always,0.02 feedback_sent,Feedback sent,In-app feedback form accepted by the server,B,Feedback volume,category:string:Y;with_email:bool:Y;with_diagnostics:bool:Y,,company_health;delight,,none,Never the message text or email,always,0.02 diff --git a/docs/analytics/glossary_emission_coverage.csv b/docs/analytics/glossary_emission_coverage.csv index e4baa7c41..9e7023826 100644 --- a/docs/analytics/glossary_emission_coverage.csv +++ b/docs/analytics/glossary_emission_coverage.csv @@ -59,7 +59,9 @@ smart_download_sync,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmis show_notification_toggled,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest share_content,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest backup_restore_result,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest -feedback_submitted,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +survey shown,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +survey sent,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +survey dismissed,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest engagement_prompt_shown,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest store_review_opened,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest feedback_sent,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest diff --git a/feature/home/README.md b/feature/home/README.md index 9fcfa2ff4..6b7138940 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -54,6 +54,8 @@ src/main/java/cx/aswin/boxlore/feature/home/ HomeViewModelLoadData.kt HomeViewModelSelected.kt HomeViewModelSerial.kt + NpsSurvey.kt # survey IDs, score bands, wording, NpsSurveyController + NpsSurveyHost.kt # shows the survey over Home when it may appear HomeFeedEditorialRows.kt HomeDataModels.kt HomeUiModels.kt @@ -64,6 +66,8 @@ src/main/java/cx/aswin/boxlore/feature/home/ components/ LibrarySection.kt LibrarySectionRows.kt + NpsSurveySheet.kt # the survey sheet, its steps and Echo + NpsSurveyParts.kt # score grid, answer box, chips and tag ... logic/ BecauseYouLikeRotationLogic.kt @@ -120,11 +124,13 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel - `HomeDiscoverySectionsFeedTest` keeps off sections hidden and Explore shows visible while the window crosses 600dp width and 480dp height in both directions, toggles them live on phone and tablet layouts, and checks the greeting link. `HomeViewModelBriefingTest` covers the off Brief, switching it back on and daypart repeats. - This module owns no storage files or stable preference keys; Home pins are stored as `home_pinned_podcast_ids` in `:core:prefs`. Toggles go through `UserPreferencesRepository.toggleHomePinnedPodcastId` (one DataStore write). - RSS IDs, ranking database rows, download cache entries, and playback media IDs are owned by core modules. -- Stable Compose test tags include `home_settings_button`. +- Stable Compose test tags include `home_settings_button`, `nps_survey_sheet`, `nps_survey_answer` and `nps_score_0`…`nps_score_10`. ## Testing notes -- Review prompt: Home counts only episodes played to the end after this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time; synced listening from another phone after that counts too), so marked-played episodes and a restore of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A decided prompt waits in `ReviewPromptRecorder` and is recorded only once its sheet is on screen with Home resumed (`onReviewPromptDisplayed`: milestone, cooldown, promoter hand-off and `engagement_prompt_shown`, not the NPS survey's trigger); deciding only reserves the session (`reserveSessionForPrompt`), so a prompt decided behind another screen isn't used up if boxlore closes first (`ReviewPromptRecorderTest`). The promoter hand-off also waits out the 2 quiet days after installing or importing. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. +- Review prompt: Home counts only episodes played to the end after this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time; synced listening from another phone after that counts too), so marked-played episodes and a restore of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A decided prompt waits in `ReviewPromptRecorder` and is recorded only once its sheet is on screen with Home resumed (`onReviewPromptDisplayed`: milestone, cooldown and `engagement_prompt_shown`); deciding only reserves the session (`reserveSessionForPrompt`), so a prompt decided behind another screen isn't used up if boxlore closes first (`ReviewPromptRecorderTest`). It also appears only at a finish moment (an episode finished in this run, `lastFinishedAt`), never on an open alone. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. +- NPS survey: Home owns it. At a finish moment with nothing playing and no other sheet, `EngagementPromptCoordinator.shouldShowNpsSurvey` (`NpsSurveySchedule` in `:core:prefs`) decides it and `NpsSurveyController` opens it; `NpsSurveyHost` shows it only once nothing plays, and it counts as shown (`survey shown`, the schedule used up) only on screen with Home resumed. Steps: a 0–10 score (two staggered rows of same-size circles, "Not likely" over the 0 and "Very likely" under the 10), one follow-up by band (0–6 "What's the one thing we should fix first?", 7–8 "What would turn this up to a 10?", 9–10 "What should we queue up next?" with the ideas tag), then thanks; 9s and 10s get "Rate on Google Play", which opens the store page (`store_review_opened`, `nps_survey`). The score is sent at once as a partial answer and Send or Skip completes the same submission; closing before a score is `survey dismissed`, closing on the follow-up keeps the score. Answers are capped at 500 characters. Wording can be changed from the `nps-survey-copy` flag's payload (any `NpsSurveyCopy` key; an empty `ideaTag` hides the tag). Long-pressing Home's Feedback icon opens it by hand without using up the schedule. Echo stands on the sheet's top edge and plays one clip per step from its manifest (`survey-ask`, `survey-notes`, `survey-think`, `survey-idea`, `survey-thanks-calm`, `survey-thanks`, `survey-celebrate`), then rests with its eyes alive; reduced motion shows the resting still. +- NPS survey layout: colours are theme roles only (score circles `primaryContainer`; the sheet white in light themes, `surfaceContainerHigh` in dark; the answer box `surfaceContainerHighest`, its edge and label `primary` when focused), so every preset, custom colour, wallpaper colour and Pure style applies. With the keyboard up the sheet rises above it, Echo shrinks to 132dp and the score chip and helper make room; windows under 600dp tall start Echo at 132dp, and the content scrolls when it still doesn't fit. On expanded and folded tablets it is a centered dialog. Before a score and on the thanks it closes like any sheet; on the follow-up only Skip, Send or Back close it, so a typed answer isn't lost to a stray tap. `NpsSurveySheetAdaptiveTest` keeps one survey, its step and its typed answer through phone ↔ tablet transitions across the 600dp and 480dp gates, 840dp, portrait/landscape and a half-opened fold in both directions with no event sent; checks the outside tap on the dialog before a score and on the follow-up, a survey waiting while something plays, and a short landscape phone reaching every score and Not now. `NpsSurveyControllerTest` covers the events and steps; `NpsSurveySheetPolicyTest` the Echo sequences against the manifest, Echo's sizes and the score circles. - `HomeRecommendationsApplyTest` covers `applyHomeRecommendations`: a failed request keeps the restored picks and saves nothing; fresh picks replace them once per episode ID and title and are saved with their fallback flag; an empty answer that did not fail clears them. - `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's last play, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. - Recommendation entry points share an immutable columns/tablet layout value. Selected-show episode rendering has focused private playback, action and layout inputs; the parent crossfade retains each show's presentation and scroll owner. `HomeDiscoveryFeedSpacingTest` checks recommendation caps, order and episode/browse attribution while switching between the phone grid and tablet rail. `HomeConnectedRailTest` also checks selected-show playback, episode and browse callbacks across sidebar resizing, loading/empty-to-content recovery, and a 200-show library keeping a bounded lazy sidebar where every show stays reachable. `HomeArtworkHandoffTest` also checks that hidden tablet artwork waits, phone round trips keep the tablet target, and artwork sized only by its own content loads. diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt index d67c9c9bc..dbf340fe7 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt @@ -25,6 +25,9 @@ data class HomeListeningHistoryItem( internal fun List.countFinishedSince(installedAtMs: Long): Int = count { it.isCompleted && it.finishedAt?.let { at -> at >= installedAtMs } == true } +/** When the latest episode played to the end finished; null when none has. */ +internal fun List.lastFinishedAt(): Long? = filter { it.isCompleted }.mapNotNull { it.finishedAt }.maxOrNull() + internal fun ListeningHistoryEntity.toHomeListeningHistoryItem(): HomeListeningHistoryItem = HomeListeningHistoryItem( episodeId = episodeId, podcastId = podcastId, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index e68810a57..5ebea18df 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -115,8 +115,6 @@ data class HomePlaybackUi( @androidx.compose.runtime.Stable data class HomeSheetUi( val showReviewPrompt: Boolean = false, - val reviewPromptVariant: cx.aswin.boxlore.feature.home.components.ReviewPromptVariant = - cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.Milestone, val showPostReview: Boolean = false, val showFeedback: Boolean = false, val candidatePodcasts: List = emptyList(), @@ -287,7 +285,6 @@ fun HomeRoute( viewModel.playerState.map { it.isLoading }.distinctUntilChanged() }.collectAsState(initial = false) val showReviewPrompt by viewModel.showReviewPrompt.collectAsState() - val reviewPromptVariant by viewModel.reviewPromptVariant.collectAsState() val showPostReview by viewModel.showPostReview.collectAsState() val showFeedback by viewModel.showFeedback.collectAsState() @@ -392,7 +389,6 @@ fun HomeRoute( sheets = HomeSheetUi( showReviewPrompt = showReviewPrompt, - reviewPromptVariant = reviewPromptVariant, showPostReview = showPostReview, showFeedback = showFeedback, candidatePodcasts = candidatePodcasts, @@ -401,6 +397,7 @@ fun HomeRoute( snackbarHostState = snackbarHostState, modifier = modifier, ) + NpsSurveyHost(viewModel.npsSurvey, isPlaying = isPlaying) } } @@ -499,7 +496,6 @@ private fun HomeScreenBottomSheets( } cx.aswin.boxlore.feature.home.components.ReviewPromptSheet( completedCount = uiState.completedEpisodeCount, - variant = sheets.reviewPromptVariant, onDismissRequest = callbacks.onDismissReviewPrompt, onNavigateToReview = callbacks.onNavigateToPlayStoreReview, onNavigateToFeedback = { @@ -537,19 +533,7 @@ private fun HomeScreenBottomSheets( callbacks.onDismissFeedback() cx.aswin.boxlore.core.analytics.AnalyticsHelper .trackStoreReviewOpened(method = "store_page", source = "home_feedback") - val pkgName = context.packageName - try { - context.startActivity( - android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("market://details?id=$pkgName")), - ) - } catch (e: Exception) { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://play.google.com/store/apps/details?id=$pkgName"), - ), - ) - } + openPlayStorePage(context) }, onDismissRequest = callbacks.onDismissFeedback, ) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt index e8b93d49b..dff3b27a7 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt @@ -102,9 +102,16 @@ class HomeViewModel( val showReviewPrompt = _showReviewPrompt.asStateFlow() internal val reviewPromptRecorder = ReviewPromptRecorder() - internal val _reviewPromptVariant = - MutableStateFlow(cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.Milestone) - val reviewPromptVariant = _reviewPromptVariant.asStateFlow() + /** A review, feedback or survey sheet is up (or decided), so no other prompt is. */ + internal val isPromptSheetOpen: Boolean + get() = _showReviewPrompt.value || _showFeedback.value || _showPostReview.value || npsSurvey.isOpen + + /** The NPS survey on screen, if any; Home decides it at a finish moment ([loadData]). */ + internal val npsSurvey = + NpsSurveyController( + onScheduledDisplay = { engagementCoordinator.onSurveyDisplayed() }, + onScored = { engagementCoordinator.onNpsRatingSubmitted(it) }, + ) internal val _showPostReview = MutableStateFlow(false) val showPostReview = _showPostReview.asStateFlow() @@ -593,7 +600,6 @@ class HomeViewModel( val record = reviewPromptRecorder.displayed() ?: return viewModelScope.launch { engagementCoordinator.recordProactivePromptShown() - if (record.isPromoterHandoff) engagementCoordinator.clearPromoterReviewPending() userPrefs.markReviewPromptShown() cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackEngagementPromptShown( promptType = record.promptType, @@ -606,8 +612,6 @@ class HomeViewModel( fun dismissReviewPrompt() { _showReviewPrompt.value = false - _reviewPromptVariant.value = - cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.Milestone } fun dismissPostReview() { @@ -631,12 +635,18 @@ class HomeViewModel( _showReviewPrompt.value = true } - /** Manually trigger the NPS survey (long-press feedback icon). Bypasses the ep-3 milestone. */ + /** Opens the NPS survey by hand (long-press Feedback), whatever its schedule; it doesn't use the schedule up. */ fun forceSurveyNps() { - cx.aswin.boxlore.core.analytics.AnalyticsHelper - .trackSurveyNpsManualTrigger(source = "long_press") + _showReviewPrompt.value = false + _showPostReview.value = false + _showFeedback.value = false + npsSurvey.open(manual = true, copy = npsSurveyCopy()) } + /** The survey's wording, with any overrides from its PostHog flag. */ + internal fun npsSurveyCopy() = + NpsSurveyCopy(cx.aswin.boxlore.core.analytics.AnalyticsHelper.surveyCopyOverrides(NpsSurveyDefinition.COPY_FLAG)) + fun triggerPostReview() { _showReviewPrompt.value = false _showPostReview.value = true diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt index e9aabbc8c..814467c2b 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt @@ -212,26 +212,19 @@ internal fun HomeViewModel.loadData() { // Prompts count only episodes finished here, so a restore or import can't trigger them. val completedCount = allHistory.countFinishedSince(installedAtMs) - // Check if review prompt should be shown (promoter handoff or milestone-based) - if (!_showReviewPrompt.value && !_showFeedback.value && !_showPostReview.value) { + // Prompts appear only at a finish moment: an episode finished in this run of boxlore. + val lastFinishedAtMs = allHistory.lastFinishedAt() + if (!isPromptSheetOpen) { val isPlayingNow = playerState.value.isPlaying - if (engagementCoordinator.shouldShowPromoterReview(isPlayingNow, installedAtMs)) { - // Recorded once the sheet is on screen (onReviewPromptDisplayed). + if (engagementCoordinator.shouldShowNpsSurvey(isPlayingNow, installedAtMs, completedCount, lastFinishedAtMs)) { + // Recorded once the sheet is on screen (NpsSurveyController.onDisplayed). engagementCoordinator.reserveSessionForPrompt() - reviewPromptRecorder.decided( - ReviewPromptRecord( - promptType = "promoter_review", - source = "nps_handoff", - npsScore = userPrefs.npsLastScore(), - isPromoterHandoff = true, - ), - ) - _reviewPromptVariant.value = - cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.PromoterHandoff - _showReviewPrompt.value = true + npsSurvey.open(manual = false, copy = npsSurveyCopy()) } else if (engagementCoordinator.canShowProactivePrompt(isPlayingNow)) { userPrefs.syncReviewMilestonePending(completedCount) - val shouldPrompt = userPrefs.shouldShowReviewPrompt(isPlayingNow, installedAtMs) + val shouldPrompt = + engagementCoordinator.isFinishMoment(lastFinishedAtMs) && + userPrefs.shouldShowReviewPrompt(isPlayingNow, installedAtMs) if (shouldPrompt) { engagementCoordinator.reserveSessionForPrompt() reviewPromptRecorder.decided( @@ -239,22 +232,14 @@ internal fun HomeViewModel.loadData() { promptType = "milestone_review", source = "episode_milestone", completedEpisodes = completedCount, + npsScore = userPrefs.npsLastScore(), ), ) - _reviewPromptVariant.value = - cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.Milestone _showReviewPrompt.value = true } } } - // NPS survey: mark eligible once the user reaches 3+ completed episodes. - // The trigger event fires on the next app open (see MainActivity) - // so it never interrupts background playback. - if (completedCount >= 3 && !userPrefs.hasNpsSurveyFired() && !userPrefs.isNpsSurveyPending()) { - userPrefs.markNpsSurveyPending(completedCount) - } - val podScoresMap = if (rankingPlan.needsScores) { adaptiveScorer.scorePodcasts( diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt new file mode 100644 index 000000000..6262346ad --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt @@ -0,0 +1,193 @@ +package cx.aswin.boxlore.feature.home + +import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.core.analytics.SurveyQuestionRef +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** The PostHog `api` survey boxlore's NPS sheet reports to, with its question IDs there. */ +internal object NpsSurveyDefinition { + const val SURVEY_ID = "01a197f4-4105-0000-4ccb-67748f392475" + const val SCORE_QUESTION_ID = "7cf85c01-079f-469b-9116-6b2eeb9ed881" + const val FIX_QUESTION_ID = "39273125-fc72-47e2-b937-2fd018009add" + const val TEN_QUESTION_ID = "45b27138-239e-4047-a5d0-801889e1dce8" + const val IDEA_QUESTION_ID = "bf4dde95-a8af-48e5-b320-6228ac2a8c25" + + /** Flag whose payload, a JSON object of [NpsSurveyCopy] keys, changes the wording with no release. */ + const val COPY_FLAG = "nps-survey-copy" + + /** The longest typed answer sent; the field stops there. */ + const val MAX_ANSWER_LENGTH = 500 +} + +/** How a score is followed up: 0–6, 7–8 and 9–10, as PostHog splits NPS. */ +internal enum class NpsScoreBand { + Low, + Middle, + High, + ; + + companion object { + fun of(score: Int): NpsScoreBand = when { + score <= 6 -> Low + score <= 8 -> Middle + else -> High + } + } +} + +internal enum class NpsSurveyStep { Score, FollowUp, Thanks } + +/** One survey on screen: its step, the score, and the submission ID every send of it shares. */ +internal data class NpsSurveySession( + val submissionId: String, + val step: NpsSurveyStep = NpsSurveyStep.Score, + val score: Int? = null, + /** Opened by long-pressing Feedback: it sends its events but never uses up the schedule. */ + val manual: Boolean = false, +) { + val band: NpsScoreBand? get() = score?.let(NpsScoreBand::of) +} + +/** + * The survey's wording. Any key here can be replaced from [NpsSurveyDefinition.COPY_FLAG]'s payload; + * an empty `ideaTag` hides the tag on the idea step. + */ +internal class NpsSurveyCopy(private val overrides: Map = emptyMap()) { + private fun text(key: String, default: String) = overrides[key] ?: default + + val eyebrow = text("eyebrow", "A quick sound check") + val question = text("question", "How likely are you to recommend boxlore to a friend?") + val notLikely = text("notLikely", "Not likely") + val veryLikely = text("veryLikely", "Very likely") + val notNow = text("notNow", "Not now") + private val picked = text("picked", "You picked {score}") + val fixTitle = text("fixTitle", "What's the one thing we should fix first?") + val fixHelper = text("fixHelper", "Every answer is read. Be as specific as you like.") + val tenTitle = text("tenTitle", "What would turn this up to a 10?") + val tenHelper = text("tenHelper", "A missing feature, a rough edge, anything.") + val ideaTitle = text("ideaTitle", "What should we queue up next?") + val ideaTag = text("ideaTag", "Most listener ideas get built within a few weeks") + val answerLabel = text("answerLabel", "Your answer (optional)") + val answerHint = text("answerHint", "Type your answer…") + val ideaLabel = text("ideaLabel", "Your idea (optional)") + val ideaHint = text("ideaHint", "Type your idea…") + val skip = text("skip", "Skip") + val send = text("send", "Send") + val sendIdea = text("sendIdea", "Send idea") + val thanksLowTitle = text("thanksLowTitle", "Thank you for telling us") + val thanksLowBody = text("thanksLowBody", "We read every answer, and yours helps us decide what to fix first.") + val thanksMiddleTitle = text("thanksMiddleTitle", "Received, loud and clear.") + val thanksMiddleBody = text("thanksMiddleBody", "Every answer gets a proper listen, and it shapes what comes next.") + val thanksHighTitle = text("thanksHighTitle", "Music to our ears.") + val thanksHighBody = + text("thanksHighBody", "Want to pass the mic? A rating on Google Play helps other listeners tune in to boxlore.") + val done = text("done", "Done") + val rateOnPlay = text("rateOnPlay", "Rate on Google Play") + + fun picked(score: Int) = picked.replace("{score}", score.toString()) + + /** The follow-up question for [band], as sent with its answer. */ + fun followUpTitle(band: NpsScoreBand) = when (band) { + NpsScoreBand.Low -> fixTitle + NpsScoreBand.Middle -> tenTitle + NpsScoreBand.High -> ideaTitle + } + + companion object { + fun followUpQuestionId(band: NpsScoreBand) = when (band) { + NpsScoreBand.Low -> NpsSurveyDefinition.FIX_QUESTION_ID + NpsScoreBand.Middle -> NpsSurveyDefinition.TEN_QUESTION_ID + NpsScoreBand.High -> NpsSurveyDefinition.IDEA_QUESTION_ID + } + } +} + +/** + * Runs one NPS survey for Home: which step is on screen, and PostHog's survey events. The score is sent + * as soon as it is picked (a partial answer), so it is kept if the listener goes no further; the + * follow-up's Send or Skip completes the same submission. [onScheduledDisplay] uses up the schedule + * once a decided survey is on screen; [onScored] keeps the score for the review prompt. + */ +internal class NpsSurveyController( + private val onScheduledDisplay: () -> Unit, + private val onScored: (Int) -> Unit, + private val newSubmissionId: () -> String = { UUID.randomUUID().toString() }, +) { + private val _session = MutableStateFlow(null) + val session: StateFlow = _session.asStateFlow() + private val displayed = AtomicBoolean(false) + var copy: NpsSurveyCopy = NpsSurveyCopy() + private set + + val isOpen: Boolean get() = _session.value != null + + /** This survey has been on screen. */ + val wasDisplayed: Boolean get() = displayed.get() + + fun open(manual: Boolean, copy: NpsSurveyCopy) { + if (isOpen) return + this.copy = copy + displayed.set(false) + _session.value = NpsSurveySession(submissionId = newSubmissionId(), manual = manual) + } + + /** The sheet is on screen with Home resumed: `survey shown`, once per survey. */ + fun onDisplayed() { + val session = _session.value ?: return + if (!displayed.compareAndSet(false, true)) return + AnalyticsHelper.trackSurveyShown(NpsSurveyDefinition.SURVEY_ID) + if (!session.manual) onScheduledDisplay() + } + + fun onScore(score: Int) { + val session = _session.value?.takeIf { it.step == NpsSurveyStep.Score } ?: return + val scored = session.copy(step = NpsSurveyStep.FollowUp, score = score.coerceIn(0, 10)) + _session.value = scored + send(scored, answer = null, completed = false) + onScored(score) + } + + /** Send (with [answer]) or Skip (blank) on the follow-up: the submission is complete. */ + fun onFollowUpDone(answer: String) { + val session = _session.value?.takeIf { it.step == NpsSurveyStep.FollowUp } ?: return + send(session, answer.trim().take(NpsSurveyDefinition.MAX_ANSWER_LENGTH).ifEmpty { null }, completed = true) + _session.value = session.copy(step = NpsSurveyStep.Thanks) + } + + /** + * The listener closed the survey (Not now, Done, back): before a score it is `survey dismissed`; + * on the follow-up the score alone completes it, so nothing already answered is lost. + */ + fun onClose() { + val session = _session.value ?: return + when (session.step) { + NpsSurveyStep.Score -> if (displayed.get()) AnalyticsHelper.trackSurveyDismissed(NpsSurveyDefinition.SURVEY_ID) + NpsSurveyStep.FollowUp -> send(session, answer = null, completed = true) + NpsSurveyStep.Thanks -> Unit + } + _session.value = null + } + + private fun send(session: NpsSurveySession, answer: String?, completed: Boolean) { + val score = session.score ?: return + val band = NpsScoreBand.of(score) + val followUpId = NpsSurveyCopy.followUpQuestionId(band) + AnalyticsHelper.trackSurveySent( + surveyId = NpsSurveyDefinition.SURVEY_ID, + submissionId = session.submissionId, + questions = listOf( + SurveyQuestionRef(NpsSurveyDefinition.SCORE_QUESTION_ID, copy.question), + SurveyQuestionRef(followUpId, copy.followUpTitle(band)), + ), + responses = buildMap { + put(NpsSurveyDefinition.SCORE_QUESTION_ID, score) + answer?.let { put(followUpId, it) } + }, + completed = completed, + ) + } +} diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt new file mode 100644 index 000000000..0ff7222b5 --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt @@ -0,0 +1,53 @@ +package cx.aswin.boxlore.feature.home + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.feature.home.components.NpsSurveySheet + +/** + * Shows [controller]'s survey over Home. A decided survey is checked again as it appears: it waits while + * something plays, and counts as shown only once Home is resumed with the sheet on screen. + */ +@Composable +internal fun NpsSurveyHost(controller: NpsSurveyController, isPlaying: Boolean) { + val session by controller.session.collectAsState() + val current = session ?: return + if (!current.manual && isPlaying && !controller.wasDisplayed) return + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner, current.submissionId) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { controller.onDisplayed() } + } + val context = LocalContext.current + NpsSurveySheet( + session = current, + copy = controller.copy, + onScore = controller::onScore, + onFollowUpDone = controller::onFollowUpDone, + onClose = controller::onClose, + onRateOnPlay = { + AnalyticsHelper.trackStoreReviewOpened(method = "store_page", source = "nps_survey") + openPlayStorePage(context) + controller.onClose() + }, + ) +} + +/** boxlore's Play Store page: the Play app, or the web page when Play isn't there. */ +internal fun openPlayStorePage(context: Context) { + val pkgName = context.packageName + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$pkgName"))) + } catch (e: android.content.ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$pkgName"))) + } +} diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt index 3a64671d3..a8bafd187 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt @@ -8,7 +8,6 @@ internal data class ReviewPromptRecord( val source: String, val completedEpisodes: Int? = null, val npsScore: Int? = null, - val isPromoterHandoff: Boolean = false, ) /** diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveyParts.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveyParts.kt new file mode 100644 index 000000000..0bb0d07c6 --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveyParts.kt @@ -0,0 +1,200 @@ +package cx.aswin.boxlore.feature.home.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Lightbulb +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.feature.home.NpsSurveyCopy +import cx.aswin.boxlore.feature.home.NpsSurveyDefinition + +/** + * Two staggered rows, 0–5 then 6–10, each button sitting between two above it: same-size circles with + * the same gap in both rows, shrinking to fit a narrow window. "Not likely" sits over the 0 and "Very + * likely" under the 10. + */ +@Composable +internal fun ScoreGrid(copy: NpsSurveyCopy, onScore: (Int) -> Unit, modifier: Modifier = Modifier) { + BoxWithConstraints(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + val button = npsScoreButtonSize(maxWidth) + Column(Modifier.wrapContentWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + BoundLabel(copy.notLikely, Modifier.align(Alignment.Start)) + ScoreRow(0..5, button, onScore, copy) + ScoreRow(6..10, button, onScore, copy, Modifier.align(Alignment.CenterHorizontally)) + BoundLabel(copy.veryLikely, Modifier.align(Alignment.End).padding(end = (button + NPS_SCORE_GAP) / 2)) + } + } +} + +@Composable +internal fun ScoreRow(scores: IntRange, button: Dp, onScore: (Int) -> Unit, copy: NpsSurveyCopy, modifier: Modifier = Modifier) { + Row(modifier, horizontalArrangement = Arrangement.spacedBy(NPS_SCORE_GAP)) { + scores.forEach { score -> + val description = when (score) { + 0 -> "0, ${copy.notLikely}" + 10 -> "10, ${copy.veryLikely}" + else -> score.toString() + } + Button( + onClick = { onScore(score) }, + modifier = Modifier.size(button).semantics { contentDescription = description }.testTag("nps_score_$score"), + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text(score.toString(), style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp)) + } + } + } +} + +@Composable +internal fun BoundLabel(text: String, modifier: Modifier = Modifier) { + Text(text, modifier, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +internal fun Title(text: String, small: Boolean = false) { + Text( + text = text, + modifier = Modifier.widthIn(max = 330.dp).semantics { heading() }, + style = if (small) MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp, lineHeight = 26.sp) else MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) +} + +@Composable +internal fun Helper(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + modifier = modifier.widthIn(max = 342.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) +} + +@Composable +internal fun ScoreChip(text: String) { + Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) { + Text( + text = text, + modifier = Modifier.heightIn(min = 32.dp).padding(horizontal = 14.dp, vertical = 7.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + +/** The idea step's promise, on a seafoam (tertiary) tag; the copy flag can change or hide it. */ +@Composable +internal fun IdeaTag(text: String) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.tertiaryContainer) { + Row( + modifier = Modifier.heightIn(min = 32.dp).padding(horizontal = 14.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Rounded.Lightbulb, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer) + Text(text, style = MaterialTheme.typography.labelMedium.copy(fontSize = 13.sp), color = MaterialTheme.colorScheme.onTertiaryContainer) + } + } +} + +/** A rounded, filled answer box with its label inside and a character count; focused, its edge and label turn primary. */ +@Composable +@Suppress("LongParameterList") +internal fun AnswerField( + value: String, + onValueChange: (String) -> Unit, + label: String, + hint: String, + interaction: MutableInteractionSource, + modifier: Modifier = Modifier, +) { + val focused by interaction.collectIsFocusedAsState() + val colors = MaterialTheme.colorScheme + val textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp, lineHeight = 24.sp, color = colors.onSurface) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth().testTag(NPS_ANSWER_TAG), + textStyle = textStyle, + cursorBrush = SolidColor(colors.primary), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + interactionSource = interaction, + minLines = 3, + maxLines = 6, + decorationBox = { inner -> + Column( + modifier = Modifier + .background(colors.surfaceContainerHighest, RoundedCornerShape(16.dp)) + .border( + if (focused) BorderStroke(2.dp, colors.primary) else BorderStroke(1.dp, colors.outlineVariant), + RoundedCornerShape(16.dp), + ) + .padding(horizontal = 16.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(label, style = MaterialTheme.typography.labelMedium, color = if (focused) colors.primary else colors.onSurfaceVariant) + Box { + if (value.isEmpty()) Text(hint, style = textStyle.copy(color = colors.onSurfaceVariant)) + inner() + } + Text( + text = "${value.length} / ${NpsSurveyDefinition.MAX_ANSWER_LENGTH}", + modifier = Modifier.align(Alignment.End), + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + } + }, + ) +} + +/** Score circles: 52dp, or smaller so six of them and their gaps fit [available]. */ +internal fun npsScoreButtonSize(available: Dp): Dp = ((available - NPS_SCORE_GAP * 5) / 6).coerceIn(36.dp, 52.dp) + +internal val NPS_SCORE_GAP = 6.dp diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt new file mode 100644 index 000000000..9586fbd7c --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt @@ -0,0 +1,303 @@ +package cx.aswin.boxlore.feature.home.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView +import cx.aswin.boxlore.core.designsystem.components.BoxloreModalBottomSheet +import cx.aswin.boxlore.core.designsystem.components.boxloreSheetIsCentered +import cx.aswin.boxlore.core.designsystem.echo.EchoBeat +import cx.aswin.boxlore.core.designsystem.echo.EchoCharacter +import cx.aswin.boxlore.core.designsystem.echo.EchoFace +import cx.aswin.boxlore.core.designsystem.echo.EchoGaze +import cx.aswin.boxlore.core.designsystem.echo.EchoIdle +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.theme.LocalEffectiveDarkTheme +import cx.aswin.boxlore.feature.home.NpsScoreBand +import cx.aswin.boxlore.feature.home.NpsSurveyCopy +import cx.aswin.boxlore.feature.home.NpsSurveyDefinition +import cx.aswin.boxlore.feature.home.NpsSurveySession +import cx.aswin.boxlore.feature.home.NpsSurveyStep + +/** + * The NPS survey: a 0–10 score, one follow-up chosen by the score, then thanks (9s and 10s are offered + * the Play Store page). Echo stands on the sheet's top edge and acts out each step, then rests with its + * eyes alive. The sheet keeps the app's own colours in every theme, rises above the keyboard (Echo shrinks + * and the score chip and helper make room), scrolls on short windows, and is a centered dialog on + * expanded and folded tablets. Before a score and after the answer it closes like any sheet; on the + * follow-up only Skip, Send or back close it, so a typed answer isn't lost to a stray tap. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod", "LongParameterList") +internal fun NpsSurveySheet( + session: NpsSurveySession, + copy: NpsSurveyCopy, + onScore: (Int) -> Unit, + onFollowUpDone: (String) -> Unit, + onClose: () -> Unit, + onRateOnPlay: () -> Unit, +) { + TrackScreenView("nps_survey", send = {}) // trail only: the survey is counted by `survey shown` + val step by rememberUpdatedState(session.step) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden || step != NpsSurveyStep.FollowUp }, + ) + var answer by rememberSaveable(session.submissionId) { mutableStateOf("") } + val centered = boxloreSheetIsCentered() + // Typing: the answer box has focus and a keyboard really takes space (insets can claim an IME before any arrives). + val answerInteraction = remember { MutableInteractionSource() } + val answerFocused by answerInteraction.collectIsFocusedAsState() + val keyboardUp = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + val typing = answerFocused && keyboardUp && session.step == NpsSurveyStep.FollowUp + val shortWindow = LocalBoxloreWindowLayout.current.heightDp < NPS_SHORT_WINDOW_DP + val echoSize by animateDpAsState(npsEchoSize(typing, shortWindow), label = "npsEchoSize") + val above = echoSize * NPS_ECHO_ABOVE_SHEET + + BoxloreModalBottomSheet( + onDismissRequest = onClose, + sheetState = sheetState, + shape = RectangleShape, + containerColor = Color.Transparent, + dragHandle = null, + contentWindowInsets = { WindowInsets(0) }, + properties = ModalBottomSheetProperties(shouldDismissOnBackPress = true), + centeredDismissAllowed = session.step != NpsSurveyStep.FollowUp, + ) { + Box(Modifier.fillMaxWidth().testTag(NPS_SHEET_TAG)) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = above), + shape = if (centered) RoundedCornerShape(28.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + color = npsSheetColor(), + ) { + Column( + modifier = Modifier + .then(if (centered) Modifier else Modifier.windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime))) + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp, top = echoSize - above + 4.dp, bottom = if (typing) 16.dp else 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = session.step, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "npsStep", + ) { shown -> + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(if (typing) 12.dp else 14.dp), + ) { + when (shown) { + NpsSurveyStep.Score -> ScoreStep(copy, onScore = onScore, onNotNow = onClose) + NpsSurveyStep.FollowUp -> FollowUpStep( + copy = copy, + score = session.score ?: 0, + answer = answer, + onAnswerChange = { answer = it.take(NpsSurveyDefinition.MAX_ANSWER_LENGTH) }, + typing = typing, + answerInteraction = answerInteraction, + onDone = onFollowUpDone, + ) + NpsSurveyStep.Thanks -> ThanksStep(copy, session.band ?: NpsScoreBand.Middle, onDone = onClose, onRateOnPlay = onRateOnPlay) + } + } + } + } + } + EchoCharacter( + sequence = npsEchoSequence(session.step, session.band), + modifier = Modifier.align(Alignment.TopCenter).size(echoSize), + idle = npsEchoIdle(session.step, session.band), + prefetch = NPS_ECHO_PREFETCH, + ) + } + } +} + +@Composable +private fun ScoreStep(copy: NpsSurveyCopy, onScore: (Int) -> Unit, onNotNow: () -> Unit) { + Text( + text = copy.eyebrow.uppercase(), + style = MaterialTheme.typography.labelMedium.copy(letterSpacing = 0.8.sp), + color = MaterialTheme.colorScheme.primary, + ) + Title(copy.question) + ScoreGrid(copy, onScore, Modifier.padding(top = 6.dp)) + TextButton(onClick = onNotNow, modifier = Modifier.heightIn(min = 48.dp)) { Text(copy.notNow) } +} + +@Composable +@Suppress("LongParameterList") +private fun FollowUpStep( + copy: NpsSurveyCopy, + score: Int, + answer: String, + onAnswerChange: (String) -> Unit, + typing: Boolean, + answerInteraction: MutableInteractionSource, + onDone: (String) -> Unit, +) { + val band = NpsScoreBand.of(score) + if (!typing) ScoreChip(copy.picked(score)) + Title(copy.followUpTitle(band), small = typing) + if (!typing) { + when (band) { + NpsScoreBand.Low -> Helper(copy.fixHelper) + NpsScoreBand.Middle -> Helper(copy.tenHelper) + NpsScoreBand.High -> if (copy.ideaTag.isNotBlank()) IdeaTag(copy.ideaTag) + } + } + val idea = band == NpsScoreBand.High + AnswerField( + value = answer, + onValueChange = onAnswerChange, + label = if (idea) copy.ideaLabel else copy.answerLabel, + hint = if (idea) copy.ideaHint else copy.answerHint, + interaction = answerInteraction, + modifier = Modifier.padding(top = if (typing) 0.dp else 4.dp), + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + TextButton(onClick = { onDone("") }, modifier = Modifier.heightIn(min = 48.dp)) { Text(copy.skip) } + Button(onClick = { onDone(answer) }, modifier = Modifier.heightIn(min = 48.dp)) { Text(if (idea) copy.sendIdea else copy.send) } + } +} + +@Composable +private fun ThanksStep(copy: NpsSurveyCopy, band: NpsScoreBand, onDone: () -> Unit, onRateOnPlay: () -> Unit) { + val (title, body) = when (band) { + NpsScoreBand.Low -> copy.thanksLowTitle to copy.thanksLowBody + NpsScoreBand.Middle -> copy.thanksMiddleTitle to copy.thanksMiddleBody + NpsScoreBand.High -> copy.thanksHighTitle to copy.thanksHighBody + } + Title(title) + Helper(body, Modifier.widthIn(max = 310.dp)) + if (band == NpsScoreBand.High) { + Column(Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = onRateOnPlay, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text(copy.rateOnPlay) } + TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text(copy.notNow) } + } + } else { + Button( + onClick = onDone, + modifier = Modifier.padding(top = 8.dp).heightIn(min = 48.dp), + contentPadding = PaddingValues(horizontal = 28.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { Text(copy.done) } + } +} + +/** Echo's part in each step: the clip plays once, then Echo rests on its still with its eyes alive. */ +internal fun npsEchoSequence(step: NpsSurveyStep, band: NpsScoreBand?): String = when (step) { + NpsSurveyStep.Score -> "survey-ask" + NpsSurveyStep.FollowUp -> when (band) { + NpsScoreBand.Low, null -> "survey-notes" + NpsScoreBand.Middle -> "survey-think" + NpsScoreBand.High -> "survey-idea" + } + NpsSurveyStep.Thanks -> when (band) { + NpsScoreBand.Low, null -> "survey-thanks-calm" + NpsScoreBand.Middle -> "survey-thanks" + NpsScoreBand.High -> "survey-celebrate" + } +} + +/** White in light themes, as approved; a raised container in dark ones. */ +@Composable +private fun npsSheetColor() = + if (LocalEffectiveDarkTheme.current) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainerLowest + +/** Echo's eyes at rest: calm after a low score (no smile), otherwise lively. */ +private fun npsEchoIdle(step: NpsSurveyStep, band: NpsScoreBand?): EchoIdle = + if (step == NpsSurveyStep.Thanks && band == NpsScoreBand.Low) NpsEchoCalmIdle else NpsEchoIdle + +/** Echo's size: smaller while typing and on short windows, so the answer and its buttons stay in reach. */ +internal fun npsEchoSize(typing: Boolean, shortWindow: Boolean): Dp = when { + typing && shortWindow -> 72.dp + typing || shortWindow -> 132.dp + else -> 172.dp +} + +/** How much of Echo stands above the sheet's top edge. */ +private const val NPS_ECHO_ABOVE_SHEET = 0.535f + +/** Below this window height (dp) Echo starts smaller. */ +private const val NPS_SHORT_WINDOW_DP = 600f + +internal const val NPS_SHEET_TAG = "nps_survey_sheet" +internal const val NPS_ANSWER_TAG = "nps_survey_answer" + +private val NPS_ECHO_PREFETCH = listOf("survey-notes", "survey-think", "survey-idea", "survey-thanks", "survey-celebrate") + +/** Echo looks at the listener, down at the sheet now and then, and smiles once in a while. */ +private val NpsEchoIdle = EchoIdle( + intro = emptyList(), + loop = listOf( + EchoBeat(EchoGaze(0f, 0f), 2600, blinks = 1), + EchoBeat(EchoGaze(0f, 0.8f), 1300, EchoFace.Curious), + EchoBeat(EchoGaze(0f, 0f), 2200, blinks = 1), + EchoBeat(EchoGaze(-0.5f, 0.3f), 1100, blinks = 1), + EchoBeat(EchoGaze(0f, 0f), 2400, EchoFace.Happy), + ), +) + +/** After a low score: Echo just listens, blinking and glancing, with no smile. */ +private val NpsEchoCalmIdle = EchoIdle( + intro = emptyList(), + loop = listOf( + EchoBeat(EchoGaze(0f, 0f), 2800, blinks = 1), + EchoBeat(EchoGaze(0f, 0.6f), 1400), + EchoBeat(EchoGaze(0f, 0f), 2600, blinks = 2), + ), +) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt index 5e49bae9f..3bebf4be4 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt @@ -25,9 +25,6 @@ import kotlinx.coroutines.launch /** * Milestone review prompt — M3 Expressive bottom sheet. * Diverts happy users to Play Store, frustrated users to Feedback. - * - * @param variant Milestone uses episode-based copy; [ReviewPromptVariant.PromoterHandoff] - * acknowledges the user already scored 8+ on NPS. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -36,54 +33,14 @@ fun ReviewPromptSheet( onDismissRequest: () -> Unit, onNavigateToReview: () -> Unit, onNavigateToFeedback: () -> Unit, - variant: ReviewPromptVariant = ReviewPromptVariant.Milestone, ) { TrackScreenView("review_prompt", send = {}) // the prompt is counted when it is decided - val title = - when (variant) { - ReviewPromptVariant.PromoterHandoff -> "Glad you're enjoying boxlore" - ReviewPromptVariant.Milestone -> - when { - completedCount >= 30 -> "You're a podcast pro" - completedCount >= 15 -> "You're a regular listener" - else -> "You're on a roll" - } - } - - val body = - when (variant) { - ReviewPromptVariant.PromoterHandoff -> - "Thanks for the great score — a quick Play Store rating helps other listeners discover boxlore." - ReviewPromptVariant.Milestone -> - when { - completedCount >= 30 -> - "$completedCount episodes and counting — your review would mean the world to us." - completedCount >= 15 -> - "You've finished $completedCount episodes. Mind leaving a quick rating?" - else -> "You've completed $completedCount episodes. How's boxlore treating you?" - } - } - - val primaryLabel = - when (variant) { - ReviewPromptVariant.PromoterHandoff -> "Rate on Play Store" - ReviewPromptVariant.Milestone -> - if (completedCount >= 15) "Rate boxlore" else "Loving it" - } - - val secondaryLabel = - when (variant) { - ReviewPromptVariant.PromoterHandoff -> "Not right now" - ReviewPromptVariant.Milestone -> - if (completedCount >= 30) "Not right now" else "Could be better" - } - - val onSecondaryClick = - when (variant) { - ReviewPromptVariant.PromoterHandoff -> onDismissRequest - ReviewPromptVariant.Milestone -> - if (completedCount >= 30) onDismissRequest else onNavigateToFeedback - } + val copy = reviewPromptCopy(completedCount) + val title = copy.title + val body = copy.body + val primaryLabel = copy.primary + val secondaryLabel = copy.secondary + val onSecondaryClick = if (copy.secondaryDismisses) onDismissRequest else onNavigateToFeedback val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -178,13 +135,31 @@ fun ReviewPromptSheet( } } -/** Copy variant for [ReviewPromptSheet] — milestone-based or post-NPS promoter handoff. */ -enum class ReviewPromptVariant { - /** Episode-count milestone (5/15/30 completed). */ - Milestone, - - /** Deferred Play review after the user scored 8+ on NPS. */ - PromoterHandoff, +/** The milestone review prompt's words for [completedCount] finished episodes; at 30 the second button only closes it. */ +internal data class ReviewPromptCopy(val title: String, val body: String, val primary: String, val secondary: String, val secondaryDismisses: Boolean) + +internal fun reviewPromptCopy(completedCount: Int): ReviewPromptCopy = when { + completedCount >= 30 -> ReviewPromptCopy( + title = "You're a podcast pro", + body = "$completedCount episodes and counting — your review would mean the world to us.", + primary = "Rate boxlore", + secondary = "Not right now", + secondaryDismisses = true, + ) + completedCount >= 15 -> ReviewPromptCopy( + title = "You're a regular listener", + body = "You've finished $completedCount episodes. Mind leaving a quick rating?", + primary = "Rate boxlore", + secondary = "Could be better", + secondaryDismisses = false, + ) + else -> ReviewPromptCopy( + title = "You're on a roll", + body = "You've completed $completedCount episodes. How's boxlore treating you?", + primary = "Loving it", + secondary = "Could be better", + secondaryDismisses = false, + ) } /** diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt index 50885d557..caae86b8b 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt @@ -63,6 +63,21 @@ class HomeListeningHistoryItemTest { assertEquals(2, history.countFinishedSince(installedAt)) } + @Test + fun `the latest finish counts only episodes still finished`() { + val history = + listOf( + item("earlier", isCompleted = true, finishedAt = 1_500L), + item("latest", isCompleted = true, finishedAt = 1_800L), + // Replayed after finishing: its old finish is not a finish moment. + item("restarted", isCompleted = false, finishedAt = 1_900L), + item("marked-played", isCompleted = true, finishedAt = null), + ) + + assertEquals(1_800L, history.lastFinishedAt()) + assertNull(listOf(item("marked-played", isCompleted = true, finishedAt = null)).lastFinishedAt()) + } + private fun item(id: String, isCompleted: Boolean, finishedAt: Long?) = HomeListeningHistoryItem(id, "show", "Show", null, 0L, 1L, isCompleted, false, 2_000L, finishedAt) } diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt new file mode 100644 index 000000000..092deabe0 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt @@ -0,0 +1,172 @@ +package cx.aswin.boxlore.feature.home + +import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** One NPS survey's steps and PostHog events: shown once, the score kept at once, one submission, never shown-and-lost. */ +class NpsSurveyControllerTest { + private val events = mutableListOf>>() + private lateinit var restore: () -> Unit + private var scheduledDisplays = 0 + private val scores = mutableListOf() + private val controller = + NpsSurveyController( + onScheduledDisplay = { scheduledDisplays++ }, + onScored = { scores += it }, + newSubmissionId = { "sub-1" }, + ) + + @Before + fun setUp() { + restore = AnalyticsHelper.installRecordingSink(events) + } + + @After + fun tearDown() = restore() + + private fun sent() = events.filter { it.first == "survey sent" }.map { it.second } + + private fun openAndShow(manual: Boolean = false) { + controller.open(manual = manual, copy = NpsSurveyCopy()) + controller.onDisplayed() + } + + @Test + fun `a scheduled survey counts as shown once, when it is on screen`() { + controller.open(manual = false, copy = NpsSurveyCopy()) + assertTrue(events.isEmpty()) + assertEquals(0, scheduledDisplays) + + controller.onDisplayed() + controller.onDisplayed() // Home resumed again with the sheet still up + + assertEquals(listOf("survey shown"), events.map { it.first }) + assertEquals(NpsSurveyDefinition.SURVEY_ID, events.single().second["\$survey_id"]) + assertEquals(1, scheduledDisplays) + } + + @Test + fun `a survey opened by hand sends its events but leaves the schedule alone`() { + openAndShow(manual = true) + + assertEquals(listOf("survey shown"), events.map { it.first }) + assertEquals(0, scheduledDisplays) + } + + @Test + fun `the score is sent at once as a partial answer, then the follow-up completes the same submission`() { + openAndShow() + controller.onScore(4) + + assertEquals(NpsSurveyStep.FollowUp, controller.session.value?.step) + assertEquals(listOf(4), scores) + val partial = sent().single() + assertEquals(false, partial["\$survey_completed"]) + assertEquals("sub-1", partial["\$survey_submission_id"]) + assertEquals(4, partial["\$survey_response_${NpsSurveyDefinition.SCORE_QUESTION_ID}"]) + assertFalse(partial.containsKey("\$survey_response_${NpsSurveyDefinition.FIX_QUESTION_ID}")) + + controller.onFollowUpDone(" Search should find episodes by guest ") + + val final = sent().last() + assertEquals(true, final["\$survey_completed"]) + assertEquals("sub-1", final["\$survey_submission_id"]) + assertEquals(4, final["\$survey_response_${NpsSurveyDefinition.SCORE_QUESTION_ID}"]) + assertEquals("Search should find episodes by guest", final["\$survey_response_${NpsSurveyDefinition.FIX_QUESTION_ID}"]) + assertEquals(NpsSurveyStep.Thanks, controller.session.value?.step) + assertFalse(events.any { it.first == "survey dismissed" }) + } + + @Test + fun `each score band asks its own follow-up`() { + mapOf( + 0 to NpsSurveyDefinition.FIX_QUESTION_ID, + 6 to NpsSurveyDefinition.FIX_QUESTION_ID, + 7 to NpsSurveyDefinition.TEN_QUESTION_ID, + 8 to NpsSurveyDefinition.TEN_QUESTION_ID, + 9 to NpsSurveyDefinition.IDEA_QUESTION_ID, + 10 to NpsSurveyDefinition.IDEA_QUESTION_ID, + ).forEach { (score, questionId) -> + events.clear() + openAndShow() + controller.onScore(score) + controller.onFollowUpDone("idea") + val questions = sent().last()["\$survey_questions"] as List<*> + assertEquals("score $score", questionId, (questions.last() as Map<*, *>)["id"]) + assertEquals("score $score", "idea", sent().last()["\$survey_response_$questionId"]) + controller.onClose() + } + } + + @Test + fun `Skip completes with the score alone, and a long answer is cut to 500 characters`() { + openAndShow() + controller.onScore(9) + controller.onFollowUpDone(" ") + assertFalse(sent().last().containsKey("\$survey_response_${NpsSurveyDefinition.IDEA_QUESTION_ID}")) + assertEquals(true, sent().last()["\$survey_completed"]) + controller.onClose() + + openAndShow() + controller.onScore(9) + controller.onFollowUpDone("x".repeat(600)) + assertEquals(500, (sent().last()["\$survey_response_${NpsSurveyDefinition.IDEA_QUESTION_ID}"] as String).length) + } + + @Test + fun `Not now before a score is a dismissal, only once the survey was seen`() { + controller.open(manual = false, copy = NpsSurveyCopy()) + controller.onClose() // decided but never on screen + assertTrue(events.isEmpty()) + + openAndShow() + controller.onClose() + + assertEquals(listOf("survey shown", "survey dismissed"), events.map { it.first }) + assertNull(controller.session.value) + } + + @Test + fun `closing on the follow-up keeps the score as a completed answer, not a dismissal`() { + openAndShow() + controller.onScore(7) + controller.onClose() + + assertEquals(listOf(false, true), sent().map { it["\$survey_completed"] }) + assertFalse(events.any { it.first == "survey dismissed" }) + assertNull(controller.session.value) + } + + @Test + fun `closing the thanks sends nothing more`() { + openAndShow() + controller.onScore(10) + controller.onFollowUpDone("") + val before = events.size + controller.onClose() + assertEquals(before, events.size) + } + + @Test + fun `a second open while one is up is ignored`() { + controller.open(manual = false, copy = NpsSurveyCopy()) + val first = controller.session.value + controller.open(manual = true, copy = NpsSurveyCopy()) + assertEquals(first, controller.session.value) + } + + @Test + fun `wording overrides replace only their keys and an empty tag hides it`() { + val copy = NpsSurveyCopy(mapOf("ideaTag" to "", "picked" to "Your score: {score}")) + assertEquals("", copy.ideaTag) + assertEquals("Your score: 8", copy.picked(8)) + assertEquals("How likely are you to recommend boxlore to a friend?", copy.question) + assertEquals("You picked 4", NpsSurveyCopy().picked(4)) + } +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt new file mode 100644 index 000000000..1ce1313c1 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt @@ -0,0 +1,206 @@ +package cx.aswin.boxlore.feature.home.components + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.click +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.core.designsystem.echo.LocalEchoMotion +import cx.aswin.boxlore.core.designsystem.layout.BoxloreFold +import cx.aswin.boxlore.core.designsystem.layout.BoxloreFoldOrientation +import cx.aswin.boxlore.core.designsystem.layout.BoxloreFoldState +import cx.aswin.boxlore.core.designsystem.layout.BoxloreLayoutBounds +import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.feature.home.NpsSurveyController +import cx.aswin.boxlore.feature.home.NpsSurveyCopy +import cx.aswin.boxlore.feature.home.NpsSurveyDefinition +import cx.aswin.boxlore.feature.home.NpsSurveyHost +import cx.aswin.boxlore.feature.home.NpsSurveyStep +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The NPS survey keeps one survey through every presentation: the phone sheet or the centered dialog as + * the window crosses the tablet gates (600dp wide, 480dp tall), the 840dp expanded width, + * portrait/landscape and a half-opened fold, in both directions. A window change never answers, dismisses + * or re-sends anything, and the typed answer and step stay. Before a score an outside tap dismisses once; + * on the follow-up it doesn't, so a typed answer can't be lost. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w1300dp-h1300dp-mdpi", application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NpsSurveySheetAdaptiveTest { + @get:Rule val compose = createAndroidComposeRule() + private val window = mutableStateOf(BoxloreWindowLayout(360f, 740f)) + private val playing = mutableStateOf(false) + private val events = mutableListOf>>() + private lateinit var restore: () -> Unit + private var scheduledDisplays = 0 + private val controller = NpsSurveyController(onScheduledDisplay = { scheduledDisplays++ }, onScored = {}) + private val copy = NpsSurveyCopy() + + private val fold = BoxloreFold(BoxloreFoldState.HalfOpened, BoxloreFoldOrientation.Horizontal, BoxloreLayoutBounds(0f, 410f, 673f, 431f), true, true) + + /** Phone → either side of each gate → expanded → short → portrait tablet → folded → flat → phone. */ + private val transitions = listOf( + BoxloreWindowLayout(599f, 900f), + BoxloreWindowLayout(600f, 900f), + BoxloreWindowLayout(839f, 600f), + BoxloreWindowLayout(840f, 600f), + BoxloreWindowLayout(1280f, 800f), + BoxloreWindowLayout(1280f, 479f), + BoxloreWindowLayout(1280f, 480f), + BoxloreWindowLayout(800f, 1280f), + BoxloreWindowLayout(673f, 841f, listOf(fold)), + BoxloreWindowLayout(673f, 841f), + BoxloreWindowLayout(360f, 740f), + ) + + @Before + fun setUp() { + restore = AnalyticsHelper.installRecordingSink(events) + } + + @After + fun tearDown() = restore() + + private fun render() { + compose.setContent { + BoxLoreTheme(darkTheme = false, dynamicColor = false) { + val layout = window.value + // Echo's idle never ends; its resting still keeps Compose idle for the checks. + CompositionLocalProvider(LocalBoxloreWindowLayout provides layout, LocalEchoMotion provides false) { + Box(Modifier.requiredSize(layout.widthDp.dp, layout.heightDp.dp)) { + NpsSurveyHost(controller, isPlaying = playing.value) + } + } + } + } + } + + private fun open() { + compose.runOnIdle { controller.open(manual = false, copy = copy) } + compose.waitForIdle() + } + + /** The survey's own events (the sheet also sends its page view). */ + private fun names() = events.map { it.first }.filter { it.startsWith("survey ") } + + @Test + fun `the typed answer and the step survive every window change`() { + render() + open() + compose.onNodeWithText(copy.question).assertIsDisplayed() + assertEquals(listOf("survey shown"), names()) + compose.onNodeWithTag("nps_score_4").performClick() + compose.waitForIdle() + compose.onNodeWithTag(NPS_ANSWER_TAG).performTextInput("Downloads that follow me") + val submission = controller.session.value?.submissionId + + transitions.forEach { next -> + compose.runOnIdle { window.value = next } + compose.waitForIdle() + compose.onNodeWithText(copy.fixTitle).assertExists() + compose.onNodeWithTag(NPS_ANSWER_TAG).assertTextContains("Downloads that follow me") + compose.onNodeWithText(copy.send).performScrollTo().assertIsDisplayed() + assertEquals("$next: same survey", submission, controller.session.value?.submissionId) + assertEquals("$next: still on the follow-up", NpsSurveyStep.FollowUp, controller.session.value?.step) + assertEquals("$next: a window change sends nothing", listOf("survey shown", "survey sent"), names()) + } + assertEquals(1, scheduledDisplays) + + compose.onNodeWithText(copy.send).performClick() + compose.waitForIdle() + assertEquals("Downloads that follow me", events.last { it.first == "survey sent" }.second["\$survey_response_${NpsSurveyDefinition.FIX_QUESTION_ID}"]) + compose.onNodeWithText(copy.thanksLowTitle).assertIsDisplayed() + compose.onNodeWithText(copy.done).performClick() + compose.waitForIdle() + assertNull(controller.session.value) + compose.onNodeWithTag(NPS_SHEET_TAG).assertDoesNotExist() + assertFalse(names().contains("survey dismissed")) + } + + @Test + fun `a tap outside the expanded dialog dismisses once before a score, and never on the follow-up`() { + window.value = BoxloreWindowLayout(1280f, 800f) + render() + open() + compose.onNodeWithText(copy.question).performClick() + compose.waitForIdle() + assertTrue(controller.isOpen) + compose.onNode(isDialog()).performTouchInput { click(Offset(16f, 16f)) } + compose.waitForIdle() + assertNull(controller.session.value) + assertEquals(listOf("survey shown", "survey dismissed"), names()) + + events.clear() + open() + compose.onNodeWithTag("nps_score_9").performClick() + compose.waitForIdle() + compose.onNode(isDialog()).performTouchInput { click(Offset(16f, 16f)) } + compose.waitForIdle() + assertEquals(NpsSurveyStep.FollowUp, controller.session.value?.step) + compose.onNodeWithText(copy.ideaTitle).assertIsDisplayed() + assertFalse(names().contains("survey dismissed")) + } + + @Test + fun `a decided survey waits while something plays, and counts as shown only when it appears`() { + playing.value = true + render() + open() + compose.onNodeWithTag(NPS_SHEET_TAG).assertDoesNotExist() + assertTrue(names().isEmpty()) + assertEquals(0, scheduledDisplays) + + compose.runOnIdle { playing.value = false } + compose.waitForIdle() + compose.onNodeWithText(copy.question).assertIsDisplayed() + assertEquals(listOf("survey shown"), names()) + assertEquals(1, scheduledDisplays) + } + + @Test + @Config(qualifiers = "w740dp-h360dp-mdpi") + fun `on a short landscape phone every score and Not now can be reached`() { + window.value = BoxloreWindowLayout(740f, 360f) + render() + open() + listOf("nps_score_0", "nps_score_5", "nps_score_10").forEach { tag -> + compose.onNodeWithTag(tag).performScrollTo().assertIsDisplayed() + assertTrue("$tag inside the 360dp window", compose.onNodeWithTag(tag).getUnclippedBoundsInRoot().bottom <= 360.dp) + } + compose.onNodeWithText(copy.notNow).performScrollTo().performClick() + compose.waitForIdle() + assertEquals(listOf("survey shown", "survey dismissed"), names()) + } +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetPolicyTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetPolicyTest.kt new file mode 100644 index 000000000..d8150f395 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetPolicyTest.kt @@ -0,0 +1,72 @@ +package cx.aswin.boxlore.feature.home.components + +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.feature.home.NpsScoreBand +import cx.aswin.boxlore.feature.home.NpsSurveyStep +import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** The survey sheet's static choices: Echo's part per step (all in Echo's manifest), its size, and the score circles. */ +class NpsSurveySheetPolicyTest { + private val manifest: JsonObject by lazy { + val root = generateSequence(File("").absoluteFile) { it.parentFile } + .first { File(it, "core/designsystem/src/main/assets/echo-character/manifest.json").isFile } + Json.parseToJsonElement(File(root, "core/designsystem/src/main/assets/echo-character/manifest.json").readText()).jsonObject + } + + private fun clipsOf(sequence: String): List { + val steps = manifest.getValue("sequences").jsonObject.getValue(sequence).jsonObject.getValue("steps").jsonArray + return steps.mapNotNull { it.jsonObject["clip"]?.jsonPrimitive?.content } + } + + private val all = NpsSurveyStep.entries.flatMap { step -> (NpsScoreBand.entries + null).map { step to it } } + + @Test + fun `every step's Echo sequence is in the manifest and uses existing clips`() { + val clips = manifest.getValue("clips").jsonObject.keys + all.forEach { (step, band) -> + val sequence = npsEchoSequence(step, band) + assertTrue(sequence in manifest.getValue("sequences").jsonObject.keys, "$step/$band: $sequence") + clipsOf(sequence).forEach { assertTrue(it in clips, "$sequence: clip $it") } + } + } + + @Test + fun `Echo acts out each step as approved, and only rests after a low score`() { + assertEquals(listOf("ask"), clipsOf(npsEchoSequence(NpsSurveyStep.Score, null))) + assertEquals(listOf("notebook"), clipsOf(npsEchoSequence(NpsSurveyStep.FollowUp, NpsScoreBand.Low))) + assertEquals(listOf("think-chin"), clipsOf(npsEchoSequence(NpsSurveyStep.FollowUp, NpsScoreBand.Middle))) + assertEquals(listOf("magnifier"), clipsOf(npsEchoSequence(NpsSurveyStep.FollowUp, NpsScoreBand.High))) + assertEquals(emptyList(), clipsOf(npsEchoSequence(NpsSurveyStep.Thanks, NpsScoreBand.Low))) + assertEquals(listOf("thumbs-up"), clipsOf(npsEchoSequence(NpsSurveyStep.Thanks, NpsScoreBand.Middle))) + assertEquals(listOf("story-celebrate"), clipsOf(npsEchoSequence(NpsSurveyStep.Thanks, NpsScoreBand.High))) + } + + @Test + fun `Echo shrinks while typing and on short windows`() { + assertEquals(172.dp, npsEchoSize(typing = false, shortWindow = false)) + assertEquals(132.dp, npsEchoSize(typing = true, shortWindow = false)) + assertEquals(132.dp, npsEchoSize(typing = false, shortWindow = true)) + assertEquals(72.dp, npsEchoSize(typing = true, shortWindow = true)) + } + + @Test + fun `score circles are 52dp where six fit, and shrink to fit narrow windows`() { + assertEquals(52.dp, npsScoreButtonSize(342.dp)) + assertEquals(52.dp, npsScoreButtonSize(592.dp)) + assertEquals(47.dp, npsScoreButtonSize(312.dp)) + assertEquals(36.dp, npsScoreButtonSize(180.dp)) + } + + @Test + fun `score bands follow PostHog's NPS split`() { + assertEquals(List(7) { NpsScoreBand.Low } + List(2) { NpsScoreBand.Middle } + List(2) { NpsScoreBand.High }, (0..10).map(NpsScoreBand::of)) + } +} diff --git a/feature/library/src/main/java/cx/aswin/boxlore/feature/library/LibraryScreen.kt b/feature/library/src/main/java/cx/aswin/boxlore/feature/library/LibraryScreen.kt index b9adef115..b6b42bbd1 100644 --- a/feature/library/src/main/java/cx/aswin/boxlore/feature/library/LibraryScreen.kt +++ b/feature/library/src/main/java/cx/aswin/boxlore/feature/library/LibraryScreen.kt @@ -134,8 +134,6 @@ fun LibraryScreen( onFeedbackLongClick = { cx.aswin.boxlore.core.analytics.AnalyticsHelper .trackTopControlbarInteraction("feedback_long_clicked", "library") - cx.aswin.boxlore.core.analytics.AnalyticsHelper - .trackSurveyNpsManualTrigger(source = "long_press") }, onSettingsClick = { cx.aswin.boxlore.core.analytics.AnalyticsHelper diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 03ca71b2d..daa8529a9 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -51,6 +51,7 @@ Announcement title: - New-episode notifications arrive sooner after a release. (#1151) - boxlore asks to send notifications only when you turn them on for a show or finish importing your library, not every time you open Home. (#1226) - On Explore, tap the Explore tab again to start searching. From a show or episode you opened in Explore, the first tap takes you back to Explore. +- Every few months, right after you finish an episode, Echo asks how likely you are to recommend boxlore, with one short follow-up question. It never pops up as you open the app, and it uses your theme's colours. ## Fixed @@ -75,4 +76,4 @@ Announcement title: - If boxlore can't check Google Play for updates, it offers to open its Play Store page so you can update there. (#1197) - Clearing boxlore's data no longer opens a random episode on the next launch. (#1185) - On an episode's page, More from shows the show's cover instead of a placeholder, and its header stands out from the episodes below it. (#1219) -- The prompt to rate boxlore counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. (#1226) +- The prompt to rate boxlore appears only right after you finish an episode, counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. (#1226) From e7d94653f467d69dc31692ce65b12c6e63e16852 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 22:41:05 +0530 Subject: [PATCH 3/5] feat(home): debug reset for the NPS survey timing The debug screen's Flags tab says what the automatic survey still waits for and can forget its last showing, the shared cooldown and this session's prompt, so the next finished episode asks again. Listener notes name the PR. --- .../navigation/NavGraphLibraryDestinations.kt | 1 + .../catalog/EngagementPromptCoordinator.kt | 26 +++++++++ .../EngagementPromptCoordinatorSurveyTest.kt | 8 +++ core/prefs/README.md | 2 +- .../core/prefs/EngagementPreferences.kt | 5 ++ .../boxlore/core/prefs/NpsSurveySchedule.kt | 25 +++++---- .../boxlore/core/prefs/NpsSurveyStore.kt | 8 +++ .../core/prefs/UserPreferencesRepository.kt | 2 + .../core/prefs/NpsSurveyScheduleTest.kt | 8 +++ .../boxlore/core/prefs/NpsSurveyStoreTest.kt | 10 ++++ feature/home/README.md | 2 +- .../aswin/boxlore/feature/home/DebugScreen.kt | 7 ++- .../feature/home/DebugScreenContent.kt | 15 ++++-- .../boxlore/feature/home/DebugViewModel.kt | 25 +++++++++ .../boxlore/feature/home/NpsSurveyDebug.kt | 54 +++++++++++++++++++ .../feature/home/NpsSurveyDebugTest.kt | 24 +++++++++ release-notes/upcoming.md | 4 +- 17 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyDebug.kt create mode 100644 feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyDebugTest.kt diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphLibraryDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphLibraryDestinations.kt index 8a3a994c3..9724ac31d 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphLibraryDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphLibraryDestinations.kt @@ -27,6 +27,7 @@ internal fun androidx.navigation.NavGraphBuilder.addDebugDestination(w: NavGraph subscriptionRepository = subscriptionRepository, userPreferencesRepository = userPrefs, adaptiveRankingRepository = container.adaptiveRankingRepository, + engagementPromptCoordinator = w.application.engagementPromptCoordinator, bottomContentPadding = w.session.miniPlayerPadding, onBack = { navController.popBackStack() }, ) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt index 6958b5686..e701157e9 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.core.catalog import android.util.Log import cx.aswin.boxlore.core.prefs.NpsSurveyMoment +import cx.aswin.boxlore.core.prefs.NpsSurveyRule import cx.aswin.boxlore.core.prefs.NpsSurveySchedule import cx.aswin.boxlore.core.prefs.NpsSurveyStore import cx.aswin.boxlore.core.prefs.UserPreferencesRepository @@ -98,6 +99,31 @@ class EngagementPromptCoordinator( ) } + /** + * Debug: what the NPS survey still waits for, apart from a finish moment (the next finished + * episode is one). + */ + suspend fun npsSurveyWaitsFor(installedAtMs: Long, finishedSinceInstall: Int, nowMs: Long = System.currentTimeMillis()): List = + NpsSurveySchedule.unmet( + NpsSurveyMoment( + nowMs = nowMs, + installedAtMs = installedAtMs, + finishedSinceInstall = finishedSinceInstall, + lastFinishedAtMs = nowMs, + processStartedAtMs = processStartedAtMs, + record = surveyStore.record(), + inQuietDays = userPrefs.isInReviewQuietPeriod(installedAtMs, nowMs), + cooldownElapsed = userPrefs.isEngagementCooldownElapsed(), + ), + ) - NpsSurveyRule.FinishMoment + + /** Debug: forgets the survey's last showing, the shared cooldown and this session's prompt, so the next finish can ask. */ + suspend fun resetNpsSurveyForTesting() { + surveyStore.resetSchedule() + userPrefs.clearEngagementCooldown() + sessionProactivePromptShown = false + } + /** * The NPS survey is on screen: it uses up this session's prompt, the shared cooldown and its own * 90 days, and lets the milestone review prompt follow later. diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt index f4e42ee42..ec11a69aa 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.core.catalog import android.content.Context import androidx.datastore.preferences.core.edit import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.NpsSurveyRule import cx.aswin.boxlore.core.prefs.NpsSurveyStore import cx.aswin.boxlore.core.prefs.UserPreferencesRepository import cx.aswin.boxlore.core.prefs.userPreferencesDataStore @@ -68,6 +69,13 @@ class EngagementPromptCoordinatorSurveyTest { "not again within 90 days", nextRun.shouldShowNpsSurvey(false, now - 30 * day, 6, lastFinishedAtMs = now + 1, nowMs = now + 2), ) + assertEquals(listOf(NpsSurveyRule.Cooldown, NpsSurveyRule.AskAgainGap), nextRun.npsSurveyWaitsFor(now - 30 * day, 6, nowMs = now + 2)) + + // The debug reset lets the next finish ask again, in this session too. + nextRun.reserveSessionForPrompt() + nextRun.resetNpsSurveyForTesting() + assertEquals(emptyList(), nextRun.npsSurveyWaitsFor(now - 30 * day, 6, nowMs = now + 2)) + assertTrue(nextRun.shouldShowNpsSurvey(false, now - 30 * day, 6, lastFinishedAtMs = now + 1, nowMs = now + 2)) context.userPreferencesDataStore.edit { it.clear() } } } diff --git a/core/prefs/README.md b/core/prefs/README.md index a9e970fca..c3c97fc5e 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -84,7 +84,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/ - `subscription_manual_order` and `home_pinned_podcast_ids` live in the same `user_preferences` DataStore (do not rename the file). Unsubscribe drops that id from both lists. - `seen_listening_story` (`seenListeningStoryStream` / `setSeenListeningStory`, added 2026-10-31): the last monthly listening story opened, as `yyyy-MM`, so Listening history stops calling it New. Local to the phone. - Review prompt (`EngagementPreferences`): a milestone (5, 15 or 30 episodes finished in boxlore on this install; Home counts them) is stored as `review_milestone_pending` and, once a prompt shows, as `review_milestone_consumed`, so the same milestone never asks again. `shouldShowReviewPrompt` and `isInReviewQuietPeriod` take Android's install time (a backup doesn't carry it) and keep a 2-day quiet period (`REVIEW_QUIET_DAYS`) after it and after `library_imported_at` (`markLibraryImported`). `review_first_launch_at` (the old lazily set install day) is no longer read. The pending milestone follows the count, so one a backup brought onto a phone with no listening behind it is dropped; on the first sync after updating, an install already prompted counts milestone 5 (or the one it has reached) as used (`ReviewPromptPreferencesTest`). `has_logged_first_play` is read only so an updated install doesn't send `first_episode_played` again; the player claims it now. `UserPreferencesRepositoryTest` covers the quiet days after install and import and a used milestone never coming back. -- NPS survey: `NpsSurveySchedule` decides when it may appear: right after an episode finishes in this run of boxlore (within 2 hours; never on an open alone), with 3 episodes finished here, a week since install, not in the first run, outside the quiet days and the shared 14-day cooldown, and 90 days after it last appeared. `NpsSurveyStore` keeps when it last appeared and how many times boxlore has started in its own file, `boxlore_nps_survey`, which backups leave out, so a restored phone is asked afresh. Once per install `carryOverLegacy` makes an updated install that showed the old popover survey (`nps_survey_fired`) wait 90 days from the update; a phone installed within a day is treated as restored and ignores the copied flag. `nps_survey_fired` and `nps_last_score` stay in `user_preferences`, because the milestone review prompt waits for the survey and skips low scores; the old pending and Play hand-off keys are no longer used. `NpsSurveyScheduleTest` and `NpsSurveyStoreTest` cover each rule. +- NPS survey: `NpsSurveySchedule` decides when it may appear: right after an episode finishes in this run of boxlore (within 2 hours; never on an open alone), with 3 episodes finished here, a week since install, not in the first run, outside the quiet days and the shared 14-day cooldown, and 90 days after it last appeared. `NpsSurveyStore` keeps when it last appeared and how many times boxlore has started in its own file, `boxlore_nps_survey`, which backups leave out, so a restored phone is asked afresh. Once per install `carryOverLegacy` makes an updated install that showed the old popover survey (`nps_survey_fired`) wait 90 days from the update; a phone installed within a day is treated as restored and ignores the copied flag. `nps_survey_fired` and `nps_last_score` stay in `user_preferences`, because the milestone review prompt waits for the survey and skips low scores; the old pending and Play hand-off keys are no longer used. `unmet` names the rules a moment misses, for the debug screen, and `resetSchedule` (debug) forgets the last showing. `NpsSurveyScheduleTest` and `NpsSurveyStoreTest` cover each rule. - `legacy_rss_repair_*` keys are a crash-recovery contract; keep them until every shipping build that can begin the repair has aged out. ## Testing notes diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt index 208542480..eb420259b 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt @@ -276,6 +276,11 @@ internal class EngagementPreferences(private val dataStore: DataStore= processStartedAtMs && nowMs - lastFinishedAtMs <= FINISH_MOMENT_WINDOW_MS - fun isDue(moment: NpsSurveyMoment): Boolean { + fun isDue(moment: NpsSurveyMoment): Boolean = unmet(moment).isEmpty() + + /** The rules [moment] doesn't meet yet, in the order a listener meets them; empty when the survey is due. */ + fun unmet(moment: NpsSurveyMoment): List { val now = moment.nowMs val lastShown = moment.record.lastShownAtMs - return isFinishMoment(moment.lastFinishedAtMs, moment.processStartedAtMs, now) && - moment.finishedSinceInstall >= MIN_FINISHED_EPISODES && - moment.installedAtMs > 0L && - now - moment.installedAtMs >= MIN_DAYS_INSTALLED * DAY_MS && - moment.record.launches >= 2 && - !moment.inQuietDays && - moment.cooldownElapsed && - (lastShown == null || now - lastShown >= ASK_AGAIN_DAYS * DAY_MS) + return buildList { + if (!isFinishMoment(moment.lastFinishedAtMs, moment.processStartedAtMs, now)) add(NpsSurveyRule.FinishMoment) + if (moment.finishedSinceInstall < MIN_FINISHED_EPISODES) add(NpsSurveyRule.FinishedEpisodes) + if (moment.installedAtMs <= 0L || now - moment.installedAtMs < MIN_DAYS_INSTALLED * DAY_MS) add(NpsSurveyRule.DaysInstalled) + if (moment.record.launches < 2) add(NpsSurveyRule.FirstRun) + if (moment.inQuietDays) add(NpsSurveyRule.QuietDays) + if (!moment.cooldownElapsed) add(NpsSurveyRule.Cooldown) + if (lastShown != null && now - lastShown < ASK_AGAIN_DAYS * DAY_MS) add(NpsSurveyRule.AskAgainGap) + } } private const val DAY_MS = 24L * 60 * 60 * 1000 diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt index 258fa4bb4..b2b342562 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt @@ -37,6 +37,14 @@ class NpsSurveyStore internal constructor(private val dataStore: DataStore + prefs.remove(LAST_SHOWN_AT) + prefs[LAUNCHES] = maxOf(prefs[LAUNCHES] ?: 0, 2) + } + } + /** * Runs once per install. An install that already showed the survey from an older version * ([legacyShown], the backed-up `nps_survey_fired`) waits the full gap from the update. A phone diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt index 4afcf8891..d771b8e41 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt @@ -644,6 +644,8 @@ class UserPreferencesRepository(context: Context,) { suspend fun isEngagementCooldownElapsed(): Boolean = engagement.isEngagementCooldownElapsed() + suspend fun clearEngagementCooldown() = engagement.clearEngagementCooldown() + suspend fun setNpsLastScore(score: Int) = engagement.setNpsLastScore(score) suspend fun npsLastScore(): Int? = engagement.npsLastScore() diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt index d24624827..126f9cfdd 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.core.prefs +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -75,4 +76,11 @@ class NpsSurveyScheduleTest { assertFalse(NpsSurveySchedule.isDue(moment(lastShownAt = now - 89 * day))) assertTrue(NpsSurveySchedule.isDue(moment(lastShownAt = now - 90 * day))) } + + @Test + fun unmetNamesEveryMissingRule() { + val blocked = moment(finished = 1, lastFinishedAt = null, installedAt = now - day, launches = 1, lastShownAt = now - day, quiet = true, cooldown = false) + assertEquals(NpsSurveyRule.entries.toList(), NpsSurveySchedule.unmet(blocked)) + assertEquals(emptyList(), NpsSurveySchedule.unmet(moment())) + } } diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt index 98881cc80..9acfa76dd 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt @@ -70,4 +70,14 @@ class NpsSurveyStoreTest { store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now) assertEquals(now - 10 * day, store.record().lastShownAtMs) } + + @Test + fun theDebugResetForgetsTheLastShowingAndCountsTwoStarts() = runTest { + store.markShown(now) + store.resetSchedule() + assertEquals(NpsSurveyRecord(lastShownAtMs = null, launches = 2), store.record()) + repeat(3) { store.recordLaunch() } + store.resetSchedule() + assertEquals(5, store.record().launches) + } } diff --git a/feature/home/README.md b/feature/home/README.md index 6b7138940..1ccefc36e 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -129,7 +129,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel ## Testing notes - Review prompt: Home counts only episodes played to the end after this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time; synced listening from another phone after that counts too), so marked-played episodes and a restore of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A decided prompt waits in `ReviewPromptRecorder` and is recorded only once its sheet is on screen with Home resumed (`onReviewPromptDisplayed`: milestone, cooldown and `engagement_prompt_shown`); deciding only reserves the session (`reserveSessionForPrompt`), so a prompt decided behind another screen isn't used up if boxlore closes first (`ReviewPromptRecorderTest`). It also appears only at a finish moment (an episode finished in this run, `lastFinishedAt`), never on an open alone. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. -- NPS survey: Home owns it. At a finish moment with nothing playing and no other sheet, `EngagementPromptCoordinator.shouldShowNpsSurvey` (`NpsSurveySchedule` in `:core:prefs`) decides it and `NpsSurveyController` opens it; `NpsSurveyHost` shows it only once nothing plays, and it counts as shown (`survey shown`, the schedule used up) only on screen with Home resumed. Steps: a 0–10 score (two staggered rows of same-size circles, "Not likely" over the 0 and "Very likely" under the 10), one follow-up by band (0–6 "What's the one thing we should fix first?", 7–8 "What would turn this up to a 10?", 9–10 "What should we queue up next?" with the ideas tag), then thanks; 9s and 10s get "Rate on Google Play", which opens the store page (`store_review_opened`, `nps_survey`). The score is sent at once as a partial answer and Send or Skip completes the same submission; closing before a score is `survey dismissed`, closing on the follow-up keeps the score. Answers are capped at 500 characters. Wording can be changed from the `nps-survey-copy` flag's payload (any `NpsSurveyCopy` key; an empty `ideaTag` hides the tag). Long-pressing Home's Feedback icon opens it by hand without using up the schedule. Echo stands on the sheet's top edge and plays one clip per step from its manifest (`survey-ask`, `survey-notes`, `survey-think`, `survey-idea`, `survey-thanks-calm`, `survey-thanks`, `survey-celebrate`), then rests with its eyes alive; reduced motion shows the resting still. +- NPS survey: Home owns it. At a finish moment with nothing playing and no other sheet, `EngagementPromptCoordinator.shouldShowNpsSurvey` (`NpsSurveySchedule` in `:core:prefs`) decides it and `NpsSurveyController` opens it; `NpsSurveyHost` shows it only once nothing plays, and it counts as shown (`survey shown`, the schedule used up) only on screen with Home resumed. Steps: a 0–10 score (two staggered rows of same-size circles, "Not likely" over the 0 and "Very likely" under the 10), one follow-up by band (0–6 "What's the one thing we should fix first?", 7–8 "What would turn this up to a 10?", 9–10 "What should we queue up next?" with the ideas tag), then thanks; 9s and 10s get "Rate on Google Play", which opens the store page (`store_review_opened`, `nps_survey`). The score is sent at once as a partial answer and Send or Skip completes the same submission; closing before a score is `survey dismissed`, closing on the follow-up keeps the score. Answers are capped at 500 characters. Wording can be changed from the `nps-survey-copy` flag's payload (any `NpsSurveyCopy` key; an empty `ideaTag` hides the tag). Long-pressing Home's Feedback icon opens it by hand without using up the schedule, and the debug screen's Flags tab says what the automatic survey still waits for and can reset its timing (last showing, shared cooldown, this session's prompt; `NpsSurveyDebugTest`). Echo stands on the sheet's top edge and plays one clip per step from its manifest (`survey-ask`, `survey-notes`, `survey-think`, `survey-idea`, `survey-thanks-calm`, `survey-thanks`, `survey-celebrate`), then rests with its eyes alive; reduced motion shows the resting still. - NPS survey layout: colours are theme roles only (score circles `primaryContainer`; the sheet white in light themes, `surfaceContainerHigh` in dark; the answer box `surfaceContainerHighest`, its edge and label `primary` when focused), so every preset, custom colour, wallpaper colour and Pure style applies. With the keyboard up the sheet rises above it, Echo shrinks to 132dp and the score chip and helper make room; windows under 600dp tall start Echo at 132dp, and the content scrolls when it still doesn't fit. On expanded and folded tablets it is a centered dialog. Before a score and on the thanks it closes like any sheet; on the follow-up only Skip, Send or Back close it, so a typed answer isn't lost to a stray tap. `NpsSurveySheetAdaptiveTest` keeps one survey, its step and its typed answer through phone ↔ tablet transitions across the 600dp and 480dp gates, 840dp, portrait/landscape and a half-opened fold in both directions with no event sent; checks the outside tap on the dialog before a score and on the follow-up, a survey waiting while something plays, and a short landscape phone reaching every score and Not now. `NpsSurveyControllerTest` covers the events and steps; `NpsSurveySheetPolicyTest` the Echo sequences against the manifest, Echo's sizes and the score circles. - `HomeRecommendationsApplyTest` covers `applyHomeRecommendations`: a failed request keeps the restored picks and saves nothing; fresh picks replace them once per episode ID and title and are saved with their fallback flag; an empty answer that did not fail clears them. - `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's last play, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreen.kt index ba9d9295d..3f9dc3dd5 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import cx.aswin.boxlore.core.catalog.EngagementPromptCoordinator import cx.aswin.boxlore.core.catalog.SubscriptionRepository import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily @@ -61,15 +62,18 @@ import kotlinx.coroutines.delay /** * Full-screen debug tools: sleep-prompt testing (including a night-window bypass), a live * playback state readout, a DB inspector, and misc flag/cache resets. Reached via a long-press - * on the Home avatar; available in all builds, matching the previous dialog's behavior. + * on the Home avatar; available in all builds, matching the previous dialog's behavior. The Flags tab can + * reset the NPS survey's timing so the next finished episode asks it again. */ @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("LongParameterList") // The debug tools take each service they inspect or reset. fun DebugScreen( playbackRepository: PlaybackRepository, subscriptionRepository: SubscriptionRepository, userPreferencesRepository: UserPreferencesRepository, adaptiveRankingRepository: AdaptiveRankingRepository, + engagementPromptCoordinator: EngagementPromptCoordinator, onBack: () -> Unit, bottomContentPadding: Dp = 0.dp, modifier: Modifier = Modifier, @@ -86,6 +90,7 @@ fun DebugScreen( subscriptionRepository = subscriptionRepository, userPrefs = userPreferencesRepository, adaptiveRankingRepository = adaptiveRankingRepository, + engagementCoordinator = engagementPromptCoordinator, ) as T }, ) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreenContent.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreenContent.kt index c6043a318..b47852274 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreenContent.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugScreenContent.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.home +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -20,11 +21,13 @@ import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.playback.PlayerState import cx.aswin.boxlore.core.ranking.LearnerInspectorSnapshot @@ -179,10 +182,14 @@ private fun DebugScreenPagerPage( DebugTab.Flags -> DebugTabScrollPane(bottomContentPadding = state.scrollBottomPadding) { - FlagsCacheSection( - onResetFeatureFlag = viewModel::resetFeatureFlag, - onClearDismissedCuriosities = viewModel::clearDismissedCuriosities, - ) + val npsSurveyStatus by viewModel.npsSurveyStatus.collectAsStateWithLifecycle() + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + FlagsCacheSection( + onResetFeatureFlag = viewModel::resetFeatureFlag, + onClearDismissedCuriosities = viewModel::clearDismissedCuriosities, + ) + NpsSurveyDebugSection(status = npsSurveyStatus, onReset = viewModel::resetNpsSurveyTiming) + } } } } diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugViewModel.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugViewModel.kt index e714bdb4d..0462d7df6 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugViewModel.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/DebugViewModel.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.feature.home import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import cx.aswin.boxlore.core.catalog.EngagementPromptCoordinator import cx.aswin.boxlore.core.catalog.SubscriptionRepository import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.playback.PlaybackRepository @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -37,6 +39,7 @@ class DebugViewModel( private val subscriptionRepository: SubscriptionRepository, private val userPrefs: UserPreferencesRepository, private val adaptiveRankingRepository: AdaptiveRankingRepository, + private val engagementCoordinator: EngagementPromptCoordinator, ) : AndroidViewModel(application) { val history: Flow> = playbackRepository @@ -64,8 +67,15 @@ class DebugViewModel( private val _shadowDiagnostics = MutableStateFlow>(emptyList()) val shadowDiagnostics: StateFlow> = _shadowDiagnostics.asStateFlow() + private val _npsSurveyStatus = MutableStateFlow(null) + val npsSurveyStatus: StateFlow = _npsSurveyStatus.asStateFlow() + + private val installedAtMs: Long = + runCatching { application.packageManager.getPackageInfo(application.packageName, 0).firstInstallTime }.getOrDefault(0L) + init { refreshLearnerSnapshot() + refreshNpsSurveyStatus() } fun setLogEnabled(enabled: Boolean) { @@ -107,6 +117,21 @@ class DebugViewModel( boxcastPrefs.clearLearnCuriosity() } + fun refreshNpsSurveyStatus() { + viewModelScope.launch { + val finished = playbackRepository.getAllHistory().first().map { it.toHomeListeningHistoryItem() }.countFinishedSince(installedAtMs) + _npsSurveyStatus.value = npsSurveyDebugStatus(engagementCoordinator.npsSurveyWaitsFor(installedAtMs, finished), finished) + } + } + + /** Lets the next finished episode ask the survey again, then shows what else it waits for. */ + fun resetNpsSurveyTiming() { + viewModelScope.launch { + engagementCoordinator.resetNpsSurveyForTesting() + refreshNpsSurveyStatus() + } + } + fun refreshLearnerSnapshot() { viewModelScope.launch { _learnerLoading.value = true diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyDebug.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyDebug.kt new file mode 100644 index 000000000..09ff25581 --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyDebug.kt @@ -0,0 +1,54 @@ +package cx.aswin.boxlore.feature.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.prefs.NpsSurveyRule +import cx.aswin.boxlore.core.prefs.NpsSurveySchedule + +/** + * Debug: what the automatic NPS survey still waits for on this phone. [waitsFor] leaves out the finish + * moment, which the next finished episode supplies; [finished] is the count of episodes finished here. + */ +internal fun npsSurveyDebugStatus(waitsFor: List, finished: Int): String { + if (waitsFor.isEmpty()) return "Ready: the survey appears after the next episode you finish, once nothing is playing." + val reasons = waitsFor.map { rule -> + when (rule) { + NpsSurveyRule.FinishMoment -> "an episode to finish" + NpsSurveyRule.FinishedEpisodes -> "${NpsSurveySchedule.MIN_FINISHED_EPISODES} episodes finished in boxlore ($finished so far)" + NpsSurveyRule.DaysInstalled -> "${NpsSurveySchedule.MIN_DAYS_INSTALLED} days since install" + NpsSurveyRule.FirstRun -> "a second start of boxlore" + NpsSurveyRule.QuietDays -> "the quiet days after an install, import or sign-in" + NpsSurveyRule.Cooldown -> "the 14 days after the last prompt" + NpsSurveyRule.AskAgainGap -> "${NpsSurveySchedule.ASK_AGAIN_DAYS} days since it last appeared" + } + } + return "Waiting for: " + reasons.joinToString("; ") + "." +} + +/** The Flags tab's survey tools: its status and a reset of its timing (last showing, cooldown, this session's prompt). */ +@Composable +internal fun NpsSurveyDebugSection(status: String?, onReset: () -> Unit) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("NPS survey", style = MaterialTheme.typography.titleSmall) + Text( + text = status ?: "Checking…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton(onClick = onReset, modifier = Modifier.fillMaxWidth()) { + Text("Reset NPS survey timing") + } + Text( + text = "Long-press Feedback on Home to open the survey now.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyDebugTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyDebugTest.kt new file mode 100644 index 000000000..a79ce2bcb --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyDebugTest.kt @@ -0,0 +1,24 @@ +package cx.aswin.boxlore.feature.home + +import cx.aswin.boxlore.core.prefs.NpsSurveyRule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** The debug screen says plainly what the automatic survey still waits for. */ +class NpsSurveyDebugTest { + @Test + fun `nothing missing reads as ready for the next finish`() { + assertEquals( + "Ready: the survey appears after the next episode you finish, once nothing is playing.", + npsSurveyDebugStatus(emptyList(), finished = 12), + ) + } + + @Test + fun `each missing rule is named, with the finished count`() { + assertEquals( + "Waiting for: 3 episodes finished in boxlore (1 so far); 90 days since it last appeared.", + npsSurveyDebugStatus(listOf(NpsSurveyRule.FinishedEpisodes, NpsSurveyRule.AskAgainGap), finished = 1), + ) + } +} diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index daa8529a9..595979e76 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -51,7 +51,7 @@ Announcement title: - New-episode notifications arrive sooner after a release. (#1151) - boxlore asks to send notifications only when you turn them on for a show or finish importing your library, not every time you open Home. (#1226) - On Explore, tap the Explore tab again to start searching. From a show or episode you opened in Explore, the first tap takes you back to Explore. -- Every few months, right after you finish an episode, Echo asks how likely you are to recommend boxlore, with one short follow-up question. It never pops up as you open the app, and it uses your theme's colours. +- Every few months, right after you finish an episode, Echo asks how likely you are to recommend boxlore, with one short follow-up question. It never pops up as you open the app, and it uses your theme's colours. (#1227) ## Fixed @@ -76,4 +76,4 @@ Announcement title: - If boxlore can't check Google Play for updates, it offers to open its Play Store page so you can update there. (#1197) - Clearing boxlore's data no longer opens a random episode on the next launch. (#1185) - On an episode's page, More from shows the show's cover instead of a placeholder, and its header stands out from the episodes below it. (#1219) -- The prompt to rate boxlore appears only right after you finish an episode, counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. (#1226) +- The prompt to rate boxlore appears only right after you finish an episode, counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. (#1226, #1227) From 48d91aa8712eee099dfdb5dc54b3c62c6e4c062b Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 22:56:45 +0530 Subject: [PATCH 4/5] fix(analytics): survey review fixes and PostHog push off A finish moment is now the player's own play-to-end in this run (PlaybackFinishes), so replays count and synced or future-dated finishes don't. A decided survey not shown within its two hours is dropped. Back closes the follow-up on the tablet dialog, the scroll position survives the sheet/dialog switch, and every send lists all four questions in survey order. The legacy carry-over uses Android's update time instead of a 24-hour guess. SDK 3.75's push token and notification-open capture are switched off. --- app/README.md | 2 +- .../cx/aswin/boxlore/BoxLoreApplication.kt | 9 +- .../catalog/EngagementPromptCoordinator.kt | 7 +- .../EngagementPromptCoordinatorSurveyTest.kt | 4 +- core/playback/README.md | 2 +- .../boxlore/core/playback/PlaybackFinishes.kt | 21 ++++ .../playback/PlaybackProgressCoordinator.kt | 1 + .../service/PlaybackServiceHistory.kt | 1 + .../core/playback/PlaybackFinishesTest.kt | 108 ++++++++++++++++++ core/prefs/README.md | 2 +- .../boxlore/core/prefs/NpsSurveySchedule.kt | 6 +- .../boxlore/core/prefs/NpsSurveyStore.kt | 12 +- .../core/prefs/NpsSurveyScheduleTest.kt | 6 + .../boxlore/core/prefs/NpsSurveyStoreTest.kt | 15 +-- feature/home/README.md | 6 +- .../feature/home/HomeListeningHistoryItem.kt | 3 - .../feature/home/HomeViewModelLoadData.kt | 10 +- .../aswin/boxlore/feature/home/NpsSurvey.kt | 18 ++- .../boxlore/feature/home/NpsSurveyHost.kt | 9 +- .../feature/home/components/NpsSurveySheet.kt | 17 ++- .../home/HomeListeningHistoryItemTest.kt | 15 --- .../feature/home/NpsSurveyControllerTest.kt | 39 ++++++- .../components/NpsSurveySheetAdaptiveTest.kt | 67 +++++++++++ 23 files changed, 318 insertions(+), 62 deletions(-) create mode 100644 core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackFinishes.kt create mode 100644 core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackFinishesTest.kt diff --git a/app/README.md b/app/README.md index 4854d8aa1..e030b6a4e 100644 --- a/app/README.md +++ b/app/README.md @@ -86,7 +86,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker. - **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, one broken-feed report limiter (`feedReportLimiter` over the `boxlore_feed_reports` stamps, the same instance `FeedAddressUpkeep.reportLimiter` gives the daily address check, so both claim inside one daily limit), `feedTelemetry` for `feed_fetch_result`, and `isFollowed` (`podcasts.isSubscribed`), so the show page leaves followed shows' reports to their own refresh. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process). - **Feed address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (`upgrade-repair-off`) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds` (`AddressCheckAnswer.fetchWith`), the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker.request` (returns once WorkManager has saved the request, so step 4 never records one lost to process death), `userSyncCoordinator.requestIdentityCatchUp` (once every followed show was read again, the next sync runs the identity catch-up again), `connectivityStatus` and `feedTelemetry`. -- The NPS survey is Home's own sheet (`feature/home`), reporting to a PostHog `api` survey; PostHog draws no surveys (`surveys = false`), so the SDK can update freely. `BoxLoreApplication` makes `EngagementPromptCoordinator` with the install-only `NpsSurveyStore` (`datastore/boxlore_nps_survey.preferences_pb`, left out of legacy backup, cloud backup and device transfer: `BackgroundCheckBackupRulesTest`) and counts each start with `onAppLaunched`. +- The NPS survey is Home's own sheet (`feature/home`), reporting to a PostHog `api` survey; PostHog draws no surveys (`surveys = false`), so the SDK can update freely. PostHog push is off too (`capturePushNotificationSubscriptions`, `capturePushNotificationOpened`, on by default in SDK 3.7x), so the FCM token and notification opens never reach PostHog. `BoxLoreApplication` makes `EngagementPromptCoordinator` with the install-only `NpsSurveyStore` (`datastore/boxlore_nps_survey.preferences_pb`, left out of legacy backup, cloud backup and device transfer: `BackgroundCheckBackupRulesTest`) and counts each start with `onAppLaunched`. - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. - `ArtworkNavigationHostTheme` captures source colors via a synchronous destination listener, paints the slide/fade underlay with the departing page’s background, and gives entering loaders temporary inherited colors. Regular pages (including Home) and their navbar use the saved app theme as one unit from the first frame, while only their loaders ease back after the slide; detail loaders keep their own entry-scoped handoff until artwork is resolved. Back also starts with the departing palette before restoring the returning page’s own colors, including rapid returns while its composition is still retained; cold starts, opt-out and theme changes reset the fallback safely. The root Scaffold and predictive-Back wrapper use the current page background so shrinking the page does not reveal the saved-theme surface. Navigation routes, duration, gesture handling and playback remain unchanged. diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index e599172a2..f5d0fd8b9 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -182,8 +182,10 @@ class BoxLoreApplication : engagementPromptCoordinator = EngagementPromptCoordinator(userPreferencesRepository, cx.aswin.boxlore.core.prefs.NpsSurveyStore(this)) applicationScope.launch { - val installedAtMs = runCatching { packageManager.getPackageInfo(packageName, 0).firstInstallTime }.getOrDefault(0L) - engagementPromptCoordinator.onAppLaunched(installedAtMs) + // Updated after installing: this install ran an older version (a fresh or restored install has not). + val updatedInstall = + runCatching { packageManager.getPackageInfo(packageName, 0).let { it.lastUpdateTime > it.firstInstallTime } }.getOrDefault(false) + engagementPromptCoordinator.onAppLaunched(updatedInstall) } scheduleOptionalStartup() @@ -386,6 +388,9 @@ class BoxLoreApplication : debug = BuildConfig.DEBUG // The NPS survey is boxlore's own sheet (Home), reporting to an API survey; PostHog draws none. surveys = false + // boxlore doesn't use PostHog push: never send it the FCM token or notification opens (on by default since 3.7x). + capturePushNotificationSubscriptions = false + capturePushNotificationOpened = false onFeatureFlags = PostHogWiring.onFlagsLoaded( // LEGACY(own-ids): staged feed-first rollout; remove at S7 with rememberRolloutAnswers. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt index e701157e9..361fdf767 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt @@ -68,11 +68,12 @@ class EngagementPromptCoordinator( /** * Counts this start of boxlore and, once per install, carries over an older version's survey - * ([NpsSurveyStore.carryOverLegacy]). Called once at startup. + * ([NpsSurveyStore.carryOverLegacy]; [updatedInstall] when Android updated boxlore after installing + * it). Called once at startup. */ - suspend fun onAppLaunched(installedAtMs: Long, nowMs: Long = System.currentTimeMillis()) { + suspend fun onAppLaunched(updatedInstall: Boolean, nowMs: Long = System.currentTimeMillis()) { surveyStore.recordLaunch() - surveyStore.carryOverLegacy(userPrefs.hasNpsSurveyFired(), installedAtMs, nowMs) + surveyStore.carryOverLegacy(userPrefs.hasNpsSurveyFired(), updatedInstall, nowMs) } /** Whether the NPS survey should appear now, by [NpsSurveySchedule] with nothing playing and no prompt yet this session. */ diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt index ec11a69aa..fe7d67901 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinatorSurveyTest.kt @@ -44,8 +44,8 @@ class EngagementPromptCoordinatorSurveyTest { suspend fun due(lastFinishedAt: Long?, playing: Boolean = false) = coordinator.shouldShowNpsSurvey(playing, installedAtMs = now - 30 * day, finishedSinceInstall = 5, lastFinishedAtMs = lastFinishedAt, nowMs = now) - coordinator.onAppLaunched(installedAtMs = now - 30 * day, nowMs = now) - coordinator.onAppLaunched(installedAtMs = now - 30 * day, nowMs = now) + coordinator.onAppLaunched(updatedInstall = false, nowMs = now) + coordinator.onAppLaunched(updatedInstall = false, nowMs = now) // A restored or older install that never showed the old survey starts with no history. assertNull(store.record().lastShownAtMs) diff --git a/core/playback/README.md b/core/playback/README.md index 82ed0bb22..7195ed9a5 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -139,7 +139,7 @@ Files under `core/data/service` are compatibility stubs for old service class na - `playback_session_ended` goes out once per listen; `playback_paused` is still built but off by default. Only buffering of 5 seconds or more is sent alone as `playback_buffering`. - Errors go out once per cause per episode until a listen starts (`PlaybackErrorOnce`, which remembers every episode tried since), also to error tracking with links and tokens removed. - `ListenNoteKeeper` writes a note when a listen starts and every 30 seconds while it plays (`boxlore_listen_note`), and clears it when the listen ends; `ListenInterruptions.reportLeftover`, called once at app start, sends `playback_interrupted` for a note Android's ending of the app left behind and clears it. -- Each timed play (`PlaybackTelemetrySession`, saved through `ListeningSessionRecordLogic`) also records its listening time (`wallMs`: each natural position advance divided by the playback speed, `ListeningSessionRecordLogic.listenedWallMs`, so buffering and pauses never count), how it started (`playSource`: a Lore or Smart Queue queue context, else the analytics glossary's canonical entry point), where it was heard (`service.ListeningOutputRoute`, set on the session by the service as `listeningOutput`: Cast, the car, Bluetooth, wired or USB headphones or the speaker; Android 13+ reads the media route, older versions the connected outputs in Android's routing order) and the episode's release date (queue, then catalog; seconds or ms stored as ms, `releaseDateMs`). A recorded play adds its show to the played shows (`PlayedShowDao.recordPlay`, with the art of the episode's history row). A progress save from playback dates the first play and a finish by listening (`ListeningHistoryUpsertLogic.buildProgressSaveEntity`); the database keeps earlier dates, and only playback dates a finish. Sessions carry the listening device ID the service sets (`listeningDeviceId`, from `BoxcastPrefs.getOrCreateListeningDeviceId`). Undo of a history removal puts back the dates and the sessions' and daily summaries' new fields, the summaries' richer totals included, their time with a known release date (Room v42) too (`PlaybackHistoryMappings`). `ListeningHistoryItem` carries `firstPlayedAt` and `finishedAt`. +- Each timed play (`PlaybackTelemetrySession`, saved through `ListeningSessionRecordLogic`) also records its listening time (`wallMs`: each natural position advance divided by the playback speed, `ListeningSessionRecordLogic.listenedWallMs`, so buffering and pauses never count), how it started (`playSource`: a Lore or Smart Queue queue context, else the analytics glossary's canonical entry point), where it was heard (`service.ListeningOutputRoute`, set on the session by the service as `listeningOutput`: Cast, the car, Bluetooth, wired or USB headphones or the speaker; Android 13+ reads the media route, older versions the connected outputs in Android's routing order) and the episode's release date (queue, then catalog; seconds or ms stored as ms, `releaseDateMs`). A recorded play adds its show to the played shows (`PlayedShowDao.recordPlay`, with the art of the episode's history row). A progress save from playback dates the first play and a finish by listening (`ListeningHistoryUpsertLogic.buildProgressSaveEntity`); the database keeps earlier dates, and only playback dates a finish. Sessions carry the listening device ID the service sets (`listeningDeviceId`, from `BoxcastPrefs.getOrCreateListeningDeviceId`). Undo of a history removal puts back the dates and the sessions' and daily summaries' new fields, the summaries' richer totals included, their time with a known release date (Room v42) too (`PlaybackHistoryMappings`). `ListeningHistoryItem` carries `firstPlayedAt` and `finishedAt`. `PlaybackFinishes.lastAtMs` is when the player on this phone last played an episode to the end in this run (both play-to-end paths set it before the row is written); a replay sets it again though the row keeps its first `finishedAt`, and marking played, sync and restores never set it. Home uses it for the NPS survey and the review prompt (`PlaybackFinishesTest`). - `PlaybackRepository` polls MediaController position only while app UI is foregrounded; the playback service is the sole owner of active-session progress persistence and telemetry. Service writes capture live Player state before a playlist can be cleared and serialize sequence-stamped snapshots through one coordinator, so an older suspended write cannot replace a later seek/restart. Replayed completed episodes reset completion via `reopenProgress` when intentional progress occurs, and `ensureCurrentHistoryRow` refreshes `lastPlayedAt` immediately on play initiation so quick plays (<10s) are discoverable on cold restart. Service-only entry points such as Android Auto atomically seed missing history rows. When the UI returns to an empty player, it refreshes from Room instead of replacing a valid restored duration with `TIME_UNSET`/zero. This adds no background UI polling or duplicate periodic Room writes. - `BoxLorePlaybackService` is a Media3 `MediaLibraryService`; it resolves shared dependencies lazily after application startup and keeps its local and remote players service-scoped so transfers continue while the Activity is absent. A local session that stays paused and backgrounded for 15 minutes saves progress, rechecks eligibility after persistence, and stops the service; resumed playback, cancellation, buffering intent, foreground UI, and Cast sessions bypass that teardown. - The local ExoPlayer opportunistically enables platform audio offload only when the device can preserve playback-speed behavior. `WAKE_MODE_NETWORK` remains enabled for reliable screen-off streaming. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackFinishes.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackFinishes.kt new file mode 100644 index 000000000..fae693c96 --- /dev/null +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackFinishes.kt @@ -0,0 +1,21 @@ +package cx.aswin.boxlore.core.playback + +/** + * When the player on this phone last played an episode to the end, in this run of boxlore. Replays + * count each time; marking an episode played, a sync from another device or a backup never does. + * Home asks the NPS survey and the review prompt only shortly after one. + */ +object PlaybackFinishes { + @Volatile + var lastAtMs: Long? = null + private set + + internal fun record(atMs: Long) { + lastAtMs = atMs + } + + /** Test hook: no finish in this run. */ + fun resetForTest() { + lastAtMs = null + } +} diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackProgressCoordinator.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackProgressCoordinator.kt index 08a7a0101..7f3ef60d1 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackProgressCoordinator.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackProgressCoordinator.kt @@ -259,6 +259,7 @@ internal class PlaybackProgressCoordinator( existing.lastPlayedAt } if (checkIsPlaybackCompleted(snapshot.positionMs, resolvedDurationMs)) { + PlaybackFinishes.record(System.currentTimeMillis()) dao.completeFromPlayback( episodeId = historyId, durationMs = resolvedDurationMs, diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/PlaybackServiceHistory.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/PlaybackServiceHistory.kt index 5efc6aa63..f96812169 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/PlaybackServiceHistory.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/PlaybackServiceHistory.kt @@ -120,6 +120,7 @@ private suspend fun BoxLorePlaybackService.persistNaturalCompletionOnce( val persisted = existing ?: dao.getHistoryItem(historyId) ?: return val completionDurationMs = durationMs.takeIf { it > 0L } ?: persisted.durationMs val completedAt = System.currentTimeMillis() + cx.aswin.boxlore.core.playback.PlaybackFinishes.record(completedAt) dao.completeFromPlayback( episodeId = historyId, durationMs = completionDurationMs, diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackFinishesTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackFinishesTest.kt new file mode 100644 index 000000000..ab63c3d3f --- /dev/null +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackFinishesTest.kt @@ -0,0 +1,108 @@ +package cx.aswin.boxlore.core.playback + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.ListeningHistoryEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The player's own record of an episode played to the end, which the NPS survey and the review prompt + * wait for. A replay counts again, though the stored finish keeps the first time (regression: a replay + * never made a new finish moment). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PlaybackFinishesTest { + private lateinit var database: BoxLoreDatabase + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + PlaybackFinishes.resetForTest() + } + + @After + fun tearDown() { + database.close() + PlaybackFinishes.resetForTest() + } + + @Test + fun playingToTheEndRecordsAFinishEveryTime() = runTest(testDispatcher) { + database.listeningHistoryDao().upsert(history()) + val progress = progressCoordinator() + + progress.saveProgressSnapshot(snapshot(positionMs = 60_000L, sequence = 1L)) + assertNull("progress mid-episode is no finish", PlaybackFinishes.lastAtMs) + + progress.saveProgressSnapshot(snapshot(positionMs = 1_799_000L, sequence = 2L)) + val first = database.listeningHistoryDao().getHistoryItem(ID)!!.finishedAt + assertNotNull(PlaybackFinishes.lastAtMs) + + // Played to the end again: a new finish here, while the row keeps its first finish time. + PlaybackFinishes.resetForTest() + progress.saveProgressSnapshot(snapshot(positionMs = 1_799_500L, sequence = 3L)) + assertNotNull(PlaybackFinishes.lastAtMs) + assertTrue(database.listeningHistoryDao().getHistoryItem(ID)!!.finishedAt == first) + } + + private fun progressCoordinator() = PlaybackProgressCoordinator( + mainDispatcher = testDispatcher, + database = database, + mediaSessionProvider = { null }, + isEffectiveEndLatched = { false }, + effectiveSkipEndingMs = { 0L }, + updateConsumedAudio = {}, + dispatchHeartbeatTelemetry = {}, + missingHistorySeedProvider = { null }, + ) + + private fun snapshot(positionMs: Long, sequence: Long) = PlaybackProgressSnapshot( + sequence = sequence, + episodeId = ID, + positionMs = positionMs, + durationMs = 1_800_000L, + hasBeenPlayingFor10s = true, + allowZeroPosition = false, + episodeTitle = "Episode", + episodeImageUrl = null, + episodeAudioUrl = "https://example.com/$ID.mp3", + podcastName = "Show", + enclosureType = null, + ) + + private fun history() = ListeningHistoryEntity( + episodeId = ID, + podcastId = "show", + episodeTitle = "Episode", + episodeImageUrl = null, + podcastImageUrl = null, + episodeAudioUrl = "https://example.com/$ID.mp3", + podcastName = "Show", + progressMs = 0L, + durationMs = 1_800_000L, + isCompleted = false, + isLiked = false, + lastPlayedAt = 1L, + ) + + private companion object { + const val ID = "episode" + } +} diff --git a/core/prefs/README.md b/core/prefs/README.md index c3c97fc5e..396a06614 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -84,7 +84,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/ - `subscription_manual_order` and `home_pinned_podcast_ids` live in the same `user_preferences` DataStore (do not rename the file). Unsubscribe drops that id from both lists. - `seen_listening_story` (`seenListeningStoryStream` / `setSeenListeningStory`, added 2026-10-31): the last monthly listening story opened, as `yyyy-MM`, so Listening history stops calling it New. Local to the phone. - Review prompt (`EngagementPreferences`): a milestone (5, 15 or 30 episodes finished in boxlore on this install; Home counts them) is stored as `review_milestone_pending` and, once a prompt shows, as `review_milestone_consumed`, so the same milestone never asks again. `shouldShowReviewPrompt` and `isInReviewQuietPeriod` take Android's install time (a backup doesn't carry it) and keep a 2-day quiet period (`REVIEW_QUIET_DAYS`) after it and after `library_imported_at` (`markLibraryImported`). `review_first_launch_at` (the old lazily set install day) is no longer read. The pending milestone follows the count, so one a backup brought onto a phone with no listening behind it is dropped; on the first sync after updating, an install already prompted counts milestone 5 (or the one it has reached) as used (`ReviewPromptPreferencesTest`). `has_logged_first_play` is read only so an updated install doesn't send `first_episode_played` again; the player claims it now. `UserPreferencesRepositoryTest` covers the quiet days after install and import and a used milestone never coming back. -- NPS survey: `NpsSurveySchedule` decides when it may appear: right after an episode finishes in this run of boxlore (within 2 hours; never on an open alone), with 3 episodes finished here, a week since install, not in the first run, outside the quiet days and the shared 14-day cooldown, and 90 days after it last appeared. `NpsSurveyStore` keeps when it last appeared and how many times boxlore has started in its own file, `boxlore_nps_survey`, which backups leave out, so a restored phone is asked afresh. Once per install `carryOverLegacy` makes an updated install that showed the old popover survey (`nps_survey_fired`) wait 90 days from the update; a phone installed within a day is treated as restored and ignores the copied flag. `nps_survey_fired` and `nps_last_score` stay in `user_preferences`, because the milestone review prompt waits for the survey and skips low scores; the old pending and Play hand-off keys are no longer used. `unmet` names the rules a moment misses, for the debug screen, and `resetSchedule` (debug) forgets the last showing. `NpsSurveyScheduleTest` and `NpsSurveyStoreTest` cover each rule. +- NPS survey: `NpsSurveySchedule` decides when it may appear: right after the player on this phone plays an episode to the end in this run of boxlore (within 2 hours and never in the future; never on an open alone, a sync or a mark-played), with 3 episodes finished here, a week since install, not in the first run, outside the quiet days and the shared 14-day cooldown, and 90 days after it last appeared. `NpsSurveyStore` keeps when it last appeared and how many times boxlore has started in its own file, `boxlore_nps_survey`, which backups leave out, so a restored phone is asked afresh. Once per install `carryOverLegacy` makes an install that Android updated from an older version and that showed the old popover survey (`nps_survey_fired`) wait 90 days from the update; a fresh install, restored from a backup or not and however late it first opens, ignores the copied flag. `nps_survey_fired` and `nps_last_score` stay in `user_preferences`, because the milestone review prompt waits for the survey and skips low scores; the old pending and Play hand-off keys are no longer used. `unmet` names the rules a moment misses, for the debug screen, and `resetSchedule` (debug) forgets the last showing. `NpsSurveyScheduleTest` and `NpsSurveyStoreTest` cover each rule. - `legacy_rss_repair_*` keys are a crash-recovery contract; keep them until every shipping build that can begin the repair has aged out. ## Testing notes diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt index 366a301f0..7c3b876bd 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt @@ -6,7 +6,7 @@ data class NpsSurveyMoment( val installedAtMs: Long, /** Episodes played to the end in boxlore since it was installed here. */ val finishedSinceInstall: Int, - /** When the latest of them finished; null when none has. */ + /** When the player on this phone last played an episode to the end in this run; null when it hasn't. */ val lastFinishedAtMs: Long?, val processStartedAtMs: Long, val record: NpsSurveyRecord, @@ -33,9 +33,9 @@ object NpsSurveySchedule { /** How long after an episode finishes Home still counts it as the moment to ask. */ const val FINISH_MOMENT_WINDOW_MS = 2L * 60 * 60 * 1000 - /** An episode finished since this run of boxlore started, and recently. */ + /** An episode finished since this run of boxlore started, within the window and not in the future. */ fun isFinishMoment(lastFinishedAtMs: Long?, processStartedAtMs: Long, nowMs: Long,): Boolean = - lastFinishedAtMs != null && lastFinishedAtMs >= processStartedAtMs && nowMs - lastFinishedAtMs <= FINISH_MOMENT_WINDOW_MS + lastFinishedAtMs != null && lastFinishedAtMs >= processStartedAtMs && nowMs - lastFinishedAtMs in 0L..FINISH_MOMENT_WINDOW_MS fun isDue(moment: NpsSurveyMoment): Boolean = unmet(moment).isEmpty() diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt index b2b342562..bcdc73341 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveyStore.kt @@ -46,23 +46,21 @@ class NpsSurveyStore internal constructor(private val dataStore: DataStore if (prefs[LEGACY_CHECKED] == true) return@edit prefs[LEGACY_CHECKED] = true - val updatedInstall = installedAtMs > 0L && nowMs - installedAtMs >= RESTORED_INSTALL_WINDOW_MS if (legacyShown && updatedInstall && prefs[LAST_SHOWN_AT] == null) prefs[LAST_SHOWN_AT] = nowMs } } companion object { const val FILE_NAME = "boxlore_nps_survey" - private const val RESTORED_INSTALL_WINDOW_MS = 24L * 60 * 60 * 1000 private val LAST_SHOWN_AT = longPreferencesKey("last_shown_at") private val LAUNCHES = intPreferencesKey("launches") private val LEGACY_CHECKED = booleanPreferencesKey("legacy_checked") diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt index 126f9cfdd..673c94878 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyScheduleTest.kt @@ -42,6 +42,12 @@ class NpsSurveyScheduleTest { assertFalse(NpsSurveySchedule.isDue(moment(lastFinishedAt = null))) } + @Test + fun aFinishStampedInTheFutureIsNotTheMoment() { + // Regression: a finish synced from a device whose clock runs ahead passed the window check. + assertFalse(NpsSurveySchedule.isDue(moment(lastFinishedAt = now + 60_000))) + } + @Test fun aFinishLongAgoInThisRunIsNotTheMoment() { val longRun = moment(lastFinishedAt = now - NpsSurveySchedule.FINISH_MOMENT_WINDOW_MS - 1) diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt index 9acfa76dd..8ba235eb3 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/NpsSurveyStoreTest.kt @@ -46,28 +46,29 @@ class NpsSurveyStoreTest { @Test fun anUpdatedInstallThatShowedTheOldSurveyWaitsFromTheUpdate() = runTest { - store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now) + store.carryOverLegacy(legacyShown = true, updatedInstall = true, nowMs = now) assertEquals(now, store.record().lastShownAtMs) } @Test - fun aRestoredPhoneIgnoresTheBackedUpFlag() = runTest { - // Installed an hour ago: the old flag came in with a backup, so this phone is asked afresh. - store.carryOverLegacy(legacyShown = true, installedAtMs = now - 60 * 60 * 1000, nowMs = now) + fun aRestoredPhoneIgnoresTheBackedUpFlagHoweverLateItFirstOpens() = runTest { + // Installed fresh (never updated) with the old flag from a backup, first opened days later: + // regression, the old 24-hour guess counted this as an updated install and waited 90 days. + store.carryOverLegacy(legacyShown = true, updatedInstall = false, nowMs = now + 2 * day) assertNull(store.record().lastShownAtMs) } @Test fun theCarryOverRunsOnce() = runTest { - store.carryOverLegacy(legacyShown = false, installedAtMs = now - 40 * day, nowMs = now) - store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now + day) + store.carryOverLegacy(legacyShown = false, updatedInstall = true, nowMs = now) + store.carryOverLegacy(legacyShown = true, updatedInstall = true, nowMs = now + day) assertNull(store.record().lastShownAtMs) } @Test fun theCarryOverNeverMovesARealShowing() = runTest { store.markShown(now - 10 * day) - store.carryOverLegacy(legacyShown = true, installedAtMs = now - 40 * day, nowMs = now) + store.carryOverLegacy(legacyShown = true, updatedInstall = true, nowMs = now) assertEquals(now - 10 * day, store.record().lastShownAtMs) } diff --git a/feature/home/README.md b/feature/home/README.md index 1ccefc36e..2ceed7b21 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -128,9 +128,9 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel ## Testing notes -- Review prompt: Home counts only episodes played to the end after this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time; synced listening from another phone after that counts too), so marked-played episodes and a restore of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A decided prompt waits in `ReviewPromptRecorder` and is recorded only once its sheet is on screen with Home resumed (`onReviewPromptDisplayed`: milestone, cooldown and `engagement_prompt_shown`); deciding only reserves the session (`reserveSessionForPrompt`), so a prompt decided behind another screen isn't used up if boxlore closes first (`ReviewPromptRecorderTest`). It also appears only at a finish moment (an episode finished in this run, `lastFinishedAt`), never on an open alone. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. -- NPS survey: Home owns it. At a finish moment with nothing playing and no other sheet, `EngagementPromptCoordinator.shouldShowNpsSurvey` (`NpsSurveySchedule` in `:core:prefs`) decides it and `NpsSurveyController` opens it; `NpsSurveyHost` shows it only once nothing plays, and it counts as shown (`survey shown`, the schedule used up) only on screen with Home resumed. Steps: a 0–10 score (two staggered rows of same-size circles, "Not likely" over the 0 and "Very likely" under the 10), one follow-up by band (0–6 "What's the one thing we should fix first?", 7–8 "What would turn this up to a 10?", 9–10 "What should we queue up next?" with the ideas tag), then thanks; 9s and 10s get "Rate on Google Play", which opens the store page (`store_review_opened`, `nps_survey`). The score is sent at once as a partial answer and Send or Skip completes the same submission; closing before a score is `survey dismissed`, closing on the follow-up keeps the score. Answers are capped at 500 characters. Wording can be changed from the `nps-survey-copy` flag's payload (any `NpsSurveyCopy` key; an empty `ideaTag` hides the tag). Long-pressing Home's Feedback icon opens it by hand without using up the schedule, and the debug screen's Flags tab says what the automatic survey still waits for and can reset its timing (last showing, shared cooldown, this session's prompt; `NpsSurveyDebugTest`). Echo stands on the sheet's top edge and plays one clip per step from its manifest (`survey-ask`, `survey-notes`, `survey-think`, `survey-idea`, `survey-thanks-calm`, `survey-thanks`, `survey-celebrate`), then rests with its eyes alive; reduced motion shows the resting still. -- NPS survey layout: colours are theme roles only (score circles `primaryContainer`; the sheet white in light themes, `surfaceContainerHigh` in dark; the answer box `surfaceContainerHighest`, its edge and label `primary` when focused), so every preset, custom colour, wallpaper colour and Pure style applies. With the keyboard up the sheet rises above it, Echo shrinks to 132dp and the score chip and helper make room; windows under 600dp tall start Echo at 132dp, and the content scrolls when it still doesn't fit. On expanded and folded tablets it is a centered dialog. Before a score and on the thanks it closes like any sheet; on the follow-up only Skip, Send or Back close it, so a typed answer isn't lost to a stray tap. `NpsSurveySheetAdaptiveTest` keeps one survey, its step and its typed answer through phone ↔ tablet transitions across the 600dp and 480dp gates, 840dp, portrait/landscape and a half-opened fold in both directions with no event sent; checks the outside tap on the dialog before a score and on the follow-up, a survey waiting while something plays, and a short landscape phone reaching every score and Not now. `NpsSurveyControllerTest` covers the events and steps; `NpsSurveySheetPolicyTest` the Echo sequences against the manifest, Echo's sizes and the score circles. +- Review prompt: Home counts only episodes played to the end after this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time; synced listening from another phone after that counts too), so marked-played episodes and a restore of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A decided prompt waits in `ReviewPromptRecorder` and is recorded only once its sheet is on screen with Home resumed (`onReviewPromptDisplayed`: milestone, cooldown and `engagement_prompt_shown`); deciding only reserves the session (`reserveSessionForPrompt`), so a prompt decided behind another screen isn't used up if boxlore closes first (`ReviewPromptRecorderTest`). It also appears only at a finish moment (the player here played an episode to the end in this run, `PlaybackFinishes` in `:core:playback`), never on an open alone, a sync or a mark-played. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. +- NPS survey: Home owns it. At a finish moment with nothing playing and no other sheet, `EngagementPromptCoordinator.shouldShowNpsSurvey` (`NpsSurveySchedule` in `:core:prefs`) decides it and `NpsSurveyController` opens it; `NpsSurveyHost` shows it only once nothing plays, drops it silently if it hasn't appeared within the finish moment's 2 hours, and it counts as shown (`survey shown`, the schedule used up) only on screen with Home resumed. Steps: a 0–10 score (two staggered rows of same-size circles, "Not likely" over the 0 and "Very likely" under the 10), one follow-up by band (0–6 "What's the one thing we should fix first?", 7–8 "What would turn this up to a 10?", 9–10 "What should we queue up next?" with the ideas tag), then thanks; 9s and 10s get "Rate on Google Play", which opens the store page (`store_review_opened`, `nps_survey`). The score is sent at once as a partial answer and Send or Skip completes the same submission; every send lists all four questions in the survey's order, as PostHog's own SDKs do; closing before a score is `survey dismissed`, closing on the follow-up keeps the score. Answers are capped at 500 characters. Wording can be changed from the `nps-survey-copy` flag's payload (any `NpsSurveyCopy` key; an empty `ideaTag` hides the tag). Long-pressing Home's Feedback icon opens it by hand without using up the schedule, and the debug screen's Flags tab says what the automatic survey still waits for and can reset its timing (last showing, shared cooldown, this session's prompt; `NpsSurveyDebugTest`). Echo stands on the sheet's top edge and plays one clip per step from its manifest (`survey-ask`, `survey-notes`, `survey-think`, `survey-idea`, `survey-thanks-calm`, `survey-thanks`, `survey-celebrate`), then rests with its eyes alive; reduced motion shows the resting still. +- NPS survey layout: colours are theme roles only (score circles `primaryContainer`; the sheet white in light themes, `surfaceContainerHigh` in dark; the answer box `surfaceContainerHighest`, its edge and label `primary` when focused), so every preset, custom colour, wallpaper colour and Pure style applies. With the keyboard up the sheet rises above it, Echo shrinks to 132dp and the score chip and helper make room; windows under 600dp tall start Echo at 132dp, and the content scrolls when it still doesn't fit. On expanded and folded tablets it is a centered dialog. Before a score and on the thanks it closes like any sheet; on the follow-up only Skip, Send or Back close it, so a typed answer isn't lost to a stray tap. `NpsSurveySheetAdaptiveTest` keeps one survey, its step and its typed answer through phone ↔ tablet transitions across the 600dp and 480dp gates, 840dp, portrait/landscape and a half-opened fold in both directions with no event sent; checks the outside tap on the dialog before a score and on the follow-up, Back on the dialog's follow-up, the scroll position across 839 → 840dp, a survey waiting while something plays and one dropped after its window, and a short landscape phone reaching every score and Not now. `NpsSurveyControllerTest` covers the events and steps; `NpsSurveySheetPolicyTest` the Echo sequences against the manifest, Echo's sizes and the score circles. - `HomeRecommendationsApplyTest` covers `applyHomeRecommendations`: a failed request keeps the restored picks and saves nothing; fresh picks replace them once per episode ID and title and are saved with their fallback flag; an empty answer that did not fail clears them. - `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's last play, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. - Recommendation entry points share an immutable columns/tablet layout value. Selected-show episode rendering has focused private playback, action and layout inputs; the parent crossfade retains each show's presentation and scroll owner. `HomeDiscoveryFeedSpacingTest` checks recommendation caps, order and episode/browse attribution while switching between the phone grid and tablet rail. `HomeConnectedRailTest` also checks selected-show playback, episode and browse callbacks across sidebar resizing, loading/empty-to-content recovery, and a 200-show library keeping a bounded lazy sidebar where every show stays reachable. `HomeArtworkHandoffTest` also checks that hidden tablet artwork waits, phone round trips keep the tablet target, and artwork sized only by its own content loads. diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt index dbf340fe7..d67c9c9bc 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt @@ -25,9 +25,6 @@ data class HomeListeningHistoryItem( internal fun List.countFinishedSince(installedAtMs: Long): Int = count { it.isCompleted && it.finishedAt?.let { at -> at >= installedAtMs } == true } -/** When the latest episode played to the end finished; null when none has. */ -internal fun List.lastFinishedAt(): Long? = filter { it.isCompleted }.mapNotNull { it.finishedAt }.maxOrNull() - internal fun ListeningHistoryEntity.toHomeListeningHistoryItem(): HomeListeningHistoryItem = HomeListeningHistoryItem( episodeId = episodeId, podcastId = podcastId, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt index 814467c2b..37f322e98 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt @@ -212,14 +212,18 @@ internal fun HomeViewModel.loadData() { // Prompts count only episodes finished here, so a restore or import can't trigger them. val completedCount = allHistory.countFinishedSince(installedAtMs) - // Prompts appear only at a finish moment: an episode finished in this run of boxlore. - val lastFinishedAtMs = allHistory.lastFinishedAt() + // Prompts appear only at a finish moment: the player here played an episode to the end in this run. + val lastFinishedAtMs = cx.aswin.boxlore.core.playback.PlaybackFinishes.lastAtMs if (!isPromptSheetOpen) { val isPlayingNow = playerState.value.isPlaying if (engagementCoordinator.shouldShowNpsSurvey(isPlayingNow, installedAtMs, completedCount, lastFinishedAtMs)) { // Recorded once the sheet is on screen (NpsSurveyController.onDisplayed). engagementCoordinator.reserveSessionForPrompt() - npsSurvey.open(manual = false, copy = npsSurveyCopy()) + npsSurvey.open( + manual = false, + copy = npsSurveyCopy(), + expiresAtMs = lastFinishedAtMs?.plus(cx.aswin.boxlore.core.prefs.NpsSurveySchedule.FINISH_MOMENT_WINDOW_MS), + ) } else if (engagementCoordinator.canShowProactivePrompt(isPlayingNow)) { userPrefs.syncReviewMilestonePending(completedCount) val shouldPrompt = diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt index 6262346ad..0093de170 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurvey.kt @@ -48,8 +48,12 @@ internal data class NpsSurveySession( val score: Int? = null, /** Opened by long-pressing Feedback: it sends its events but never uses up the schedule. */ val manual: Boolean = false, + /** A decided survey not on screen by then is dropped: its finish moment has passed. */ + val expiresAtMs: Long? = null, ) { val band: NpsScoreBand? get() = score?.let(NpsScoreBand::of) + + fun isExpired(nowMs: Long): Boolean = !manual && expiresAtMs != null && nowMs > expiresAtMs } /** @@ -128,11 +132,16 @@ internal class NpsSurveyController( /** This survey has been on screen. */ val wasDisplayed: Boolean get() = displayed.get() - fun open(manual: Boolean, copy: NpsSurveyCopy) { + fun open(manual: Boolean, copy: NpsSurveyCopy, expiresAtMs: Long? = null) { if (isOpen) return this.copy = copy displayed.set(false) - _session.value = NpsSurveySession(submissionId = newSubmissionId(), manual = manual) + _session.value = NpsSurveySession(submissionId = newSubmissionId(), manual = manual, expiresAtMs = expiresAtMs) + } + + /** Drops a decided survey that never reached the screen; nothing is sent and nothing is used up. */ + fun discardUndisplayed() { + if (!displayed.get()) _session.value = null } /** The sheet is on screen with Home resumed: `survey shown`, once per survey. */ @@ -179,9 +188,12 @@ internal class NpsSurveyController( AnalyticsHelper.trackSurveySent( surveyId = NpsSurveyDefinition.SURVEY_ID, submissionId = session.submissionId, + // Every question in the survey's order, as PostHog's own SDKs send them: its queries read answers by position. questions = listOf( SurveyQuestionRef(NpsSurveyDefinition.SCORE_QUESTION_ID, copy.question), - SurveyQuestionRef(followUpId, copy.followUpTitle(band)), + SurveyQuestionRef(NpsSurveyDefinition.FIX_QUESTION_ID, copy.fixTitle), + SurveyQuestionRef(NpsSurveyDefinition.TEN_QUESTION_ID, copy.tenTitle), + SurveyQuestionRef(NpsSurveyDefinition.IDEA_QUESTION_ID, copy.ideaTitle), ), responses = buildMap { put(NpsSurveyDefinition.SCORE_QUESTION_ID, score) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt index 0ff7222b5..28dee5ef0 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt @@ -16,12 +16,17 @@ import cx.aswin.boxlore.feature.home.components.NpsSurveySheet /** * Shows [controller]'s survey over Home. A decided survey is checked again as it appears: it waits while - * something plays, and counts as shown only once Home is resumed with the sheet on screen. + * something plays, is dropped once its finish moment has passed ([nowMs]), and counts as shown only once + * Home is resumed with the sheet on screen. */ @Composable -internal fun NpsSurveyHost(controller: NpsSurveyController, isPlaying: Boolean) { +internal fun NpsSurveyHost(controller: NpsSurveyController, isPlaying: Boolean, nowMs: () -> Long = System::currentTimeMillis) { val session by controller.session.collectAsState() val current = session ?: return + if (!controller.wasDisplayed && current.isExpired(nowMs())) { + LaunchedEffect(current.submissionId) { controller.discardUndisplayed() } + return + } if (!current.manual && isPlaying && !controller.wasDisplayed) return val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner, current.submissionId) { diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt index 9586fbd7c..2e9d8d67a 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheet.kt @@ -73,7 +73,8 @@ import cx.aswin.boxlore.feature.home.NpsSurveyStep * eyes alive. The sheet keeps the app's own colours in every theme, rises above the keyboard (Echo shrinks * and the score chip and helper make room), scrolls on short windows, and is a centered dialog on * expanded and folded tablets. Before a score and after the answer it closes like any sheet; on the - * follow-up only Skip, Send or back close it, so a typed answer isn't lost to a stray tap. + * follow-up only Skip, Send or Back close it (Back keeps the score), so a typed answer isn't lost to a + * stray tap. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -102,6 +103,8 @@ internal fun NpsSurveySheet( val shortWindow = LocalBoxloreWindowLayout.current.heightDp < NPS_SHORT_WINDOW_DP val echoSize by animateDpAsState(npsEchoSize(typing, shortWindow), label = "npsEchoSize") val above = echoSize * NPS_ECHO_ABOVE_SHEET + // Outside the sheet, so the position survives switching between the phone sheet and the tablet dialog. + val scroll = rememberScrollState() BoxloreModalBottomSheet( onDismissRequest = onClose, @@ -110,8 +113,12 @@ internal fun NpsSurveySheet( containerColor = Color.Transparent, dragHandle = null, contentWindowInsets = { WindowInsets(0) }, - properties = ModalBottomSheetProperties(shouldDismissOnBackPress = true), - centeredDismissAllowed = session.step != NpsSurveyStep.FollowUp, + // Back closes every step (the follow-up keeps the score); an outside tap never closes the follow-up. + properties = ModalBottomSheetProperties( + shouldDismissOnBackPress = true, + shouldDismissOnClickOutside = session.step != NpsSurveyStep.FollowUp, + ), + centeredDismissAllowed = true, ) { Box(Modifier.fillMaxWidth().testTag(NPS_SHEET_TAG)) { Surface( @@ -122,7 +129,8 @@ internal fun NpsSurveySheet( Column( modifier = Modifier .then(if (centered) Modifier else Modifier.windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime))) - .verticalScroll(rememberScrollState()) + .verticalScroll(scroll) + .testTag(NPS_SCROLL_TAG) .padding(start = 24.dp, end = 24.dp, top = echoSize - above + 4.dp, bottom = if (typing) 16.dp else 28.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -277,6 +285,7 @@ private const val NPS_SHORT_WINDOW_DP = 600f internal const val NPS_SHEET_TAG = "nps_survey_sheet" internal const val NPS_ANSWER_TAG = "nps_survey_answer" +internal const val NPS_SCROLL_TAG = "nps_survey_scroll" private val NPS_ECHO_PREFETCH = listOf("survey-notes", "survey-think", "survey-idea", "survey-thanks", "survey-celebrate") diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt index caae86b8b..50885d557 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt @@ -63,21 +63,6 @@ class HomeListeningHistoryItemTest { assertEquals(2, history.countFinishedSince(installedAt)) } - @Test - fun `the latest finish counts only episodes still finished`() { - val history = - listOf( - item("earlier", isCompleted = true, finishedAt = 1_500L), - item("latest", isCompleted = true, finishedAt = 1_800L), - // Replayed after finishing: its old finish is not a finish moment. - item("restarted", isCompleted = false, finishedAt = 1_900L), - item("marked-played", isCompleted = true, finishedAt = null), - ) - - assertEquals(1_800L, history.lastFinishedAt()) - assertNull(listOf(item("marked-played", isCompleted = true, finishedAt = null)).lastFinishedAt()) - } - private fun item(id: String, isCompleted: Boolean, finishedAt: Long?) = HomeListeningHistoryItem(id, "show", "Show", null, 0L, 1L, isCompleted, false, 2_000L, finishedAt) } diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt index 092deabe0..2880ca082 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/NpsSurveyControllerTest.kt @@ -97,9 +97,19 @@ class NpsSurveyControllerTest { openAndShow() controller.onScore(score) controller.onFollowUpDone("idea") - val questions = sent().last()["\$survey_questions"] as List<*> - assertEquals("score $score", questionId, (questions.last() as Map<*, *>)["id"]) + val questions = (sent().last()["\$survey_questions"] as List<*>).map { (it as Map<*, *>)["id"] } + // All four in the survey's order (PostHog reads answers by position), answers only for those given. + assertEquals( + listOf( + NpsSurveyDefinition.SCORE_QUESTION_ID, + NpsSurveyDefinition.FIX_QUESTION_ID, + NpsSurveyDefinition.TEN_QUESTION_ID, + NpsSurveyDefinition.IDEA_QUESTION_ID, + ), + questions, + ) assertEquals("score $score", "idea", sent().last()["\$survey_response_$questionId"]) + assertEquals("score $score", 2, sent().last().keys.count { it.startsWith("\$survey_response_") }) controller.onClose() } } @@ -153,6 +163,31 @@ class NpsSurveyControllerTest { assertEquals(before, events.size) } + @Test + fun `a decided survey that never appeared before its finish moment passed is dropped silently`() { + controller.open(manual = false, copy = NpsSurveyCopy(), expiresAtMs = 1_000L) + val session = controller.session.value!! + assertFalse(session.isExpired(1_000L)) + assertTrue(session.isExpired(1_001L)) + + controller.discardUndisplayed() + + assertNull(controller.session.value) + assertTrue(events.isEmpty()) + assertEquals(0, scheduledDisplays) + } + + @Test + fun `a survey on screen, or opened by hand, never expires or gets dropped`() { + controller.open(manual = true, copy = NpsSurveyCopy(), expiresAtMs = 1_000L) + assertFalse(controller.session.value!!.isExpired(5_000L)) + controller.onClose() + + openAndShow() + controller.discardUndisplayed() + assertTrue(controller.isOpen) + } + @Test fun `a second open while one is up is ignored`() { controller.open(manual = false, copy = NpsSurveyCopy()) diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt index 1ce1313c1..aeb3375a0 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/NpsSurveySheetAdaptiveTest.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.click @@ -20,6 +21,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.designsystem.echo.LocalEchoMotion @@ -47,6 +49,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowDialog /** * The NPS survey keeps one survey through every presentation: the phone sheet or the centered dialog as @@ -173,6 +176,70 @@ class NpsSurveySheetAdaptiveTest { assertFalse(names().contains("survey dismissed")) } + @Test + fun `Back on the expanded dialog's follow-up keeps the score and closes`() { + // Regression: the follow-up blocked every implicit dismissal on tablets, Back included. + window.value = BoxloreWindowLayout(1280f, 800f) + render() + open() + compose.onNodeWithTag("nps_score_8").performClick() + compose.waitForIdle() + + compose.runOnUiThread { ShadowDialog.getLatestDialog().onBackPressed() } + compose.waitForIdle() + + assertNull(controller.session.value) + assertEquals(listOf("survey shown", "survey sent", "survey sent"), names()) + assertEquals(listOf(false, true), events.filter { it.first == "survey sent" }.map { it.second["\$survey_completed"] }) + } + + @Test + @Config(qualifiers = "w840dp-h480dp-mdpi") + fun `the scroll position survives crossing from the sheet to the dialog`() { + // Regression: the scroll state lived inside the modal, so the dialog started back at the top. + window.value = BoxloreWindowLayout(839f, 480f) + render() + open() + compose.onNodeWithTag("nps_score_3").performClick() + compose.waitForIdle() + compose.onNodeWithTag(NPS_SCROLL_TAG).performTouchInput { swipeUp() } + compose.waitForIdle() + val before = scrollRange().value() + assertTrue("scrolled in the sheet", before > 0f) + + compose.runOnIdle { window.value = BoxloreWindowLayout(840f, 480f) } + compose.waitForIdle() + + compose.onNode(isDialog()).assertExists() + assertEquals("same position in the dialog", minOf(before, scrollRange().maxValue()), scrollRange().value(), 1f) + } + + /** The survey content's scroll position and its end, from its scroll semantics. */ + private fun scrollRange() = compose.onNodeWithTag(NPS_SCROLL_TAG).fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange] + + @Test + fun `a decided survey dropped after its finish moment never appears`() { + var now = 1_000L + compose.setContent { + BoxLoreTheme(darkTheme = false, dynamicColor = false) { + CompositionLocalProvider(LocalBoxloreWindowLayout provides window.value, LocalEchoMotion provides false) { + Box(Modifier.requiredSize(360.dp, 740.dp)) { NpsSurveyHost(controller, isPlaying = playing.value, nowMs = { now }) } + } + } + } + playing.value = true + compose.runOnIdle { controller.open(manual = false, copy = copy, expiresAtMs = 2_000L) } + compose.waitForIdle() + now = 3_000L + compose.runOnIdle { playing.value = false } + compose.waitForIdle() + + compose.onNodeWithTag(NPS_SHEET_TAG).assertDoesNotExist() + assertNull(controller.session.value) + assertTrue(names().isEmpty()) + assertEquals(0, scheduledDisplays) + } + @Test fun `a decided survey waits while something plays, and counts as shown only when it appears`() { playing.value = true From 2c80cf4f8b21a9c3d6b514f5fc1e78ab38ac44f8 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 22:59:59 +0530 Subject: [PATCH 5/5] fix(analytics): guard the survey launch count and simplify its rules A disk error while counting a launch is logged instead of escaping the startup coroutine, and the survey rules are checked one by one. --- .../cx/aswin/boxlore/BoxLoreApplication.kt | 9 +++++++- .../boxlore/core/prefs/NpsSurveySchedule.kt | 21 ++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index f5d0fd8b9..3e9db3f28 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -185,7 +185,14 @@ class BoxLoreApplication : // Updated after installing: this install ran an older version (a fresh or restored install has not). val updatedInstall = runCatching { packageManager.getPackageInfo(packageName, 0).let { it.lastUpdateTime > it.firstInstallTime } }.getOrDefault(false) - engagementPromptCoordinator.onAppLaunched(updatedInstall) + try { + engagementPromptCoordinator.onAppLaunched(updatedInstall) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + // A disk error here only skips one launch count; it must never crash startup. + android.util.Log.w("BoxLoreApplication", "Failed to record the launch for the NPS survey", e) + } } scheduleOptionalStartup() diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt index 7c3b876bd..798a38162 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/NpsSurveySchedule.kt @@ -40,17 +40,18 @@ object NpsSurveySchedule { fun isDue(moment: NpsSurveyMoment): Boolean = unmet(moment).isEmpty() /** The rules [moment] doesn't meet yet, in the order a listener meets them; empty when the survey is due. */ - fun unmet(moment: NpsSurveyMoment): List { + fun unmet(moment: NpsSurveyMoment): List = NpsSurveyRule.entries.filterNot { isMet(it, moment) } + + private fun isMet(rule: NpsSurveyRule, moment: NpsSurveyMoment): Boolean { val now = moment.nowMs - val lastShown = moment.record.lastShownAtMs - return buildList { - if (!isFinishMoment(moment.lastFinishedAtMs, moment.processStartedAtMs, now)) add(NpsSurveyRule.FinishMoment) - if (moment.finishedSinceInstall < MIN_FINISHED_EPISODES) add(NpsSurveyRule.FinishedEpisodes) - if (moment.installedAtMs <= 0L || now - moment.installedAtMs < MIN_DAYS_INSTALLED * DAY_MS) add(NpsSurveyRule.DaysInstalled) - if (moment.record.launches < 2) add(NpsSurveyRule.FirstRun) - if (moment.inQuietDays) add(NpsSurveyRule.QuietDays) - if (!moment.cooldownElapsed) add(NpsSurveyRule.Cooldown) - if (lastShown != null && now - lastShown < ASK_AGAIN_DAYS * DAY_MS) add(NpsSurveyRule.AskAgainGap) + return when (rule) { + NpsSurveyRule.FinishMoment -> isFinishMoment(moment.lastFinishedAtMs, moment.processStartedAtMs, now) + NpsSurveyRule.FinishedEpisodes -> moment.finishedSinceInstall >= MIN_FINISHED_EPISODES + NpsSurveyRule.DaysInstalled -> moment.installedAtMs > 0L && now - moment.installedAtMs >= MIN_DAYS_INSTALLED * DAY_MS + NpsSurveyRule.FirstRun -> moment.record.launches >= 2 + NpsSurveyRule.QuietDays -> !moment.inQuietDays + NpsSurveyRule.Cooldown -> moment.cooldownElapsed + NpsSurveyRule.AskAgainGap -> moment.record.lastShownAtMs?.let { now - it >= ASK_AGAIN_DAYS * DAY_MS } ?: true } }