Skip to content

Subtitle System Enhancements

Status: ✅ Production Version: 2.0 Last Updated: 2026-01-31 Commit: 6aa71f45e


Overview

Comprehensive subtitle system enhancements implementing 21 fixes from multi-agent review, focusing on quality, accessibility, performance, and user experience across web, iOS, and tvOS platforms.

Key Improvements

  • Backend: LRU cache, concurrency control, token capping
  • Frontend UX: Error categorization, first-time hints, state explanations
  • Accessibility: WCAG AA compliance, VoiceOver, Dynamic Type, tvOS focus
  • Storage: Zod validation, type-safe data retrieval
  • Configuration: Externalized all hardcoded values

Backend Enhancements

1. AI Token Usage Capping

Problem: Unbounded token usage could result in excessive API costs for large subtitle texts.

Solution:

  • Added SUBTITLE_AI_MAX_TOKENS configuration (default: 4096)
  • Capped all AI requests: max_tokens = min(calculated_tokens, settings.SUBTITLE_AI_MAX_TOKENS)

Files Modified:

  • backend/app/core/config.py
  • backend/app/services/ai_text_transform_service.py
  • backend/app/services/nikud_service.py
  • backend/app/services/shoresh_service.py

Configuration:

bash
# .env
SUBTITLE_AI_MAX_TOKENS=4096  # Maximum tokens per AI request

Code Example:

python
# Before
response = await client.messages.create(
    model=settings.SUBTITLE_AI_MODEL,
    max_tokens=len(text) * 3,  # Unbounded!
    messages=[{"role": "user", "content": prompt}],
)

# After
max_tokens = min(len(text) * 3, settings.SUBTITLE_AI_MAX_TOKENS)
response = await client.messages.create(
    model=settings.SUBTITLE_AI_MODEL,
    max_tokens=max_tokens,
    messages=[{"role": "user", "content": prompt}],
)

2. LRU Cache Eviction

Problem: Cache silently stopped accepting new entries when full, causing performance degradation.

Solution:

  • Replaced Dict with OrderedDict for LRU tracking
  • Implemented least-recently-used eviction
  • Move accessed items to end on cache hit

Files Modified:

  • backend/app/services/ai_text_transform_service.py

Code Example:

python
from collections import OrderedDict

def __init__(self, cache_max_size: int, service_name: str):
    self._cache: OrderedDict[str, T] = OrderedDict()
    # ...

def _get_from_cache(self, cache_key: str) -> Optional[T]:
    if cache_key in self._cache:
        # Move to end (most recently used)
        self._cache.move_to_end(cache_key)
        return self._cache[cache_key]
    return None

def _add_to_cache(self, cache_key: str, value: T) -> None:
    # If cache is full, remove least recently used
    if len(self._cache) >= self._cache_max_size:
        evicted_key = next(iter(self._cache))
        self._cache.pop(evicted_key)

    # Add new item to end
    self._cache[cache_key] = value

Performance Impact:

  • Maintains optimal cache hit rates
  • Prevents memory growth beyond configured limits
  • Prioritizes frequently accessed subtitle texts

3. Concurrency Control

Problem: Unlimited concurrent API calls could exhaust rate limits.

Solution:

  • Added asyncio.Semaphore(3) to limit concurrent API calls
  • Prevents rate limit exhaustion from parallel requests
  • Maintains throughput while respecting API limits

Files Modified:

  • backend/app/services/ai_text_transform_service.py

Code Example:

python
import asyncio

def __init__(self, cache_max_size: int, service_name: str):
    # ...
    self._api_semaphore = asyncio.Semaphore(3)  # Max 3 concurrent calls

async def transform(self, text: str, use_cache: bool = True) -> T:
    # ...
    async with self._api_semaphore:  # Concurrency control
        result = await self._transform_single(text)
    # ...

Rate Limiting:

  • Maximum 3 concurrent API requests
  • Queues additional requests automatically
  • Prevents 429 Too Many Requests errors

4. Liskov Substitution Principle (LSP) Fix

Problem: TranslationService raised NotImplementedError for batch methods, violating LSP.

Solution:

  • Added supports_batch parameter to base class
  • Base class handles fallback to sequential processing
  • No exceptions raised in subclasses

Files Modified:

  • backend/app/services/ai_text_transform_service.py
  • backend/app/services/nikud_service.py

Code Example:

python
class TranslationService(AITextTransformService[TranslationResult]):
    def __init__(self):
        super().__init__(
            cache_max_size=settings.SUBTITLE_NIKUD_CACHE_MAX_SIZE,
            service_name="translation",
            supports_batch=False,  # Sequential processing
        )

# Base class handles fallback
async def transform_batch(self, texts: List[str]) -> List[T]:
    if not self._supports_batch:
        results = []
        for text in texts:
            result = await self.transform(text, use_cache)
            results.append(result)
        return results
    # ... batch processing

Frontend UX Improvements

5. Error Categorization

Problem: Generic error messages didn't help users understand what went wrong.

Solution:

  • Categorize errors into 4 types: network, server, client, unknown
  • Different icons and colors for each category
  • Category-specific error messages

Files Modified:

  • web/src/components/player/subtitle/SubtitleErrorDisplay.tsx

Error Categories:

CategoryIconColorExamples
Network📡AmberConnection, timeout, offline
Server🔧Red500, 502, 503, 504
ClientRed400, 401, 403, 404
Unknown⚠️RedGeneric errors

Code Example:

typescript
function categorizeError(error: Error | string): ErrorInfo {
  const errorMessage = typeof error === 'string' ? error : error.message
  const errorLower = errorMessage.toLowerCase()

  // Network errors
  if (errorLower.includes('network') || errorLower.includes('timeout')) {
    return {
      category: 'network',
      icon: '📡',
      titleKey: 'subtitles.error.networkTitle',
      messageKey: 'subtitles.error.networkError',
    }
  }

  // Server errors (5xx)
  if (errorLower.includes('500') || errorLower.includes('server error')) {
    return {
      category: 'server',
      icon: '🔧',
      titleKey: 'subtitles.error.serverTitle',
      messageKey: 'subtitles.error.serverError',
    }
  }

  // ... client and unknown categories
}

User Experience:

  • Network errors (amber) suggest checking connection
  • Server errors (red) suggest trying again later
  • Clear, actionable error messages

6. First-Time Hint

Problem: Users didn't understand Hebrew mode options on first use.

Solution:

  • Dismissible hint box shown on first modal open
  • Explains nikud and shoresh modes
  • Stored in localStorage to show only once

Files Modified:

  • web/src/components/player/subtitle/HebrewModePickerModal.tsx

Code Example:

tsx
const [showFirstTimeHint, setShowFirstTimeHint] = useState(false)

useEffect(() => {
  if (visible) {
    const hasSeenBefore = storageHelpers.get('hebrew_mode_first_time_seen')
    if (!hasSeenBefore) {
      setShowFirstTimeHint(true)
      storageHelpers.set('hebrew_mode_first_time_seen', 'true')
    }
  }
}, [visible])

// Render hint
{showFirstTimeHint && (
  <div className="mb-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg p-3">
    <div className="flex items-start gap-2">
      <span className="text-indigo-400 text-xl">💡</span>
      <p className="text-sm text-indigo-200">
        {t('subtitles.hebrewMode.firstTimeHint')}
      </p>
      <button onClick={() => setShowFirstTimeHint(false)}>✕</button>
    </div>
  </div>
)}

i18n Key:

json
{
  "subtitles": {
    "hebrewMode": {
      "firstTimeHint": "Choose how you want Hebrew subtitles displayed. Nikud adds vowel marks for easier reading, while Shoresh shows root words for language learning."
    }
  }
}

7. Unavailable State Explanations

Problem: Users saw "Unavailable" badge but didn't know why modes were disabled.

Solution:

  • Added explanatory text for unavailable Hebrew modes
  • Context-specific reasons (AI processing, content availability)
  • Displayed in amber text below mode descriptions

Files Modified:

  • web/src/components/player/subtitle/HebrewModePickerModal.tsx
  • packages/ui/shared-i18n/locales/en.json

Code Example:

tsx
{!isAvailable && (
  <p className="text-xs text-amber-500/90 mb-2 italic">
    {option.mode === 'nikud'
      ? t('subtitles.hebrewMode.nikud.unavailableReason',
          'AI processing not available for this content')
      : t('subtitles.hebrewMode.shoresh.unavailableReason',
          'Root word analysis not available for this content')
    }
  </p>
)}

User Experience:

  • Clear explanation of why features are unavailable
  • Reduces user confusion and support requests
  • Sets proper expectations

8. React Hooks Violations Fixed

Problem: 7 functions called useSafeAreaInsets() hook but weren't hooks themselves.

Solution:

  • Converted all functions to proper React hooks
  • Added useMemo optimization
  • Added React import

Files Modified:

  • shared/hooks/useSafeArea.ts
  • mobile-app/src/utils/safeAreaHelper.ts

Converted Functions:

Old FunctionNew HookOptimization
hasNotch()useHasNotch()useMemo
getTabBarHeight()useTabBarHeight()useMemo
getSafeAreaPadding()useSafeAreaPadding()useMemo
getSafeAreaValue()useSafeAreaValue()useMemo
createSafeAreaStyle()useSafeAreaStyle()useMemo
getPlatformSafeArea()usePlatformSafeArea()useMemo

Code Example:

typescript
// Before (WRONG - violates Rules of Hooks)
export function hasNotch(): boolean {
  const insets = useSafeAreaInsets()  // ❌ Hook call in regular function
  return insets.bottom > 20
}

// After (CORRECT - proper hook)
export function useHasNotch(): boolean {
  const insets = useSafeAreaInsets()

  return useMemo(() => {
    if (Platform.OS !== 'ios') return false
    return insets.bottom > 20
  }, [insets.bottom])
}

Accessibility & Design

9. WCAG AA Compliance

Problem: text-gray-600 failed WCAG AA with only 3.8:1 contrast ratio.

Solution:

  • Replaced with text-gray-500 (5.5:1 contrast ✅)
  • Created comprehensive color token documentation
  • Defined 40+ WCAG AA compliant color combinations

Files Modified:

  • web/src/components/player/subtitle/HebrewModePickerModal.tsx
  • docs/design/COLOR_TOKENS_WCAG.md (new)

WCAG AA Requirements:

  • Normal text (<18pt): 4.5:1 minimum contrast
  • Large text (≥18pt): 3.0:1 minimum contrast
  • UI components: 3.0:1 minimum contrast

Compliant Color Tokens:

BackgroundText TokenContrastWCAG AAUse Case
bg-gray-900text-white21:1✅ PassHeadings
bg-gray-900text-gray-4008.0:1✅ PassDescriptions
bg-gray-900text-gray-5005.5:1✅ PassMuted text
bg-gray-900text-gray-6003.8:1❌ FailDO NOT USE
bg-red-500/10text-red-4005.1:1✅ PassError titles
bg-amber-500/10text-amber-4007.2:1✅ PassWarnings

Documentation: See COLOR_TOKENS_WCAG.md


10. VoiceOver Support

Problem: Subtitle language list lacked proper screen reader support.

Solution:

  • Added accessible={true} to all interactive elements
  • Added descriptive accessibilityLabel with context
  • Added contextual accessibilityHint for actions
  • Added accessibilityState=\{\{ selected \}\} for state announcements

Files Modified:

  • web/src/components/player/subtitle/SubtitleLanguageList.tsx

Code Example:

tsx
<Pressable
  accessible={true}
  accessibilityRole="button"
  accessibilityLabel={`${track.language_name} ${t('subtitles.subtitles')}`}
  accessibilityHint={
    isActive
      ? t('subtitles.currentLanguage', 'Currently selected')
      : t('subtitles.selectLanguage', 'Double tap to select')
  }
  accessibilityState={{ selected: isActive }}
>
  {/* Button content */}
</Pressable>

VoiceOver Announcements:

  • Off button: "Subtitles off. Turn off subtitles. Selected."
  • Language button: "Hebrew subtitles. Double tap to select."
  • Active language: "English subtitles. Currently selected. Selected."
  • Mode picker: "Hebrew mode: Nikud. Double tap to change Hebrew display mode."

11. iOS Dynamic Type Support

Problem: Text sizes didn't respect iOS accessibility settings.

Solution:

  • Added allowFontScaling={isIOS} to all Text components
  • Added maxFontSizeMultiplier={1.5} to prevent excessive scaling
  • Detects user's iOS text size preferences

Files Modified:

  • web/src/components/player/subtitle/SubtitleLanguageList.tsx

Code Example:

typescript
const isIOS = Platform.OS === 'ios'
const fontScale = isIOS ? PixelRatio.getFontScale() : 1

<Text
  style={styles.languageName}
  allowFontScaling={isIOS}
  maxFontSizeMultiplier={isIOS ? 1.5 : undefined}
>
  {track.language_name}
</Text>

User Experience:

  • Respects Settings > Accessibility > Display & Text Size
  • Scales text from 75% to 150% of base size
  • Maintains readability for vision-impaired users
  • Prevents layout breaking with maxFontSizeMultiplier

12. tvOS Focus Navigation

Problem: Subtitle language list lacked proper focus states for Apple TV.

Solution:

  • Added hasTVPreferredFocus to first focusable element
  • Added tvParallaxProperties for 3D focus effects
  • Added tvFocused style with border and scale

Files Modified:

  • web/src/components/player/subtitle/SubtitleLanguageList.tsx

Code Example:

tsx
const isTV = Platform.isTV || Platform.OS === 'tvos'

<Pressable
  hasTVPreferredFocus={isTV}
  tvParallaxProperties={{
    enabled: true,
    magnification: 1.05,
    pressMagnification: 0.95,
  }}
  style={({ focused }) => [
    styles.option,
    focused && isTV && styles.tvFocused,
  ]}
>
  {/* Content */}
</Pressable>

// Styles
const styles = StyleSheet.create({
  tvFocused: {
    borderColor: colors.primary.DEFAULT,
    borderWidth: 3,
    transform: [{ scale: 1.05 }],
  },
})

10-Foot UI:

  • Clear focus indication with 3px border
  • 1.05x scale on focus for visibility
  • Parallax effect for depth perception
  • Supports Siri Remote navigation

Storage & Validation

13. Zod Validation for localStorage

Problem: No runtime validation for data retrieved from localStorage.

Solution:

  • Added getValidatedJSON<T>(key, schema) method
  • Created predefined StorageSchemas for common types
  • Auto-removes invalid data from storage

Files Modified:

  • web/src/utils/storage.ts
  • web/src/components/player/hooks/useSubtitles.ts

Code Example:

typescript
import { z } from 'zod'

// Define schema
export const StorageSchemas = {
  SubtitlePreferences: z.object({
    enabled: z.boolean(),
    language: z.string().nullable(),
    hebrew_mode: z.enum(['regular', 'nikud', 'shoresh']).optional(),
    settings: z.object({
      fontSize: z.enum(['small', 'medium', 'large']),
      position: z.enum(['top', 'bottom']),
      backgroundColor: z.string(),
      textColor: z.string(),
    }),
  }),
}

// Validated retrieval
async getValidatedJSON<T>(key: string, schema: z.ZodSchema<T>): Promise<T | null> {
  const value = await storage.getItem(key)
  if (!value) return null

  const parsed = JSON.parse(value)
  const result = schema.safeParse(parsed)

  if (result.success) {
    return result.data
  } else {
    // Remove invalid data
    await storage.removeItem(key)
    return null
  }
}

// Usage
const prefs = await storageHelpers.getValidatedJSON(
  STORAGE_KEYS.SUBTITLE_PREFERENCES,
  StorageSchemas.SubtitlePreferences
)

Benefits:

  • Type-safe data retrieval
  • Prevents corrupted data issues
  • Auto-cleanup of invalid data
  • Runtime type validation

14. Global Notification Integration

Problem: Errors were logged but not shown to users.

Solution:

  • Integrated useNotificationStore from Glass UI
  • Added notifications for all error types
  • Categorized by severity (error, warning)

Files Modified:

  • web/src/components/player/hooks/useSubtitles.ts

Code Example:

typescript
import { useNotificationStore } from '@olorin/glass-ui/stores'

const addNotification = useNotificationStore((state) => state.add)

// Error notification
addNotification({
  message: 'Failed to load subtitle languages. Please try again.',
  level: 'error',
  duration: 5000,
})

// Warning notification
addNotification({
  message: 'Could not save subtitle preferences',
  level: 'warning',
  duration: 3000,
})

Notification Types:

TypeLevelDurationUse Case
Subtitle tracks errorerror5000msFailed to fetch languages
Subtitle cues errorerror5000msFailed to load text
Preference save failurewarning3000msNon-critical save error
Hebrew mode save failurewarning3000msNon-critical save error

Configuration

15. Externalized API Retry Configuration

Problem: Hardcoded retry configuration in api.js violated zero-tolerance policy.

Solution:

  • Externalized all retry config to environment variables
  • Created GCloud secrets documentation
  • Removed all hardcoded values

Files Modified:

  • web/src/services/api.js
  • docs/deployment/GCLOUD_SECRETS_API_CONFIGURATION.md (new)

Environment Variables:

bash
# .env
VITE_API_RETRY_COUNT=3
VITE_API_RETRY_DELAY=1000
VITE_API_RETRY_STATUS_CODES=408,429,500,502,503,504

Code Example:

javascript
// Before (FORBIDDEN)
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]

// After (CORRECT)
const MAX_RETRIES = parseInt(import.meta.env.VITE_API_RETRY_COUNT || '3', 10)
const RETRY_DELAY_MS = parseInt(import.meta.env.VITE_API_RETRY_DELAY || '1000', 10)
const RETRYABLE_STATUS_CODES = (import.meta.env.VITE_API_RETRY_STATUS_CODES || '408,429,500,502,503,504')
  .split(',')
  .map(Number)

Documentation: See GCLOUD_SECRETS_API_CONFIGURATION.md


Performance Optimizations

16. useMemo for Options List

Problem: Hebrew mode options re-rendered on every state change.

Solution:

  • Memoized HEBREW_MODE_OPTIONS with useMemo
  • Empty dependency array (static options)

Files Modified:

  • web/src/components/player/subtitle/HebrewModePickerModal.tsx

Code Example:

tsx
const memoizedOptions = useMemo(
  () => HEBREW_MODE_OPTIONS,
  [] // Static options, never change
)

// Use in JSX
{memoizedOptions.map((option) => (
  // Render option
))}

Performance Impact:

  • Prevents unnecessary re-renders
  • Reduces React reconciliation overhead
  • Improves modal performance

Testing & Verification

Manual Testing Checklist

Backend:

  • [ ] AI token usage capped at configured limit
  • [ ] Cache evicts least-recently-used entries
  • [ ] Concurrent API calls limited to 3
  • [ ] Translation service uses sequential processing
  • [ ] All services start without errors

Frontend - Web:

  • [ ] Error categorization displays correct icons/colors
  • [ ] First-time hint appears on initial modal open
  • [ ] Unavailable states show explanatory text
  • [ ] React hooks work without violations
  • [ ] WCAG AA contrast ratios verified

Frontend - iOS:

  • [ ] VoiceOver announces all elements correctly
  • [ ] Dynamic Type scales text appropriately
  • [ ] Safe area insets calculated correctly
  • [ ] All touch targets ≥44×44pt

Frontend - tvOS:

  • [ ] Focus navigation works in all directions
  • [ ] Focus states visible with border and scale
  • [ ] Parallax effects working
  • [ ] No focus traps

Storage:

  • [ ] Zod validation accepts valid data
  • [ ] Zod validation rejects invalid data
  • [ ] Invalid data removed from storage
  • [ ] All schemas defined

Rollout Plan

Phase 1: Backend (Week 1)

  1. Deploy backend changes to staging
  2. Monitor token usage and cache performance
  3. Verify concurrency control effectiveness
  4. Load test with concurrent requests

Phase 2: Frontend Web (Week 2)

  1. Deploy web changes to beta users (Beta 500)
  2. Monitor error categorization accuracy
  3. Collect feedback on first-time hint
  4. A/B test unavailable state explanations

Phase 3: Mobile Platforms (Week 3)

  1. Deploy to iOS TestFlight
  2. Test VoiceOver support with blind users
  3. Test Dynamic Type at all text sizes
  4. Deploy to tvOS TestFlight
  5. Test focus navigation on Apple TV

Phase 4: Production (Week 4)

  1. Gradual rollout to 10% → 25% → 50% → 100%
  2. Monitor error rates and notification engagement
  3. Track cache hit rates and API costs
  4. Collect user feedback

Metrics & Monitoring

Success Metrics

Performance:

  • Cache hit rate: >70%
  • API token usage: <4096 per request
  • Concurrent API calls: ≤3 at any time
  • Error rate: <0.5%

Accessibility:

  • VoiceOver completion rate: >90%
  • Dynamic Type usage: Track distribution
  • tvOS navigation success: >95%
  • WCAG AA contrast: 100% compliance

User Experience:

  • First-time hint dismissal rate: Track %
  • Error notification interaction: Track clicks
  • Unavailable state feedback: Monitor support tickets

Storage:

  • Validation failure rate: <1%
  • Invalid data cleanup: Track frequency

Monitoring Dashboard

Backend Metrics:

python
# Cache performance
cache_hit_rate = cache_hits / (cache_hits + cache_misses)
cache_evictions_per_hour = total_evictions / hours

# API usage
avg_tokens_per_request = sum(tokens) / request_count
concurrent_api_calls_max = max(concurrent_calls)
api_errors_rate = api_errors / total_requests

Frontend Metrics:

typescript
// Error categorization
error_category_distribution = {
  network: network_errors / total_errors,
  server: server_errors / total_errors,
  client: client_errors / total_errors,
}

// Accessibility
voiceover_users = voiceover_sessions / total_sessions
dynamic_type_users = dynamic_type_sessions / total_sessions


Support & Feedback

Slack Channels:

  • #subtitle-system - General discussion
  • #accessibility - VoiceOver, Dynamic Type, WCAG
  • #backend-performance - Cache, API optimization

Issue Tracking:

  • Tag: subtitle-system
  • Priority: P1 (accessibility), P2 (performance), P3 (UX)

Last Review: 2026-01-31 Next Review: 2026-02-28 Owner: Engineering Team

Released under the MIT License.