Skip to content

Exercise Video & Thumbnail Rendering

This document explains how exercise thumbnails and videos are rendered from different video sources across the Okta-Health platform (web and mobile).

Overview

The platform supports multiple video hosting providers for exercise demonstration videos. When displaying thumbnails or playing videos, the system uses a priority-based fallback system to determine which source to use.

Supported Video Sources

  1. Personalized Video - Therapist-recorded per-patient demos (Cloudflare Stream)
  2. Cloudflare Stream - Primary video hosting for new content
  3. Kinescope - Legacy video hosting (Russian market)
  4. YouTube - External videos via URL
  5. Static Photo - Fallback for exercises without video

Video Sources Reference

Source ID Field Thumbnail Priority Video Priority Web Player Mobile Player
Personalized Video personalizedVideo.cloudflareStreamId 0 (Highest) 0 (Highest) @cloudflare/stream-react WebView iframe (Cloudflare)
Kinescope kinescopeVideoId 1 2 iframe WebView to embed page
Cloudflare Stream cloudflareStreamId 2 1 @cloudflare/stream-react WebView iframe
YouTube videoUrl 3 3 iframe react-native-youtube-iframe
Static Photo photoUrl 4 (Lowest) N/A img tag Image component

Note: Thumbnail and video playback priorities differ. Kinescope thumbnails load faster/more reliably, so they're preferred for thumbnails. Cloudflare Stream provides better playback quality, so it's preferred for video playback.

Note: Personalized videos are per-patient overrides recorded by therapists. They take absolute priority when present on both web and mobile. The personalizedVideo field is attached to exercise objects in the GET /v2/workouts/patient-upcoming response. See personalized-exercise-videos.md for full details.

Thumbnail URL Generation

Utility functions in OktaPT-FE/lib/utils/exercise.ts (and ported to Okta-Mobile/lib/exerciseUtils.ts) handle thumbnail URL generation.

Main Function: getExerciseThumbnailUrl()

Returns the appropriate thumbnail URL based on priority fallback:

const getExerciseThumbnailUrl = (exercise: ExerciseMedia): string | null => {
  // Priority 0: Personalized Video (Cloudflare Stream)
  if (exercise.personalizedVideo?.cloudflareStreamId) {
    return getCloudflareVideoThumbnail(exercise.personalizedVideo.cloudflareStreamId);
  }
  // Priority 1: Kinescope
  if (exercise.kinescopeVideoId) {
    return getKinescopeThumbnail(exercise.kinescopeVideoId);
  }
  // Priority 2: Cloudflare Stream
  if (exercise.cloudflareStreamId) {
    return getCloudflareVideoThumbnail(exercise.cloudflareStreamId);
  }
  // Priority 3: YouTube
  if (exercise.videoUrl) {
    const youtubeId = getYouTubeVideoId(exercise.videoUrl);
    if (youtubeId) return getYouTubeThumbnail(youtubeId);
  }
  // Priority 4: Direct photo URL
  if (exercise.photoUrl) return exercise.photoUrl;

  return null;
};

Provider-Specific Functions

Function Description URL Pattern
getKinescopeThumbnail(id) Kinescope poster image https://kinescope.io/{id}/poster.jpg
getCloudflareVideoThumbnail(id) Cloudflare thumbnail at 1s https://customer-j8qsy7zjqvqbtuqp.cloudflarestream.com/{id}/thumbnails/thumbnail.jpg?time=1s
getYouTubeThumbnail(id) YouTube high-quality default https://img.youtube.com/vi/{id}/hqdefault.jpg

Helper Function: hasVideoSource()

Checks if an exercise has any playable video source (excludes static photos):

const hasVideoSource = (exercise: ExerciseMedia): boolean => {
  return !!(
    exercise.personalizedVideo?.cloudflareStreamId ||
    exercise.kinescopeVideoId ||
    exercise.cloudflareStreamId ||
    (exercise.videoUrl && getYouTubeVideoId(exercise.videoUrl))
  );
};

Video Playback Implementation

Web (OktaPT-FE)

Component: components/patient/VideoPreviewModal.tsx

The web video player uses the shared getVideoSource() resolver with a priority system:

  1. Personalized Video (highest priority) - Therapist-recorded demo via @cloudflare/stream-react <Stream> component
  2. Cloudflare Stream - Uses @cloudflare/stream-react <Stream> component
  3. Kinescope - Embedded via iframe
  4. YouTube - Embedded via iframe with autoplay
const renderVideoPlayer = () => {
  const source = getVideoSource(exercise.exercise);
  if (!source) return null;

  switch (source.type) {
    case "cloudflare":
      return <Stream controls src={source.streamId} autoplay={true} />;
    case "kinescope":
      return <iframe src={`https://kinescope.io/${source.videoId}?autoplay=true`} />;
    case "youtube":
      return <iframe src={getYouTubeEmbedUrl(source.url)} />;
  }
};

Mobile (Okta-Mobile)

Component: components/workout/WorkoutVideoPlayer.tsx

The mobile player uses a sources array with fallback. It tries each source in priority order and falls back to the next on error:

  1. Personalized Video (highest) - Therapist-recorded demo via Cloudflare Stream
  2. Kinescope - WebView to custom embed page hosted on web app (/embed/k/[id])
  3. Cloudflare Stream - WebView to iframe URL
  4. YouTube - Native react-native-youtube-iframe component
// Sources are built in priority order; player falls back on error via sourceIndex state
if (exercise.personalizedVideo?.cloudflareStreamId) {
  sources.push({ type: 'cloudflare', id: exercise.personalizedVideo.cloudflareStreamId });
}
if (exercise.kinescopeVideoId) {
  sources.push({ type: 'kinescope', id: exercise.kinescopeVideoId });
}
if (exercise.cloudflareStreamId) {
  sources.push({ type: 'cloudflare', id: exercise.cloudflareStreamId });
}
if (exercise.videoUrl) {
  sources.push({ type: 'youtube', id: videoId });
}

Kinescope Embed Page: pages/embed/k/[id].tsx

A minimal Next.js page that wraps the Kinescope iframe for mobile WebView consumption. This avoids Kinescope's mobile detection issues.

Thumbnail Display Components

Web: ExerciseThumbnailRow.tsx

Located at components/patient/ExerciseThumbnailRow.tsx

Features: - Size: 84×84 pixels, rounded corners - Loading state: Spinner while image loads - Error state: Placeholder SVG icon when thumbnail fails - Play overlay: Semi-transparent overlay with play icon for video sources - Click handler: Opens VideoPreviewModal when clicked (if video exists)

<button className="relative w-[84px] h-[84px] rounded-lg overflow-hidden">
  <img src={thumbnailUrl} />
  {hasVideo && (
    <div className="absolute inset-0 bg-black/25">
      <PlayIcon />
    </div>
  )}
</button>

Mobile: Exercise Thumbnails

Mobile uses similar logic via Okta-Mobile/lib/exerciseUtils.ts with React Native <Image> components.

PDF Export Considerations

When generating PDF workout plans, thumbnails need special handling due to CORS restrictions.

Key file: lib/pdf/imageUtils.ts

Process:

  1. Proxy fetch: Images are fetched through /api/image-proxy to bypass CORS
  2. Base64 encoding: Images are converted to base64 data URLs for PDF embedding
  3. Batch processing: prefetchImagesToBase64() processes multiple thumbnails in parallel
  4. Caching: Uses a Map<string, string> to cache base64 results and avoid duplicate fetches
// Pre-fetch all thumbnails before PDF generation
const thumbnailUrls = exercises.map(e => getExerciseThumbnailUrl(e.exercise));
const thumbnailCache = await prefetchImagesToBase64(thumbnailUrls);

// Use cached base64 in PDF
const base64 = thumbnailCache.get(thumbnailUrl);

Key Files Reference

File Purpose
OktaPT-FE/lib/utils/exercise.ts Core utility functions (thumbnail URLs, video ID extraction)
Okta-Mobile/lib/exerciseUtils.ts Mobile utility functions (ported from web)
OktaPT-FE/components/patient/VideoPreviewModal.tsx Web video player modal component
OktaPT-FE/components/patient/ExerciseThumbnailRow.tsx Web thumbnail row component
Okta-Mobile/components/workout/WorkoutVideoPlayer.tsx Mobile multi-provider video player
OktaPT-FE/pages/embed/k/[id].tsx Kinescope embed page for mobile WebView
OktaPT-FE/lib/pdf/imageUtils.ts PDF image processing utilities

URL Patterns Quick Reference

Video Embed URLs

Provider URL Pattern
Kinescope https://kinescope.io/{id}?autoplay=true&muted=true
Cloudflare iframe https://iframe.cloudflarestream.com/{id}
YouTube https://www.youtube.com/embed/{id}?autoplay=1&modestbranding=1&rel=0

Thumbnail URLs

Provider URL Pattern
Kinescope https://kinescope.io/{id}/poster.jpg
Cloudflare https://customer-j8qsy7zjqvqbtuqp.cloudflarestream.com/{id}/thumbnails/thumbnail.jpg?time=1s
YouTube https://img.youtube.com/vi/{id}/hqdefault.jpg

Adding a New Video Source

If you need to add support for a new video provider:

1. Database Schema

Add the new ID field to the Exercise model in Prisma schema:

model Exercise {
  // ... existing fields
  newProviderId String? // Add new provider ID field
}

2. TypeScript Types

Update the ExerciseMedia interface in both: - OktaPT-FE/lib/utils/exercise.ts - Okta-Mobile/lib/exerciseUtils.ts

export interface ExerciseMedia {
  // ... existing fields
  newProviderId?: string | null;
}

3. Utility Functions

Add thumbnail generation function and update getExerciseThumbnailUrl():

export const getNewProviderThumbnail = (id: string): string => {
  return `https://newprovider.com/${id}/thumbnail.jpg`;
};

export const getExerciseThumbnailUrl = (exercise: ExerciseMedia): string | null => {
  // Add at appropriate priority level
  if (exercise.newProviderId) {
    return getNewProviderThumbnail(exercise.newProviderId);
  }
  // ... rest of function
};

4. Video Player Components

Web: Update VideoPreviewModal.tsx to handle the new provider:

if (ex.newProviderId) {
  return <NewProviderPlayer src={ex.newProviderId} />;
}

Mobile: Update WorkoutVideoPlayer.tsx:

if (exercise.newProviderId) {
  return <WebView source={{ uri: `https://newprovider.com/embed/${id}` }} />;
}

5. Update hasVideoSource()

Include the new provider in the video source check:

export const hasVideoSource = (exercise: ExerciseMedia): boolean => {
  return !!(
    exercise.newProviderId || // Add new provider
    exercise.kinescopeVideoId ||
    exercise.cloudflareStreamId ||
    (exercise.videoUrl && getYouTubeVideoId(exercise.videoUrl))
  );
};