From 9f4efb215e83cdc377c298d3c02cd56cf85e36a7 Mon Sep 17 00:00:00 2001 From: The Joel Date: Sat, 30 May 2026 15:07:21 +0100 Subject: [PATCH 1/2] feat: implement cursor-based pagination for courses and add related hooks and tests --- README.md | 25 ++++ src/__tests__/services/api/courseApi.test.ts | 63 ++++++++++ .../services/api/cursorPagination.test.ts | 77 ++++++++++++ src/hooks/index.ts | 4 +- src/hooks/useCoursePagination.ts | 103 ++++++++++++++++ src/services/api/courseApi.ts | 21 ++++ src/services/api/cursorPagination.ts | 111 ++++++++++++++++++ src/services/api/index.ts | 6 +- 8 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/services/api/courseApi.test.ts create mode 100644 src/__tests__/services/api/cursorPagination.test.ts create mode 100644 src/hooks/useCoursePagination.ts create mode 100644 src/services/api/cursorPagination.ts diff --git a/README.md b/README.md index b69a7603..876a6716 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,31 @@ appLogger.infoSync('Quick log'); appLogger.errorSync('Error', error); ``` +### Cursor-based API pagination +The app now supports cursor-based list pagination for backend endpoints such as `/courses`. + +- `limit` — number of records per page +- `cursor` — opaque token returned by the previous page +- `orderBy` — stable sort field (`id`) +- `direction` — `asc` or `desc` + +Response shape: + +```json +{ + "items": [/* Course[] */], + "nextCursor": "string | null", + "hasMore": true +} +``` + +Cursor format: + +- URI-safe encoded JSON +- contains `{ lastId, orderBy, direction }` + +Use `courseApi.getCoursesPage({ limit, cursor, orderBy, direction })` to fetch each page. + ### Retrieve Logs (Development) ```typescript diff --git a/src/__tests__/services/api/courseApi.test.ts b/src/__tests__/services/api/courseApi.test.ts new file mode 100644 index 00000000..99f64ee9 --- /dev/null +++ b/src/__tests__/services/api/courseApi.test.ts @@ -0,0 +1,63 @@ +import apiClient from '@/services/api/axios.config'; +import { clearCache } from '@/services/api/cache'; +import { courseApi } from '@/services/api/courseApi'; +import { CursorPageResponse } from '@/services/api/cursorPagination'; + +jest.mock('@/services/api/axios.config', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockedApiClient = apiClient as jest.Mocked; + +describe('courseApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + clearCache(); + }); + + it('calls the course page endpoint with cursor parameters', async () => { + const payload: CursorPageResponse = { + items: [{ id: 'course-123', title: 'Course 123', description: '', instructor: { id: 'inst-1', name: 'Instructor' }, sections: [], totalLessons: 0, totalDuration: 0, level: 'beginner', category: 'general' }], + nextCursor: 'abc', + hasMore: false, + }; + + mockedApiClient.get.mockResolvedValueOnce({ data: payload } as any); + + const result = await courseApi.getCoursesPage({ limit: 10, cursor: 'cursor-abc', orderBy: 'id', direction: 'desc' }); + + expect(mockedApiClient.get).toHaveBeenCalledTimes(1); + expect(mockedApiClient.get).toHaveBeenCalledWith('/courses', { + params: { + limit: 10, + cursor: 'cursor-abc', + orderBy: 'id', + direction: 'desc', + }, + }); + expect(result).toEqual(payload); + }); + + it('reuses cached page data for the same cursor request', async () => { + const payload: CursorPageResponse = { + items: [{ id: 'course-456', title: 'Course 456', description: '', instructor: { id: 'inst-2', name: 'Instructor 2' }, sections: [], totalLessons: 0, totalDuration: 0, level: 'intermediate', category: 'design' }], + nextCursor: null, + hasMore: false, + }; + + mockedApiClient.get.mockResolvedValueOnce({ data: payload } as any); + + const firstResult = await courseApi.getCoursesPage({ limit: 5, cursor: 'cursor-xyz', orderBy: 'id', direction: 'asc' }); + const secondResult = await courseApi.getCoursesPage({ limit: 5, cursor: 'cursor-xyz', orderBy: 'id', direction: 'asc' }); + + expect(mockedApiClient.get).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(payload); + expect(secondResult).toEqual(payload); + }); +}); diff --git a/src/__tests__/services/api/cursorPagination.test.ts b/src/__tests__/services/api/cursorPagination.test.ts new file mode 100644 index 00000000..f946fe79 --- /dev/null +++ b/src/__tests__/services/api/cursorPagination.test.ts @@ -0,0 +1,77 @@ +import { + buildCursor, + CursorPageRequest, + paginateWithCursor, + parseCursor, +} from '@/services/api/cursorPagination'; + +describe('cursorPagination', () => { + it('encodes and decodes cursor values cleanly', () => { + const cursor = buildCursor('course-123', 'id', 'asc'); + const payload = parseCursor(cursor); + + expect(typeof cursor).toBe('string'); + expect(payload).toEqual({ lastId: 'course-123', orderBy: 'id', direction: 'asc' }); + }); + + it('paginates a large dataset consistently and without duplicates', () => { + const items = Array.from({ length: 100 }, (_, index) => ({ + id: `course-${String(index + 1).padStart(3, '0')}`, + title: `Course ${index + 1}`, + })); + + let cursor: string | undefined; + const seen = new Set(); + const results: string[] = []; + + for (let page = 0; page < 10; page += 1) { + const request: CursorPageRequest = { + limit: 10, + cursor, + orderBy: 'id', + direction: 'asc', + }; + + const response = paginateWithCursor(items, request); + response.items.forEach((item) => { + expect(seen.has(item.id)).toBe(false); + seen.add(item.id); + results.push(item.id); + }); + + if (!response.hasMore) { + break; + } + + expect(response.nextCursor).not.toBeNull(); + cursor = response.nextCursor ?? undefined; + } + + expect(results).toHaveLength(100); + expect(results[0]).toBe('course-001'); + expect(results[results.length - 1]).toBe('course-100'); + }); + + it('supports descending ordering and stable cursor continuation', () => { + const items = Array.from({ length: 30 }, (_, index) => ({ + id: `course-${String(index + 1).padStart(3, '0')}`, + title: `Course ${index + 1}`, + })); + + const firstPage = paginateWithCursor(items, { limit: 5, direction: 'desc' }); + + expect(firstPage.items[0].id).toBe('course-030'); + expect(firstPage.hasMore).toBe(true); + expect(firstPage.nextCursor).toBeTruthy(); + + const secondPage = paginateWithCursor(items, { + limit: 5, + direction: 'desc', + cursor: firstPage.nextCursor ?? undefined, + }); + + expect(secondPage.items[0].id).toBe('course-025'); + expect(secondPage.items).toHaveLength(5); + expect(secondPage.hasMore).toBe(true); + }); +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c88ddcc0..e1c90dd6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,7 +4,9 @@ export * from './useAnalytics'; export { AuthProvider, useAuth } from './useAuth'; export * from './useBiometricAuth'; export * from './useCamera'; +export * from './useCoursePagination'; export * from './useCourseProgress'; +export * from './useDebounce'; export * from './useDynamicFontSize'; export * from './useFormCache'; export * from './useFormValidation'; @@ -22,10 +24,10 @@ export * from './usePinchZoom'; export * from './usePrefetchImages'; export * from './useSafeArea'; export * from './useScreenReader'; +export * from './useStreamingData'; export * from './useSwipe'; export * from './useVideoGestures'; export * from './useVoiceRecognition'; -export * from './useStreamingData'; // Optimized gesture handlers (named exports avoid duplicate SwipeDirection/SwipeInfo types) export { OptimizedLongPressView, useOptimizedLongPress } from './useOptimizedLongPress'; diff --git a/src/hooks/useCoursePagination.ts b/src/hooks/useCoursePagination.ts new file mode 100644 index 00000000..b641962f --- /dev/null +++ b/src/hooks/useCoursePagination.ts @@ -0,0 +1,103 @@ +import { courseApi } from '@/services/api/courseApi'; +import { CursorPageRequest } from '@/services/api/cursorPagination'; +import { Course } from '@/types/course'; +import { useCallback, useEffect, useState } from 'react'; + +export interface UseCoursePaginationOptions { + initialLimit?: number; + orderBy?: string; + direction?: 'asc' | 'desc'; +} + +export interface UseCoursePaginationResult { + items: Course[]; + isLoading: boolean; + isRefreshing: boolean; + hasMore: boolean; + nextCursor: string | null; + loadNextPage: () => Promise; + refresh: () => Promise; +} + +export function useCoursePagination( + options: UseCoursePaginationOptions = {}, +): UseCoursePaginationResult { + const { + initialLimit = 20, + orderBy = 'id', + direction = 'asc', + } = options; + + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const fetchPage = useCallback(async (cursor?: string) => { + if (isLoading) { + return; + } + + setIsLoading(true); + + const request: CursorPageRequest = { + limit: initialLimit, + cursor, + orderBy, + direction, + }; + + try { + const response = await courseApi.getCoursesPage(request); + + setItems((previous) => { + if (!cursor) { + return response.items; + } + + const existingIds = new Set(previous.map((course) => course.id)); + return [...previous, ...response.items.filter((course) => !existingIds.has(course.id))]; + }); + setNextCursor(response.nextCursor); + setHasMore(response.hasMore); + } finally { + setIsLoading(false); + } + }, [direction, initialLimit, isLoading, orderBy]); + + const refresh = useCallback(async () => { + setIsRefreshing(true); + setNextCursor(null); + setHasMore(true); + setItems([]); + + try { + await fetchPage(); + } finally { + setIsRefreshing(false); + } + }, [fetchPage]); + + const loadNextPage = useCallback(async () => { + if (!hasMore || isLoading) { + return; + } + + await fetchPage(nextCursor ?? undefined); + }, [fetchPage, hasMore, isLoading, nextCursor]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { + items, + isLoading, + isRefreshing, + hasMore, + nextCursor, + loadNextPage, + refresh, + }; +} diff --git a/src/services/api/courseApi.ts b/src/services/api/courseApi.ts index 7b0a3fff..3a1d7ebf 100644 --- a/src/services/api/courseApi.ts +++ b/src/services/api/courseApi.ts @@ -1,6 +1,11 @@ import { Course } from "../../types/course"; import apiClient from "./axios.config"; import { fetchWithSWR, invalidateCache } from "./cache"; +import { + buildCursorCacheKey, + CursorPageRequest, + CursorPageResponse, +} from "./cursorPagination"; const COURSES_KEY = "courses:list"; const courseKey = (id: string) => `courses:${id}`; @@ -19,6 +24,22 @@ export const courseApi = { ); }, + getCoursesPage(request: CursorPageRequest = {}): Promise> { + const { limit = 20, cursor, orderBy = 'id', direction = 'asc' } = request; + const cacheKey = buildCursorCacheKey({ limit, cursor, orderBy, direction }); + + return fetchWithSWR( + cacheKey, + () => apiClient + .get>("/courses", { + params: { limit, cursor, orderBy, direction }, + }) + .then((r) => r.data), + TTL, + STALE_TTL, + ); + }, + getCourse(id: string): Promise { return fetchWithSWR( courseKey(id), diff --git a/src/services/api/cursorPagination.ts b/src/services/api/cursorPagination.ts new file mode 100644 index 00000000..13f91882 --- /dev/null +++ b/src/services/api/cursorPagination.ts @@ -0,0 +1,111 @@ +export type CursorDirection = 'asc' | 'desc'; + +export interface CursorPageRequest { + limit?: number; + cursor?: string; + orderBy?: string; + direction?: CursorDirection; +} + +export interface CursorPageResponse { + items: T[]; + nextCursor: string | null; + hasMore: boolean; +} + +interface CursorPayload { + lastId: string; + orderBy: string; + direction: CursorDirection; +} + +const DEFAULT_LIMIT = 20; +const DEFAULT_ORDER_BY = 'id'; +const DEFAULT_DIRECTION: CursorDirection = 'asc'; + +function encodeCursorPayload(payload: CursorPayload): string { + return encodeURIComponent(JSON.stringify(payload)); +} + +function decodeCursorPayload(cursor?: string): CursorPayload | null { + if (!cursor) { + return null; + } + + try { + return JSON.parse(decodeURIComponent(cursor)) as CursorPayload; + } catch { + return null; + } +} + +/** + * Build an opaque cursor for a paginated item boundary. + * The cursor is a URL-safe encoded JSON payload. + */ +export function buildCursor(lastId: string, orderBy = DEFAULT_ORDER_BY, direction: CursorDirection = DEFAULT_DIRECTION): string { + return encodeCursorPayload({ lastId, orderBy, direction }); +} + +/** + * Parse a cursor returned by the API. + * Returns null for invalid or missing values. + */ +export function parseCursor(cursor?: string): CursorPayload | null { + return decodeCursorPayload(cursor); +} + +/** + * Create a cache key for paginated list requests. + */ +export function buildCursorCacheKey(request: CursorPageRequest): string { + const { limit = DEFAULT_LIMIT, cursor = '', orderBy = DEFAULT_ORDER_BY, direction = DEFAULT_DIRECTION } = request; + return `cursor-page:${limit}:${cursor || 'start'}:${orderBy}:${direction}`; +} + +/** + * Pure helper for cursor-based pagination on an in-memory list. + * This is useful for tests, local fallbacks, and backend integration. + */ +export function paginateWithCursor( + items: T[], + request: CursorPageRequest = {}, +): CursorPageResponse { + const limit = Math.max(1, request.limit ?? DEFAULT_LIMIT); + const orderBy = request.orderBy ?? DEFAULT_ORDER_BY; + const direction = request.direction ?? DEFAULT_DIRECTION; + + const cursorPayload = decodeCursorPayload(request.cursor); + + const normalized = [...items].sort((left, right) => { + const leftValue = String((left as any)[orderBy] ?? left.id); + const rightValue = String((right as any)[orderBy] ?? right.id); + + if (leftValue === rightValue) { + return left.id.localeCompare(right.id); + } + + return leftValue.localeCompare(rightValue); + }); + + if (direction === 'desc') { + normalized.reverse(); + } + + const startIndex = cursorPayload && cursorPayload.orderBy === orderBy && cursorPayload.direction === direction + ? normalized.findIndex((item) => item.id === cursorPayload.lastId) + 1 + : 0; + + const paginated = normalized.slice(Math.max(0, startIndex), Math.max(0, startIndex) + limit); + const lastItem = paginated[paginated.length - 1]; + const hasMore = (Math.max(0, startIndex) + paginated.length) < normalized.length; + const nextCursor = hasMore && lastItem + ? buildCursor(lastItem.id, orderBy, direction) + : null; + + return { + items: paginated, + nextCursor, + hasMore, + }; +} diff --git a/src/services/api/index.ts b/src/services/api/index.ts index f024100a..bbcd8dfc 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -8,8 +8,12 @@ export const apiService = { delete: (url: string) => apiClient.delete(url), }; +export { clearCache, fetchWithSWR, invalidateCache } from "./cache"; export { courseApi } from "./courseApi"; +export { + buildCursor, buildCursorCacheKey, + paginateWithCursor, parseCursor +} from "./cursorPagination"; export { userApi } from "./userApi"; -export { fetchWithSWR, invalidateCache, clearCache } from "./cache"; export default apiService; From b0b82923c0d9bc8c4f746a0f635a9604b0a75218 Mon Sep 17 00:00:00 2001 From: The Joel Date: Sat, 30 May 2026 13:53:33 +0100 Subject: [PATCH 2/2] feat: Implement efficient animation scheduling with requestAnimationFrame (#349) - Add animationScheduler utility with requestAnimationFrame-based timing - Update useLongPress hook to use rAF for gesture timing - Update useGestures (double-tap) hook to use rAF for timing - Update OfflineIndicatorProvider toast dismissals to use scheduleAnimationFrame - Update MobileVideoPlayer auto-hide to use scheduleAnimationFrame - Add comprehensive animation scheduling documentation - Ensure animations sync with browser refresh rate for 60fps performance - Replace setTimeout with requestAnimationFrame for animation-related timing This change improves animation smoothness and frame scheduling by using requestAnimationFrame instead of setTimeout for animation-related operations. --- docs/ANIMATION_SCHEDULING.md | 208 +++++++++++++++++ src/components/mobile/MobileVideoPlayer.tsx | 48 ++-- .../mobile/OfflineIndicatorProvider.tsx | 7 +- src/hooks/useGestures.ts | 37 ++- src/hooks/useLongPress.ts | 49 ++-- src/utils/animationScheduler.ts | 213 ++++++++++++++++++ 6 files changed, 519 insertions(+), 43 deletions(-) create mode 100644 docs/ANIMATION_SCHEDULING.md create mode 100644 src/utils/animationScheduler.ts diff --git a/docs/ANIMATION_SCHEDULING.md b/docs/ANIMATION_SCHEDULING.md new file mode 100644 index 00000000..94073447 --- /dev/null +++ b/docs/ANIMATION_SCHEDULING.md @@ -0,0 +1,208 @@ +# Animation Scheduling Best Practices + +## Overview + +This document outlines the best practices for animation scheduling in the teachLink_mobile project, focusing on using `requestAnimationFrame` for smooth, frame-synced animations at 60fps. + +## Why requestAnimationFrame? + +Using `requestAnimationFrame` (rAF) instead of `setTimeout`/`setInterval` for animations provides several benefits: + +- **Frame Synchronization**: rAF syncs with the browser's refresh rate, ensuring animations run at 60fps on capable devices +- **Battery Efficiency**: rAF pauses when the tab is inactive, saving battery +- **Smooth Animations**: Avoids jank caused by setTimeout's imprecise timing +- **Better Performance**: Browsers can optimize rAF callbacks more effectively + +## Animation Scheduler Utility + +The project includes a comprehensive animation scheduler utility at `src/utils/animationScheduler.ts` that provides: + +### Core Classes and Functions + +#### `AnimationScheduler` +A class for managing complex animations with frame-synced timing. + +```typescript +const scheduler = new AnimationScheduler(); +scheduler.schedule((timestamp) => { + // Animation logic + return true; // Return false to stop +}, 1000); // Optional duration in ms +``` + +#### `scheduleAnimationFrame` +A drop-in replacement for setTimeout that uses rAF for execution. + +```typescript +const cancel = scheduleAnimationFrame(() => { + // Your code +}, 1000); // Optional delay + +// Cancel if needed +cancel(); +``` + +#### `debounceAnimationFrame` +Debounce function that ensures callbacks run on the next animation frame. + +```typescript +const debouncedFn = debounceAnimationFrame((value) => { + // Handle value +}, 100); +``` + +#### `throttleAnimationFrame` +Throttle function that ensures callbacks run at most once per animation frame. + +```typescript +const throttledFn = throttleAnimationFrame((event) => { + // Handle event +}); +``` + +## When to Use requestAnimationFrame + +### Use rAF for: +- Visual animations (transitions, transforms, opacity changes) +- Gesture timing (long press, double tap detection) +- UI feedback animations (toasts, loading states) +- Scroll-related animations +- Any animation that needs to run smoothly at 60fps + +### Use setTimeout for: +- Network request timeouts +- Debouncing API calls +- Non-animation timing requirements +- Operations that don't need frame synchronization + +## Implementation Examples + +### Gesture Timing (Long Press) + +```typescript +// Before: Using setTimeout +timerRef.current = setTimeout(() => { + onLongPress({ pageX, pageY }); +}, durationMs); + +// After: Using requestAnimationFrame +startTimeRef.current = performance.now(); +const checkDuration = (timestamp: number) => { + const elapsed = timestamp - startTimeRef.current; + if (elapsed >= durationMs) { + onLongPress({ pageX, pageY }); + } else { + rafRef.current = requestAnimationFrame(checkDuration); + } +}; +rafRef.current = requestAnimationFrame(checkDuration); +``` + +### Toast Dismissal + +```typescript +// Before: Using setTimeout +setTimeout(() => { + removeToast(id); +}, toastDuration); + +// After: Using scheduleAnimationFrame +const cancelSchedule = scheduleAnimationFrame(() => { + removeToast(id); +}, toastDuration); +``` + +### Video Player Auto-Hide + +```typescript +// Before: Using setTimeout +hideTimerRef.current = setTimeout(() => { + setControlsVisible(false); +}, AUTO_HIDE_MS); + +// After: Using scheduleAnimationFrame +hideTimerRef.current = scheduleAnimationFrame(() => { + setControlsVisible(false); +}, AUTO_HIDE_MS); +``` + +## React Native Animated API + +For React Native animations, prefer using the built-in `Animated` API or `react-native-reanimated`: + +```typescript +// These are already optimized and use native drivers +Animated.timing(value, { + toValue: 1, + duration: 300, + useNativeDriver: true, +}).start(); + +// react-native-reanimated (runs on UI thread) +withSpring(translateX.value, SPRING_CONFIG); +withTiming(translateY.value, { duration: 200 }); +``` + +## Performance Considerations + +### Adaptive Frame Rate + +The project includes `useAdaptiveFrameRate` hook to adjust animations based on device capabilities: + +```typescript +const { durationMultiplier } = useAdaptiveFrameRate(); + +// Use multiplier for animation durations +Animated.timing(value, { + duration: 300 * durationMultiplier, // Scales based on device + useNativeDriver: true, +}).start(); +``` + +### Cleanup + +Always clean up animation callbacks to prevent memory leaks: + +```typescript +useEffect(() => { + const scheduler = new AnimationScheduler(); + scheduler.schedule(callback, duration); + + return () => { + scheduler.dispose(); // Clean up + }; +}, []); +``` + +## Testing Animation Performance + +To verify 60fps performance: + +1. Use React Native's `PerformanceOverlay` to monitor FPS +2. Test on low-end devices to ensure smooth performance +3. Use the `useAdaptiveFrameRate` hook for device-aware animations +4. Profile animations using React DevTools or Flipper + +## Migration Checklist + +When migrating from setTimeout to requestAnimationFrame: + +- [ ] Identify all setTimeout calls used for animations +- [ ] Replace with scheduleAnimationFrame or direct rAF usage +- [ ] Ensure proper cleanup of rAF callbacks +- [ ] Test animations on different devices +- [ ] Verify 60fps performance +- [ ] Update documentation + +## Common Pitfalls + +1. **Forgetting to cancel rAF callbacks**: Always cancel on unmount +2. **Using rAF for non-animation timing**: Use setTimeout for network timeouts +3. **Not using native drivers**: Always use `useNativeDriver: true` when possible +4. **Ignoring device capabilities**: Use adaptive frame rate for low-end devices + +## References + +- [MDN: Window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) +- [React Native: Animated API](https://reactnative.dev/docs/animations) +- [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) diff --git a/src/components/mobile/MobileVideoPlayer.tsx b/src/components/mobile/MobileVideoPlayer.tsx index a0c99fd4..3389f9ca 100644 --- a/src/components/mobile/MobileVideoPlayer.tsx +++ b/src/components/mobile/MobileVideoPlayer.tsx @@ -2,27 +2,27 @@ import { Audio, AVPlaybackStatus, AVPlaybackStatusToSet, ResizeMode, Video } fro import * as Network from 'expo-network'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ActivityIndicator, - Modal, - Pressable, - StyleProp, - StyleSheet, - Text, - View, - ViewStyle, + ActivityIndicator, + Modal, + Pressable, + StyleProp, + StyleSheet, + Text, + View, + ViewStyle, } from 'react-native'; import VideoControls from './VideoControls'; import { usePictureInPicture, useVideoGestures } from '../../hooks'; import { - AUTO_QUALITY_ID, - deriveNetworkType, - getQualityOptions, - normalizeSources, - selectSourceById, - type NetworkType, - type NormalizedVideoSource, - type VideoSource, + AUTO_QUALITY_ID, + deriveNetworkType, + getQualityOptions, + normalizeSources, + selectSourceById, + type NetworkType, + type NormalizedVideoSource, + type VideoSource, } from '../../services/videoQuality'; import { ErrorBoundary } from '../common/ErrorBoundary'; @@ -77,7 +77,7 @@ const MobileVideoPlayer = ({ const videoRef = useRef