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¶
- Personalized Video - Therapist-recorded per-patient demos (Cloudflare Stream)
- Cloudflare Stream - Primary video hosting for new content
- Kinescope - Legacy video hosting (Russian market)
- YouTube - External videos via URL
- 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
personalizedVideofield is attached to exercise objects in theGET /v2/workouts/patient-upcomingresponse. 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:
- Personalized Video (highest priority) - Therapist-recorded demo via
@cloudflare/stream-react<Stream>component - Cloudflare Stream - Uses
@cloudflare/stream-react<Stream>component - Kinescope - Embedded via iframe
- 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:
- Personalized Video (highest) - Therapist-recorded demo via Cloudflare Stream
- Kinescope - WebView to custom embed page hosted on web app (
/embed/k/[id]) - Cloudflare Stream - WebView to iframe URL
- YouTube - Native
react-native-youtube-iframecomponent
// 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:¶
- Proxy fetch: Images are fetched through
/api/image-proxyto bypass CORS - Base64 encoding: Images are converted to base64 data URLs for PDF embedding
- Batch processing:
prefetchImagesToBase64()processes multiple thumbnails in parallel - 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:
2. TypeScript Types¶
Update the ExerciseMedia interface in both:
- OktaPT-FE/lib/utils/exercise.ts
- Okta-Mobile/lib/exerciseUtils.ts
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:
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: