diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7e726f5
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,48 @@
+# More information about envs and how the flow is set up
+# can be found in src/services/appConfig/README.md
+
+# use this for additional debug option
+# possible values: true, false, 1, 0 or undefined
+REACT_APP_DEBUG=
+
+# use this for tweak API url
+# possible values: string values or undefined
+REACT_APP_API_URL=
+
+# use this for API authentication
+# possible values: string values or undefined
+REACT_APP_API_KEY=
+
+# the email to sign automatically in with when in dev mode
+# if not specified or not in dev mode, the auto-sign-in feature is inactive
+#
+# possible values: string values or undefined
+REACT_APP_DEV_AUTO_SIGN_IN_EMAIL=
+
+# the password to automatically sign in with when in dev mode
+# if not specified or not in dev mode, the auto-sign-in feature is inactive
+#
+# possible values: string values or undefined
+REACT_APP_DEV_AUTO_SIGN_IN_PASSWORD=
+
+# the boolean flag to skip input validation on login screen when in dev mode
+# if not specified or not in dev mode, the input validation remains enabled
+#
+# possible values: true, false, 1, 0 or undefined
+REACT_APP_SKIP_LOGIN_INPUTS_VALIDATION=
+
+# the boolean flag that forces to show the login screen & make the user log in manually
+# if not specified, a false is assumed by default
+# useful for testing environment
+#
+# possible values: true, false or undefined
+REACT_APP_FORCE_LOGIN_SCREEN=
+
+# the numeric flag in ms that overrides the default
+# if not specified, a default value is used
+# useful for testing environment
+#
+# possible values: number or undefined
+REACT_APP_OVERRIDE_SPLASH_SCREEN_DURATION_MS=
+
+# REACT_APP_VERSION is set automatically by scripts/get-current-commit.sh
diff --git a/.eslintignore b/.eslintignore
index fb5f744..ace4f07 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,5 +1,20 @@
+# Build output
+build/
+dist/
+
+# Node modules
+node_modules/
+
+# Coverage directory
+coverage/
+
+# Config files
+*.config.js
+
# Shaka Player files
src/w3cmedia/App*
src/w3cmedia/shakaplayer/dist
src/w3cmedia/polyfills
+# ESLint plugins
+plugins/
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..53fbd44
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,228 @@
+const storagePattern = {
+ group: ['@react-native-async-storage/*'],
+ message:
+ "\n\nImport of @react-native-async-storage is allowed only in 'src/services/storage'. \n\nUse `import { DeviceStorage } from '@AppServices/storage'` instead",
+};
+
+const deviceInfoPattern = {
+ group: [
+ '@amazon-devices/react-native-device-info',
+ '@amazon-devices/react-native-device-info/*',
+ ],
+ message:
+ "\n\nImport of @amazon-devices/react-native-device-info is allowed only in 'src/services/deviceInfo'. \n\nUse `import { DeviceInfo } from '@AppServices/deviceInfo'` instead",
+};
+
+const netInfoPattern = {
+ group: [
+ '@amazon-devices/keplerscript-netmgr-lib',
+ '@amazon-devices/keplerscript-netmgr-lib/*',
+ ],
+ message:
+ "\n\nImport of @amazon-devices/keplerscript-netmgr-lib is allowed only in 'src/services/netInfo'. \n\nUse `import { NetInfo } from '@AppServices/netInfo'` instead",
+};
+
+const appConfigPattern = {
+ group: ['@AppEnvs'],
+ message:
+ "\n\nImport envs is allowed only in 'src/services/appConfig'. \n\nUse `import { AppEnvs } from '@AppServices/appConfig'` instead",
+};
+
+const keplerUiComponentsGroup = {
+ group: ['@amazon-devices/kepler-ui-components'],
+ message:
+ "\n\nImport Kepler UI component is allowed only in 'src/components/core' and 'src/theme'.\n\nImport needed component from '@AppComponents/core",
+};
+
+const tvFocusGuideViewPath = {
+ name: '@amazon-devices/react-native-kepler',
+ importNames: ['TVFocusGuideView'],
+ message: 'Please use FocusGuideView from @AppServices/focusGuide instead',
+};
+
+const useTVEventHandlerPath = {
+ name: '@amazon-devices/react-native-kepler',
+ importNames: ['useTVEventHandler'],
+ message:
+ 'Please use useFocusGuideEventHandler from @AppServices/focusGuide instead',
+};
+
+const rules = {
+ 'react-compiler/react-compiler': 'error',
+ 'import/order': [
+ 'error',
+ {
+ groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
+ pathGroups: [
+ {
+ pattern: '@App*',
+ group: 'parent',
+ position: 'before',
+ },
+ {
+ pattern: '@App*/**',
+ group: 'parent',
+ position: 'before',
+ },
+ {
+ pattern: './**',
+ group: 'parent',
+ position: 'after',
+ },
+ ],
+ alphabetize: {
+ order: 'asc',
+ caseInsensitive: false,
+ },
+ distinctGroup: false,
+ 'newlines-between': 'always',
+ warnOnUnassignedImports: true,
+ },
+ ],
+ 'no-restricted-imports': [
+ 'error',
+ {
+ paths: [tvFocusGuideViewPath, useTVEventHandlerPath],
+ patterns: [
+ storagePattern,
+ deviceInfoPattern,
+ netInfoPattern,
+ appConfigPattern,
+ keplerUiComponentsGroup,
+ ],
+ },
+ ],
+ 'react-native/no-raw-text': [
+ 'error',
+ {
+ skip: ['Typography'],
+ },
+ ],
+ 'no-console': 'error',
+ 'no-restricted-properties': [
+ 'error',
+ {
+ object: 'console',
+ property: 'log',
+ message: 'Please use `logDebug` from `@AppUtils/logging`',
+ },
+ {
+ object: 'console',
+ property: 'warn',
+ message: 'Please use `logWarning` from `@AppUtils/logging`',
+ },
+ {
+ object: 'console',
+ property: 'error',
+ message: 'Please use `logError` from `@AppUtils/logging`',
+ },
+ ],
+ curly: ['error'],
+ '@typescript-eslint/no-explicit-any': 'error',
+ '@typescript-eslint/consistent-type-imports': 'error',
+ 'react-native-a11y/has-accessibility-hint': 'off',
+ 'react-native-a11y/has-valid-accessibility-descriptors': 'off', // eslint-plugin-amzn-a11y rule is used instead
+ 'amzn-a11y/no-inferior-a11y-props': 'error',
+ 'amzn-a11y/no-missing-a11y-props': 'error',
+};
+
+const overrides = [
+ {
+ files: ['src/services/storage/**/*'],
+ rules: {
+ 'no-restricted-imports': [
+ 'off',
+ {
+ patterns: [storagePattern],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/services/deviceInfo/**/*'],
+ rules: {
+ 'no-restricted-imports': [
+ 'off',
+ {
+ patterns: [deviceInfoPattern],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/services/netInfo/**/*'],
+ rules: {
+ 'no-restricted-imports': [
+ 'off',
+ {
+ patterns: [netInfoPattern],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/services/appConfig/**/*'],
+ rules: {
+ 'no-restricted-imports': [
+ 'off',
+ {
+ patterns: [appConfigPattern],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/components/core/**/*', 'src/theme/**/*'],
+ rules: {
+ 'no-restricted-imports': [
+ 'off',
+ {
+ patterns: [keplerUiComponentsGroup],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/**/*.test.tsx', 'src/**/*.spec.tsx'],
+ rules: {
+ 'react-native/no-color-literals': ['off'],
+ 'react-native/no-inline-styles': ['off'],
+ },
+ },
+ {
+ files: ['src/services/focusGuide/**/*'],
+ rules: {
+ 'no-restricted-imports': [
+ 'off',
+ {
+ paths: [tvFocusGuideViewPath, useTVEventHandlerPath],
+ },
+ ],
+ },
+ },
+];
+
+module.exports = {
+ root: true,
+ extends: ['@callstack', 'plugin:react-native-a11y/basic'],
+ plugins: [
+ '@typescript-eslint',
+ 'import',
+ 'amzn-a11y',
+ 'eslint-plugin-react-compiler',
+ ],
+ rules,
+ overrides,
+ parserOptions: {
+ project: './tsconfig.json',
+ },
+ parser: '@typescript-eslint/parser',
+ settings: {
+ 'import/resolver': 'typescript',
+ /**
+ * below is to make all `@` imports internal to make sure we can
+ * group them (external imports cannot be group with `groupPath`)
+ */
+ 'import/internal-regex': '^@(.*?)/',
+ },
+};
diff --git a/.npmrc b/.npmrc
index 5a0fe49..d3107e5 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,2 @@
- legacy-peer-deps
+engine-strict=true
+unsafe-perm=true
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..d4b7699
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+20.18.1
diff --git a/.prettierignore b/.prettierignore
index 071607f..78f88de 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1 +1,17 @@
-src/w3cmedia/shakaplayer/dist
\ No newline at end of file
+# Build output
+build/
+dist/
+
+# Node modules
+node_modules/
+
+# Coverage directory
+coverage/
+
+# Logs
+*.log
+
+# Shaka Player files
+src/w3cmedia/polyfills/*
+src/w3cmedia/shakaplayer/dist/*
+src/w3cmedia/App*.tsx
diff --git a/.prettierrc.js b/.prettierrc.js
new file mode 100644
index 0000000..9b6da67
--- /dev/null
+++ b/.prettierrc.js
@@ -0,0 +1,7 @@
+module.exports = {
+ bracketSameLine: true,
+ bracketSpacing: true,
+ singleQuote: true,
+ trailingComma: "all",
+ tabWidth: 2,
+};
diff --git a/README.md b/README.md
index 6a1387b..44782b2 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,27 @@
-Vega Audio Sample App
-==============================================
+Vega Sports Sample App
+========================
-The Vega Audio Sample App demonstrates how to implement audio playback functionality in Vega applications. This feature-focused sample app serves as a practical guide for developers building audio-centric TV apps.
+The Vega Sports Sample App demonstrates how to build a sports streaming application with TV-optimized interfaces and video playback functionality.

+
Introduction
------------
-Key features include:
-- **Dynamic home interface** with album carousels and TV-optimized navigation.
-- **Album detail pages** featuring track listings and metadata presentation.
-- **Audio playback** using W3C Media API with preview functionality.
-- **TV-optimized navigation** with focus management and remote control support.
-- **Standard audio controls** including play/pause, track navigation, and time-based seeking.
-- **Background audio handling** with proper app state management.
+### Key features
+
+- Home page layout with hero carousels and sports category sections.
+- Content detail pages for live streams, teams, and documentaries.
+- Video player integration with Shaka Player for HLS/DASH streaming.
+- User authentication and profile management.
+- TV-optimized drawer navigation, focus management, and remote control.
+- Practical examples of video controls and playback resume functionality.
+- Language selection and internationalization.
+
+**Note**: While this app contains recommendations, there are always different approaches to building components, screens, or using libraries. This guide serves as a starting point for what needs to be done, but we encourage you to explore and adopt best practices that suit your specific project requirements.
+
@@ -32,57 +38,58 @@ Before you launch the sample app, make sure that you have:
**Note**: The Shaka Player integration runs automatically during `npm install` and requires these dependencies. If you encounter build errors related to Java or Python, install the missing prerequisites and run `npm install` again. For any other prerequisite Shaka issue please visit: https://shaka-project.github.io/shaka-player/docs/api/tutorial-welcome.html.
+
### Step 1: Build the app
-After you download the source code from GitHub, you can build the Vega Audio Sample App from the command line to generate VPKG files. The VPKG files run on the Vega Virtual Device and Vega OS Fire TV Stick.
+After you download the source code from GitHub, you can build the Vega Sports Sample App from the command line to generate VPKG files. The VPKG files run on the Vega Virtual Device and Vega OS Fire TV Stick.
You can also use [Vega Studio](https://developer.amazon.com/docs/vega/0.21/setup-extension.html#learn-the-basic-features) with Visual Studio Code to build the app.
-1. At the command prompt, navigate to the Vega Audio Sample App source code directory.
+1. At the command prompt, navigate to the Vega Sports Sample App source code directory.
-2. To install the app dependencies, run the following command.
+2. To install the app dependencies, run the following command.
- ```bash
+ ```
npm install
```
3. To build the app to generate .vpkg files, run the following command.
- ```bash
+ ```
npm run build:app
```
4. At the command prompt, in the **build** folder, verify that you generated the VPKG files for your device's architecture.
- * **armv7-release/kepleraudioreferenceapp_armv7.vpkg**—generated on x86_64 and Mac-M series devices to run on the Vega OS Fire TV Stick.
- * **x86_64-release/kepleraudioreferenceapp_x86_64.vpkg**—generated on x86_64 device to run on the VVD.
- * **aarch64-release/kepleraudioreferenceapp_aarch64.vpkg**—generated on Mac M-series device to run on the VVD.
-
+ * **armv7-release/keplersportapp_armv7.vpkg**—generated on x86_64 and Mac-M series devices to run on the Vega OS Fire TV Stick.
+ * **x86_64-release/keplersportapp_x86_64.vpkg**—generated on x86_64 device to run on the VVD.
+ * **aarch64-release/keplersportapp_aarch64.vpkg**—generated on Mac M-series device to run on the VVD.
+
### Step 2: Run the app
#### Vega Virtual Device
1. To start the Vega Virtual Device, at the command prompt, run the following command.
- ```bash
+ ```
kepler virtual-device start
```
-2. Go to the directory where you placed the VPKG files.
+2. Go to the directory where you placed the VPKG files.
3. To install and launch the app on the Vega Virtual Device, run the following command, depending on your device architecture.
- - On Mac M-series based devices.
+ - On Mac M-series based devices.
- ```bash
- kepler run-kepler build/aarch64-release/kepleraudioreferenceapp_aarch64.vpkg
+ ```
+ kepler run-kepler build/aarch64-release/keplersportapp_aarch64.vpkg
```
- On x86_64 based devices.
- ```bash
- kepler run-kepler build/x86_64-release/kepleraudioreferenceapp_x86_64.vpkg
```
+ kepler run-kepler build/x86_64-release/keplersportapp_x86_64.vpkg
+ ```
#### Vega OS Fire TV Stick
@@ -90,313 +97,814 @@ You can also use [Vega Studio](https://developer.amazon.com/docs/vega/0.21/setup
2. To install and launch the app on your Vega OS Fire TV Stick, run the following command.
- ```bash
- kepler run-kepler build/armv7-release/kepleraudioreferenceapp_armv7.vpkg
+ ```
+ kepler run-kepler build/armv7-release/keplersportapp_armv7.vpkg
```
-Troubleshooting the app
------------------------
-If you're facing unexpected issues while trying to build and run the app (For example, the build is failing randomly, the app is not starting, or the app is crashing randomly.) try the following solutions:
+API module and DTO pattern
+--------------------------
-* Run the `npm run clean` command. This removes the `node_modules` folder and other files related to your previous builds.
+The API module, located in `src/api`, is responsible for handling data fetching, parsing, and error management across supported endpoints. The API module follows the **DTO (Data Transfer Object) pattern**, which standardizes data received from the server, ensuring it's transformed into consistent app models that live in the `src/models` folder, which defines the object shapes expected by the app. This approach helps maintain clear type definitions, reliable data structures, and centralized error handling.
-* When working in debug mode, you might need to use `npm run start -- --reset-cache` to clear the cache.
+Refer to the [API README](./src/api/README.md) for full implementation details, usage examples, and guidance on adding new endpoints.
-* In some cases (For example, changes done to patches or changes in the package.json file.) you may need to make sure there is no cache present in the project, in order to build successfully. Cleaning ALL cache files in the project can be done by running the following commands:
+Services
+--------
-```
-npm run clean
-npm cache clean --force
-watchman watch-del-all
-rm -fr $TMPDIR/haste-map-*
-rm -rf $TMPDIR/metro-cache
-
-npm install
-npm start -- --reset-cache
-```
+The Sports app is structured to delegate specific tasks to encapsulated units of logic known as services. Each service is designed to handle a particular functionality, allowing for clean separation of concerns and ensuring that the logic is reusable and maintainable. This approach makes services to be potentially extracted for use in other applications with ease.
-* Restart the simulator.
+- [**AppConfig Service**](./src/services/appConfig/README.md): Manages environment variables defined in the `.env` file, enabling the app to read and apply configuration settings.
-* Run the `kepler clean` command. This removes the artifacts generated in the top level `/.build` folder. To learn more, see the [Vega CLI Functions](https://developer.amazon.com/docs/vega/0.21/cli-tools.html) document.
+- [**ApiClient Service**](./src/services/apiClient/README.md): Manages fetching data from various data sources exposing common API for different clients that can be consumed across the app especially in fetchers implemented in `src/api` folder.
-Testing the app
----------------
+- [**DeviceInfo Service**](./src/services/deviceInfo/README.md): Provides access to device-specific information, such as determining the type of device, and interacts with device-related APIs.
-To run the test suite, use the following commands:
+- [**DeviceStorage Service**](./src/services/storage/README.md): Provides access to device storage, by abstracting storage related APIs exposed by `AsyncStorage`.
-```
-npm test
-```
+- [**NetInfo Service**](./src/services/netInfo/README.md): Monitors internet connectivity, manages the network state, and listens for network-related events.
-Run tests in watch mode:
+- [**Auth Service**](./src/services/auth/README.md): Manages user authentication, including signing users in and out, and restoring session data from device storage.
-```
-npm run test:watch
-```
+- [**i18n Service**](./src/services/i18n/README.md): Provides methods to manage translations in the app.
-Advanced features
------------------
+- [**Focus Guide Service**](./src/services/focusGuide/README.md): Provides hooks and wrappers to manage focusing elements in the app.
-### W3C Media API Integration
+- [**Accessibility (a11y) Service**](./src/services/a11y/README.md): Provides common logic for applying complex accessibility properties to components.
-This app uses Vega's W3C Media API (`@amazon-devices/react-native-w3cmedia`) for audio playback and demonstrates:
-- **Audio preview system** with focus-based playback.
-- **Background audio handling** with proper app state management.
-- **Custom audio controls** with TV remote integration.
+Shaka Player Integration
+------------------------
-**For comprehensive W3C Media API documentation:**
+This app integrates a customized version of Shaka Player for video streaming functionality. The integration process is automated through the `postinstall` script and involves several steps:
-- [Media Player Overview](https://developer.amazon.com/docs/vega/0.21/media-player.html) - Complete API reference and concepts.
-- [Media Player Setup Guide](https://developer.amazon.com/docs/vega/0.21/media-player-setup.html) - Step-by-step implementation instructions.
+### Installation Process
-### Focus management
+When you run `npm install`, the following automated process occurs:
-Focus management is critical for TV applications. This app implements:
+1. **Patch Application**: `npx patch-package` applies any local patches to dependencies
+2. **Shaka Player Setup**: The `shaka-setup/build.sh` script executes:
+ - Clones the official [Shaka Player repository](https://github.com/shaka-project/shaka-player.git) from GitHub
+ - Checks out the specified version and creates a corresponding branch
+ - Extracts the custom tarball containing:
+ - **Custom patches** for Vega TV platform compatibility
+ - **Polyfills** for W3C Media APIs
+ - **Custom source files** optimized for TV environments
+ - Applies all patches using `git am` to modify Shaka Player for TV platform support
+ - Builds the customized Shaka Player using the Vega build system
+ - Adds `// @ts-nocheck` to TypeScript files to prevent linting issues
+3. **File Distribution**: The `shaka-setup/copyOutputs.sh` script:
+ - Copies the built Shaka Player files to `src/w3cmedia/shakaplayer/`
+ - Copies custom source files and polyfills to `src/w3cmedia/`
+ - Removes obsolete files that could cause build issues
-- **Initial focus** specification using `hasTVPreferredFocus`.
-- **Focus-based audio preview** with 1-second delay and 10-second timeout.
-- **TV-optimized navigation** patterns for album browsing.
+### Custom Patches Overview
-### Audio preview system
+The tarball includes custom patches that provide:
+- **Dolby Audio Support** for enhanced audio experiences.
+- **Performance Optimizations** for TV hardware.
+- **W3C Media API Compatibility** for Vega platform.
+- **Bug Fixes** for HLS/DASH playback on TV devices.
+- **Custom Event Handling** for TV-specific interactions.
-The app features a TV-optimized preview system:
-- 1-second delay before starting preview (prevents conflicts during rapid navigation).
-- 10-second maximum preview duration.
-- Automatic cleanup when screen loses focus.
-- Background audio handling per TV platform guidelines.
+The Shaka Player integration is essential for video playback functionality. The custom patches ensure optimal performance and compatibility with Vega TV platform requirements.
-### AudioPlayer and hooks usage
-This app demonstrates comprehensive audio management through custom hooks and context providers.
+Customize the Sports app
+------------------------
-#### useAudioHandler hook
+### Connect the app to your backend
-The core audio management hook provides complete playback control:
+The following steps show how to configure the API client and set up data related components to fit to your backend.
-```typescript
-import { useAudioHandler } from '../utils/AudioHandler';
-import React, { useCallback, useEffect, useState } from 'react';
+To learn more about creating API fetchers, see the [API README](./src/api/README.md). To learn more about all options and technical background for the API client, see the [API Client README](./src/services/apiClient/README.md).
-const MyComponent = () => {
- const [progress, setProgress] = useState(0);
-
- const onLoadedMetadata = useCallback(() => {
- console.log('Audio ready');
- }, []);
+#### Configure the API client
+
+1. To copy the **.env.example** file and name it **.env**, run the following command.
+
+ ```
+ cp .env.example .env
+ ```
- const {
- audioRef,
- initializePlayerInstance,
- destroyAudioElements,
- isLoading,
- isBuffering
- } = useAudioHandler({
- onTimeChange: setProgress,
- onLoadedMetadata,
- });
-
- // Initialize audio with track data.
- const playTrack = (trackInfo, albumThumbnail) => {
- initializePlayerInstance(trackInfo, albumThumbnail);
- };
-
- // Cleanup on unmount.
- useEffect(() => {
- return () => destroyAudioElements();
- }, []);
-};
-```
+2. Update the `REACT_APP_API_KEY` variable with your backend URL, for example, `REACT_APP_API_KEY=https://exampleapi.com/`.
+3. To apply the new configuration, stop the metro server, and start the metro server again.
-#### AudioProvider context
-Global audio state management for cross-component synchronization:
+#### Data flow
-```typescript
-import React, { useContext } from 'react';
-import { AudioProvider, AudioContext } from '../store/AudioProvider';
+Some of the screens in the Sports app have a configurable layout. You can create your preferred screen layout for a specific screen.
-// Wrap your app.
-
-
-
+To standardize transfer information form your backend to the app, the DTO (Data Transfer Object) pattern is used. The ***DTO.ts** files contain a definition of your backend response and parsing functions to transition object keys to the Amazon models.
-// Access in components.
-const { isAudioStarted, audioThumbnail, setIsAudioStarted } = useContext(AudioContext);
-```
+#### Adjust the Home screen of the app
-#### Key features
+The `Home`component in the **Home.tsx** file is the Home screen view for the app, and is composed of a few carousels. The configuration for the Home screen is returned from the `useCarouselLayout` hook in the [**fetchCarouselLayout.ts**](./src/api/carouselLayout/fetchCarouselLayout.ts) file.
-- **Multi-format support**: Automatic detection between MP3/MP4 (AudioPlayer) and DASH/HLS (ShakaPlayer).
-- **Race condition prevention**: Prevents concurrent initialization during rapid track changes.
-- **Vega Media Controls**: TV remote integration for play/pause/seek operations.
-- **Memory management**: Automatic cleanup and resource management.
-- **Debounced seeking**: Prevents audio glitches during rapid seek operations.
+The `useCarouselLayout` hook in the [**fetchCarouselLayout.ts**](./src/api/carouselLayout/fetchCarouselLayout.ts) file returns the following array of objects:
-#### Usage patterns
+* `itemId` - (required) ID to identify configuration .
+* `carouselType` - (required) Type of carousel that affects visual styles. The available types include hero, square, and card .
+* `carouselTitle` - (optional) Custom title displayed above the carousel.
+* `endpoint` - Endpoint to fetch data.
-**Basic Playback:**
-```typescript
-const trackInfo = {
- id: 1,
- title: 'Track Title',
- description: 'Track Description',
- duration: '3:45',
- type: 'mp3',
- audioURL: 'https://example.com/track.mp3',
- thumbURL: 'https://example.com/thumb.jpg'
-};
+##### Set up the configuration
-const albumThumbnail = 'https://example.com/album-thumb.jpg';
-initializePlayerInstance(trackInfo, albumThumbnail);
-```
+1. To set a proper endpoint that corresponds with your backend, change a value for `CarouselLayout` in the [**types.ts**](./src/services/apiClient/types.ts) file.
-**Track Navigation:**
-```typescript
-const handleNext = (nextTrackInfo) => {
- onNextPreviousClick(nextTrackInfo);
-};
-```
+2. Modify the DTO definition and parser methods to fit your backend in the [**CarouselLayoutDto.ts**](./src/api/carouselLayout/dtos/CarouselLayoutDto.ts) file.
-**Cleanup:**
-```typescript
-useEffect(() => {
- return () => destroyAudioElements();
-}, []);
-```
+#### Add a new fetcher
-Implementation guide
--------------------
+Make sure that all endpoints from the [**types.ts**](./src/services/apiClient/types.ts) file follows the backend setup. Instead of creating new fetchers, you can edit an existing fetcher.
+
+1. Define the DTO and parsing logic.
+
+ The data transfer object is defined in the **liveStreams/dtos/LiveStreamsDto.ts** file.
+
+ ```typescript
+ export type LiveStreamDto = {
+ id?: string;
+ title?: string;
+ description?: string;
+ streamUrl?: string;
+ observers_count?: number;
+ start_time?: string;
+ };
+
+ export function parseLiveStreamsDtoArray(
+ data: LiveStreamDto[],
+ ): LiveStreamModel[] {
+ return data.map((dto) => ({
+ id: dto.id,
+ title: dto.title,
+ description: dto.description,
+ url: dto.streamUrl,
+ viewers: parseNumber(dto.observers_count),
+ startTime: new Date(dto.start_time),
+ }));
+ }
+ ```
+
+2. Define the Model type.
+
+ In `src/models/liveStreams`, define the data model object. This is the final Model structure, so you must be sure about what value has to be here and its type is properly parsed in Step 1.
+
+ After the data has been transformed from the Data Type Object (properties are optional) into the Data Model Object (properties are required), any code that subsequently uses this data can rely on these properties being present and correctly typed. This provides confidence when working with the data later in the app flow and creates a more reliable and maintainable codebase where TypeScript can provide better type safety and IDE support.
+
+ ```typescript
+ export type LiveStreamModel = {
+ id: string;
+ title: string;
+ description: string;
+ streamUrl: string;
+ viewers: number;
+ startTime: Date;
+ };
+
+ export function parseLiveStreamsDtoArray(
+ data: LiveStreamDto[],
+ ): LiveStreamModel[] {
+ return data.map((dto) => ({
+ id: dto.id,
+ title: dto.title,
+ description: dto.description,
+ url: dto.streamUrl,
+ startTime: new Date(dto.startTime),
+ }));
+ }
+ ```
+
+3. Implement the Fetch function.
+
+ The **fetchLiveStreams.ts** file includes the combination of API fetch, error handling, and parsing functionality.
+
+ ```typescript
+ import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+ } from '@AppServices/apiClient';
+ import { parseLiveStreamsDtoArray, LiveStreamDto } from './dtos/LiveStreamsDto';
+ import staticData from './staticData/liveStreams.json';
+
+ type ResponseDto = LiveStreamDto[];
+
+ const endpoint = Endpoints.LiveStreams;
+
+ export const fetchLiveStreamsApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchLiveStreamsApiCall(): Resource does not exist for endpoint '${endpoint}'`,
+ );
+ default:
+ throw new Error(
+ `fetchLiveStreamsApiCall(): Failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseLiveStreamsDtoArray(response.data);
+ };
+ ```
+
+4. Create the custom hook.
-### Audio architecture overview
+ In the **fetchLiveStreams.ts** file, use `SWR` to create a dedicated reusable hook for a given endpoint.
-The app uses a layered architecture for audio management:
+ ```typescript
+ import useSWR from 'swr';
-- **AudioProvider**: Global state management using React Context.
-- **useAudioHandler**: Core audio logic and player management.
-- **W3C Media API**: Low-level audio playback via AudioPlayer/ShakaPlayer.
-- **Vega Media Controls**: TV remote integration.
+ export const useLiveStreams = () => {
+ const { data, error, isLoading } = useSWR(endpoint, fetchLiveStreamsApiCall);
-### Adding audio to your components
+ return {
+ liveStreams: data,
+ isLoading,
+ isError: error,
+ };
+ };
+ ```
-1. Setup AudioProvider
+#### Example: Usage of the hook
+
+Use `useLiveStreams` within a component to fetch and consume live stream related data.
-Example:
```typescript
-import { AudioProvider } from './store/AudioProvider';
+import React from 'react';
+import { useLiveStreams } from '@AppServices/api/liveStreams/fetchLiveStreams';
+
+export const LiveStreamsList = () => {
+ const { liveStreams, isLoading, isError } = useLiveStreams();
+
+ if (isLoading) return Loading...;
+ if (isError) return Error loading streams.;
-function App() {
return (
-
-
-
+
+ {liveStreams.map((stream) => (
+ {stream.title}
+ ))}
+
);
-}
+};
```
-2. Use the audio hook
+### Customize the theme of the app
-Example:
-```typescript
-import { useAudioHandler } from './utils/AudioHandler';
-import React, { useCallback, useState } from 'react';
+You can customize the color scheme and font settings.
-const MyAudioComponent = () => {
- const [currentTime, setCurrentTime] = useState(0);
- const [ready, setReady] = useState(false);
-
- const onLoadedMetadata = useCallback(() => {
- setReady(true);
- }, []);
+#### Set up the color scheme
+
+1. Create a color scheme that your prefer. You can use a color scheme generator, for example, [Material Design](https://material-foundation.github.io/material-theme-builder/).
+
+2. Export the theme that you created as a JSON file.
+
+3. Go to the [**palette.ts**](./src/theme/palette.ts) file and replace `lightPalette` & `darkPalette` objects with the generated objects that you created.
+
+4. Add additional custom colors.
+
+ * `transparent` - Use this option if you want to prevent transparent colors from being used in your app theme. For example, if you set `backgroundColor` to `transparent`, an error occurs.
+ * `focusPrimary` - Colors used for highlight focused element.
+ * `gradientPrimary` - Definition of gradient color displayed as a background.
+
+ Example:
+
+ ```js
+ transparent: 'transparent',
+ focusPrimary: '#FDE8C7',
+ gradientPrimary: ['rgba(255, 255, 255, 0)', 'rgba(255, 255, 255, 0.8)'],
+ ```
+
+
+5. To view the updated theme, reload the app.
+
+#### Set up components UI
+
+You can customize font sizes, colors, and spaces for particular components.
+
+1. Go to the **tokens** (./src/theme/tokens) directory.
+
+2. Edit any values to suit your needs.
+
+3. To view the updated components, reload the app.
+
+### Customize the dynamic layout of the app
+
+The Details screen and Home screen carousels in the app are composed of two key elements: **layout** and **data**. Both of these elements can be fetched from your backend and work together to render the screen dynamically.
+
+#### Layout
+
+##### Home screen carousels layout
+
+The layout of the carousels includes information about carousel type, data type to be displayed in the given carousel, and the carousel title. These layouts are provided as static JSON files (../src/api/carouselLayout/staticData/carouselLayout.json).
+
+Home screen carousels can use one of three types: Hero, Square, or Card. Each type has a different visual style and layout.
+
+##### Details screen layout
+
+The Details screen layout determines the structure and visual arrangement of the Details screen. It is defined by a JSON file that can be managed through a backend or CMS. This JSON file specifies the components to use and their arrangement. The available components include the following:
+
+* **Container**: Defines a section or grouping of components.
+
+* **Image**: Displays an image within the layout.
+
+* **Text**: Shows textual content, such as titles or descriptions.
+
+The layout can be configured by your backend or CMS team, offering flexibility to structure and manage different content types effectively. These layouts are provided as static JSON files (../src/api/detailsLayout/staticData/detailsLayout.json).
+
+Example static JSON files:
+
+* **Documentaries**: The layout might include two text components at the top followed by an image.
+
+* **Teams**: The layout might feature a container with text and an image side by side.
+
+* **LiveStreams**: A layout optimized for showcasing live video content.
+
+Each layout can be tailored to the content type it represents and provides instructions for how the screen should be visually structured.
+
+#### Data
+
+##### Home Screen Carousels Data
+
+The data represents the content to be displayed in the carousels on the Home screen. It can be of one of 4 types: live streams, documentaries, teams, or "suggested for you".
+
+##### Details screen data
+
+The data represents the specific content to be displayed on the Details screen. The data includes the following:
+
+* Titles
+
+* Descriptions
+
+* Images
+
+* Any other content-specific details
+
+The app fetches the layout JSON file from the backend, and then combines it with the data for the specific content type to render the screen. For example, if the backend sets a layout for documentaries to include two text components and an image, the app completes the following:
+
+1. Parse the layout JSON file to understand the structure.
+
+2. Fetch the documentary-specific data (for example, title, description, image URL).
+
+3. Render the layout with the fetched data, displaying the title and description in the text components and the image in the image component.
+
+##### Dynamic data
+
+1. Reference external layout contents.
+
+ To reference existing layout contents from a different endpoint, the `linked_content` property can be used. An example for this feature in the codebase exists: [**suggestedForYou.json**](./src/api/suggestedForYou/staticData/suggestedForYou.json) items display actual layout and contents of the [**liveStreams.json**](./src/api/liveStreams/staticData/liveStreams.json) file.
+
+ ```json
+ [
+ // ...
+ "suggestedforyou": [
+ {
+ "show_name": "American Vagabond",
+ "id": "e9a06a8f-9d40-41b9-a8b4-38bbc67159a2",
+ // ...
+ "linked_content": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370"
+ }
+ },
+ // ...
+ }
+ ```
+
+ The `linked_content` property must specify the `endpoint` from which to fetch the data and the `itemID` referencing an `id` from a different endpoint. The referenced item from the [`livestreams`](./src/api/liveStreams/staticData/liveStreams.json) endpoint looks as follows:
+
+ ```json
+ {
+ "id": "50128bae-e954-4233-8e15-cd5867a31370",
+ "stream_date": "...",
+ "title": "...",
+ "thumbnail": "..."
+ },
+ ```
+
+2. Display dynamic content using `DCText`.
+
+ With the `DCText` element type, you can display dynamic text in the layout. The `text` property includes the dynamic text. The path might be nested, such as `team.trainer.firstName`.
+
+ A layout example can be found in the [**detailsLayout.json**](./src/api/detailsLayout/staticData/detailsLayout.json) file for `"layout_type": "teams"`.
+
+
+ `DCText` element type example:
+
- const { initializePlayerInstance, audioRef } = useAudioHandler({
- onTimeChange: setCurrentTime,
- onLoadedMetadata,
- });
-
- const playAudio = () => {
- if (audioRef.current) {
- audioRef.current.play();
- }
- };
-};
+ ```json
+ {
+ "elementType": "DCText",
+ "text": "Athlete stories provide an insight into the lives of sports stars beyond the game. They highlight the challenges, sacrifices, and triumphs that shape their journeys to success. From overcoming injuries to breaking records, these stories inspire fans and aspiring athletes alike. They often reveal the personal struggles and resilience that drive their passion for the sport. Athlete stories also celebrate their achievements, both on and off the field, showcasing their impact on the community. Through these narratives, we connect with the human side of sports, finding motivation and admiration in their experiences.",
+ "displayProps": {
+ "variant": "body",
+ "alignContent": "center",
+ "alignItems": "center",
+ "justifyContent": "center"
+ },
+ "id": "container-body1"
+ }
+ ```
+
+3. Display images using `DCImage`.
+
+ **Example: Dynamic image source**
+
+ The `DCImage` component allows for displaying images from dynamic URL sources using the `targetUrl` property. `DCImage` must specify the path to the **parsed** item property that includes the URL to the image value. The path might be nested, such as `team1.thumbnail`.
+
+ An example can be found in the [**detailsLayout.json**](./src/api/detailsLayout/staticData/detailsLayout.json) file for `"layout_type": "livestreams"` that references the `team1.thumbnail` property of a `livestreams` endpoint item:
+
+ ```json
+ // ...
+ {
+ "id": "livestreams",
+ "layout_type": "livestreams",
+ // ...
+ "layoutElements": [
+ {
+ "layoutElements": [
+ {
+ "id": "containerlivestreams-imagetile1",
+ "elementType": "DCImageTile",
+ "image": {
+ "targetUrl": "team1.thumbnail"
+ },
+ // ...
+ },
+ // ...
+ },
+ // ...
+ ]
+ },
+ // ...
+ ```
+
+
+
+ **Example: Dynamic image title**
+
+ The `DCImage` component allows for dynamic image titles from dynamic URL sources using the `titleTarget` property. `DCImage` must specify the path to the **parsed** item property that includes the URL to the image title. The path might be nested, such as `team1.teamName`.
+
+ An example can be found in the [**detailsLayout.json**](./src/api/detailsLayout/staticData/detailsLayout.json) file for `"layout_type": "livestreams"` that references the `team1.name` property of a `livestreams` endpoint item:
+
+
+ ```json
+ // ...
+ {
+ "id": "livestreams",
+ "layout_type": "livestreams",
+ // ...
+ "layoutElements": [
+ {
+ "layoutElements": [
+ {
+ "id": "containerlivestreams-imagetile1",
+ "titleTarget": "team1.name",
+ "elementType": "DCImageTile"
+ // ...
+ },
+ // ...
+ },
+ // ...
+ ]
+ },
+ // ...
+ ```
+
+
+#### Backend Configuration
+
+The backend team or CMS manager is responsible for setting the appropriate layout for each content type. By defining these layouts in JSON, they control how each type of content appears in the app, ensuring consistency and flexibility across various screens.
+
+**Example JSON**
+
+You can find the minimum product implementation of the Details screen JSON files in the [**detailsLayout.json**](./src/api/detailsLayout/staticData/detailsLayout.json).
+
+**Customizing dynamic layout**
+
+If you decide to use the dynamic layout, as it is configured in the Sports app, you are free to use any type of backend or CMS you wish. The only constraint from the point of view of the app is the shape of JSON files, which are expected by the Home screen carousels and Details screen.
+
+You can familiarize yourself with what is expected from the backend by looking at the data models placed in the [**CarouselLayout.ts**](./src/models/carouselLayout/CarouselLayout.ts) file and the [**DetailsLayout.ts**](./src/models/detailsLayout/DetailsLayout.ts) file.
+
+
+
+### Change the language of the app
+
+You can change the existing app language to any language in the language list using the i18n service. To learn more about the i18n service, see the [README](./src/services/i18n/README.md).
+
+#### Language list
+
+All available languages are defined in the [**languages.ts**](./src/services/i18n/languages.ts) file.
+
+```js
+export const languages = [
+ { key: 'en-US', label: 'english' },
+ { key: 'pl', label: 'polish' },
+] as const;
+
```
-3. Handle the track data
+All items must correspond with directories in the `assets/text` directory.
-Example:
-```typescript
-const trackData = {
- id: 1,
- title: 'Track Title',
- description: 'Track Description',
- duration: '3:45',
- type: 'mp3', // or 'mp4', 'dash', 'hls'
- audioURL: 'your-audio-url',
- thumbURL: 'thumbnail-url'
-};
+#### Change existing languages
+
+All languages are located in `assets/text` directory, that follows this structure:
-const albumThumbnail = 'album-thumbnail-url';
-initializePlayerInstance(trackData, albumThumbnail);
+```
+assets
+└── text
+├── en-US
+│ └── strings.puff.json
+├── pl
+│ └── strings.puff.json
+└── es
+ └── strings.puff.json
+
+```
+
+
+To change the translated text displayed in the app, change the values in the applicable files. If you are adding a new key/value pair, you can start by changing only one file, for example **assets/en-US/strings.puff.json**. Then, to synchronize all files, at the command prompt run the following script:
+
+```
+npm run i18n:sync
```
-Third-party library integration
-------------------------------
-This section provides the minimum integration steps necessary to integrate the third-party libraries with the sample app.
-### TVFocusGuideView
+After running the above script you will need to manually update every JSON file to contain correct translations.
+If you prefer, you can skip using the above script and update every .puff.json file present in the app manually.
+
+#### Add new languages
+
+1. In the **assets/en-US/** directory, open the default language file, **strings.puff.json**.
+
+2. Add your preferred languages.
+
+3. To copy created keys to other language files, at the command prompt, run the following command.
+
+ ```
+ npm run i18n:sync
+ ```
-`TVFocusGuide` helps you to write intuitive TV apps. It supports `autofocus` that helps in finding the focus, as well as remembering the focus on multiple visits. It also supports `trapping` and `focus redirection` which allow you to customize the focus behavior in your app.
+4. Adjust values of new keys to particular language.
-To implement `TVFocusGuideView`:
-1. Add the following package dependency in your [package.json](./package.json) file.
+## Accessibility
+
+The VegaSportsApp implements comprehensive accessibility features to ensure the app is usable by everyone, including users with disabilities. The app follows TV accessibility best practices and provides screen reader support.
+
+For more information about the specifics of Accessibility for Vega, check the [A11Y API](https://developer.amazon.com/docs/react-native-vega/0.72/accessibility.html) documentation.
+
+### Accessibility Service
+
+The app uses a dedicated [Accessibility Service](../../src/services/a11y/README.md) that provides utilities for complex navigation scenarios and screen reader integration.
+
+### Core Utilities
+
+#### Screen Reader Detection
```typescript
- "dependencies": {
- ...
- "@amazon-devices/react-native-kepler": "~2.0.0"
- }
+import { useScreenReaderEnabled } from '@AppServices/a11y';
+
+const screenReaderEnabled = useScreenReaderEnabled();
```
-2. Reinstall the dependencies using `npm install`.
-3. Import the corresponding `TVFocusGuideView` component.
+#### Navigation Hints
+For lists and carousels, the service automatically generates navigation hints:
```typescript
- import { TVFocusGuideView } from '@amazon-devices/react-native-kepler';
+import { injectListNavigationHints } from '@AppServices/a11y';
+
+{injectListNavigationHints(items, {
+ directionLabels: { previous: 'up', next: 'down' },
+ formatOtherItemNavigationHint: ({ item, direction }) =>
+ `Use ${direction} to go to ${item.label}`
+}).map(({ item, hints }) => (
+
+))}
```
-4. In the render block of your app, add the imported component.
+#### Conditional Hints
+The `HintBuilder` class allows building complex, conditional accessibility hints:
```typescript
-
-
- Hello World
-
-
+import { HintBuilder } from '@AppServices/a11y';
+
+accessibilityHint={new HintBuilder()
+ .appendHint('Press to select', true)
+ .appendHint('First item', { type: 'first-item', index })
+ .appendHint('Last item', { type: 'last-item', index, length })
+ .asString(' ')}
+```
+
+## Implementation Guidelines
+
+### Labels and Hints
+- All interactive elements have meaningful `ariaLabel` properties
+- Navigation hints are provided for complex layouts like carousels
+- Dynamic content includes contextual accessibility information
+
+### Translation Integration
+
+Accessibility labels and hints support internationalization through the [i18n service](../../src/services/i18n/README.md). All accessibility strings are translatable and follow the same localization patterns as other app content.
+
+
+### Splash screen
+
+**For comprehensive implementation guidance**, developers should refer to the detailed [Splash Screen Manager documentation](https://developer.amazon.com/docs/react-native-vega/0.72/splashscreenmanager.html) which covers API usage, lifecycle management, asset optimization, and platform-specific considerations for creating engaging splash screen experiences on Fire TV.
+
+#### Splash screen assets structure
+
+```
+/assets/raw/SplashScreenImages.zip
+├── _loop/
+│ ├── loop00000.png
+│ ├── loop00001.png
+│ ├── loop00002.png
+│ ├── loop00003.png
+│ ├── loop00004.png
+│ └── loop00005.png
+└── desc.txt
+```
+
+The `SplashScreenImages.zip` contains images from the `SplashScreenImages` folder. Individual assets are available in `assets/raw/SplashScreenImages/` for reference. Only the ZIP file is required for implementation.
+
+
+Testing the app
+---------------
+
+We use [React Native Testing Library (RNTL)](https://callstack.github.io/react-native-testing-library/) for component and integration testing in this project. RNTL provides a robust set of tools for testing React Native applications with a focus on user interactions and accessibility.
+
+### React Native Testing Library (RNTL)
+
+RNTL allows us to write tests that closely resemble how users interact with our app. It provides utilities for rendering components, finding elements, and simulating user actions.
+
+### Test commands
+
+To run the test suite, use the following commands.
+
+```
+npm test
+```
+
+Run tests in watch mode:
+
+```
+npm run test:watch
```
-For more details about this vega supported library, see [TVFocusGuideView](https://developer.amazon.com/docs/vega-api/0.21/tvfocusguideview.html) in the Vega documentation.
+For more detailed information about our testing set up, best practices, and custom utilities, please refer to the [testing documentation](./src/test-utils/README.md) in the test-utils directory.
+
+
+Performance testing
+-------------------
+
+### Set up testing
-Release Notes
+For performance testing, the example test scenarios have been set up in `kpi-test-scenarios` folder.
+
+### Assessment
+
+Before running tests, run `npm run build:e2e` to make sure the app is built properly in the `test` babel environment so that the `.env.e2e` file is used instead of `.env`. This is needed, for example, to force the login screen to appear and make sure the initial state of the app is what the tests would expect.
+
+Afterwards, install and run the `Release` variant using `npm run kepler:run:aarch64:release`. Please keep in mind that _only_ the `Release` variant is suitable to obtain valid performance metrics.
+
+Troubleshooting the app
+-----------------------
+
+If you're facing unexpected issues while trying to build and run the app (the build is failing randomly, the app is not starting, the app is crashing randomly, etc.), try the following solutions:
+
+1. Run `npm run clean` -> this command removes `node_modules` folder and other files related to your previous builds.
+
+2. When working in debug mode you may need to use `npm run start -- --reset-cache` to clear the cache.
+
+3. In some cases (changes done to patches, changes in package.json, etc.) you may need to make sure there is no cache present in the project, in order to build successfully. Cleaning ALL cache files in the project can be done by running the following commands:
+
+ ```
+ npm run clean
+ npm cache clean --force
+ watchman watch-del-all
+ rm -fr $TMPDIR/haste-map-*
+ rm -rf $TMPDIR/metro-cache
+
+ npm install
+ npm start -- --reset-cache
+ ```
+
+You can read more about those commands in the Expo documentation [here](https://docs.expo.dev/workflow/customizing/#clearing-cache).
+
+4. Restart the Vega Virtual Device -> we have observed the VVD crashing randomly if it's used without restarting for extended periods of time.
+
+5. Run `kepler clean` -> removes the artifacts generated in the top level `/.build` folder (documentation [here](https://developer.amazon.com/docs/vega/0.21/cli-tools.html)).
+
+
+Release notes
-------------
---
### v0.22
-#### Shaka Player Integration Updates
+#### Video Asset Infrastructure Migration
+
+* **Infrastructure Migration** - Migrated all video assets from external demo URLs to unified CloudFront CDN (`d1v0fxmwkpxbrg.cloudfront.net`).
+ - Replaced external demo assets with new videos infrastructure.
+ - Improved reliability and performance for video streaming across all content types.
+
+* **Format Standardization** - Standardized video source formats across the application
+ - **HLS Streams**: Consistent `.m3u8` video content with proper HLS labeling.
+ - **DASH Streams**: Unified `.mpd` manifest format with DASH type identification.
+ - **MP4 Videos**: Direct `.mp4` file access for progressive download content.
+
+* **Thumbnail Support** - Added `thumbnailUrl` properties to all video sources:
+ - Integrated trickplay thumbnail support for video scrubbing and preview functionality.
+ - Consistent thumbnail URL structure across all video formats.
+
+#### Headless Video Player Implementation
+
+* **NEW: Headless Video Player Architecture** - Introduced a complete headless video player system that runs on a separate JavaScript thread for improved performance and UI responsiveness.
+ - `HeadlessVideoPlayerService`: Core service managing video playback in headless mode.
+ - `HeadlessVideoPlayerClient`: Client-side wrapper providing VideoPlayerService-compatible interface.
+ - `useHeadlessVideoPlayer` and `useHeadlessVideoPlayerWithSettings` hooks for React integration.
+
+* **NEW: Smart Video Player Selection** - Added intelligent player selection system via `VideoPlayerSelector`
+ - Automatically chooses between regular and headless video players based on:
+ - Device capabilities (TV platform support, memory requirements).
+ - Content type (live streams vs VOD content).
+ - User configuration preferences.
+ - Configurable thresholds for memory requirements (default: 2GB for headless).
+ - Content-specific enablement (headless enabled for live streams by default, disabled for VOD).
+
+* **NEW: Hybrid Video Player Hook** - Introduced `useSmartVideoPlayer` that seamlessly switches between player implementations.
+ - Automatic fallback to regular player if headless initialization fails.
+ - Maintains consistent API across both player types.
+ - Real-time availability checking and recommendations.
+
+#### Enhanced Video Player Integration
+
+* **Performance Improvements** - Headless implementation provides:
+ - Improved Time to First Video Frame (TTFVF).
+ - Better UI responsiveness during video operations.
+ - Reduced main thread blocking during video processing.
+
+* **Advanced Features** - Headless player supports:
+ - Full HLS/DASH streaming via Shaka Player integration.
+ - Audio, video, and text track management.
+ - Buffered ranges and status reporting.
+ - Surface handle management for video rendering.
+ - Caption view handling for subtitles.
+ - Playback rate control and seeking operations.
+ - Volume and mute state management.
+
+#### Technical Infrastructure
+
+* **Thread Separation** - Video processing moved to dedicated JavaScript thread.
+* **IPlayerServer/IPlayerClient Communication** - Standardized message passing between UI and headless service.
+* **W3C Media API Integration** - Full compatibility with existing W3C Media standards.
+* **Type Safety** - Complete TypeScript definitions for all headless components.
+* **Comprehensive Testing** - Full test coverage for headless player components and selectors.
+
+#### Developer Experience
+
+* **Backward Compatibility** - Existing VideoPlayerService implementations remain unchanged.
+* **Configuration Options** - Extensive configuration for fine-tuning headless behavior:
+ ```typescript
+ {
+ enableHeadless: true,
+ minMemoryForHeadless: 2048,
+ enableHeadlessForLiveStreams: true,
+ enableHeadlessForVOD: false,
+ forcePlayerType?: VideoPlayerType
+ }
+ ```
+* **Debug Support** - Comprehensive logging for troubleshooting player selection and headless operations..
+
-* **Automated Shaka Player Integration** - Updated Shaka Player setup and build process:
- - Shaka Player integration now occurs during post-install process, downloading source from Shaka GitHub repository to reduce source code base size.
- - Shaka patches for Vega are applied automatically after the download process.
---
### v0.21
-- Initial release.
+* Initial release.
---
+#### ⚠️ Known Issues
+
+* **Debug Build Crash with Headless Player** - When `useHeadless={true}` is enabled, the debug version of the app may crash during video playback. This is a known issue that will be resolved in the upcoming SDK 0.22 release. For development purposes, use the release build when testing headless functionality or keep `useHeadless={false}` for debug builds.
+
+**Note**: Headless functionality is currently disabled by default in VideoPlayerScreen (`useHeadless={false}`) but can be enabled through the smart player selection system or direct configuration.
+
License
--------------
-This project is licensed under the MIT-0 License - see the [LICENSE](LICENSE) file for details.
\ No newline at end of file
+-------
+
+See [LICENSE](LICENSE) file.
diff --git a/app.json b/app.json
index ef12cbe..c5dca76 100644
--- a/app.json
+++ b/app.json
@@ -1,8 +1,6 @@
{
"//": "The declared app name must follow this pattern to be KRL-compatible",
- "name": "com.amazondeveloper.kepleraudioreferenceapp.main",
- "displayName": "Vegaaudioreferenceapp",
- "reactNativeAppComponentName": "Vegaaudioreferenceapp",
- "appId": "com.amazondeveloper.kepleraudioreferenceapp.main",
- "windowTitle": "VegaAudioReferenceApp"
+ "name": "com.amazondeveloper.keplersportapp.main",
+ "syncSourceName": "com.amazondeveloper.keplersportapp.sync_source",
+ "displayName": "VegaSportApp"
}
diff --git a/assets/image/SportsApp.png b/assets/image/SportsApp.png
new file mode 100644
index 0000000..09a086c
Binary files /dev/null and b/assets/image/SportsApp.png differ
diff --git a/assets/raw/SplashScreenImages.zip b/assets/raw/SplashScreenImages.zip
new file mode 100644
index 0000000..8bf0de7
Binary files /dev/null and b/assets/raw/SplashScreenImages.zip differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop000.png b/assets/raw/SplashScreenImages/_loop/loop000.png
new file mode 100644
index 0000000..c9d6773
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop000.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop001.png b/assets/raw/SplashScreenImages/_loop/loop001.png
new file mode 100644
index 0000000..0af15bb
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop001.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop002.png b/assets/raw/SplashScreenImages/_loop/loop002.png
new file mode 100644
index 0000000..ad8ae5a
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop002.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop003.png b/assets/raw/SplashScreenImages/_loop/loop003.png
new file mode 100644
index 0000000..8f1278e
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop003.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop004.png b/assets/raw/SplashScreenImages/_loop/loop004.png
new file mode 100644
index 0000000..c17bcdc
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop004.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop005.png b/assets/raw/SplashScreenImages/_loop/loop005.png
new file mode 100644
index 0000000..ca6c057
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop005.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop006.png b/assets/raw/SplashScreenImages/_loop/loop006.png
new file mode 100644
index 0000000..e09cb1d
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop006.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop007.png b/assets/raw/SplashScreenImages/_loop/loop007.png
new file mode 100644
index 0000000..0fe548f
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop007.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop008.png b/assets/raw/SplashScreenImages/_loop/loop008.png
new file mode 100644
index 0000000..e03f877
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop008.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop009.png b/assets/raw/SplashScreenImages/_loop/loop009.png
new file mode 100644
index 0000000..b0bf662
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop009.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop010.png b/assets/raw/SplashScreenImages/_loop/loop010.png
new file mode 100644
index 0000000..4860099
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop010.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop011.png b/assets/raw/SplashScreenImages/_loop/loop011.png
new file mode 100644
index 0000000..ffa4794
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop011.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop012.png b/assets/raw/SplashScreenImages/_loop/loop012.png
new file mode 100644
index 0000000..2fa1a6f
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop012.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop013.png b/assets/raw/SplashScreenImages/_loop/loop013.png
new file mode 100644
index 0000000..6628652
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop013.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop014.png b/assets/raw/SplashScreenImages/_loop/loop014.png
new file mode 100644
index 0000000..2c1e509
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop014.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop015.png b/assets/raw/SplashScreenImages/_loop/loop015.png
new file mode 100644
index 0000000..bc8775c
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop015.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop016.png b/assets/raw/SplashScreenImages/_loop/loop016.png
new file mode 100644
index 0000000..c3006b0
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop016.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop017.png b/assets/raw/SplashScreenImages/_loop/loop017.png
new file mode 100644
index 0000000..21858c2
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop017.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop018.png b/assets/raw/SplashScreenImages/_loop/loop018.png
new file mode 100644
index 0000000..ab07fbe
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop018.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop019.png b/assets/raw/SplashScreenImages/_loop/loop019.png
new file mode 100644
index 0000000..494df77
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop019.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop020.png b/assets/raw/SplashScreenImages/_loop/loop020.png
new file mode 100644
index 0000000..2735f44
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop020.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop021.png b/assets/raw/SplashScreenImages/_loop/loop021.png
new file mode 100644
index 0000000..c675059
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop021.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop022.png b/assets/raw/SplashScreenImages/_loop/loop022.png
new file mode 100644
index 0000000..2351f48
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop022.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop023.png b/assets/raw/SplashScreenImages/_loop/loop023.png
new file mode 100644
index 0000000..5439ac4
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop023.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop024.png b/assets/raw/SplashScreenImages/_loop/loop024.png
new file mode 100644
index 0000000..c49e8c0
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop024.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop025.png b/assets/raw/SplashScreenImages/_loop/loop025.png
new file mode 100644
index 0000000..e8ab2e1
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop025.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop026.png b/assets/raw/SplashScreenImages/_loop/loop026.png
new file mode 100644
index 0000000..37d39af
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop026.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop027.png b/assets/raw/SplashScreenImages/_loop/loop027.png
new file mode 100644
index 0000000..724be5a
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop027.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop028.png b/assets/raw/SplashScreenImages/_loop/loop028.png
new file mode 100644
index 0000000..2d38fe5
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop028.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop029.png b/assets/raw/SplashScreenImages/_loop/loop029.png
new file mode 100644
index 0000000..ecddb49
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop029.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop030.png b/assets/raw/SplashScreenImages/_loop/loop030.png
new file mode 100644
index 0000000..2e66587
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop030.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop031.png b/assets/raw/SplashScreenImages/_loop/loop031.png
new file mode 100644
index 0000000..456a0ab
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop031.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop032.png b/assets/raw/SplashScreenImages/_loop/loop032.png
new file mode 100644
index 0000000..a7fe317
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop032.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop033.png b/assets/raw/SplashScreenImages/_loop/loop033.png
new file mode 100644
index 0000000..1d54df8
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop033.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop034.png b/assets/raw/SplashScreenImages/_loop/loop034.png
new file mode 100644
index 0000000..fd27eb5
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop034.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop035.png b/assets/raw/SplashScreenImages/_loop/loop035.png
new file mode 100644
index 0000000..0d3838c
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop035.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop036.png b/assets/raw/SplashScreenImages/_loop/loop036.png
new file mode 100644
index 0000000..abefd1a
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop036.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop037.png b/assets/raw/SplashScreenImages/_loop/loop037.png
new file mode 100644
index 0000000..151a181
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop037.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop038.png b/assets/raw/SplashScreenImages/_loop/loop038.png
new file mode 100644
index 0000000..8205ebc
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop038.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop039.png b/assets/raw/SplashScreenImages/_loop/loop039.png
new file mode 100644
index 0000000..d2278de
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop039.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop040.png b/assets/raw/SplashScreenImages/_loop/loop040.png
new file mode 100644
index 0000000..2d6063e
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop040.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop041.png b/assets/raw/SplashScreenImages/_loop/loop041.png
new file mode 100644
index 0000000..335daa0
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop041.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop042.png b/assets/raw/SplashScreenImages/_loop/loop042.png
new file mode 100644
index 0000000..d50d04e
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop042.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop043.png b/assets/raw/SplashScreenImages/_loop/loop043.png
new file mode 100644
index 0000000..b65310f
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop043.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop044.png b/assets/raw/SplashScreenImages/_loop/loop044.png
new file mode 100644
index 0000000..2444189
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop044.png differ
diff --git a/assets/raw/SplashScreenImages/_loop/loop045.png b/assets/raw/SplashScreenImages/_loop/loop045.png
new file mode 100644
index 0000000..f75b855
Binary files /dev/null and b/assets/raw/SplashScreenImages/_loop/loop045.png differ
diff --git a/assets/raw/SplashScreenImages/desc.txt b/assets/raw/SplashScreenImages/desc.txt
new file mode 100644
index 0000000..00ef962
--- /dev/null
+++ b/assets/raw/SplashScreenImages/desc.txt
@@ -0,0 +1,3 @@
+1920 1080 30
+c 0 0 _loop
+
diff --git a/assets/raw/fonts/LICENSE-MaterialCommunityIcons b/assets/raw/fonts/LICENSE-MaterialCommunityIcons
new file mode 100644
index 0000000..c6244e3
--- /dev/null
+++ b/assets/raw/fonts/LICENSE-MaterialCommunityIcons
@@ -0,0 +1,23 @@
+https://github.com/oblador/react-native-vector-icons/tree/master/packages/material-design-icons/fonts
+(formerly MaterialCommunityIcons.ttf until PR #1679)
+
+MIT License
+
+Copyright (c) 2015 Joel Arvidsson
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/assets/raw/fonts/MaterialCommunityIcons.ttf b/assets/raw/fonts/MaterialCommunityIcons.ttf
new file mode 100644
index 0000000..ba87359
Binary files /dev/null and b/assets/raw/fonts/MaterialCommunityIcons.ttf differ
diff --git a/assets/text/ar/strings.puff.json b/assets/text/ar/strings.puff.json
new file mode 100644
index 0000000..da05509
--- /dev/null
+++ b/assets/text/ar/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "rtl",
+ "resources": {
+ "common-close": "إغلاق",
+ "common-save": "حِفظ",
+ "common-play": "تشغيل",
+ "common-resume": "استئناف",
+ "english": "الإنجليزية",
+ "polish": "البولندية",
+ "login-title": "تسجيل الدخول",
+ "login-password-label": "كلمة المرور",
+ "login-password-error": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل وبحد أقصى 20 حرفًا",
+ "login-email-label": "البريد الإلكتروني",
+ "login-email-error": "يرجى تعيين عنوان بريد إلكتروني مناسب، على سبيل المثال me@somemail.com",
+ "login-button": "تسجيل الدخول",
+ "carousel-go-to-details": "انقر للانتقال إلى التفاصيل.",
+ "carousel-more": "المزيد",
+ "carousel-no-title": "بدون عنوان",
+ "details-screen-play-description-section-a11y-hint": "صف الفيلم",
+ "details-screen-no-available": "هذا الفيديو غير متوفر في خطتك.",
+ "profile-title": "اختر ملف تعريف المستخدم",
+ "profile-add-new": "إضافة ملف تعريف جديد",
+ "add-profile-title": "إضافة ملف تعريف جديد",
+ "add-profile-choose-avatar": "اختر صورة رمزية",
+ "add-profile-add-name": "إضافة اسم",
+ "add-profile-form-name-label": "الاسم",
+ "add-profile-form-name-error": "يجب أن يحتوي الاسم على حرفين على الأقل و20 حرفًا كحد أقصى",
+ "add-profile-text-of": "من",
+ "settings": "الإعدادات",
+ "settings-app-version": "إصدار التطبيق",
+ "settings-theme": "النسق",
+ "settings-profile": "ملف التعريف",
+ "settings-language": "اللغة",
+ "settings-current-locale": "اللغة الحالية",
+ "settings-change-language": "تغيير اللغة",
+ "light": "الإضاءة",
+ "dark": "داكن",
+ "settings-log-out": "تسجيل الخروج",
+ "video-player-caption-placeholder-na": "غير متاح",
+ "video-player-no-captions": "بدون ترجمة نصية",
+ "video-player-switch-text-track-to": "تبديل مسار النص إلى",
+ "loading": "جارٍ التحميل",
+ "favorites": "المُفضَّلات",
+ "favorites-search-team": "البحث عن فريق",
+ "favorites-search-team-input": "إدخال البحث",
+ "favorites-sort": "ترتيب",
+ "favorites-sort-ascending": "ترتيب الفرق تصاعديًا",
+ "favorites-sort-descending": "ترتيب الفرق تنازليًا",
+ "favorites-sort-favorites": "ترتيب الفرق حسب المفضلة",
+ "favorites-options-list": "قائمة المفضلة",
+ "progress-bar-a11y-label-indeterminate": "تقدم غير محدد",
+ "avatar-a11y-label": " الصورة الرمزية",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "انقر لتسجيل الدخول باسم {profileName}.",
+ "a11y-hint-direction-left": "يسار",
+ "a11y-hint-direction-right": "يمين",
+ "a11y-hint-direction-up": "أعلى",
+ "a11y-hint-direction-down": "أسفل",
+ "a11y-hint-use-direction-select-item": "استخدم {direction} لتحديد {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "قائمة التطبيق،",
+ "menu-wrapper-a11y-label-menu-name": "قائمة التطبيق",
+ "menu-wrapper-a11y-label-profile-avatar": "الصورة الرمزية لملف التعريف",
+ "menu-wrapper-a11y-hint-profile-avatar": "انقر للانتقال إلى شاشة اختيار ملف التعريف.",
+ "close-menu-button-a11y-label": "أغلق قائمة التطبيقات",
+ "menu-item-use-direction-to-go-to-a11y-label": "استخدم {direction} لتحديد: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "يوجد عنصر في {direction}.",
+ "a11y-hint-there-is-an-item-below": "يوجد عنصر في الأسفل.",
+ "a11y-hint-there-is-an-item-above": "يوجد عنصر في الأعلى.",
+ "carousel-item-a11y-label": "\"{item}\" في المجموعة \"{group}\".",
+ "carousel-item-without-title-a11y-label": "فيلم بدون اسم",
+ "settings-screen-a11y-theme-section-hint": "تحديد نسق",
+ "settings-screen-a11y-label-theme-variant": "النسق {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "انقر لتغيير النسق إلى {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "في الأعلى قائمة الأفلام.",
+ "a11y-hint-there-is-a-movie-list-below": "في الأسفل قائمة الأفلام.",
+ "menu-wrapper-item-home-label": "الرئيسية",
+ "menu-wrapper-item-settings-label": "الإعدادات",
+ "login-title-form": "نموذج تسجيل الدخول",
+ "email-field-a11y-label": "حقل البريد الإلكتروني. القيمة: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/bg/strings.puff.json b/assets/text/bg/strings.puff.json
new file mode 100644
index 0000000..5910384
--- /dev/null
+++ b/assets/text/bg/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Затвори",
+ "common-save": "Запазване",
+ "common-play": "Възпроизвеждане",
+ "common-resume": "Възобнови",
+ "english": "английски",
+ "polish": "Полски",
+ "login-title": "Вход",
+ "login-password-label": "Парола",
+ "login-password-error": "Паролата трябва е поне 8 и макс. 20 знака",
+ "login-email-label": "Имейл",
+ "login-email-error": "Моля, задайте подходящ имейл адрес, напр. me@somemail.com",
+ "login-button": "Вход",
+ "carousel-go-to-details": "Кликнете, за да отидете на подробности.",
+ "carousel-more": "Повече",
+ "carousel-no-title": "Без заглавие",
+ "details-screen-play-description-section-a11y-hint": "Описание на съдържанието",
+ "details-screen-no-available": "Този видеоклип не е наличен във вашия план.",
+ "profile-title": "Изберете потребителски профил",
+ "profile-add-new": "Добавяне на нов профил",
+ "add-profile-title": "Добавяне на нов профил",
+ "add-profile-choose-avatar": "Изберете аватар",
+ "add-profile-add-name": "Добавяне на име",
+ "add-profile-form-name-label": "Име",
+ "add-profile-form-name-error": "Името трябва да има между 2 и 20 знака",
+ "add-profile-text-of": "на",
+ "settings": "Настройки",
+ "settings-app-version": "Версия на приложението",
+ "settings-theme": "Тема",
+ "settings-profile": "Профил",
+ "settings-language": "Език",
+ "settings-current-locale": "Текуща местна настройка",
+ "settings-change-language": "Промяна на езика",
+ "light": "Светло",
+ "dark": "Тъмно",
+ "settings-log-out": "Излезте",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Без надписи",
+ "video-player-switch-text-track-to": "Превключване на текстов запис на",
+ "loading": "Зареждане",
+ "favorites": "Любими",
+ "favorites-search-team": "Търсене на екип",
+ "favorites-search-team-input": "Търсене на вход",
+ "favorites-sort": "Сортиране",
+ "favorites-sort-ascending": "Сортиране на екипи възходящо",
+ "favorites-sort-descending": "Сортиране на екипи низходящо",
+ "favorites-sort-favorites": "Сортиране на екипи по любими",
+ "favorites-options-list": "Списък с приложения",
+ "progress-bar-a11y-label-indeterminate": "Неустановен прогрес",
+ "avatar-a11y-label": " аватар",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Кликнете, за да влезете като {profileName}.",
+ "a11y-hint-direction-left": "наляво",
+ "a11y-hint-direction-right": "надясно",
+ "a11y-hint-direction-up": "нагоре",
+ "a11y-hint-direction-down": "надолу",
+ "a11y-hint-use-direction-select-item": "Използвайте {direction}, за да изберете {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Меню на приложението,",
+ "menu-wrapper-a11y-label-menu-name": "Меню на приложението",
+ "menu-wrapper-a11y-label-profile-avatar": "Аватар на профил",
+ "menu-wrapper-a11y-hint-profile-avatar": "Кликнете, за да отидете на екрана за избор на профил.",
+ "close-menu-button-a11y-label": "Затваряне на менюто на приложението",
+ "menu-item-use-direction-to-go-to-a11y-label": "Използвайте {direction} , за да изберете: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Има елемент към {direction}.",
+ "a11y-hint-there-is-an-item-below": "Има елемент по-долу.",
+ "a11y-hint-there-is-an-item-above": "Има елемент по-горе.",
+ "carousel-item-a11y-label": "'{item}' в група '{group}'.",
+ "carousel-item-without-title-a11y-label": "Неназовано устройство",
+ "settings-screen-a11y-theme-section-hint": "Избрана тема",
+ "settings-screen-a11y-label-theme-variant": "{themeName} тема",
+ "settings-screen-a11y-label-theme-variant-hint": "Кликнете, за да промените темата на {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "По-горе е списък с филми.",
+ "a11y-hint-there-is-a-movie-list-below": "По-долу е списък с филми.",
+ "menu-wrapper-item-home-label": "Начало",
+ "menu-wrapper-item-settings-label": "Настройки",
+ "login-title-form": "Форма за влизане",
+ "email-field-a11y-label": "Имейл адрес. Стойност: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/cs/strings.puff.json b/assets/text/cs/strings.puff.json
new file mode 100644
index 0000000..4410ecd
--- /dev/null
+++ b/assets/text/cs/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Zavřít",
+ "common-save": "Uložit",
+ "common-play": "Přehrát",
+ "common-resume": "Pokračovat",
+ "english": "Angličtina",
+ "polish": "Polský",
+ "login-title": "Odhlásit se",
+ "login-password-label": "Heslo",
+ "login-password-error": "Heslo musí mít minimálně 8 a maximálně 20 znaků",
+ "login-email-label": "E-mailová adresa",
+ "login-email-error": "Nastavte správnou e-mailovou adresu, např. jmeno@emailovadomena.com",
+ "login-button": "Odhlásit se",
+ "carousel-go-to-details": "Kliknutím přejdete na podrobnosti.",
+ "carousel-more": "Více",
+ "carousel-no-title": "Bez názvu",
+ "details-screen-play-description-section-a11y-hint": "Popis filmu",
+ "details-screen-no-available": "Toto video není ve vašem plánu k dispozici.",
+ "profile-title": "Vyberte uživatelský profil",
+ "profile-add-new": "Přidat nový profil",
+ "add-profile-title": "Přidat nový profil",
+ "add-profile-choose-avatar": "Vybrat avatara",
+ "add-profile-add-name": "Přidat název",
+ "add-profile-form-name-label": "Název",
+ "add-profile-form-name-error": "Název musí mít minimálně 2 znaky a maximálně 20 znaků",
+ "add-profile-text-of": "z",
+ "settings": "Nastavení",
+ "settings-app-version": "Verze aplikace",
+ "settings-theme": "Motiv",
+ "settings-profile": "Profil",
+ "settings-language": "Jazyk",
+ "settings-current-locale": "Aktuální národní prostředí",
+ "settings-change-language": "Změnit jazyk",
+ "light": "Světlý",
+ "dark": "Tmavý",
+ "settings-log-out": "Odhlásit se",
+ "video-player-caption-placeholder-na": "Není k dispozici",
+ "video-player-no-captions": "Žádné titulky",
+ "video-player-switch-text-track-to": "Přepnout textovou stopu na",
+ "loading": "Načítání",
+ "favorites": "Oblíbené",
+ "favorites-search-team": "Vyhledat tým",
+ "favorites-search-team-input": "Vstup hledání",
+ "favorites-sort": "Seřadit",
+ "favorites-sort-ascending": "Seřadit týmy vzestupně",
+ "favorites-sort-descending": "Seřadit týmy sestupně",
+ "favorites-sort-favorites": "Seřadit týmy podle oblíbených",
+ "favorites-options-list": "Seznam oblíbených",
+ "progress-bar-a11y-label-indeterminate": "Nejasný průběh",
+ "avatar-a11y-label": "Avatar ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kliknutím se přihlásíte jako {profileName}.",
+ "a11y-hint-direction-left": "doleva",
+ "a11y-hint-direction-right": "doprava",
+ "a11y-hint-direction-up": "nahoru",
+ "a11y-hint-direction-down": "dolů",
+ "a11y-hint-use-direction-select-item": "K výběru položky {item} použijte šipku {direction}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Nabídka aplikace,",
+ "menu-wrapper-a11y-label-menu-name": "Nabídka aplikace",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar profilu",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kliknutím přejdete na obrazovku pro výběr profilu.",
+ "close-menu-button-a11y-label": "Zavřít nabídku aplikace",
+ "menu-item-use-direction-to-go-to-a11y-label": "K výběru cíle {destination} použijte šipku {direction}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Směrem {direction} je položka.",
+ "a11y-hint-there-is-an-item-below": "Níže je položka.",
+ "a11y-hint-there-is-an-item-above": "Výše je položka.",
+ "carousel-item-a11y-label": "Položky {item} ve skupině {group}.",
+ "carousel-item-without-title-a11y-label": "Film bez názvu",
+ "settings-screen-a11y-theme-section-hint": "Výběr motivu",
+ "settings-screen-a11y-label-theme-variant": "Motiv {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Kliknutím změníte motiv na {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Výše je seznam filmů.",
+ "a11y-hint-there-is-a-movie-list-below": "Níže je seznam filmů.",
+ "menu-wrapper-item-home-label": "Domů",
+ "menu-wrapper-item-settings-label": "Nastavení",
+ "login-title-form": "Odhlašovací formulář",
+ "email-field-a11y-label": "Pole e-mailové adresy. Hodnota: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/da/strings.puff.json b/assets/text/da/strings.puff.json
new file mode 100644
index 0000000..b1f25aa
--- /dev/null
+++ b/assets/text/da/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Luk",
+ "common-save": "Gem",
+ "common-play": "Afspil",
+ "common-resume": "Genoptag",
+ "english": "Engelsk",
+ "polish": "Polsk",
+ "login-title": "Log ind",
+ "login-password-label": "Adgangskode",
+ "login-password-error": "Adgangskoden skal bestå af mindst 8 og højst 20 tegn",
+ "login-email-label": "E-mail",
+ "login-email-error": "E-mailadressen skal have korrekt format, f.eks. min@mailkonto.com",
+ "login-button": "Log ind",
+ "carousel-go-to-details": "Klik for flere detaljer.",
+ "carousel-more": "Mere",
+ "carousel-no-title": "Ingen titel",
+ "details-screen-play-description-section-a11y-hint": "Filmbeskrivelse",
+ "details-screen-no-available": "Dit abonnement giver ikke adgang til denne video.",
+ "profile-title": "Vælg brugerprofil",
+ "profile-add-new": "Tilføj ny profil",
+ "add-profile-title": "Tilføj ny profil",
+ "add-profile-choose-avatar": "Vælg avatar",
+ "add-profile-add-name": "Tilføj navn",
+ "add-profile-form-name-label": "Navn",
+ "add-profile-form-name-error": "Navnet skal bestå af mindst 2 og højst 20 tegn",
+ "add-profile-text-of": "af",
+ "settings": "Indstillinger",
+ "settings-app-version": "Appversion",
+ "settings-theme": "Tema",
+ "settings-profile": "Profil",
+ "settings-language": "Sprog",
+ "settings-current-locale": "Nuværende sprogindstilling",
+ "settings-change-language": "Skift sprog",
+ "light": "Lys",
+ "dark": "Mørk",
+ "settings-log-out": "Log ud",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Ingen tekster",
+ "video-player-switch-text-track-to": "Skift tekster til",
+ "loading": "Indlæser",
+ "favorites": "Favoritter",
+ "favorites-search-team": "Søg efter hold",
+ "favorites-search-team-input": "Søgeinput",
+ "favorites-sort": "Sortér",
+ "favorites-sort-ascending": "Sortér hold i stigende rækkefølge",
+ "favorites-sort-descending": "Sortér hold i faldende rækkefølge",
+ "favorites-sort-favorites": "Sortér hold efter favoritter",
+ "favorites-options-list": "Liste over favoritter",
+ "progress-bar-a11y-label-indeterminate": "Ukendt status",
+ "avatar-a11y-label": "-avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Klik for at logge ind som {profileName}.",
+ "a11y-hint-direction-left": "venstre",
+ "a11y-hint-direction-right": "højre",
+ "a11y-hint-direction-up": "op",
+ "a11y-hint-direction-down": "ned",
+ "a11y-hint-use-direction-select-item": "Brug {direction} til at vælge {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App-menu,",
+ "menu-wrapper-a11y-label-menu-name": "App-menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profilavatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Klik for at åbne skærmen for valg af profil.",
+ "close-menu-button-a11y-label": "Luk app-menuen",
+ "menu-item-use-direction-to-go-to-a11y-label": "Brug {direction} til at vælge {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Der er et element til {direction}.",
+ "a11y-hint-there-is-an-item-below": "Der er et element nedenfor.",
+ "a11y-hint-there-is-an-item-above": "Der er et element ovenfor.",
+ "carousel-item-a11y-label": "{item} i gruppen {group}.",
+ "carousel-item-without-title-a11y-label": "Film uden titel",
+ "settings-screen-a11y-theme-section-hint": "Valg af tema",
+ "settings-screen-a11y-label-theme-variant": "{themeName} tema",
+ "settings-screen-a11y-label-theme-variant-hint": "Klik for at skifte tema til {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Ovenfor vises der en filmliste.",
+ "a11y-hint-there-is-a-movie-list-below": "Nedenfor vises der en filmliste.",
+ "menu-wrapper-item-home-label": "Hjemme",
+ "menu-wrapper-item-settings-label": "Indstillinger",
+ "login-title-form": "Loginformular",
+ "email-field-a11y-label": "Feltet e-mail. Værdi: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/de/strings.puff.json b/assets/text/de/strings.puff.json
new file mode 100644
index 0000000..febc2e8
--- /dev/null
+++ b/assets/text/de/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Schließen",
+ "common-save": "Speichern",
+ "common-play": "Wiedergabe",
+ "common-resume": "Fortsetzen",
+ "english": "Englisch",
+ "polish": "Polnisch",
+ "login-title": "Anmelden",
+ "login-password-label": "Passwort",
+ "login-password-error": "Das Passwort muss aus mindestens 8 Zeichen und höchstens 20 Zeichen bestehen.",
+ "login-email-label": "E-Mail-Adresse",
+ "login-email-error": "Bitte gib eine gültige E-Mail-Adresse ein, z. B. ich@emailanbieter.de",
+ "login-button": "Anmelden",
+ "carousel-go-to-details": "Hier klicken, um Details aufzurufen.",
+ "carousel-more": "Mehr",
+ "carousel-no-title": "Kein Titel",
+ "details-screen-play-description-section-a11y-hint": "Film-Beschreibung",
+ "details-screen-no-available": "Dieses Video ist in deinem Abonnement nicht verfügbar.",
+ "profile-title": "Benutzerprofil auswählen",
+ "profile-add-new": "Neues Profil hinzufügen",
+ "add-profile-title": "Neues Profil hinzufügen",
+ "add-profile-choose-avatar": "Avatar auswählen",
+ "add-profile-add-name": "Namen hinzufügen",
+ "add-profile-form-name-label": "Name",
+ "add-profile-form-name-error": "Der Name sollte mindestens 2 und maximal 20 Zeichen lang sein.",
+ "add-profile-text-of": "von",
+ "settings": "Einstellungen",
+ "settings-app-version": "App-Version",
+ "settings-theme": "Design",
+ "settings-profile": "Profil",
+ "settings-language": "Sprache",
+ "settings-current-locale": "Aktuelles Gebietsschema",
+ "settings-change-language": "Sprache ändern",
+ "light": "Hell",
+ "dark": "Dunkel",
+ "settings-log-out": "Abmelden",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Keine Untertitel",
+ "video-player-switch-text-track-to": "Text-Track ändern zu",
+ "loading": "Wird geladen",
+ "favorites": "Favoriten",
+ "favorites-search-team": "Teamsuche",
+ "favorites-search-team-input": "Sucheingabe",
+ "favorites-sort": "Sortieren",
+ "favorites-sort-ascending": "Teams aufsteigend sortieren",
+ "favorites-sort-descending": "Teams absteigend sortieren",
+ "favorites-sort-favorites": "Teams nach Favoriten sortieren",
+ "favorites-options-list": "Favoritenliste",
+ "progress-bar-a11y-label-indeterminate": "Unbestimmter Fortschritt",
+ "avatar-a11y-label": "-Avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Hier klicken, um dich als {profileName} anzumelden.",
+ "a11y-hint-direction-left": "links",
+ "a11y-hint-direction-right": "rechts",
+ "a11y-hint-direction-up": "oben",
+ "a11y-hint-direction-down": "unten",
+ "a11y-hint-use-direction-select-item": "Wähle mit „nach {direction}“ {item} aus.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App-Menü,",
+ "menu-wrapper-a11y-label-menu-name": "App-Menü",
+ "menu-wrapper-a11y-label-profile-avatar": "Profilavatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Hier klicken, um den Profilauswahlbildschirm aufzurufen.",
+ "close-menu-button-a11y-label": "App-Menü schließen",
+ "menu-item-use-direction-to-go-to-a11y-label": "Verwende „nach {direction}“ für die Auswahl von: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Es gibt ein Element {direction}.",
+ "a11y-hint-there-is-an-item-below": "Es gibt ein Element unten.",
+ "a11y-hint-there-is-an-item-above": "Es gibt ein Element oben.",
+ "carousel-item-a11y-label": "„{item}“ in der Gruppe „{group}“.",
+ "carousel-item-without-title-a11y-label": "Unbenannter Film",
+ "settings-screen-a11y-theme-section-hint": "Designauswahl",
+ "settings-screen-a11y-label-theme-variant": "{themeName}-Design",
+ "settings-screen-a11y-label-theme-variant-hint": "Hier klicken, um das Design zu {themeName} zu ändern.",
+ "a11y-hint-there-is-a-movie-list-above": "Oben ist eine Filmliste.",
+ "a11y-hint-there-is-a-movie-list-below": "Unten ist eine Filmliste.",
+ "menu-wrapper-item-home-label": "Startseite",
+ "menu-wrapper-item-settings-label": "Einstellungen",
+ "login-title-form": "Anmeldeformular",
+ "email-field-a11y-label": "E-Mail-Feld. Wert: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/el/strings.puff.json b/assets/text/el/strings.puff.json
new file mode 100644
index 0000000..c57db96
--- /dev/null
+++ b/assets/text/el/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Κλείσιμο",
+ "common-save": "Αποθήκευση",
+ "common-play": "Αναπαραγωγή",
+ "common-resume": "Συνέχιση",
+ "english": "Αγγλικά",
+ "polish": "Πολωνικό",
+ "login-title": "Σύνδεση",
+ "login-password-label": "Κωδικός πρόσβασης",
+ "login-password-error": "Ο κωδικός πρόσβασης πρέπει να αποτελείται από 8 έως και 20 χαρακτήρες.",
+ "login-email-label": "Email",
+ "login-email-error": "Ορίστε μια σωστή διεύθυνση email, π.χ. me@somemail.com",
+ "login-button": "Σύνδεση",
+ "carousel-go-to-details": "Κάντε κλικ για να μεταβείτε στις λεπτομέρειες.",
+ "carousel-more": "Περισσότερα",
+ "carousel-no-title": "Χωρίς τίτλο",
+ "details-screen-play-description-section-a11y-hint": "Περιγραφή ταινίας",
+ "details-screen-no-available": "Αυτό το βίντεο δεν είναι διαθέσιμο στο πρόγραμμά σας.",
+ "profile-title": "Επιλογή προφίλ χρήστη",
+ "profile-add-new": "Προσθήκη νέου προφίλ",
+ "add-profile-title": "Προσθήκη νέου προφίλ",
+ "add-profile-choose-avatar": "Επιλογή άβαταρ",
+ "add-profile-add-name": "Προσθήκη ονόματος",
+ "add-profile-form-name-label": "Όνομα",
+ "add-profile-form-name-error": "Το όνομα πρέπει να αποτελείται από 2 έως και 20 χαρακτήρες.",
+ "add-profile-text-of": "του",
+ "settings": "Ρυθμίσεις",
+ "settings-app-version": "Έκδοση εφαρμογής",
+ "settings-theme": "Θέμα",
+ "settings-profile": "Προφίλ",
+ "settings-language": "Γλώσσα",
+ "settings-current-locale": "Τρέχουσες τοπικές ρυθμίσεις",
+ "settings-change-language": "Αλλαγή γλώσσας",
+ "light": "Ανοιχτό",
+ "dark": "Σκούρο",
+ "settings-log-out": "Αποσύνδεση",
+ "video-player-caption-placeholder-na": "Μη διαθέσιμο",
+ "video-player-no-captions": "Χωρίς υπότιτλους",
+ "video-player-switch-text-track-to": "Εναλλαγή κομματιού κειμένου σε",
+ "loading": "Φόρτωση",
+ "favorites": "Αγαπημένα",
+ "favorites-search-team": "Αναζήτηση ομάδας",
+ "favorites-search-team-input": "Αναζήτηση καταχώρισης",
+ "favorites-sort": "Ταξινόμηση",
+ "favorites-sort-ascending": "Αύξουσα ταξινόμηση ομάδων",
+ "favorites-sort-descending": "Φθίνουσα ταξινόμηση ομάδων",
+ "favorites-sort-favorites": "Ταξινόμηση ομάδων με βάση τα αγαπημένα",
+ "favorites-options-list": "Λίστα αγαπημένων",
+ "progress-bar-a11y-label-indeterminate": "Άγνωστη πρόοδος",
+ "avatar-a11y-label": " άβαταρ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Κάντε κλικ για να συνδεθείτε ως {profileName}.",
+ "a11y-hint-direction-left": "αριστερά",
+ "a11y-hint-direction-right": "δεξιά",
+ "a11y-hint-direction-up": "πάνω",
+ "a11y-hint-direction-down": "κάτω",
+ "a11y-hint-use-direction-select-item": "Χρησιμοποιήστε το κουμπί {direction} για να επιλέξετε το {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Μενού εφαρμογών,",
+ "menu-wrapper-a11y-label-menu-name": "Μενού εφαρμογών",
+ "menu-wrapper-a11y-label-profile-avatar": "Άβαταρ προφίλ",
+ "menu-wrapper-a11y-hint-profile-avatar": "Κάντε κλικ για να μεταβείτε στην οθόνη επιλογής προφίλ.",
+ "close-menu-button-a11y-label": "Κλείσιμο μενού εφαρμογής",
+ "menu-item-use-direction-to-go-to-a11y-label": "Χρησιμοποιήστε το κουμπί {direction} για να επιλέξετε: {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Υπάρχει ένα στοιχείο προς τα {direction}.",
+ "a11y-hint-there-is-an-item-below": "Υπάρχει ένα στοιχείο προς τα κάτω.",
+ "a11y-hint-there-is-an-item-above": "Υπάρχει ένα στοιχείο προς τα πάνω.",
+ "carousel-item-a11y-label": "«{item}» στην ομάδα «{group}».",
+ "carousel-item-without-title-a11y-label": "Ταινία χωρίς τίτλο",
+ "settings-screen-a11y-theme-section-hint": "Επιλογή θέματος",
+ "settings-screen-a11y-label-theme-variant": "{themeName} θέμα",
+ "settings-screen-a11y-label-theme-variant-hint": "Κάντε κλικ για να αλλάξετε το θέμα σε {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Προς τα πάνω είναι μια λίστα ταινιών.",
+ "a11y-hint-there-is-a-movie-list-below": "Προς τα κάτω είναι μια λίστα ταινιών.",
+ "menu-wrapper-item-home-label": "Αρχική",
+ "menu-wrapper-item-settings-label": "Ρυθμίσεις",
+ "login-title-form": "Φόρμα σύνδεσης",
+ "email-field-a11y-label": "Πεδίο email. Τιμή: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/en-AU/strings.puff.json b/assets/text/en-AU/strings.puff.json
new file mode 100644
index 0000000..d4a04f9
--- /dev/null
+++ b/assets/text/en-AU/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Close",
+ "common-save": "Save",
+ "common-play": "Play",
+ "common-resume": "Resume",
+ "english": "English",
+ "polish": "Polish",
+ "login-title": "Log In",
+ "login-password-label": "Password",
+ "login-password-error": "Password must have between 8 and 20 characters",
+ "login-email-label": "E-mail",
+ "login-email-error": "Please set a proper e-mail address, for example me@somemail.com",
+ "login-button": "Log In",
+ "carousel-go-to-details": "Click to go to details.",
+ "carousel-more": "More",
+ "carousel-no-title": "No title",
+ "details-screen-play-description-section-a11y-hint": "Movie description",
+ "details-screen-no-available": "This video is not available in your plan.",
+ "profile-title": "Select User Profile",
+ "profile-add-new": "Add new profile",
+ "add-profile-title": "Add New Profile",
+ "add-profile-choose-avatar": "Select avatar",
+ "add-profile-add-name": "Add name",
+ "add-profile-form-name-label": "Name",
+ "add-profile-form-name-error": "Name must have between 2 and 20 characters",
+ "add-profile-text-of": "of",
+ "settings": "Settings",
+ "settings-app-version": "App version",
+ "settings-theme": "Theme",
+ "settings-profile": "Profile",
+ "settings-language": "Language",
+ "settings-current-locale": "Current locale",
+ "settings-change-language": "Change Language",
+ "light": "Light",
+ "dark": "Dark",
+ "settings-log-out": "Log out",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "No captions",
+ "video-player-switch-text-track-to": "Switch text track to",
+ "loading": "Loading",
+ "favorites": "Favourites",
+ "favorites-search-team": "Search team",
+ "favorites-search-team-input": "Search input",
+ "favorites-sort": "Sort",
+ "favorites-sort-ascending": "Sort teams ascending",
+ "favorites-sort-descending": "Sort teams descending",
+ "favorites-sort-favorites": "Sort teams by favourites",
+ "favorites-options-list": "Favourites list",
+ "progress-bar-a11y-label-indeterminate": "Indeterminate progress",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Click to log in as {profileName}.",
+ "a11y-hint-direction-left": "left",
+ "a11y-hint-direction-right": "right",
+ "a11y-hint-direction-up": "up",
+ "a11y-hint-direction-down": "down",
+ "a11y-hint-use-direction-select-item": "Use {direction} to select {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App menu,",
+ "menu-wrapper-a11y-label-menu-name": "App menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profile avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Click to go to profile selection screen.",
+ "close-menu-button-a11y-label": "Close app menu",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} to select: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "There is an item to the {direction}.",
+ "a11y-hint-there-is-an-item-below": "There is an item below.",
+ "a11y-hint-there-is-an-item-above": "There is an item above.",
+ "carousel-item-a11y-label": "'{item}' in group '{group}'.",
+ "carousel-item-without-title-a11y-label": "Unnamed movie",
+ "settings-screen-a11y-theme-section-hint": "Theme selection",
+ "settings-screen-a11y-label-theme-variant": "{themeName} theme",
+ "settings-screen-a11y-label-theme-variant-hint": "Click to change theme to {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Above is a movie list.",
+ "a11y-hint-there-is-a-movie-list-below": "Below is a movie list.",
+ "menu-wrapper-item-home-label": "Home screen",
+ "menu-wrapper-item-settings-label": "Settings",
+ "login-title-form": "Log-in form",
+ "email-field-a11y-label": "E-mail field. Value: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/en-CA/strings.puff.json b/assets/text/en-CA/strings.puff.json
new file mode 100644
index 0000000..d4a04f9
--- /dev/null
+++ b/assets/text/en-CA/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Close",
+ "common-save": "Save",
+ "common-play": "Play",
+ "common-resume": "Resume",
+ "english": "English",
+ "polish": "Polish",
+ "login-title": "Log In",
+ "login-password-label": "Password",
+ "login-password-error": "Password must have between 8 and 20 characters",
+ "login-email-label": "E-mail",
+ "login-email-error": "Please set a proper e-mail address, for example me@somemail.com",
+ "login-button": "Log In",
+ "carousel-go-to-details": "Click to go to details.",
+ "carousel-more": "More",
+ "carousel-no-title": "No title",
+ "details-screen-play-description-section-a11y-hint": "Movie description",
+ "details-screen-no-available": "This video is not available in your plan.",
+ "profile-title": "Select User Profile",
+ "profile-add-new": "Add new profile",
+ "add-profile-title": "Add New Profile",
+ "add-profile-choose-avatar": "Select avatar",
+ "add-profile-add-name": "Add name",
+ "add-profile-form-name-label": "Name",
+ "add-profile-form-name-error": "Name must have between 2 and 20 characters",
+ "add-profile-text-of": "of",
+ "settings": "Settings",
+ "settings-app-version": "App version",
+ "settings-theme": "Theme",
+ "settings-profile": "Profile",
+ "settings-language": "Language",
+ "settings-current-locale": "Current locale",
+ "settings-change-language": "Change Language",
+ "light": "Light",
+ "dark": "Dark",
+ "settings-log-out": "Log out",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "No captions",
+ "video-player-switch-text-track-to": "Switch text track to",
+ "loading": "Loading",
+ "favorites": "Favourites",
+ "favorites-search-team": "Search team",
+ "favorites-search-team-input": "Search input",
+ "favorites-sort": "Sort",
+ "favorites-sort-ascending": "Sort teams ascending",
+ "favorites-sort-descending": "Sort teams descending",
+ "favorites-sort-favorites": "Sort teams by favourites",
+ "favorites-options-list": "Favourites list",
+ "progress-bar-a11y-label-indeterminate": "Indeterminate progress",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Click to log in as {profileName}.",
+ "a11y-hint-direction-left": "left",
+ "a11y-hint-direction-right": "right",
+ "a11y-hint-direction-up": "up",
+ "a11y-hint-direction-down": "down",
+ "a11y-hint-use-direction-select-item": "Use {direction} to select {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App menu,",
+ "menu-wrapper-a11y-label-menu-name": "App menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profile avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Click to go to profile selection screen.",
+ "close-menu-button-a11y-label": "Close app menu",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} to select: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "There is an item to the {direction}.",
+ "a11y-hint-there-is-an-item-below": "There is an item below.",
+ "a11y-hint-there-is-an-item-above": "There is an item above.",
+ "carousel-item-a11y-label": "'{item}' in group '{group}'.",
+ "carousel-item-without-title-a11y-label": "Unnamed movie",
+ "settings-screen-a11y-theme-section-hint": "Theme selection",
+ "settings-screen-a11y-label-theme-variant": "{themeName} theme",
+ "settings-screen-a11y-label-theme-variant-hint": "Click to change theme to {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Above is a movie list.",
+ "a11y-hint-there-is-a-movie-list-below": "Below is a movie list.",
+ "menu-wrapper-item-home-label": "Home screen",
+ "menu-wrapper-item-settings-label": "Settings",
+ "login-title-form": "Log-in form",
+ "email-field-a11y-label": "E-mail field. Value: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/en-IN/strings.puff.json b/assets/text/en-IN/strings.puff.json
new file mode 100644
index 0000000..50e31b4
--- /dev/null
+++ b/assets/text/en-IN/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Close",
+ "common-save": "Save",
+ "common-play": "Play",
+ "common-resume": "Resume",
+ "english": "English",
+ "polish": "Polish",
+ "login-title": "Log In",
+ "login-password-label": "Password",
+ "login-password-error": "Password must have between 8 and 20 characters",
+ "login-email-label": "E-mail",
+ "login-email-error": "Please set a proper e-mail address, for example me@somemail.com",
+ "login-button": "Log In",
+ "carousel-go-to-details": "Click to go to details.",
+ "carousel-more": "More",
+ "carousel-no-title": "No title",
+ "details-screen-play-description-section-a11y-hint": "Movie description",
+ "details-screen-no-available": "This video is not available in your plan.",
+ "profile-title": "Select User Profile",
+ "profile-add-new": "Add new profile",
+ "add-profile-title": "Add New Profile",
+ "add-profile-choose-avatar": "Select avatar",
+ "add-profile-add-name": "Add name",
+ "add-profile-form-name-label": "Name",
+ "add-profile-form-name-error": "Name must have between 2 and 20 characters",
+ "add-profile-text-of": "of",
+ "settings": "Settings",
+ "settings-app-version": "App version",
+ "settings-theme": "Theme",
+ "settings-profile": "Profile",
+ "settings-language": "Language",
+ "settings-current-locale": "Current locale",
+ "settings-change-language": "Change Language",
+ "light": "Light",
+ "dark": "Dark",
+ "settings-log-out": "Log out",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "No subtitles",
+ "video-player-switch-text-track-to": "Switch text track to",
+ "loading": "Loading",
+ "favorites": "Favourites",
+ "favorites-search-team": "Search team",
+ "favorites-search-team-input": "Search input",
+ "favorites-sort": "Sort",
+ "favorites-sort-ascending": "Sort teams ascending",
+ "favorites-sort-descending": "Sort teams descending",
+ "favorites-sort-favorites": "Sort teams by favourites",
+ "favorites-options-list": "Favourites list",
+ "progress-bar-a11y-label-indeterminate": "Indeterminate progress",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Click to log in as {profileName}.",
+ "a11y-hint-direction-left": "left",
+ "a11y-hint-direction-right": "right",
+ "a11y-hint-direction-up": "up",
+ "a11y-hint-direction-down": "down",
+ "a11y-hint-use-direction-select-item": "Use {direction} to select {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App menu,",
+ "menu-wrapper-a11y-label-menu-name": "App menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profile avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Click to go to profile selection screen.",
+ "close-menu-button-a11y-label": "Close app menu",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} to select: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "There is an item to the {direction}.",
+ "a11y-hint-there-is-an-item-below": "There is an item below.",
+ "a11y-hint-there-is-an-item-above": "There is an item above.",
+ "carousel-item-a11y-label": "'{item}' in group '{group}'.",
+ "carousel-item-without-title-a11y-label": "Unnamed movie",
+ "settings-screen-a11y-theme-section-hint": "Theme selection",
+ "settings-screen-a11y-label-theme-variant": "{themeName} theme",
+ "settings-screen-a11y-label-theme-variant-hint": "Click to change theme to {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Above is a movie list.",
+ "a11y-hint-there-is-a-movie-list-below": "Below is a movie list.",
+ "menu-wrapper-item-home-label": "Home screen",
+ "menu-wrapper-item-settings-label": "Settings",
+ "login-title-form": "Log-in form",
+ "email-field-a11y-label": "E-mail field. Value: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/en-US/strings.puff.json b/assets/text/en-US/strings.puff.json
new file mode 100644
index 0000000..8343781
--- /dev/null
+++ b/assets/text/en-US/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Close",
+ "common-save": "Save",
+ "common-play": "Play",
+ "common-resume": "Resume",
+ "english": "English",
+ "polish": "Polish",
+ "login-title": "Log In",
+ "login-password-label": "Password",
+ "login-password-error": "Password must have at least 8 characters and maximum 20 characters",
+ "login-email-label": "Email",
+ "login-email-error": "Please set a proper email address, e.g me@somemail.com",
+ "login-button": "Log In",
+ "carousel-go-to-details": "Click to go to details.",
+ "carousel-more": "More",
+ "carousel-no-title": "No title",
+ "details-screen-play-description-section-a11y-hint": "Movie description",
+ "details-screen-no-available": "This video is not available in your plan.",
+ "profile-title": "Select User Profile",
+ "profile-add-new": "Add new profile",
+ "add-profile-title": "Add New Profile",
+ "add-profile-choose-avatar": "Select avatar",
+ "add-profile-add-name": "Add name",
+ "add-profile-form-name-label": "Name",
+ "add-profile-form-name-error": "Name should have at least 2 and max 20 characters",
+ "add-profile-text-of": "of",
+ "settings": "Settings",
+ "settings-app-version": "App version",
+ "settings-theme": "Theme",
+ "settings-profile": "Profile",
+ "settings-language": "Language",
+ "settings-current-locale": "Current locale",
+ "settings-change-language": "Change Language",
+ "light": "Light",
+ "dark": "Dark",
+ "settings-log-out": "Log out",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "No captions",
+ "video-player-switch-text-track-to": "Switch text track to",
+ "loading": "Loading",
+ "favorites": "Favorites",
+ "favorites-search-team": "Search Team",
+ "favorites-search-team-input": "Search input",
+ "favorites-sort": "Sort",
+ "favorites-sort-ascending": "Sort teams ascending",
+ "favorites-sort-descending": "Sort teams descending",
+ "favorites-sort-favorites": "Sort teams by favorites",
+ "favorites-options-list": "Favorites list",
+ "progress-bar-a11y-label-indeterminate": "Indeterminate progress",
+ "avatar-a11y-label": "{profileName} avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Click to log in as {profileName}.",
+ "a11y-hint-direction-left": "left",
+ "a11y-hint-direction-right": "right",
+ "a11y-hint-direction-up": "up",
+ "a11y-hint-direction-down": "down",
+ "a11y-hint-use-direction-select-item": "Use {direction} to select {item}.",
+ "menu-wrapper-section-a11-label": "App menu: {menuItem}",
+ "menu-wrapper-a11y-label-menu-name": "App menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profile avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Click to go to profile selection screen.",
+ "close-menu-button-a11y-label": "Close app menu",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} to select: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "There is an item to the {direction}.",
+ "a11y-hint-there-is-an-item-below": "There is an item below.",
+ "a11y-hint-there-is-an-item-above": "There is an item above.",
+ "carousel-item-a11y-label": "'{item}' in group '{group}'.",
+ "carousel-item-without-title-a11y-label": "Unnamed movie",
+ "settings-screen-a11y-theme-section-hint": "Theme selection",
+ "settings-screen-a11y-label-theme-variant": "{themeName} theme",
+ "settings-screen-a11y-label-theme-variant-hint": "Click to change theme to {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Above is a movie list.",
+ "a11y-hint-there-is-a-movie-list-below": "Below is a movie list.",
+ "menu-wrapper-item-home-label": "Home",
+ "menu-wrapper-item-settings-label": "Settings",
+ "login-title-form": "Log in form",
+ "email-field-a11y-label": "Email field. Value: {email}"
+ }
+}
diff --git a/assets/text/en/strings.puff.json b/assets/text/en/strings.puff.json
new file mode 100644
index 0000000..6380cf9
--- /dev/null
+++ b/assets/text/en/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Close",
+ "common-save": "Save",
+ "common-play": "Play",
+ "common-resume": "Resume",
+ "english": "English",
+ "polish": "Polish",
+ "login-title": "Log In",
+ "login-password-label": "Password",
+ "login-password-error": "Password must have between 8 and 20 characters",
+ "login-email-label": "E-mail",
+ "login-email-error": "Please set a proper e-mail address, for example me@somemail.com",
+ "login-button": "Log In",
+ "carousel-go-to-details": "Click to go to details.",
+ "carousel-more": "More",
+ "carousel-no-title": "No title",
+ "details-screen-play-description-section-a11y-hint": "Film description",
+ "details-screen-no-available": "This video is not available in your plan.",
+ "profile-title": "Select User Profile",
+ "profile-add-new": "Add new profile",
+ "add-profile-title": "Add New Profile",
+ "add-profile-choose-avatar": "Select avatar",
+ "add-profile-add-name": "Add name",
+ "add-profile-form-name-label": "Name",
+ "add-profile-form-name-error": "Name must have between 2 and 20 characters",
+ "add-profile-text-of": "of",
+ "settings": "Settings",
+ "settings-app-version": "App version",
+ "settings-theme": "Theme",
+ "settings-profile": "Profile",
+ "settings-language": "Language",
+ "settings-current-locale": "Current locale",
+ "settings-change-language": "Change Language",
+ "light": "Light",
+ "dark": "Dark",
+ "settings-log-out": "Log out",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "No subtitles",
+ "video-player-switch-text-track-to": "Switch text track to",
+ "loading": "Loading",
+ "favorites": "Favourites",
+ "favorites-search-team": "Search team",
+ "favorites-search-team-input": "Search input",
+ "favorites-sort": "Sort",
+ "favorites-sort-ascending": "Sort teams ascending",
+ "favorites-sort-descending": "Sort teams descending",
+ "favorites-sort-favorites": "Sort teams by favourites",
+ "favorites-options-list": "Favourites list",
+ "progress-bar-a11y-label-indeterminate": "Indeterminate progress",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Click to log in as {profileName}.",
+ "a11y-hint-direction-left": "left",
+ "a11y-hint-direction-right": "right",
+ "a11y-hint-direction-up": "up",
+ "a11y-hint-direction-down": "down",
+ "a11y-hint-use-direction-select-item": "Use {direction} to select {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App menu,",
+ "menu-wrapper-a11y-label-menu-name": "App menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profile avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Click to go to profile selection screen.",
+ "close-menu-button-a11y-label": "Close app menu",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} to select: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "There is an item to the {direction}.",
+ "a11y-hint-there-is-an-item-below": "There is an item below.",
+ "a11y-hint-there-is-an-item-above": "There is an item above.",
+ "carousel-item-a11y-label": "'{item}' in group '{group}'.",
+ "carousel-item-without-title-a11y-label": "Unnamed film",
+ "settings-screen-a11y-theme-section-hint": "Theme selection",
+ "settings-screen-a11y-label-theme-variant": "{themeName} theme",
+ "settings-screen-a11y-label-theme-variant-hint": "Click to change theme to {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Above is a film list.",
+ "a11y-hint-there-is-a-movie-list-below": "Below is a film list.",
+ "menu-wrapper-item-home-label": "Home screen",
+ "menu-wrapper-item-settings-label": "Settings",
+ "login-title-form": "Log-in form",
+ "email-field-a11y-label": "E-mail field. Value: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/es-MX/strings.puff.json b/assets/text/es-MX/strings.puff.json
new file mode 100644
index 0000000..39e5cf6
--- /dev/null
+++ b/assets/text/es-MX/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Cerrar",
+ "common-save": "Guardar",
+ "common-play": "Reproducir",
+ "common-resume": "Continuar",
+ "english": "Inglés",
+ "polish": "polaco",
+ "login-title": "Iniciar sesión",
+ "login-password-label": "Contraseña",
+ "login-password-error": "La contraseña debe tener al menos 8 y un máximo 20 caracteres",
+ "login-email-label": "E-mail",
+ "login-email-error": "Configura una dirección de e-mail válida, por ejemplo, me@somemail.com",
+ "login-button": "Iniciar sesión",
+ "carousel-go-to-details": "Haz clic para ir a los detalles.",
+ "carousel-more": "Más",
+ "carousel-no-title": "Sin título",
+ "details-screen-play-description-section-a11y-hint": "Descripción de la película",
+ "details-screen-no-available": "Este video no está disponible en tu plan.",
+ "profile-title": "Seleccionar perfil de usuario",
+ "profile-add-new": "Agregar nuevo perfil",
+ "add-profile-title": "Agregar nuevo perfil",
+ "add-profile-choose-avatar": "Seleccionar avatar",
+ "add-profile-add-name": "Agregar nombre",
+ "add-profile-form-name-label": "Nombre",
+ "add-profile-form-name-error": "El nombre debe tener al menos 2 y un máximo de 20 caracteres",
+ "add-profile-text-of": "de",
+ "settings": "Configuración",
+ "settings-app-version": "Versión de la app",
+ "settings-theme": "Tema",
+ "settings-profile": "Perfil",
+ "settings-language": "Idioma",
+ "settings-current-locale": "Región actual",
+ "settings-change-language": "Cambiar idioma",
+ "light": "Luz",
+ "dark": "Oscuro",
+ "settings-log-out": "Cerrar sesión",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Sin subtítulos",
+ "video-player-switch-text-track-to": "Cambiar pista de texto a",
+ "loading": "Cargando",
+ "favorites": "Favoritos",
+ "favorites-search-team": "Equipo de búsqueda",
+ "favorites-search-team-input": "Entrada de búsqueda",
+ "favorites-sort": "Ordenar",
+ "favorites-sort-ascending": "Ordenar equipos de manera ascendente",
+ "favorites-sort-descending": "Ordenar equipos de manera descendente",
+ "favorites-sort-favorites": "Ordenar equipos por favoritos",
+ "favorites-options-list": "Lista de favoritos",
+ "progress-bar-a11y-label-indeterminate": "Progreso no determinado",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Haz clic para iniciar sesión como {profileName}.",
+ "a11y-hint-direction-left": "izquierda",
+ "a11y-hint-direction-right": "derecha",
+ "a11y-hint-direction-up": "hacia arriba",
+ "a11y-hint-direction-down": "hacia abajo",
+ "a11y-hint-use-direction-select-item": "Usa {direction} para seleccionar {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menú de la app,",
+ "menu-wrapper-a11y-label-menu-name": "Menú de la app",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de perfil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Haz clic para ir a la pantalla de selección de perfil.",
+ "close-menu-button-a11y-label": "Cerrar el menú de la app",
+ "menu-item-use-direction-to-go-to-a11y-label": "Usa {direction} para seleccionar: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Hay un elemento a la {direction}.",
+ "a11y-hint-there-is-an-item-below": "Hay un elemento abajo.",
+ "a11y-hint-there-is-an-item-above": "Hay un elemento arriba.",
+ "carousel-item-a11y-label": "'{item}' en el grupo '{group}'.",
+ "carousel-item-without-title-a11y-label": "Película sin nombre",
+ "settings-screen-a11y-theme-section-hint": "Selección de tema",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Haz clic para cambiar el tema a {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Arriba hay una lista de películas.",
+ "a11y-hint-there-is-a-movie-list-below": "Abajo hay una lista de películas.",
+ "menu-wrapper-item-home-label": "Inicio",
+ "menu-wrapper-item-settings-label": "Configuración",
+ "login-title-form": "Formulario de inicio de sesión",
+ "email-field-a11y-label": "Campo de e-mail. Valor: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/es-US/strings.puff.json b/assets/text/es-US/strings.puff.json
new file mode 100644
index 0000000..39e5cf6
--- /dev/null
+++ b/assets/text/es-US/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Cerrar",
+ "common-save": "Guardar",
+ "common-play": "Reproducir",
+ "common-resume": "Continuar",
+ "english": "Inglés",
+ "polish": "polaco",
+ "login-title": "Iniciar sesión",
+ "login-password-label": "Contraseña",
+ "login-password-error": "La contraseña debe tener al menos 8 y un máximo 20 caracteres",
+ "login-email-label": "E-mail",
+ "login-email-error": "Configura una dirección de e-mail válida, por ejemplo, me@somemail.com",
+ "login-button": "Iniciar sesión",
+ "carousel-go-to-details": "Haz clic para ir a los detalles.",
+ "carousel-more": "Más",
+ "carousel-no-title": "Sin título",
+ "details-screen-play-description-section-a11y-hint": "Descripción de la película",
+ "details-screen-no-available": "Este video no está disponible en tu plan.",
+ "profile-title": "Seleccionar perfil de usuario",
+ "profile-add-new": "Agregar nuevo perfil",
+ "add-profile-title": "Agregar nuevo perfil",
+ "add-profile-choose-avatar": "Seleccionar avatar",
+ "add-profile-add-name": "Agregar nombre",
+ "add-profile-form-name-label": "Nombre",
+ "add-profile-form-name-error": "El nombre debe tener al menos 2 y un máximo de 20 caracteres",
+ "add-profile-text-of": "de",
+ "settings": "Configuración",
+ "settings-app-version": "Versión de la app",
+ "settings-theme": "Tema",
+ "settings-profile": "Perfil",
+ "settings-language": "Idioma",
+ "settings-current-locale": "Región actual",
+ "settings-change-language": "Cambiar idioma",
+ "light": "Luz",
+ "dark": "Oscuro",
+ "settings-log-out": "Cerrar sesión",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Sin subtítulos",
+ "video-player-switch-text-track-to": "Cambiar pista de texto a",
+ "loading": "Cargando",
+ "favorites": "Favoritos",
+ "favorites-search-team": "Equipo de búsqueda",
+ "favorites-search-team-input": "Entrada de búsqueda",
+ "favorites-sort": "Ordenar",
+ "favorites-sort-ascending": "Ordenar equipos de manera ascendente",
+ "favorites-sort-descending": "Ordenar equipos de manera descendente",
+ "favorites-sort-favorites": "Ordenar equipos por favoritos",
+ "favorites-options-list": "Lista de favoritos",
+ "progress-bar-a11y-label-indeterminate": "Progreso no determinado",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Haz clic para iniciar sesión como {profileName}.",
+ "a11y-hint-direction-left": "izquierda",
+ "a11y-hint-direction-right": "derecha",
+ "a11y-hint-direction-up": "hacia arriba",
+ "a11y-hint-direction-down": "hacia abajo",
+ "a11y-hint-use-direction-select-item": "Usa {direction} para seleccionar {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menú de la app,",
+ "menu-wrapper-a11y-label-menu-name": "Menú de la app",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de perfil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Haz clic para ir a la pantalla de selección de perfil.",
+ "close-menu-button-a11y-label": "Cerrar el menú de la app",
+ "menu-item-use-direction-to-go-to-a11y-label": "Usa {direction} para seleccionar: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Hay un elemento a la {direction}.",
+ "a11y-hint-there-is-an-item-below": "Hay un elemento abajo.",
+ "a11y-hint-there-is-an-item-above": "Hay un elemento arriba.",
+ "carousel-item-a11y-label": "'{item}' en el grupo '{group}'.",
+ "carousel-item-without-title-a11y-label": "Película sin nombre",
+ "settings-screen-a11y-theme-section-hint": "Selección de tema",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Haz clic para cambiar el tema a {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Arriba hay una lista de películas.",
+ "a11y-hint-there-is-a-movie-list-below": "Abajo hay una lista de películas.",
+ "menu-wrapper-item-home-label": "Inicio",
+ "menu-wrapper-item-settings-label": "Configuración",
+ "login-title-form": "Formulario de inicio de sesión",
+ "email-field-a11y-label": "Campo de e-mail. Valor: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/es/strings.puff.json b/assets/text/es/strings.puff.json
new file mode 100644
index 0000000..e932c53
--- /dev/null
+++ b/assets/text/es/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Cerrar",
+ "common-save": "Guardar",
+ "common-play": "Reproducir",
+ "common-resume": "Reanudar",
+ "english": "Inglés",
+ "polish": "polaco",
+ "login-title": "Iniciar sesión",
+ "login-password-label": "Contraseña",
+ "login-password-error": "La contraseña debe tener un mínimo de 8 caracteres y un máximo de 20",
+ "login-email-label": "E-mail",
+ "login-email-error": "Introduce una dirección de e-mail válida, por ejemplo, yo@e-mail.com",
+ "login-button": "Iniciar sesión",
+ "carousel-go-to-details": "Pulsa aquí para ir a los detalles.",
+ "carousel-more": "Más",
+ "carousel-no-title": "Sin título",
+ "details-screen-play-description-section-a11y-hint": "Descripción de la película",
+ "details-screen-no-available": "Este vídeo no está disponible con tu plan.",
+ "profile-title": "Seleccionar perfil de usuario",
+ "profile-add-new": "Añadir nuevo perfil",
+ "add-profile-title": "Añadir nuevo perfil",
+ "add-profile-choose-avatar": "Seleccionar avatar",
+ "add-profile-add-name": "Añadir nombre",
+ "add-profile-form-name-label": "Nombre",
+ "add-profile-form-name-error": "El nombre debe tener un mínimo de 2 caracteres y un máximo de 20",
+ "add-profile-text-of": "de",
+ "settings": "Configuración",
+ "settings-app-version": "Versión de la app",
+ "settings-theme": "Tema",
+ "settings-profile": "Perfil",
+ "settings-language": "Idioma",
+ "settings-current-locale": "Región actual",
+ "settings-change-language": "Cambiar idioma",
+ "light": "Claro",
+ "dark": "Oscuro",
+ "settings-log-out": "Cerrar sesión",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Sin subtítulos",
+ "video-player-switch-text-track-to": "Cambiar la pista de texto a",
+ "loading": "Cargando",
+ "favorites": "Favoritos",
+ "favorites-search-team": "Buscar equipo",
+ "favorites-search-team-input": "Entrada de búsqueda",
+ "favorites-sort": "Ordenar",
+ "favorites-sort-ascending": "Ordenar equipos en orden ascendente",
+ "favorites-sort-descending": "Ordenar equipos en orden descendente",
+ "favorites-sort-favorites": "Ordenar equipos por favoritos",
+ "favorites-options-list": "Lista de favoritos",
+ "progress-bar-a11y-label-indeterminate": "Progreso indeterminado",
+ "avatar-a11y-label": "Avatar de ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Pulsa aquí para iniciar sesión como {profileName}.",
+ "a11y-hint-direction-left": "Izquierda",
+ "a11y-hint-direction-right": "Derecha",
+ "a11y-hint-direction-up": "hacia arriba",
+ "a11y-hint-direction-down": "hacia abajo",
+ "a11y-hint-use-direction-select-item": "Pulsa el botón {direction} para seleccionar {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menú de la app,",
+ "menu-wrapper-a11y-label-menu-name": "Menú de la app",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de perfil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Pulsa aquí para ir a la pantalla de selección del perfil.",
+ "close-menu-button-a11y-label": "Cerrar menú de la app",
+ "menu-item-use-direction-to-go-to-a11y-label": "Pulsa el botón {direction} para seleccionar {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Hay un elemento a la {direction}.",
+ "a11y-hint-there-is-an-item-below": "Hay un elemento debajo.",
+ "a11y-hint-there-is-an-item-above": "Hay un elemento arriba.",
+ "carousel-item-a11y-label": "\"{item}\" en el grupo \"{group}\".",
+ "carousel-item-without-title-a11y-label": "Película sin título",
+ "settings-screen-a11y-theme-section-hint": "Selección del tema",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Pulsa aquí para cambiar el tema a {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Arriba hay una lista de películas.",
+ "a11y-hint-there-is-a-movie-list-below": "Debajo hay una lista de películas.",
+ "menu-wrapper-item-home-label": "Inicio",
+ "menu-wrapper-item-settings-label": "Configuración",
+ "login-title-form": "Formulario de inicio de sesión",
+ "email-field-a11y-label": "Campo de e-mail. Valor: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/fi/strings.puff.json b/assets/text/fi/strings.puff.json
new file mode 100644
index 0000000..9bd935b
--- /dev/null
+++ b/assets/text/fi/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Sulje",
+ "common-save": "Tallenna",
+ "common-play": "Toista",
+ "common-resume": "Jatka",
+ "english": "Englanti",
+ "polish": "Puolalainen",
+ "login-title": "Kirjaudu sisään",
+ "login-password-label": "Salasana",
+ "login-password-error": "Salasanassa on oltava 8–20 merkkiä",
+ "login-email-label": "Sähköposti",
+ "login-email-error": "Anna kelvollinen sähköpostiosoite, esim. oma@jokuposti.fi",
+ "login-button": "Kirjaudu sisään",
+ "carousel-go-to-details": "Siirry tietoihin napsauttamalla.",
+ "carousel-more": "Lisää",
+ "carousel-no-title": "Ei nimeä",
+ "details-screen-play-description-section-a11y-hint": "Elokuvan kuvaus",
+ "details-screen-no-available": "Tämä video ei ole saatavilla tilauksellasi.",
+ "profile-title": "Valitse käyttäjän profiili",
+ "profile-add-new": "Lisää uusi profiili",
+ "add-profile-title": "Lisää uusi profiili",
+ "add-profile-choose-avatar": "Valitse avatar",
+ "add-profile-add-name": "Lisää nimi",
+ "add-profile-form-name-label": "Nimi",
+ "add-profile-form-name-error": "Nimessä on oltava 2–20 merkkiä",
+ "add-profile-text-of": "/",
+ "settings": "Asetukset",
+ "settings-app-version": "Sovellusversio",
+ "settings-theme": "Teema",
+ "settings-profile": "Profiili",
+ "settings-language": "Kieli",
+ "settings-current-locale": "Tämänhetkinen sijainti",
+ "settings-change-language": "Vaihda kieltä",
+ "light": "Vaalea",
+ "dark": "Tumma",
+ "settings-log-out": "Kirjaudu ulos",
+ "video-player-caption-placeholder-na": "Ei saatavilla",
+ "video-player-no-captions": "Ei ohjelmatekstitystä",
+ "video-player-switch-text-track-to": "Vaihda tekstitykseksi",
+ "loading": "Ladataan",
+ "favorites": "Suosikit",
+ "favorites-search-team": "Hae joukkuetta",
+ "favorites-search-team-input": "Hakusyöte",
+ "favorites-sort": "Lajittele",
+ "favorites-sort-ascending": "Lajittele joukkueet nousevasti",
+ "favorites-sort-descending": "Lajittele joukkueet laskevasti",
+ "favorites-sort-favorites": "Lajittele joukkueet suosikkien mukaan",
+ "favorites-options-list": "Suosikit-luettelo",
+ "progress-bar-a11y-label-indeterminate": "Määrittelemätön edistyminen",
+ "avatar-a11y-label": " -avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Napsauta kirjautuaksesi sisään profiililla {profileName}.",
+ "a11y-hint-direction-left": "vasemmalle",
+ "a11y-hint-direction-right": "oikealle",
+ "a11y-hint-direction-up": "ylös",
+ "a11y-hint-direction-down": "alas",
+ "a11y-hint-use-direction-select-item": "Valitse {direction}-painikkeella {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Sovellusvalikko,",
+ "menu-wrapper-a11y-label-menu-name": "Sovellusvalikko",
+ "menu-wrapper-a11y-label-profile-avatar": "Profiilin avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Siirry profiilin valintanäyttöön napsauttamalla.",
+ "close-menu-button-a11y-label": "Sulje sovellusvalikko",
+ "menu-item-use-direction-to-go-to-a11y-label": "Valitse {direction}-painikkeella {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction} on kohde.",
+ "a11y-hint-there-is-an-item-below": "Alla on kohde.",
+ "a11y-hint-there-is-an-item-above": "Yllä on kohde.",
+ "carousel-item-a11y-label": "{item} ryhmässä {group}.",
+ "carousel-item-without-title-a11y-label": "Nimeämätön elokuva",
+ "settings-screen-a11y-theme-section-hint": "Teeman valinta",
+ "settings-screen-a11y-label-theme-variant": "{themeName} teema",
+ "settings-screen-a11y-label-theme-variant-hint": "Napsauta vaihtaaksesi teemaan {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Yllä on elokuvaluettelo.",
+ "a11y-hint-there-is-a-movie-list-below": "Alla on elokuvaluettelo.",
+ "menu-wrapper-item-home-label": "Aloitus",
+ "menu-wrapper-item-settings-label": "Asetukset",
+ "login-title-form": "Kirjautumislomake",
+ "email-field-a11y-label": "Sähköpostikenttä. Arvo: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/fr-CA/strings.puff.json b/assets/text/fr-CA/strings.puff.json
new file mode 100644
index 0000000..44e7eea
--- /dev/null
+++ b/assets/text/fr-CA/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Fermer",
+ "common-save": "Enregistrer",
+ "common-play": "Lecture",
+ "common-resume": "Reprendre",
+ "english": "Anglais",
+ "polish": "polonais",
+ "login-title": "Se connecter",
+ "login-password-label": "Mot de passe",
+ "login-password-error": "Le mot de passe doit comporter au moins 8 caractères et au maximum 20 caractères",
+ "login-email-label": "Courriel",
+ "login-email-error": "Veuillez définir une adresse courriel appropriée, p. ex., me@somemail.com",
+ "login-button": "Se connecter",
+ "carousel-go-to-details": "Cliquez pour accéder aux détails.",
+ "carousel-more": "Plus",
+ "carousel-no-title": "Sans titre",
+ "details-screen-play-description-section-a11y-hint": "Description du film",
+ "details-screen-no-available": "Cette vidéo n'est pas disponible en votre forfait.",
+ "profile-title": "Sélectionner un profil utilisateur",
+ "profile-add-new": "Ajouter un nouveau profil",
+ "add-profile-title": "Ajouter un nouveau profil",
+ "add-profile-choose-avatar": "Sélectionnez un avatar",
+ "add-profile-add-name": "Ajouter un nom",
+ "add-profile-form-name-label": "Nom",
+ "add-profile-form-name-error": "Le nom doit comporter au moins 2 caractères et au plus 20 caractères",
+ "add-profile-text-of": "sur",
+ "settings": "Paramètres",
+ "settings-app-version": "Version de l\\'application",
+ "settings-theme": "Thème",
+ "settings-profile": "Profil",
+ "settings-language": "Langue",
+ "settings-current-locale": "Région actuelle",
+ "settings-change-language": "Changer la langue",
+ "light": "Lumière",
+ "dark": "Sombre",
+ "settings-log-out": "Se déconnecter",
+ "video-player-caption-placeholder-na": "Non applicable",
+ "video-player-no-captions": "Pas de sous-titres",
+ "video-player-switch-text-track-to": "Passer à la ligne de texte",
+ "loading": "Chargement",
+ "favorites": "Favoris",
+ "favorites-search-team": "Équipe de recherche",
+ "favorites-search-team-input": "Entrée de recherche",
+ "favorites-sort": "Trier",
+ "favorites-sort-ascending": "Trier les équipes par ordre croissant",
+ "favorites-sort-descending": "Trier les équipes par ordre décroissant",
+ "favorites-sort-favorites": "Trier les équipes par favoris",
+ "favorites-options-list": "Menu des favoris",
+ "progress-bar-a11y-label-indeterminate": "Progrès indéterminés",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Cliquez pour vous connecter en tant que {profileName}.",
+ "a11y-hint-direction-left": "gauche",
+ "a11y-hint-direction-right": "droite",
+ "a11y-hint-direction-up": "haut",
+ "a11y-hint-direction-down": "bas",
+ "a11y-hint-use-direction-select-item": "Utilisez {direction} pour sélectionner {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menu de l'application,",
+ "menu-wrapper-a11y-label-menu-name": "Menu de l'application",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de profil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Cliquez ici pour accéder à l'écran de sélection de profil.",
+ "close-menu-button-a11y-label": "Fermer le menu de l'application",
+ "menu-item-use-direction-to-go-to-a11y-label": "Utilisez {direction} pour sélectionner : {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Il y a un élément à {direction}.",
+ "a11y-hint-there-is-an-item-below": "Il y a un élément ci-dessous.",
+ "a11y-hint-there-is-an-item-above": "Il y a un élément ci-dessus.",
+ "carousel-item-a11y-label": "« {item} » dans le groupe « {group} ».",
+ "carousel-item-without-title-a11y-label": "Groupe sans nom",
+ "settings-screen-a11y-theme-section-hint": "Sélection de thèmes",
+ "settings-screen-a11y-label-theme-variant": "{themeName} thème",
+ "settings-screen-a11y-label-theme-variant-hint": "Cliquez pour changer le thème pour {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Vous trouverez ci-haut une liste de films.",
+ "a11y-hint-there-is-a-movie-list-below": "Vous trouverez ci-dessous une liste de films.",
+ "menu-wrapper-item-home-label": "Accueil",
+ "menu-wrapper-item-settings-label": "Paramètres",
+ "login-title-form": "Formulaire de connexion",
+ "email-field-a11y-label": "Valeur du champ de courriel : {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/fr/strings.puff.json b/assets/text/fr/strings.puff.json
new file mode 100644
index 0000000..7151329
--- /dev/null
+++ b/assets/text/fr/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Fermer",
+ "common-save": "Enregistrer",
+ "common-play": "Lecture",
+ "common-resume": "Reprendre",
+ "english": "Anglais",
+ "polish": "polonais",
+ "login-title": "Se connecter",
+ "login-password-label": "Mot de passe",
+ "login-password-error": "Le mot de passe doit comporter entre 8 et 20 caractères",
+ "login-email-label": "Adresse e-mail",
+ "login-email-error": "Veuillez définir une adresse e-mail adéquate, par exemple, moi@somemail.com",
+ "login-button": "Se connecter",
+ "carousel-go-to-details": "Cliquez pour vous rendre sur les détails.",
+ "carousel-more": "Plus",
+ "carousel-no-title": "Aucun titre",
+ "details-screen-play-description-section-a11y-hint": "Description du fil",
+ "details-screen-no-available": "Cette vidéo n'est pas disponible dans votre abonnement.",
+ "profile-title": "Sélectionner un profil utilisateur",
+ "profile-add-new": "Ajouter un nouveau profil",
+ "add-profile-title": "Ajouter un nouveau profil",
+ "add-profile-choose-avatar": "Sélectionner un avatar",
+ "add-profile-add-name": "Ajouter un nom",
+ "add-profile-form-name-label": "Nom",
+ "add-profile-form-name-error": "Le nom doit comporter entre 2 et 20 caractères",
+ "add-profile-text-of": "sur",
+ "settings": "Paramètres",
+ "settings-app-version": "Version de l'application",
+ "settings-theme": "Thème",
+ "settings-profile": "Profil",
+ "settings-language": "Langue",
+ "settings-current-locale": "Endroit actuel",
+ "settings-change-language": "Changer de langue",
+ "light": "Lumière",
+ "dark": "Sombre",
+ "settings-log-out": "Se déconnecter",
+ "video-player-caption-placeholder-na": "Non applicable",
+ "video-player-no-captions": "Pas de sous-titres",
+ "video-player-switch-text-track-to": "Faire basculer le suivi de texte sur",
+ "loading": "Chargement",
+ "favorites": "Favoris",
+ "favorites-search-team": "Équipe de recherche",
+ "favorites-search-team-input": "Saisie de la recherche",
+ "favorites-sort": "Trier",
+ "favorites-sort-ascending": "Trier les équipes par ordre croissant",
+ "favorites-sort-descending": "Trier les équipes par ordre décroissant",
+ "favorites-sort-favorites": "Trier les équipes par favoris",
+ "favorites-options-list": "Liste des favoris",
+ "progress-bar-a11y-label-indeterminate": "Progression indéterminée",
+ "avatar-a11y-label": "avatar ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Cliquez pour vous connecter en tant que {profileName}.",
+ "a11y-hint-direction-left": "gauche",
+ "a11y-hint-direction-right": "droit",
+ "a11y-hint-direction-up": "vers le haut",
+ "a11y-hint-direction-down": "vers le bas",
+ "a11y-hint-use-direction-select-item": "Utilisez la {direction} pour sélectionner {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menu des applications,",
+ "menu-wrapper-a11y-label-menu-name": "Menu des applications",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de profil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Cliquez pour vous rendre sur l'écran de sélection du profil.",
+ "close-menu-button-a11y-label": "Fermer le menu des applications",
+ "menu-item-use-direction-to-go-to-a11y-label": "Utilisez la {direction} pour sélectionner {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Il y a un élément à {direction}.",
+ "a11y-hint-there-is-an-item-below": "Il y a un élément ci-dessous.",
+ "a11y-hint-there-is-an-item-above": "Il y a un élément ci-dessus.",
+ "carousel-item-a11y-label": "« {item} » dans le groupe « {group} ».",
+ "carousel-item-without-title-a11y-label": "Film sans nom",
+ "settings-screen-a11y-theme-section-hint": "Sélection du thème",
+ "settings-screen-a11y-label-theme-variant": "Thème {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Cliquez pour changer le thème pour {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Vous trouverez ci-dessus une liste de films.",
+ "a11y-hint-there-is-a-movie-list-below": "Vous trouverez ci-dessous une liste de films.",
+ "menu-wrapper-item-home-label": "Accueil",
+ "menu-wrapper-item-settings-label": "Paramètres",
+ "login-title-form": "Formulaire de connexion",
+ "email-field-a11y-label": "Champ d'e-mail. Valeur : {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/hi-IN/strings.puff.json b/assets/text/hi-IN/strings.puff.json
new file mode 100644
index 0000000..1b90f97
--- /dev/null
+++ b/assets/text/hi-IN/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "बंद करें",
+ "common-save": "सेव करें",
+ "common-play": "चलाएं",
+ "common-resume": "फिर से चालू करें",
+ "english": "अंग्रेज़ी",
+ "polish": "पोलिश",
+ "login-title": "लॉगिन करें",
+ "login-password-label": "पासवर्ड",
+ "login-password-error": "पासवर्ड में कम से कम 8 और ज़्यादा से ज़्यादा 20 कैरेक्टर होने चाहिए",
+ "login-email-label": "ईमेल",
+ "login-email-error": "कृपया एक सही ईमेल पता सेट करें, जैसे कि me@somemail.com",
+ "login-button": "लॉगिन करें",
+ "carousel-go-to-details": "जानकारी पर जाने के लिए क्लिक करें.",
+ "carousel-more": "अधिक",
+ "carousel-no-title": "कोई टाइटल नहीं है",
+ "details-screen-play-description-section-a11y-hint": "फ़िल्म की जानकारी",
+ "details-screen-no-available": "यह वीडियो आपके प्लान में उपलब्ध नहीं है.",
+ "profile-title": "यूज़र प्रोफ़ाइल चुनें",
+ "profile-add-new": "नयी प्रोफ़ाइल जोड़ें",
+ "add-profile-title": "नयी प्रोफ़ाइल जोड़ें",
+ "add-profile-choose-avatar": "अवतार चुनें",
+ "add-profile-add-name": "नाम ऐड करें",
+ "add-profile-form-name-label": "नाम",
+ "add-profile-form-name-error": "नाम में कम से कम 2 और ज़्यादा से ज़्यादा 20 कैरेक्टर होने चाहिए",
+ "add-profile-text-of": "में से",
+ "settings": "सेटिंग",
+ "settings-app-version": "ऐप का वर्ज़न",
+ "settings-theme": "थीम",
+ "settings-profile": "प्रोफ़ाइल",
+ "settings-language": "भाषा",
+ "settings-current-locale": "मौजूदा लोकेल",
+ "settings-change-language": "भाषा बदलें",
+ "light": "लाइट",
+ "dark": "डार्क",
+ "settings-log-out": "लॉग आउट करें",
+ "video-player-caption-placeholder-na": "लागू नहीं",
+ "video-player-no-captions": "सबटाइटल नहीं है",
+ "video-player-switch-text-track-to": "टेक्स्ट ट्रैक को इस पर स्विच करें",
+ "loading": "लोड हो रहा है",
+ "favorites": "पसंदीदा",
+ "favorites-search-team": "टीम खोजें",
+ "favorites-search-team-input": "खोजने के लिए इनपुट",
+ "favorites-sort": "क्रम में लगाएं",
+ "favorites-sort-ascending": "टीमों को बढ़ते क्रम में लगाएं",
+ "favorites-sort-descending": "टीमों को घटते क्रम में लगाएं",
+ "favorites-sort-favorites": "टीमों को पसंदीदा क्रम में लगाएं",
+ "favorites-options-list": "पसंदीदा की लिस्ट",
+ "progress-bar-a11y-label-indeterminate": "प्रोग्रेस की स्थिति की स्पष्ट जानकारी नहीं है",
+ "avatar-a11y-label": " अवतार",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "{profileName} के रूप में लॉगिन करने के लिए क्लिक करें.",
+ "a11y-hint-direction-left": "बायें",
+ "a11y-hint-direction-right": "दायें",
+ "a11y-hint-direction-up": "ऊपर",
+ "a11y-hint-direction-down": "नीचे",
+ "a11y-hint-use-direction-select-item": "{item} को चुनने के लिए {direction} का इस्तेमाल करें.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "ऐप मेन्यू,",
+ "menu-wrapper-a11y-label-menu-name": "ऐप मेन्यू",
+ "menu-wrapper-a11y-label-profile-avatar": "प्रोफ़ाइल अवतार",
+ "menu-wrapper-a11y-hint-profile-avatar": "प्रोफ़ाइल चुनने की स्क्रीन पर जाने के लिए क्लिक करें.",
+ "close-menu-button-a11y-label": "ऐप मेन्यू बंद करें",
+ "menu-item-use-direction-to-go-to-a11y-label": "इसे चुनने के लिए {direction} का इस्तेमाल करें: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction} साइड में एक आइटम है.",
+ "a11y-hint-there-is-an-item-below": "नीचे एक आइटम है.",
+ "a11y-hint-there-is-an-item-above": "ऊपर एक आइटम है.",
+ "carousel-item-a11y-label": "'{group}' ग्रुप में '{item}'.",
+ "carousel-item-without-title-a11y-label": "बिना नाम की फ़िल्म",
+ "settings-screen-a11y-theme-section-hint": "थीम चुनना",
+ "settings-screen-a11y-label-theme-variant": "{themeName} थीम",
+ "settings-screen-a11y-label-theme-variant-hint": "थीम को {themeName} में बदलने के लिए क्लिक करें.",
+ "a11y-hint-there-is-a-movie-list-above": "ऊपर फ़िल्मों की लिस्ट दी गई है.",
+ "a11y-hint-there-is-a-movie-list-below": "नीचे फ़िल्मों की लिस्ट दी गई है.",
+ "menu-wrapper-item-home-label": "होम स्क्रीन",
+ "menu-wrapper-item-settings-label": "सेटिंग",
+ "login-title-form": "लॉगिन फ़ॉर्म",
+ "email-field-a11y-label": "ईमेल फ़ील्ड. वैल्यू: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/hr/strings.puff.json b/assets/text/hr/strings.puff.json
new file mode 100644
index 0000000..56a571c
--- /dev/null
+++ b/assets/text/hr/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Zatvori",
+ "common-save": "Spremi",
+ "common-play": "Reprodukuj",
+ "common-resume": "Nastavi",
+ "english": "Engleski",
+ "polish": "poljski",
+ "login-title": "Prijava",
+ "login-password-label": "Lozinka",
+ "login-password-error": "Lozinka treba imati najmanje 8 znakova i najviše 20 znakova",
+ "login-email-label": "E-pošta",
+ "login-email-error": "Molimo postavite odgovarajuću adresu e-pošte, na primjer me@somemail.com",
+ "login-button": "Prijava",
+ "carousel-go-to-details": "Kliknite da biste vidjeli detalje.",
+ "carousel-more": "Više",
+ "carousel-no-title": "Bez naslova",
+ "details-screen-play-description-section-a11y-hint": "Opis filma",
+ "details-screen-no-available": "Ovaj videozapis nije dostupan u vašem planu.",
+ "profile-title": "Odaberite korisnički profil",
+ "profile-add-new": "Dodavanje novog profila",
+ "add-profile-title": "Dodavanje novog profila",
+ "add-profile-choose-avatar": "Odaberi avatar",
+ "add-profile-add-name": "Dodaj naziv",
+ "add-profile-form-name-label": "Ime",
+ "add-profile-form-name-error": "Ime treba imati najmanje 2 i najviše 20 znakova",
+ "add-profile-text-of": "od",
+ "settings": "Postavke",
+ "settings-app-version": "Verzija aplikacije",
+ "settings-theme": "Tema",
+ "settings-profile": "Profil",
+ "settings-language": "Jezik",
+ "settings-current-locale": "Trenutačni lokalni",
+ "settings-change-language": "Promijeni jezik",
+ "light": "Svijetlo",
+ "dark": "Tamno",
+ "settings-log-out": "Odjava",
+ "video-player-caption-placeholder-na": "Nije primjenjivo",
+ "video-player-no-captions": "Bez titlova",
+ "video-player-switch-text-track-to": "Prebaci tekstualni zapis na",
+ "loading": "Učitavanje",
+ "favorites": "Favoriti",
+ "favorites-search-team": "Pretraživanje timova",
+ "favorites-search-team-input": "Unos za pretraživanje",
+ "favorites-sort": "Razvrstaj prema",
+ "favorites-sort-ascending": "Razvrstaj timove rastuće",
+ "favorites-sort-descending": "Razvrstaj timove opadajuće",
+ "favorites-sort-favorites": "Razvrstaj timove po favoritima",
+ "favorites-options-list": "Popis favorita",
+ "progress-bar-a11y-label-indeterminate": "Neodređeni napredak",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kliknite da biste se prijavili kao {profileName}.",
+ "a11y-hint-direction-left": "lijevo",
+ "a11y-hint-direction-right": "desno",
+ "a11y-hint-direction-up": "gore",
+ "a11y-hint-direction-down": "dolje",
+ "a11y-hint-use-direction-select-item": "Upotrijebite {direction} za odabir {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Izbornik aplikacije,",
+ "menu-wrapper-a11y-label-menu-name": "Izbornik aplikacije",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar profila",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kliknite da biste otišli na zaslon za odabir profila.",
+ "close-menu-button-a11y-label": "Zatvori izbornik aplikacija",
+ "menu-item-use-direction-to-go-to-a11y-label": "Upotrijebite {direction} za odabir: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Postoji stavka {direction}.",
+ "a11y-hint-there-is-an-item-below": "Postoji stavka ispod.",
+ "a11y-hint-there-is-an-item-above": "Postoji stavka iznad.",
+ "carousel-item-a11y-label": "'{item}' u grupi '{group}'.",
+ "carousel-item-without-title-a11y-label": "Neimenovani filmovi",
+ "settings-screen-a11y-theme-section-hint": "Odabir teme",
+ "settings-screen-a11y-label-theme-variant": "{themeName} tema",
+ "settings-screen-a11y-label-theme-variant-hint": "Kliknite da biste promijenili temu u {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Iznad je popis filmova.",
+ "a11y-hint-there-is-a-movie-list-below": "Ispod je popis filmova.",
+ "menu-wrapper-item-home-label": "Početni zaslon",
+ "menu-wrapper-item-settings-label": "Postavke",
+ "login-title-form": "Obrazac prijave",
+ "email-field-a11y-label": "Polje e-pošte. Vrijednost: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/hu/strings.puff.json b/assets/text/hu/strings.puff.json
new file mode 100644
index 0000000..a060549
--- /dev/null
+++ b/assets/text/hu/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Bezárás",
+ "common-save": "Mentés",
+ "common-play": "Lejátszás",
+ "common-resume": "Folytatás",
+ "english": "angol",
+ "polish": "lengyel",
+ "login-title": "Bejelentkezés",
+ "login-password-label": "Jelszó",
+ "login-password-error": "A jelszónak legalább 8 és legfeljebb 20 karakterből kell állnia",
+ "login-email-label": "E-mail",
+ "login-email-error": "Kérjük, adjon meg egy helyes e-mail címet, pl. me@somemail.com",
+ "login-button": "Bejelentkezés",
+ "carousel-go-to-details": "Kattintson a részletek megtekintéséhez.",
+ "carousel-more": "Továbbiak",
+ "carousel-no-title": "Nincs cím",
+ "details-screen-play-description-section-a11y-hint": "Film leírása",
+ "details-screen-no-available": "Ez a videó nem érhető el a csomagjában.",
+ "profile-title": "Felhasználói profil kiválasztása",
+ "profile-add-new": "Új profil hozzáadása",
+ "add-profile-title": "Új profil hozzáadása",
+ "add-profile-choose-avatar": "Avatár kiválasztása",
+ "add-profile-add-name": "Név hozzáadása",
+ "add-profile-form-name-label": "Név",
+ "add-profile-form-name-error": "A névnek legalább 2 és legfeljebb 20 karakterből kell állnia",
+ "add-profile-text-of": "/",
+ "settings": "Beállítások",
+ "settings-app-version": "Alkalmazás verziója",
+ "settings-theme": "Téma",
+ "settings-profile": "Profil",
+ "settings-language": "nyelv",
+ "settings-current-locale": "Jelenlegi helyi",
+ "settings-change-language": "Nyelv módosítása",
+ "light": "Világos",
+ "dark": "Sötét",
+ "settings-log-out": "Kijelentkezés",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Nincsenek feliratok",
+ "video-player-switch-text-track-to": "Szövegsáv váltása erre:",
+ "loading": "Betöltés",
+ "favorites": "Kedvencek",
+ "favorites-search-team": "Csapat keresése",
+ "favorites-search-team-input": "Keresési bevitel",
+ "favorites-sort": "Rendezés",
+ "favorites-sort-ascending": "Csapatok rendezése növekvő sorrendben",
+ "favorites-sort-descending": "Csapatok rendezése csökkenő sorrendben",
+ "favorites-sort-favorites": "Csapatok rendezése kedvencek szerint",
+ "favorites-options-list": "Kedvencek listája",
+ "progress-bar-a11y-label-indeterminate": "Meghatározatlan előrehaladás",
+ "avatar-a11y-label": " avatár",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kattintson ide a(z) {profileName} névvel való bejelentkezéshez.",
+ "a11y-hint-direction-left": "balra",
+ "a11y-hint-direction-right": "jobbra",
+ "a11y-hint-direction-up": "fel",
+ "a11y-hint-direction-down": "le",
+ "a11y-hint-use-direction-select-item": "A {direction} használatával válassza ki a(z) {item} elemet.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Alkalmazásmenü,",
+ "menu-wrapper-a11y-label-menu-name": "Alkalmazásmenü",
+ "menu-wrapper-a11y-label-profile-avatar": "Profil avatár",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kattintson ide a profilválasztó képernyő megnyitásához.",
+ "close-menu-button-a11y-label": "Alkalmazásmenü bezárása",
+ "menu-item-use-direction-to-go-to-a11y-label": "A {direction} használatával válassza ki a(z) {destination} célt.",
+ "a11y-hint-there-is-an-item-to-the-side": "Egy elem található {direction}.",
+ "a11y-hint-there-is-an-item-below": "Lent egy elem található.",
+ "a11y-hint-there-is-an-item-above": "Fent egy elem található.",
+ "carousel-item-a11y-label": "'{item}' a(z) '{group}' csoportban.",
+ "carousel-item-without-title-a11y-label": "Névtelen film",
+ "settings-screen-a11y-theme-section-hint": "Témaválasztás",
+ "settings-screen-a11y-label-theme-variant": "{themeName} téma",
+ "settings-screen-a11y-label-theme-variant-hint": "Kattintson ide, ha témát a következőtani: {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Fent egy filmlista látható.",
+ "a11y-hint-there-is-a-movie-list-below": "Lent egy filmlista található.",
+ "menu-wrapper-item-home-label": "Kezdőlap",
+ "menu-wrapper-item-settings-label": "Beállítások",
+ "login-title-form": "Bejelentkezési űrlap",
+ "email-field-a11y-label": "E-mail mező. Érték: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/it/strings.puff.json b/assets/text/it/strings.puff.json
new file mode 100644
index 0000000..7752065
--- /dev/null
+++ b/assets/text/it/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Chiudi",
+ "common-save": "Salva",
+ "common-play": "Riproduci",
+ "common-resume": "Riprendi",
+ "english": "Inglese",
+ "polish": "Polacco",
+ "login-title": "Accedi",
+ "login-password-label": "Password",
+ "login-password-error": "La password deve essere formata da minimo 8 caratteri e massimo 20 caratteri",
+ "login-email-label": "E-mail",
+ "login-email-error": "Imposta un indirizzo e-mail corretto, per esempio: io@serverdiposta.com",
+ "login-button": "Accedi",
+ "carousel-go-to-details": "Fai clic per accedere ai dettagli.",
+ "carousel-more": "Altro",
+ "carousel-no-title": "Nessun titolo",
+ "details-screen-play-description-section-a11y-hint": "Descrizione del film",
+ "details-screen-no-available": "Questo video non è disponibile con il tuo piano.",
+ "profile-title": "Seleziona un profilo utente",
+ "profile-add-new": "Aggiungi un nuovo profilo",
+ "add-profile-title": "Aggiungi un nuovo profilo",
+ "add-profile-choose-avatar": "Seleziona un avatar",
+ "add-profile-add-name": "Aggiungi un nome",
+ "add-profile-form-name-label": "Nome",
+ "add-profile-form-name-error": "Il nome deve contenere minimo 2 e massimo 20 caratteri",
+ "add-profile-text-of": "di",
+ "settings": "Impostazioni",
+ "settings-app-version": "Versione applicazione",
+ "settings-theme": "Tema",
+ "settings-profile": "Profilo",
+ "settings-language": "Lingua",
+ "settings-current-locale": "Regione attuale",
+ "settings-change-language": "Cambia lingua",
+ "light": "Chiaro",
+ "dark": "Scuro",
+ "settings-log-out": "Esci",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Senza sottotitoli",
+ "video-player-switch-text-track-to": "Passa alla traccia di testo",
+ "loading": "Caricamento in corso",
+ "favorites": "Preferiti",
+ "favorites-search-team": "Cerca squadra",
+ "favorites-search-team-input": "Input di ricerca",
+ "favorites-sort": "Ordina",
+ "favorites-sort-ascending": "Disponi le squadre in ordine crescente",
+ "favorites-sort-descending": "Disponi le squadre in ordine decrescente",
+ "favorites-sort-favorites": "Disponi le squadre in ordine di preferenza",
+ "favorites-options-list": "Elenco preferiti",
+ "progress-bar-a11y-label-indeterminate": "Avanzamento non determinato",
+ "avatar-a11y-label": "Avatar ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Fai clic per accedere come {profileName}.",
+ "a11y-hint-direction-left": "sinistra",
+ "a11y-hint-direction-right": "destra",
+ "a11y-hint-direction-up": "su",
+ "a11y-hint-direction-down": "giù",
+ "a11y-hint-use-direction-select-item": "Usa il tasto {direction} per selezionare {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menu delle app,",
+ "menu-wrapper-a11y-label-menu-name": "Menu delle app",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar del profilo",
+ "menu-wrapper-a11y-hint-profile-avatar": "Fai clic per andare alla schermata di selezione del profilo.",
+ "close-menu-button-a11y-label": "Chiudi il menu delle app",
+ "menu-item-use-direction-to-go-to-a11y-label": "Usa il tasto {direction} per selezionare: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "C'è un elemento a {direction}.",
+ "a11y-hint-there-is-an-item-below": "C'è un elemento qui sotto.",
+ "a11y-hint-there-is-an-item-above": "C'è un elemento qui sopra.",
+ "carousel-item-a11y-label": "'{item}' nel gruppo '{group}'.",
+ "carousel-item-without-title-a11y-label": "Film senza nome",
+ "settings-screen-a11y-theme-section-hint": "Selezione del tema",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Fai clic per cambiare il tema in {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Trovi qui sopra un elenco di film.",
+ "a11y-hint-there-is-a-movie-list-below": "Trovi di sotto un elenco di film.",
+ "menu-wrapper-item-home-label": "Home",
+ "menu-wrapper-item-settings-label": "Impostazioni",
+ "login-title-form": "Modulo di accesso",
+ "email-field-a11y-label": "Campo e-mail. Valore: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/ja/strings.puff.json b/assets/text/ja/strings.puff.json
new file mode 100644
index 0000000..ac43b86
--- /dev/null
+++ b/assets/text/ja/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "閉じる",
+ "common-save": "保存",
+ "common-play": "再生",
+ "common-resume": "再開",
+ "english": "英語",
+ "polish": "ポーランド",
+ "login-title": "ログイン",
+ "login-password-label": "パスワード",
+ "login-password-error": "パスワードは8文字~20文字でなければなりません",
+ "login-email-label": "Eメール",
+ "login-email-error": "正しいEメールアドレスを設定してください。例: me@somemail.com",
+ "login-button": "ログイン",
+ "carousel-go-to-details": "クリックして、詳細に移動します。",
+ "carousel-more": "その他",
+ "carousel-no-title": "タイトルなし",
+ "details-screen-play-description-section-a11y-hint": "映画の説明",
+ "details-screen-no-available": "ご利用のプランでは、このビデオは再生できません。",
+ "profile-title": "ユーザープロフィールを選択",
+ "profile-add-new": "新しいプロフィールを追加",
+ "add-profile-title": "新しいプロフィールを追加",
+ "add-profile-choose-avatar": "プロフィールのイラストを選択",
+ "add-profile-add-name": "名前を追加",
+ "add-profile-form-name-label": "名前",
+ "add-profile-form-name-error": "名前は2文字~20文字で入力してください",
+ "add-profile-text-of": "/",
+ "settings": "設定",
+ "settings-app-version": "アプリのバージョン",
+ "settings-theme": "テーマ",
+ "settings-profile": "プロフィール",
+ "settings-language": "言語",
+ "settings-current-locale": "現在の言語",
+ "settings-change-language": "言語を変更",
+ "light": "照明",
+ "dark": "ダーク",
+ "settings-log-out": "ログアウト",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "字幕なし",
+ "video-player-switch-text-track-to": "テキストトラックの切り替え",
+ "loading": "読み込み中",
+ "favorites": "お気に入り",
+ "favorites-search-team": "チームを検索",
+ "favorites-search-team-input": "検索の入力",
+ "favorites-sort": "並べ替え",
+ "favorites-sort-ascending": "チームを昇順で並べ替え",
+ "favorites-sort-descending": "チームを降順で並べ替え",
+ "favorites-sort-favorites": "チームをお気に入りの順に並べ替え",
+ "favorites-options-list": "お気に入りリスト",
+ "progress-bar-a11y-label-indeterminate": "不確定な進歩状況",
+ "avatar-a11y-label": "のプロフィールのイラスト",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "クリックして、{profileName}としてログインします。",
+ "a11y-hint-direction-left": "左ボタン",
+ "a11y-hint-direction-right": "右ボタン",
+ "a11y-hint-direction-up": "上",
+ "a11y-hint-direction-down": "下",
+ "a11y-hint-use-direction-select-item": "{direction}を使用して{item}を選択します。",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "アプリメニュー、",
+ "menu-wrapper-a11y-label-menu-name": "アプリメニュー",
+ "menu-wrapper-a11y-label-profile-avatar": "プロフィールのアバター",
+ "menu-wrapper-a11y-hint-profile-avatar": "クリックして、プロフィール選択画面に移動します。",
+ "close-menu-button-a11y-label": "アプリのメニューを閉じる",
+ "menu-item-use-direction-to-go-to-a11y-label": "{direction}を使用して{destination}を選択します。",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction}にアイテムがあります。",
+ "a11y-hint-there-is-an-item-below": "アイテムは以下です。",
+ "a11y-hint-there-is-an-item-above": "アイテムは以上です。",
+ "carousel-item-a11y-label": "「{item}」はグループ「{group}」にあります。",
+ "carousel-item-without-title-a11y-label": "名称未設定の映画",
+ "settings-screen-a11y-theme-section-hint": "テーマの選択",
+ "settings-screen-a11y-label-theme-variant": "{themeName}テーマ",
+ "settings-screen-a11y-label-theme-variant-hint": "クリックして、テーマを{themeName}に変更します。",
+ "a11y-hint-there-is-a-movie-list-above": "以上が映画リストです。",
+ "a11y-hint-there-is-a-movie-list-below": "以下が映画リストです。",
+ "menu-wrapper-item-home-label": "在宅",
+ "menu-wrapper-item-settings-label": "設定",
+ "login-title-form": "ログインのフォーム",
+ "email-field-a11y-label": "Eメールの入力欄: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/ko/strings.puff.json b/assets/text/ko/strings.puff.json
new file mode 100644
index 0000000..7486006
--- /dev/null
+++ b/assets/text/ko/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "닫기",
+ "common-save": "저장하기",
+ "common-play": "재생하기",
+ "common-resume": "다시 시작하기",
+ "english": "영어",
+ "polish": "폴란드어",
+ "login-title": "로그인",
+ "login-password-label": "비밀번호",
+ "login-password-error": "비밀번호는 최소 8자에서 최대 20자여야 합니다",
+ "login-email-label": "이메일",
+ "login-email-error": "올바른 이메일 주소를 설정하세요(예: me@somemail.com).",
+ "login-button": "로그인",
+ "carousel-go-to-details": "세부정보로 이동하려면 클릭하세요.",
+ "carousel-more": "더보기",
+ "carousel-no-title": "제목 없음",
+ "details-screen-play-description-section-a11y-hint": "영화 설명",
+ "details-screen-no-available": "현재 요금제로는 이 비디오를 시청할 수 없습니다.",
+ "profile-title": "사용자 프로필 선택하기",
+ "profile-add-new": "새 프로필 추가하기",
+ "add-profile-title": "새 프로필 추가하기",
+ "add-profile-choose-avatar": "아바타 선택하기",
+ "add-profile-add-name": "이름 추가하기",
+ "add-profile-form-name-label": "이름",
+ "add-profile-form-name-error": "이름은 최소 2자에서 최대 20자여야 합니다",
+ "add-profile-text-of": "/",
+ "settings": "설정",
+ "settings-app-version": "앱 버전",
+ "settings-theme": "테마",
+ "settings-profile": "프로필",
+ "settings-language": "언어",
+ "settings-current-locale": "현재 로케일",
+ "settings-change-language": "언어 변경하기",
+ "light": "라이트",
+ "dark": "다크",
+ "settings-log-out": "로그아웃",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "자막 없음",
+ "video-player-switch-text-track-to": "텍스트 트랙을 다음으로 전환하기:",
+ "loading": "로드 중",
+ "favorites": "즐겨찾기",
+ "favorites-search-team": "팀 검색하기",
+ "favorites-search-team-input": "검색 입력",
+ "favorites-sort": "정렬하기",
+ "favorites-sort-ascending": "오름차순으로 팀 정렬하기",
+ "favorites-sort-descending": "내림차순으로 팀 정렬하기",
+ "favorites-sort-favorites": "즐겨찾기별로 팀 정렬하기",
+ "favorites-options-list": "즐겨찾기 목록",
+ "progress-bar-a11y-label-indeterminate": "불확실한 진행 상황",
+ "avatar-a11y-label": " 아바타",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "{profileName}(으)로 로그인하려면 클릭하세요.",
+ "a11y-hint-direction-left": "왼쪽",
+ "a11y-hint-direction-right": "오른쪽",
+ "a11y-hint-direction-up": "위",
+ "a11y-hint-direction-down": "아래",
+ "a11y-hint-use-direction-select-item": "{item} 항목을 선택하려면 {direction} 버튼을 사용하세요.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "앱 메뉴,",
+ "menu-wrapper-a11y-label-menu-name": "앱 메뉴",
+ "menu-wrapper-a11y-label-profile-avatar": "프로필 아바타",
+ "menu-wrapper-a11y-hint-profile-avatar": "프로필 선택 화면으로 이동하려면 클릭하세요.",
+ "close-menu-button-a11y-label": "앱 메뉴 닫기",
+ "menu-item-use-direction-to-go-to-a11y-label": "{destination}을(를) 선택하려면 {direction} 버튼을 사용하세요.",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction}에 항목이 있습니다.",
+ "a11y-hint-there-is-an-item-below": "아래에 항목이 있습니다.",
+ "a11y-hint-there-is-an-item-above": "위에 항목이 있습니다.",
+ "carousel-item-a11y-label": "'{group}' 그룹의 '{item}'.",
+ "carousel-item-without-title-a11y-label": "제목 없는 영화",
+ "settings-screen-a11y-theme-section-hint": "테마 선택",
+ "settings-screen-a11y-label-theme-variant": "{themeName} 테마",
+ "settings-screen-a11y-label-theme-variant-hint": "{themeName}(으)로 테마를 변경하려면 클릭하세요.",
+ "a11y-hint-there-is-a-movie-list-above": "위는 영화 목록입니다.",
+ "a11y-hint-there-is-a-movie-list-below": "아래는 영화 목록입니다.",
+ "menu-wrapper-item-home-label": "홈",
+ "menu-wrapper-item-settings-label": "설정",
+ "login-title-form": "로그인 양식",
+ "email-field-a11y-label": "이메일 필드. 값: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/lt/strings.puff.json b/assets/text/lt/strings.puff.json
new file mode 100644
index 0000000..f4529fd
--- /dev/null
+++ b/assets/text/lt/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Užverti",
+ "common-save": "Įrašyti",
+ "common-play": "Paleisti",
+ "common-resume": "Tęsti",
+ "english": "Anglų",
+ "polish": "Lenkų",
+ "login-title": "Prisijungti",
+ "login-password-label": "Slaptažodis",
+ "login-password-error": "Slaptažodis turi turėti bent 8 simbolius ir ne daugiau kaip 20 simbolių",
+ "login-email-label": "El. paštas",
+ "login-email-error": "Prašome nustatyti tinkamą el. paštas adresą, pvz.: me@somemail.com",
+ "login-button": "Prisijungti",
+ "carousel-go-to-details": "Spustelėkite, jei norite gauti išsamią informaciją.",
+ "carousel-more": "Daugiau",
+ "carousel-no-title": "Be pavadinimo",
+ "details-screen-play-description-section-a11y-hint": "Filmo aprašas",
+ "details-screen-no-available": "Šis vaizdo įrašas nėra Pasiekiamas jūsų plane.",
+ "profile-title": "Pasirinkite naudotojo profilį",
+ "profile-add-new": "Pridėti naują profilį",
+ "add-profile-title": "Pridėti naują profilį",
+ "add-profile-choose-avatar": "Pasirinkite pseudoportretą",
+ "add-profile-add-name": "Pridėti vardą",
+ "add-profile-form-name-label": "Pavadinimas",
+ "add-profile-form-name-error": "Vardas turėtų turėti bent 2 ir maks. 20 simbolių",
+ "add-profile-text-of": "(kieno)",
+ "settings": "Nustatymai",
+ "settings-app-version": "Programėlės versija",
+ "settings-theme": "Tema",
+ "settings-profile": "Profilis",
+ "settings-language": "Kalba",
+ "settings-current-locale": "Dabartinė programėlė",
+ "settings-change-language": "Pakeisti kalbą",
+ "light": "Lengvas",
+ "dark": "Tamsu",
+ "settings-log-out": "Atsijungti",
+ "video-player-caption-placeholder-na": "Netaikoma",
+ "video-player-no-captions": "Nėra titrų",
+ "video-player-switch-text-track-to": "Perjungti teksto įrašą į",
+ "loading": "Įkeliama",
+ "favorites": "Mėgstamiausi",
+ "favorites-search-team": "Paieškos komanda",
+ "favorites-search-team-input": "Paieškos įvestis",
+ "favorites-sort": "Rūšiuoti",
+ "favorites-sort-ascending": "Rūšiuoti komandas didėjančia tvarka",
+ "favorites-sort-descending": "Rūšiuoti komandas mažėjančia tvarka",
+ "favorites-sort-favorites": "Rūšiuoti komandas pagal mėgstamiausius",
+ "favorites-options-list": "Mėgstamiausių sąrašas",
+ "progress-bar-a11y-label-indeterminate": "Nenustatytas procesas",
+ "avatar-a11y-label": " avataras",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Spustelėkite, jei norite prisijungti kaip {profileName}.",
+ "a11y-hint-direction-left": "kairėn",
+ "a11y-hint-direction-right": "dešinėn",
+ "a11y-hint-direction-up": "aukštyn",
+ "a11y-hint-direction-down": "žemyn",
+ "a11y-hint-use-direction-select-item": "Naudokite {direction} , kad pasirinktumėte {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Programėlių meniu,",
+ "menu-wrapper-a11y-label-menu-name": "Programėlių meniu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profilio pseudoportretas",
+ "menu-wrapper-a11y-hint-profile-avatar": "Spustelėkite, jei norite pereiti į profilio pasirinkimo ekraną.",
+ "close-menu-button-a11y-label": "Užverti programėlės meniu",
+ "menu-item-use-direction-to-go-to-a11y-label": "Naudokite {direction} norėdami pasirinkti: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Yra elementas į {direction}.",
+ "a11y-hint-there-is-an-item-below": "Žemiau yra elementas.",
+ "a11y-hint-there-is-an-item-above": "Aukščiau yra elementas.",
+ "carousel-item-a11y-label": "'{item}' grupėje '{group}'.",
+ "carousel-item-without-title-a11y-label": "Neįvardytas filmas",
+ "settings-screen-a11y-theme-section-hint": "Teksto pasirinkimas",
+ "settings-screen-a11y-label-theme-variant": "{themeName} tema",
+ "settings-screen-a11y-label-theme-variant-hint": "Spustelėkite norėdami pakeisti temą į {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Viršuje yra filmų sąrašas.",
+ "a11y-hint-there-is-a-movie-list-below": "Žemiau yra filmų sąrašas.",
+ "menu-wrapper-item-home-label": "Pradžia",
+ "menu-wrapper-item-settings-label": "Nustatymai",
+ "login-title-form": "Prisijungimo forma",
+ "email-field-a11y-label": "El. pašto laukelis. Vertė: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/lv/strings.puff.json b/assets/text/lv/strings.puff.json
new file mode 100644
index 0000000..e8a5349
--- /dev/null
+++ b/assets/text/lv/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Aizvērt",
+ "common-save": "Saglabāt",
+ "common-play": "Atskaņot",
+ "common-resume": "Atsākt",
+ "english": "Angļu",
+ "polish": "Poļu",
+ "login-title": "Pierakstīties",
+ "login-password-label": "Parole",
+ "login-password-error": "Parolē jābūt vismaz 8 rakstzīmēm un ne vairāk kā 20 rakstzīmēm",
+ "login-email-label": "E-pasts",
+ "login-email-error": "Lūdzu, iestatiet pareizu e-pasta adresi, piemēram, me@somemail.com",
+ "login-button": "Pierakstīties",
+ "carousel-go-to-details": "Noklikšķiniet, lai pārietu uz papildinformāciju.",
+ "carousel-more": "Vairāk",
+ "carousel-no-title": "Nav nosaukuma",
+ "details-screen-play-description-section-a11y-hint": "Filmas apraksts",
+ "details-screen-no-available": "Šis video nav pieejams jūsu plānā.",
+ "profile-title": "Atlasīt lietotāja profilu",
+ "profile-add-new": "Pievienot jaunu profilu",
+ "add-profile-title": "Pievienot jaunu profilu",
+ "add-profile-choose-avatar": "Atlasīt avatāru",
+ "add-profile-add-name": "Pievienot nosaukumu",
+ "add-profile-form-name-label": "Nosaukums",
+ "add-profile-form-name-error": "Nosaukumā jābūt vismaz 2 un ne vairāk kā 20 rakstzīmēm",
+ "add-profile-text-of": "no",
+ "settings": "Iestatījumi",
+ "settings-app-version": "Lietotnes versija",
+ "settings-theme": "Motīvs",
+ "settings-profile": "Profils",
+ "settings-language": "Valoda",
+ "settings-current-locale": "Pašreizējā lokāle",
+ "settings-change-language": "Mainīt valodu",
+ "light": "Gaišs",
+ "dark": "Tumšs",
+ "settings-log-out": "Izrakstīties",
+ "video-player-caption-placeholder-na": "Nav attiecināms",
+ "video-player-no-captions": "Nav aprakstu",
+ "video-player-switch-text-track-to": "Pārslēgt teksta celiņu uz",
+ "loading": "Ielādē",
+ "favorites": "Izlase",
+ "favorites-search-team": "Meklēšanas komanda",
+ "favorites-search-team-input": "Meklēšanas ievade",
+ "favorites-sort": "Kārtot",
+ "favorites-sort-ascending": "Kārtot komandas augošā secībā",
+ "favorites-sort-descending": "Kārtot komandas dilstošā secībā",
+ "favorites-sort-favorites": "Kārtot komandas pēc izlases",
+ "favorites-options-list": "Izlases saraksts",
+ "progress-bar-a11y-label-indeterminate": "Pašreizējais progress",
+ "avatar-a11y-label": " avatārs",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Noklikšķiniet, lai pierakstītos kā {profileName}.",
+ "a11y-hint-direction-left": "pa kreisi",
+ "a11y-hint-direction-right": "pa labi",
+ "a11y-hint-direction-up": "uz augšu",
+ "a11y-hint-direction-down": "uz leju",
+ "a11y-hint-use-direction-select-item": "Izmantojiet {direction}, lai atlasītu {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Lietotņu izvēlne,",
+ "menu-wrapper-a11y-label-menu-name": "Lietotņu izvēlne",
+ "menu-wrapper-a11y-label-profile-avatar": "Profila avatārs",
+ "menu-wrapper-a11y-hint-profile-avatar": "Noklikšķiniet, lai dotos uz profila atlases ekrānu.",
+ "close-menu-button-a11y-label": "Aizvērt lietotnes izvēlni",
+ "menu-item-use-direction-to-go-to-a11y-label": "Izmantojiet {direction}, lai atlasītu {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Virzienā {direction} ir vienums.",
+ "a11y-hint-there-is-an-item-below": "Zemāk ir vienums.",
+ "a11y-hint-there-is-an-item-above": "Augstāk ir vienums.",
+ "carousel-item-a11y-label": "“{item}” grupā “{group}”.",
+ "carousel-item-without-title-a11y-label": "Filma bez nosaukuma",
+ "settings-screen-a11y-theme-section-hint": "Motīva atlase",
+ "settings-screen-a11y-label-theme-variant": "{themeName} motīvs",
+ "settings-screen-a11y-label-theme-variant-hint": "Noklikšķiniet, lai mainītu motīvu uz {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Augstāk ir filmu saraksts.",
+ "a11y-hint-there-is-a-movie-list-below": "Zemāk ir filmu saraksts.",
+ "menu-wrapper-item-home-label": "Sākums",
+ "menu-wrapper-item-settings-label": "Iestatījumi",
+ "login-title-form": "Pierakstīšanās veidne",
+ "email-field-a11y-label": "E-pasta laukums. Vērtība: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/nb-NO/strings.puff.json b/assets/text/nb-NO/strings.puff.json
new file mode 100644
index 0000000..b5cd8af
--- /dev/null
+++ b/assets/text/nb-NO/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Lukk",
+ "common-save": "Lagre",
+ "common-play": "Spill av",
+ "common-resume": "Gjenoppta",
+ "english": "Engelsk",
+ "polish": "Polsk",
+ "login-title": "Logg på",
+ "login-password-label": "Passord",
+ "login-password-error": "Passordet må ha minst 8 tegn og maksimalt 20 tegn",
+ "login-email-label": "E-post",
+ "login-email-error": "Angi en riktig e-postadresse, f.eks. meg@epost.no",
+ "login-button": "Logg på",
+ "carousel-go-to-details": "Klikk for å gå til detaljer.",
+ "carousel-more": "Mer",
+ "carousel-no-title": "Ingen tittel",
+ "details-screen-play-description-section-a11y-hint": "Filmbeskrivelse",
+ "details-screen-no-available": "Denne videoen er ikke tilgjengelig i abonnementet ditt.",
+ "profile-title": "Velg brukerprofil",
+ "profile-add-new": "Legg til ny profil",
+ "add-profile-title": "Legg til ny profil",
+ "add-profile-choose-avatar": "Velg avatar",
+ "add-profile-add-name": "Legg til navn",
+ "add-profile-form-name-label": "Navn",
+ "add-profile-form-name-error": "Navnet må ha minst 2 og maks 20 tegn",
+ "add-profile-text-of": "av",
+ "settings": "Innstillinger",
+ "settings-app-version": "Appversjon",
+ "settings-theme": "Modus",
+ "settings-profile": "Profil",
+ "settings-language": "Språk",
+ "settings-current-locale": "Gjeldende sted",
+ "settings-change-language": "Endre språk",
+ "light": "Lys",
+ "dark": "Mørk",
+ "settings-log-out": "Logg av",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Ingen teksting",
+ "video-player-switch-text-track-to": "Bytt tekstspor til",
+ "loading": "Laster inn",
+ "favorites": "Favoritter",
+ "favorites-search-team": "Søk etter lag",
+ "favorites-search-team-input": "Inndata for søk",
+ "favorites-sort": "Sorter",
+ "favorites-sort-ascending": "Sorter lag stigende",
+ "favorites-sort-descending": "Sorter lag synkende",
+ "favorites-sort-favorites": "Sorter lag etter favoritter",
+ "favorites-options-list": "Liste over favoritter",
+ "progress-bar-a11y-label-indeterminate": "Ubestemt fremgang",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Klikk for å logge på som {profileName}.",
+ "a11y-hint-direction-left": "venstre",
+ "a11y-hint-direction-right": "høyre",
+ "a11y-hint-direction-up": "opp",
+ "a11y-hint-direction-down": "ned",
+ "a11y-hint-use-direction-select-item": "Bruk {direction} for å velge {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Appmeny,",
+ "menu-wrapper-a11y-label-menu-name": "Appmeny",
+ "menu-wrapper-a11y-label-profile-avatar": "Profilavatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Klikk for å gå til skjermen for profilvalg.",
+ "close-menu-button-a11y-label": "Lukk appmeny",
+ "menu-item-use-direction-to-go-to-a11y-label": "Bruk {direction} for å velge: {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Det er et element til {direction}.",
+ "a11y-hint-there-is-an-item-below": "Det er et element nedenfor.",
+ "a11y-hint-there-is-an-item-above": "Det er et element ovenfor.",
+ "carousel-item-a11y-label": "«{item}» i gruppe «{group}».",
+ "carousel-item-without-title-a11y-label": "Film uten navn",
+ "settings-screen-a11y-theme-section-hint": "Velg modus",
+ "settings-screen-a11y-label-theme-variant": "{themeName} modus",
+ "settings-screen-a11y-label-theme-variant-hint": "Klikk for å endre modus til {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Ovenfor er en filmliste.",
+ "a11y-hint-there-is-a-movie-list-below": "Nedenfor er en filmliste.",
+ "menu-wrapper-item-home-label": "Hjem",
+ "menu-wrapper-item-settings-label": "Innstillinger",
+ "login-title-form": "Påloggingsskjema",
+ "email-field-a11y-label": "Verdi for e-postadresse-felt: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/nl/strings.puff.json b/assets/text/nl/strings.puff.json
new file mode 100644
index 0000000..db11a50
--- /dev/null
+++ b/assets/text/nl/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Sluiten",
+ "common-save": "Opslaan",
+ "common-play": "Afspelen",
+ "common-resume": "Hervatten",
+ "english": "Engels",
+ "polish": "Pools",
+ "login-title": "Inloggen",
+ "login-password-label": "Wachtwoord",
+ "login-password-error": "Het wachtwoord moet minimaal 8 tekens en maximaal 20 tekens bevatten",
+ "login-email-label": "E-mail",
+ "login-email-error": "Voer een correct e-mailadres in, bijv. ik@eenmail.com",
+ "login-button": "Inloggen",
+ "carousel-go-to-details": "Klik om naar de details te gaan.",
+ "carousel-more": "Meer",
+ "carousel-no-title": "Geen titel",
+ "details-screen-play-description-section-a11y-hint": "Beschrijving film",
+ "details-screen-no-available": "Deze video is niet beschikbaar in je abonnement.",
+ "profile-title": "Gebruikersprofiel selecteren",
+ "profile-add-new": "Nieuw profiel toevoegen",
+ "add-profile-title": "Nieuw profiel toevoegen",
+ "add-profile-choose-avatar": "Avatar selecteren",
+ "add-profile-add-name": "Naam toevoegen",
+ "add-profile-form-name-label": "Naam",
+ "add-profile-form-name-error": "De naam moet minimaal 2 en maximaal 20 tekens bevatten",
+ "add-profile-text-of": "van",
+ "settings": "Instellingen",
+ "settings-app-version": "App-versie",
+ "settings-theme": "Thema",
+ "settings-profile": "Profiel",
+ "settings-language": "Taal",
+ "settings-current-locale": "Huidige landinstelling",
+ "settings-change-language": "Taal wijzigen",
+ "light": "Licht",
+ "dark": "Donker",
+ "settings-log-out": "Uitloggen",
+ "video-player-caption-placeholder-na": "N.v.t.",
+ "video-player-no-captions": "Geen ondertiteling",
+ "video-player-switch-text-track-to": "Teksttrack wijzigen naar",
+ "loading": "Laden",
+ "favorites": "Favorieten",
+ "favorites-search-team": "Team zoeken",
+ "favorites-search-team-input": "Zoekopdracht",
+ "favorites-sort": "Sorteren",
+ "favorites-sort-ascending": "Teams oplopend sorteren",
+ "favorites-sort-descending": "Teams aflopend sorteren",
+ "favorites-sort-favorites": "Teams sorteren op favorieten",
+ "favorites-options-list": "Favorietenlijst",
+ "progress-bar-a11y-label-indeterminate": "Onduidelijke voortgang",
+ "avatar-a11y-label": "Avatar ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Klik om in te loggen als {profileName}.",
+ "a11y-hint-direction-left": "links",
+ "a11y-hint-direction-right": "rechts",
+ "a11y-hint-direction-up": "omhoog",
+ "a11y-hint-direction-down": "omlaag",
+ "a11y-hint-use-direction-select-item": "Gebruik {direction} om {item} te selecteren.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "App-menu,",
+ "menu-wrapper-a11y-label-menu-name": "App-menu",
+ "menu-wrapper-a11y-label-profile-avatar": "Profiel-avatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Klik om naar het profielselectiescherm te gaan.",
+ "close-menu-button-a11y-label": "App-menu sluiten",
+ "menu-item-use-direction-to-go-to-a11y-label": "Gebruik {direction} om {destination} te selecteren.",
+ "a11y-hint-there-is-an-item-to-the-side": "Er staat een item {direction}.",
+ "a11y-hint-there-is-an-item-below": "Hieronder staat een item.",
+ "a11y-hint-there-is-an-item-above": "Hierboven staat een item.",
+ "carousel-item-a11y-label": "'{item}' in groep '{group}'.",
+ "carousel-item-without-title-a11y-label": "Naamloze film",
+ "settings-screen-a11y-theme-section-hint": "Themaselectie",
+ "settings-screen-a11y-label-theme-variant": "Thema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Klik om het thema te wijzigen naar {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Hierboven staat een filmlijst.",
+ "a11y-hint-there-is-a-movie-list-below": "Hieronder staat een filmlijst.",
+ "menu-wrapper-item-home-label": "Start",
+ "menu-wrapper-item-settings-label": "Instellingen",
+ "login-title-form": "Inlogformulier",
+ "email-field-a11y-label": "E-mailveld. Waarde: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/pl/strings.puff.json b/assets/text/pl/strings.puff.json
new file mode 100644
index 0000000..2a6dd40
--- /dev/null
+++ b/assets/text/pl/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Zamknij",
+ "common-save": "Zapisz",
+ "common-play": "Odtwórz",
+ "common-resume": "Wznów",
+ "english": "Angielski",
+ "polish": "Polski",
+ "login-title": "Zaloguj się",
+ "login-password-label": "Hasło",
+ "login-password-error": "Hasło musi zawierać od 8 do 20 znaków",
+ "login-email-label": "E-mail",
+ "login-email-error": "Ustaw poprawny adres e-mail, np. ja@adresemail.com",
+ "login-button": "Zaloguj się",
+ "carousel-go-to-details": "Kliknij, aby przejść do szczegółów.",
+ "carousel-more": "Więcej",
+ "carousel-no-title": "Brak tytułu",
+ "details-screen-play-description-section-a11y-hint": "Opis filmu",
+ "details-screen-no-available": "To wideo nie jest dostępne w Twoim planie.",
+ "profile-title": "Wybierz profil użytkownika",
+ "profile-add-new": "Dodaj nowy profil",
+ "add-profile-title": "Dodaj nowy profil",
+ "add-profile-choose-avatar": "Wybierz awatar",
+ "add-profile-add-name": "Dodaj nazwę",
+ "add-profile-form-name-label": "Nazwa",
+ "add-profile-form-name-error": "Nazwa musi zawierać od 2 do 20 znaków",
+ "add-profile-text-of": "z",
+ "settings": "Ustawienia",
+ "settings-app-version": "Wersja aplikacji",
+ "settings-theme": "Motyw",
+ "settings-profile": "Profil",
+ "settings-language": "Język",
+ "settings-current-locale": "Bieżący język",
+ "settings-change-language": "Zmień język",
+ "light": "Jasny",
+ "dark": "Ciemny",
+ "settings-log-out": "Wyloguj się",
+ "video-player-caption-placeholder-na": "nd.",
+ "video-player-no-captions": "Bez napisów",
+ "video-player-switch-text-track-to": "Przełącz tekst na",
+ "loading": "Ładowanie",
+ "favorites": "Ulubione",
+ "favorites-search-team": "Wyszukaj drużynę",
+ "favorites-search-team-input": "Hasło wyszukiwania",
+ "favorites-sort": "Sortuj",
+ "favorites-sort-ascending": "Sortuj drużyny rosnąco",
+ "favorites-sort-descending": "Sortuj drużyny malejąco",
+ "favorites-sort-favorites": "Sortuj drużyny według ulubionych",
+ "favorites-options-list": "Lista ulubionych",
+ "progress-bar-a11y-label-indeterminate": "Nieokreślony postęp",
+ "avatar-a11y-label": " awatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kliknij, aby zalogować się jako {profileName}.",
+ "a11y-hint-direction-left": "w lewo",
+ "a11y-hint-direction-right": "w prawo",
+ "a11y-hint-direction-up": "w górę",
+ "a11y-hint-direction-down": "w dół",
+ "a11y-hint-use-direction-select-item": "Użyj strzałki {direction}, aby wybrać {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menu aplikacji,",
+ "menu-wrapper-a11y-label-menu-name": "Menu aplikacji",
+ "menu-wrapper-a11y-label-profile-avatar": "Awatar profilu",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kliknij, aby przejść do ekranu wyboru profilu.",
+ "close-menu-button-a11y-label": "Zamknij menu aplikacji",
+ "menu-item-use-direction-to-go-to-a11y-label": "Użyj strzałki {direction}, aby wybrać: {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Element znajduje się {direction}.",
+ "a11y-hint-there-is-an-item-below": "Poniżej znajduje się element.",
+ "a11y-hint-there-is-an-item-above": "Powyżej znajduje się element.",
+ "carousel-item-a11y-label": "„{item}” w grupie „{group}”.",
+ "carousel-item-without-title-a11y-label": "Film bez nazwy",
+ "settings-screen-a11y-theme-section-hint": "Wybór motywu",
+ "settings-screen-a11y-label-theme-variant": "Motyw {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Kliknij, aby zmienić motyw na {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Powyżej znajduje się lista filmów.",
+ "a11y-hint-there-is-a-movie-list-below": "Poniżej znajduje się lista filmów.",
+ "menu-wrapper-item-home-label": "Strona główna",
+ "menu-wrapper-item-settings-label": "Ustawienia",
+ "login-title-form": "Formularz logowania",
+ "email-field-a11y-label": "Pole adresu e-mail. Wartość: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/pt-BR/strings.puff.json b/assets/text/pt-BR/strings.puff.json
new file mode 100644
index 0000000..692d63f
--- /dev/null
+++ b/assets/text/pt-BR/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Fechar",
+ "common-save": "Salvar",
+ "common-play": "Reproduzir",
+ "common-resume": "Continuar",
+ "english": "Inglês",
+ "polish": "Polonês",
+ "login-title": "Entrar",
+ "login-password-label": "Senha",
+ "login-password-error": "A senha deve ter, no mínimo, 8 caracteres e, no máximo, 20 caracteres",
+ "login-email-label": "E-mail",
+ "login-email-error": "Defina um endereço de e-mail adequado, por exemplo, eu@algumemail.com",
+ "login-button": "Entrar",
+ "carousel-go-to-details": "Clique para acessar os detalhes.",
+ "carousel-more": "Mais",
+ "carousel-no-title": "Nenhum título",
+ "details-screen-play-description-section-a11y-hint": "Descrição do filme",
+ "details-screen-no-available": "Este vídeo não está disponível em seu plano.",
+ "profile-title": "Selecionar perfil de usuário",
+ "profile-add-new": "Adicionar novo perfil",
+ "add-profile-title": "Adicionar novo perfil",
+ "add-profile-choose-avatar": "Selecionar avatar",
+ "add-profile-add-name": "Adicionar nome",
+ "add-profile-form-name-label": "Nome",
+ "add-profile-form-name-error": "O nome deve ter no mínimo 2 e no máximo 20 caracteres",
+ "add-profile-text-of": "de",
+ "settings": "Configurações",
+ "settings-app-version": "Versão do aplicativo",
+ "settings-theme": "Tema",
+ "settings-profile": "Perfil",
+ "settings-language": "Idioma",
+ "settings-current-locale": "Localização atual",
+ "settings-change-language": "Alterar idioma",
+ "light": "Claro",
+ "dark": "Escuro",
+ "settings-log-out": "Sair",
+ "video-player-caption-placeholder-na": "N/D",
+ "video-player-no-captions": "Sem legenda",
+ "video-player-switch-text-track-to": "Mudar faixa de texto para",
+ "loading": "Carregando",
+ "favorites": "Favoritos",
+ "favorites-search-team": "Pesquisar equipe",
+ "favorites-search-team-input": "Pesquisar entrada",
+ "favorites-sort": "Classificar",
+ "favorites-sort-ascending": "Classificar equipes em ordem crescente",
+ "favorites-sort-descending": "Classificar equipes em ordem decrescente",
+ "favorites-sort-favorites": "Classificar equipes por favoritos",
+ "favorites-options-list": "Lista de favoritos",
+ "progress-bar-a11y-label-indeterminate": "Progresso indeterminado",
+ "avatar-a11y-label": "Avatar ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Clique para entrar como {profileName}.",
+ "a11y-hint-direction-left": "esquerda",
+ "a11y-hint-direction-right": "direita",
+ "a11y-hint-direction-up": "para cima",
+ "a11y-hint-direction-down": "descer",
+ "a11y-hint-use-direction-select-item": "Use {direction} para selecionar {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menu do aplicativo,",
+ "menu-wrapper-a11y-label-menu-name": "Menu do aplicativo",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar do perfil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Clique para ir para a tela de seleção de perfil.",
+ "close-menu-button-a11y-label": "Fechar menu do aplicativo",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} para selecionar: {destination}\".",
+ "a11y-hint-there-is-an-item-to-the-side": "Há um item à {direction}.",
+ "a11y-hint-there-is-an-item-below": "Há um item abaixo.",
+ "a11y-hint-there-is-an-item-above": "Há um item acima.",
+ "carousel-item-a11y-label": "\"{item}\" no grupo \"{group}\".",
+ "carousel-item-without-title-a11y-label": "Filme sem nome",
+ "settings-screen-a11y-theme-section-hint": "Seleção de tema",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Clique para mudar o tema para {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Acima você encontra uma lista de filmes.",
+ "a11y-hint-there-is-a-movie-list-below": "Abaixo você encontra uma lista de filmes.",
+ "menu-wrapper-item-home-label": "Tela inicial",
+ "menu-wrapper-item-settings-label": "Configurações",
+ "login-title-form": "Formulário de login",
+ "email-field-a11y-label": "Campo de e-mail. Valor: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/pt-PT/strings.puff.json b/assets/text/pt-PT/strings.puff.json
new file mode 100644
index 0000000..0b187e9
--- /dev/null
+++ b/assets/text/pt-PT/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Fechar",
+ "common-save": "Guardar",
+ "common-play": "Reproduzir",
+ "common-resume": "Retomar",
+ "english": "Inglês",
+ "polish": "Polaco",
+ "login-title": "Iniciar sessão",
+ "login-password-label": "Palavra-passe",
+ "login-password-error": "A palavra-passe deve ter pelo menos 8 carateres e um máximo de 20 carateres",
+ "login-email-label": "E-mail",
+ "login-email-error": "Defina um endereço de e-mail correto, por exemplo, eu@algumemail.com",
+ "login-button": "Iniciar sessão",
+ "carousel-go-to-details": "Clique para ver os detalhes.",
+ "carousel-more": "Mais",
+ "carousel-no-title": "Sem título",
+ "details-screen-play-description-section-a11y-hint": "Descrição do filme",
+ "details-screen-no-available": "Este vídeo não está disponível no seu plano.",
+ "profile-title": "Selecionar perfil do utilizador",
+ "profile-add-new": "Adicionar novo perfil",
+ "add-profile-title": "Adicionar novo perfil",
+ "add-profile-choose-avatar": "Selecionar avatar",
+ "add-profile-add-name": "Adicionar nome",
+ "add-profile-form-name-label": "Nome",
+ "add-profile-form-name-error": "O nome deve ter no mínimo 2 carateres e um máximo de 20 carateres",
+ "add-profile-text-of": "de",
+ "settings": "Definições",
+ "settings-app-version": "Versão da aplicação",
+ "settings-theme": "Tema",
+ "settings-profile": "Perfil",
+ "settings-language": "Idioma",
+ "settings-current-locale": "Região atual",
+ "settings-change-language": "Alterar idioma",
+ "light": "Claro",
+ "dark": "Escuro",
+ "settings-log-out": "Terminar sessão",
+ "video-player-caption-placeholder-na": "N/D",
+ "video-player-no-captions": "Sem legenda",
+ "video-player-switch-text-track-to": "Alternar faixa de texto para",
+ "loading": "A carregar",
+ "favorites": "Favoritos",
+ "favorites-search-team": "Pesquisar equipa",
+ "favorites-search-team-input": "Pesquisar entrada",
+ "favorites-sort": "Ordenar",
+ "favorites-sort-ascending": "Ordenar equipas ascendente",
+ "favorites-sort-descending": "Ordenar equipas descendente",
+ "favorites-sort-favorites": "Ordenar equipas por favoritos",
+ "favorites-options-list": "Lista de favoritos",
+ "progress-bar-a11y-label-indeterminate": "Progresso indeterminado",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Clique para iniciar sessão como {profileName}.",
+ "a11y-hint-direction-left": "esquerda",
+ "a11y-hint-direction-right": "direita",
+ "a11y-hint-direction-up": "acima",
+ "a11y-hint-direction-down": "abaixo",
+ "a11y-hint-use-direction-select-item": "Use {direction} para selecionar {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Menu da app,",
+ "menu-wrapper-a11y-label-menu-name": "Menu da app",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de perfil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Clique para ir para o ecrã de seleção de perfil.",
+ "close-menu-button-a11y-label": "Fechar menu da app",
+ "menu-item-use-direction-to-go-to-a11y-label": "Use {direction} para selecionar: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Há um item à {direction}.",
+ "a11y-hint-there-is-an-item-below": "Há um item abaixo.",
+ "a11y-hint-there-is-an-item-above": "Há um item acima.",
+ "carousel-item-a11y-label": "'{item}' no grupo '{group}'.",
+ "carousel-item-without-title-a11y-label": "Filme sem nome",
+ "settings-screen-a11y-theme-section-hint": "Seleção de tema",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Clique para alterar o tema para {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Acima está uma lista de filmes.",
+ "a11y-hint-there-is-a-movie-list-below": "Abaixo está uma lista de filmes.",
+ "menu-wrapper-item-home-label": "Página inicial",
+ "menu-wrapper-item-settings-label": "Definições",
+ "login-title-form": "Formulário de início de sessão",
+ "email-field-a11y-label": "Campo do e-mail. Valor: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/ro/strings.puff.json b/assets/text/ro/strings.puff.json
new file mode 100644
index 0000000..6e54d02
--- /dev/null
+++ b/assets/text/ro/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Închidere",
+ "common-save": "Salvare",
+ "common-play": "Redare",
+ "common-resume": "Reluare",
+ "english": "Engleză",
+ "polish": "Polonez",
+ "login-title": "Autentificare",
+ "login-password-label": "Parola",
+ "login-password-error": "Parola trebuie să conțină minimum 8 caractere și maximum 20 de caractere",
+ "login-email-label": "E-mail",
+ "login-email-error": "Stabilește o adresă de e-mail corectă, de ex. me@somemail.com",
+ "login-button": "Autentificare",
+ "carousel-go-to-details": "Fă clic pentru a accesa detaliile.",
+ "carousel-more": "Mai multe",
+ "carousel-no-title": "Fără titlu",
+ "details-screen-play-description-section-a11y-hint": "Descrierea filmului",
+ "details-screen-no-available": "Acest videoclip nu este disponibil în planul tău.",
+ "profile-title": "Selectare profil utilizator",
+ "profile-add-new": "Adaugă profil nou",
+ "add-profile-title": "Adaugă profil nou",
+ "add-profile-choose-avatar": "Selectează avatar",
+ "add-profile-add-name": "Adaugă nume",
+ "add-profile-form-name-label": "Nume",
+ "add-profile-form-name-error": "Numele trebuie să conțină minimum 2 și maximum 20 de caractere",
+ "add-profile-text-of": "din",
+ "settings": "Setări",
+ "settings-app-version": "Versiunea aplicației",
+ "settings-theme": "Temă",
+ "settings-profile": "Profil",
+ "settings-language": "Limbă",
+ "settings-current-locale": "Localizare curentă",
+ "settings-change-language": "Schimbă limba",
+ "light": "Lumină",
+ "dark": "Întunecat",
+ "settings-log-out": "Deconectare",
+ "video-player-caption-placeholder-na": "N/A",
+ "video-player-no-captions": "Fără subtitrări",
+ "video-player-switch-text-track-to": "Comută pista de text la",
+ "loading": "Se încarcă",
+ "favorites": "Favorite",
+ "favorites-search-team": "Caută echipă",
+ "favorites-search-team-input": "Caută intrare",
+ "favorites-sort": "Sortare",
+ "favorites-sort-ascending": "Sortează echipele în ordine crescătoare",
+ "favorites-sort-descending": "Sortează echipele în ordine descrescătoare",
+ "favorites-sort-favorites": "Sortează echipele după favorite",
+ "favorites-options-list": "Lista de favorite",
+ "progress-bar-a11y-label-indeterminate": "Progresul nedeterminat",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Fă clic pentru a te conecta ca {profileName}.",
+ "a11y-hint-direction-left": "stânga",
+ "a11y-hint-direction-right": "dreapta",
+ "a11y-hint-direction-up": "sus",
+ "a11y-hint-direction-down": "jos",
+ "a11y-hint-use-direction-select-item": "Folosește {direction} pentru a selecta {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Meniul aplicației",
+ "menu-wrapper-a11y-label-menu-name": "Meniul aplicației,",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar de profil",
+ "menu-wrapper-a11y-hint-profile-avatar": "Fă clic pentru a accesa ecranul de selectare a profilului.",
+ "close-menu-button-a11y-label": "Închide meniul aplicației",
+ "menu-item-use-direction-to-go-to-a11y-label": "Folosește {direction} pentru a selecta: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Există un articol la {direction}.",
+ "a11y-hint-there-is-an-item-below": "Există un articol mai jos.",
+ "a11y-hint-there-is-an-item-above": "Există un articol mai sus.",
+ "carousel-item-a11y-label": "'{item}' în grupul '{group}'.",
+ "carousel-item-without-title-a11y-label": "Film fără nume",
+ "settings-screen-a11y-theme-section-hint": "Selectarea temei",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Fă clic pentru a schimba tema la {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Mai sus există o listă de filme.",
+ "a11y-hint-there-is-a-movie-list-below": "Mai jos există o listă de filme.",
+ "menu-wrapper-item-home-label": "Acasă",
+ "menu-wrapper-item-settings-label": "Setări",
+ "login-title-form": "Formular de conectare",
+ "email-field-a11y-label": "Aplicație de e-mail Valoare: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/sk/strings.puff.json b/assets/text/sk/strings.puff.json
new file mode 100644
index 0000000..32fa7e1
--- /dev/null
+++ b/assets/text/sk/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Zavrieť",
+ "common-save": "Uložiť",
+ "common-play": "Prehrať",
+ "common-resume": "Pokračovať",
+ "english": "Anglický",
+ "polish": "poľský",
+ "login-title": "Prihlásiť sa",
+ "login-password-label": "Heslo",
+ "login-password-error": "Heslo musí obsahovať aspoň 8 znakov a maximálne 20 znakov",
+ "login-email-label": "E-mail",
+ "login-email-error": "Zadajte správnu e-mailovú adresu, napr. mojemeno@email.com",
+ "login-button": "Prihlásiť sa",
+ "carousel-go-to-details": "Kliknutím prejdete na podrobnosti.",
+ "carousel-more": "Viac",
+ "carousel-no-title": "Bez názvu",
+ "details-screen-play-description-section-a11y-hint": "Informácie o filme",
+ "details-screen-no-available": "Toto video nie je dostupné vo vašom predplatnom.",
+ "profile-title": "Vybrať profil používateľa",
+ "profile-add-new": "Pridať nový profil",
+ "add-profile-title": "Pridať nový profil",
+ "add-profile-choose-avatar": "Vybrať avatar",
+ "add-profile-add-name": "Pridať meno",
+ "add-profile-form-name-label": "Meno",
+ "add-profile-form-name-error": "Meno by malo mať aspoň 2 znaky a maximálne 20 znakov",
+ "add-profile-text-of": "z",
+ "settings": "Nastavenia",
+ "settings-app-version": "Verzia aplikácie",
+ "settings-theme": "Téma",
+ "settings-profile": "Profil",
+ "settings-language": "Jazyk",
+ "settings-current-locale": "Aktuálne miestne nastavenie",
+ "settings-change-language": "Zmeniť jazyk",
+ "light": "Svetlé",
+ "dark": "Tmavý",
+ "settings-log-out": "Odhlásiť sa",
+ "video-player-caption-placeholder-na": "Neuplatňuje sa",
+ "video-player-no-captions": "Žiadne titulky",
+ "video-player-switch-text-track-to": "Prepnúť textovú stopu na",
+ "loading": "Načítava sa",
+ "favorites": "Obľúbené",
+ "favorites-search-team": "Hľadať tím",
+ "favorites-search-team-input": "Hľadať vstup",
+ "favorites-sort": "Zoradiť",
+ "favorites-sort-ascending": "Zoradiť tímy vzostupne",
+ "favorites-sort-descending": "Zoradiť tímy zostupne",
+ "favorites-sort-favorites": "Zoradiť tímy podľa obľúbených",
+ "favorites-options-list": "Zoznam obľúbených položiek",
+ "progress-bar-a11y-label-indeterminate": "Nedefinovaný priebeh",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kliknutím sa prihlásite ako {profileName}.",
+ "a11y-hint-direction-left": "vľavo",
+ "a11y-hint-direction-right": "vpravo",
+ "a11y-hint-direction-up": "hore",
+ "a11y-hint-direction-down": "dole",
+ "a11y-hint-use-direction-select-item": "Vyberte položku {item} pomocou šípky {direction}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Ponuka aplikácií,",
+ "menu-wrapper-a11y-label-menu-name": "Ponuka aplikácií",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar v profile",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kliknutím prejdete na obrazovku výberu profilu.",
+ "close-menu-button-a11y-label": "Zavrieť ponuku aplikácie",
+ "menu-item-use-direction-to-go-to-a11y-label": "Vyberte položku {destination} pomocou šípky: {direction}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Jedna položka je {direction}.",
+ "a11y-hint-there-is-an-item-below": "Nižšie je jedna položka.",
+ "a11y-hint-there-is-an-item-above": "Vyššie je jedna položka.",
+ "carousel-item-a11y-label": "„{item}“ v skupine „{group}“.",
+ "carousel-item-without-title-a11y-label": "Film bez názvu",
+ "settings-screen-a11y-theme-section-hint": "Výber témy",
+ "settings-screen-a11y-label-theme-variant": "Téma {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Kliknutím zmeníte tému na {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Vyššie je zoznam filmov.",
+ "a11y-hint-there-is-a-movie-list-below": "Nižšie je zoznam filmov.",
+ "menu-wrapper-item-home-label": "Domovská obrazovka",
+ "menu-wrapper-item-settings-label": "Nastavenia",
+ "login-title-form": "Prihlasovací formulár",
+ "email-field-a11y-label": "Pole E-mail. Hodnota: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/sl/strings.puff.json b/assets/text/sl/strings.puff.json
new file mode 100644
index 0000000..e7ed87c
--- /dev/null
+++ b/assets/text/sl/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Zapri",
+ "common-save": "Shrani",
+ "common-play": "Predvajanje",
+ "common-resume": "Nadaljuj",
+ "english": "angleščina",
+ "polish": "poljsko",
+ "login-title": "Prijava",
+ "login-password-label": "Geslo",
+ "login-password-error": "Geslo mora biti 8–20-mestno",
+ "login-email-label": "E-pošta",
+ "login-email-error": "Nastavite ustrezen e-poštni naslov, na primer jaz@enaposta.si",
+ "login-button": "Prijava",
+ "carousel-go-to-details": "Kliknite, da odprete podrobnosti.",
+ "carousel-more": "Več",
+ "carousel-no-title": "Brez naslova",
+ "details-screen-play-description-section-a11y-hint": "Opis filma",
+ "details-screen-no-available": "Ta video ni na voljo v vašem paketu.",
+ "profile-title": "Izbira profila uporabnika",
+ "profile-add-new": "Dodaj nov profil",
+ "add-profile-title": "Dodaj nov profil",
+ "add-profile-choose-avatar": "Izbira avatarja",
+ "add-profile-add-name": "Dodaj ime",
+ "add-profile-form-name-label": "Ime",
+ "add-profile-form-name-error": "Ime mora imeti vsaj 2 in največ 20 znakov",
+ "add-profile-text-of": "od",
+ "settings": "Nastavitve",
+ "settings-app-version": "Različica aplikacije",
+ "settings-theme": "Tema",
+ "settings-profile": "Profil",
+ "settings-language": "Jezik",
+ "settings-current-locale": "Trenutna območna nastavitev",
+ "settings-change-language": "Zamenjava jezika",
+ "light": "Svetlo",
+ "dark": "Temno",
+ "settings-log-out": "Odjava",
+ "video-player-caption-placeholder-na": "/",
+ "video-player-no-captions": "Brez napisov",
+ "video-player-switch-text-track-to": "Preklopite besedilno sled na",
+ "loading": "Nalaganje",
+ "favorites": "Priljubljeno",
+ "favorites-search-team": "Iskanje ekipe",
+ "favorites-search-team-input": "Vnos za iskanje",
+ "favorites-sort": "Razvrsti",
+ "favorites-sort-ascending": "Naraščajoča razvrstitev ekip",
+ "favorites-sort-descending": "Padajoča razvrstitev ekip",
+ "favorites-sort-favorites": "Razvrstitev ekip po priljubljenih",
+ "favorites-options-list": "Seznam priljubljenih",
+ "progress-bar-a11y-label-indeterminate": "Nedoločljiv napredek",
+ "avatar-a11y-label": "Avatar ",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kliknite za prijavo kot {profileName}.",
+ "a11y-hint-direction-left": "levo",
+ "a11y-hint-direction-right": "desno",
+ "a11y-hint-direction-up": "gor",
+ "a11y-hint-direction-down": "dol",
+ "a11y-hint-use-direction-select-item": "Uporabite {direction}, da izberete: {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Meni aplikacije,",
+ "menu-wrapper-a11y-label-menu-name": "Meni aplikacije",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar profila",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kliknite za odpiranje zaslona za izbiro profila.",
+ "close-menu-button-a11y-label": "Zapri meni aplikacije",
+ "menu-item-use-direction-to-go-to-a11y-label": "Uporabite {direction}, da izberete: {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Element je na {direction}.",
+ "a11y-hint-there-is-an-item-below": "Element je spodaj.",
+ "a11y-hint-there-is-an-item-above": "Element je zgoraj.",
+ "carousel-item-a11y-label": "»{item}« v skupini »{group}«.",
+ "carousel-item-without-title-a11y-label": "Neimenovan film",
+ "settings-screen-a11y-theme-section-hint": "Izbira teme",
+ "settings-screen-a11y-label-theme-variant": "Tema {themeName}",
+ "settings-screen-a11y-label-theme-variant-hint": "Kliknite, če želite temo spremeniti v: {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Zgoraj je seznam filmov.",
+ "a11y-hint-there-is-a-movie-list-below": "Spodaj je seznam filmov.",
+ "menu-wrapper-item-home-label": "Začetni zaslon",
+ "menu-wrapper-item-settings-label": "Nastavitve",
+ "login-title-form": "Obrazec za prijavo",
+ "email-field-a11y-label": "E-poštno polje. Vrednost: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/sr/strings.puff.json b/assets/text/sr/strings.puff.json
new file mode 100644
index 0000000..718f122
--- /dev/null
+++ b/assets/text/sr/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Zatvori",
+ "common-save": "Sačuvaj",
+ "common-play": "Reprodukuj",
+ "common-resume": "Nastavi",
+ "english": "Engleski",
+ "polish": "poljski",
+ "login-title": "Prijava",
+ "login-password-label": "Lozinka",
+ "login-password-error": "Lozinka mora imati najmanje 8 znakova i najviše 20 znakova",
+ "login-email-label": "E-pošta",
+ "login-email-error": "Postavite odgovarajuću adresu e-pošte, npr. me@somemail.com",
+ "login-button": "Prijava",
+ "carousel-go-to-details": "Kliknite da biste prešli na detalje.",
+ "carousel-more": "Više",
+ "carousel-no-title": "Bez naslova",
+ "details-screen-play-description-section-a11y-hint": "Opis filma",
+ "details-screen-no-available": "Ovaj video zapis nije dostupan u vašem planu.",
+ "profile-title": "Odaberi profil korisnika",
+ "profile-add-new": "Dodaj novi profil",
+ "add-profile-title": "Dodaj novi profil",
+ "add-profile-choose-avatar": "Odaberi avatar",
+ "add-profile-add-name": "Dodaj naziv",
+ "add-profile-form-name-label": "Naziv",
+ "add-profile-form-name-error": "Naziv treba da ima najmanje 2 i najviše 20 znakova",
+ "add-profile-text-of": "od",
+ "settings": "Podešavanja",
+ "settings-app-version": "Verzija aplikacije",
+ "settings-theme": "Tema",
+ "settings-profile": "Profil",
+ "settings-language": "Jezik",
+ "settings-current-locale": "Trenutni lokalni",
+ "settings-change-language": "Promeni jezik",
+ "light": "Svetlo",
+ "dark": "Tamno",
+ "settings-log-out": "Odjava",
+ "video-player-caption-placeholder-na": "Nedostupno",
+ "video-player-no-captions": "Bez titlova",
+ "video-player-switch-text-track-to": "Prebacite tekstualni zapis na",
+ "loading": "Učitavanje",
+ "favorites": "Omiljeno",
+ "favorites-search-team": "Traži tim",
+ "favorites-search-team-input": "Unos za pretragu",
+ "favorites-sort": "Poređaj",
+ "favorites-sort-ascending": "Poređaj timove po rastućem redosledu",
+ "favorites-sort-descending": "Poređaj timove po opadajućem redosledu",
+ "favorites-sort-favorites": "Poređaj timove po favoritima",
+ "favorites-options-list": "Lista omiljenih",
+ "progress-bar-a11y-label-indeterminate": "Neodređeni napredak",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Kliknite da biste se prijavili kao {profileName}.",
+ "a11y-hint-direction-left": "levo",
+ "a11y-hint-direction-right": "desno",
+ "a11y-hint-direction-up": "gore",
+ "a11y-hint-direction-down": "dole",
+ "a11y-hint-use-direction-select-item": "Koristite {direction} za odabir {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Meni aplikacije,",
+ "menu-wrapper-a11y-label-menu-name": "Meni aplikacije",
+ "menu-wrapper-a11y-label-profile-avatar": "Avatar profila",
+ "menu-wrapper-a11y-hint-profile-avatar": "Kliknite da biste otišli na ekran za odabir profila.",
+ "close-menu-button-a11y-label": "Zatvori meni aplikacije",
+ "menu-item-use-direction-to-go-to-a11y-label": "Koristite {direction} za odabir: {destination}'.",
+ "a11y-hint-there-is-an-item-to-the-side": "Postoji stavka {direction}.",
+ "a11y-hint-there-is-an-item-below": "Postoji stavka ispod.",
+ "a11y-hint-there-is-an-item-above": "Postoji stavka iznad.",
+ "carousel-item-a11y-label": "'{item}' u grupi '{group}'.",
+ "carousel-item-without-title-a11y-label": "Neimenovani film",
+ "settings-screen-a11y-theme-section-hint": "Odabir teme",
+ "settings-screen-a11y-label-theme-variant": "{themeName} tema",
+ "settings-screen-a11y-label-theme-variant-hint": "Kliknite da biste promenili temu u {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Iznad je lista filmova.",
+ "a11y-hint-there-is-a-movie-list-below": "Ispod je lista filmova.",
+ "menu-wrapper-item-home-label": "Početni ekran",
+ "menu-wrapper-item-settings-label": "Podešavanja",
+ "login-title-form": "Obrazac za prijavu",
+ "email-field-a11y-label": "Polje za e-poštu. Vrednost: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/sv/strings.puff.json b/assets/text/sv/strings.puff.json
new file mode 100644
index 0000000..7f1e713
--- /dev/null
+++ b/assets/text/sv/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Stäng",
+ "common-save": "Spara",
+ "common-play": "Spela upp",
+ "common-resume": "Återuppta",
+ "english": "Engelska",
+ "polish": "Polska",
+ "login-title": "Logga in",
+ "login-password-label": "Lösenord",
+ "login-password-error": "Lösenordet måste innehålla minst 8 tecken och högst 20 tecken",
+ "login-email-label": "E-post",
+ "login-email-error": "Ange en korrekt e-postadress, t.ex. me@somemail.com",
+ "login-button": "Logga in",
+ "carousel-go-to-details": "Klicka för att gå till detaljer.",
+ "carousel-more": "Mer",
+ "carousel-no-title": "Ingen titel",
+ "details-screen-play-description-section-a11y-hint": "Beskrivning av filmen",
+ "details-screen-no-available": "Videon är inte tillgänglig i prenumerationen.",
+ "profile-title": "Välj användarprofil",
+ "profile-add-new": "Lägg till ny profil",
+ "add-profile-title": "Lägg till ny profil",
+ "add-profile-choose-avatar": "Välj avatar",
+ "add-profile-add-name": "Lägg till namn",
+ "add-profile-form-name-label": "Namn",
+ "add-profile-form-name-error": "Namnet ska ha minst 2 och max 20 tecken",
+ "add-profile-text-of": "av",
+ "settings": "Inställningar",
+ "settings-app-version": "Appversion",
+ "settings-theme": "Tema",
+ "settings-profile": "Profil",
+ "settings-language": "Språk",
+ "settings-current-locale": "Aktuell plats",
+ "settings-change-language": "Ändra språk",
+ "light": "Belysning",
+ "dark": "Mörk",
+ "settings-log-out": "Logga ut",
+ "video-player-caption-placeholder-na": "Ej tillämplig",
+ "video-player-no-captions": "Inga undertexter",
+ "video-player-switch-text-track-to": "Byt textspår till",
+ "loading": "Läser in",
+ "favorites": "Favoriter",
+ "favorites-search-team": "Sök lag",
+ "favorites-search-team-input": "Sökinmatning",
+ "favorites-sort": "Sortera",
+ "favorites-sort-ascending": "Sortera lag i stigande ordning",
+ "favorites-sort-descending": "Sortera lag i fallande ordning",
+ "favorites-sort-favorites": "Sortera lag efter favoriter",
+ "favorites-options-list": "Lista över favoriter",
+ "progress-bar-a11y-label-indeterminate": "Obestämd utveckling",
+ "avatar-a11y-label": " avatar",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "Klicka för att logga in som {profileName}.",
+ "a11y-hint-direction-left": "vänster",
+ "a11y-hint-direction-right": "höger",
+ "a11y-hint-direction-up": "upp",
+ "a11y-hint-direction-down": "ned",
+ "a11y-hint-use-direction-select-item": "Använd {direction} för att välja {item}.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Appmeny,",
+ "menu-wrapper-a11y-label-menu-name": "Appmeny",
+ "menu-wrapper-a11y-label-profile-avatar": "Profilavatar",
+ "menu-wrapper-a11y-hint-profile-avatar": "Klicka för att gå till skärmen för profilval.",
+ "close-menu-button-a11y-label": "Stäng appmenyn",
+ "menu-item-use-direction-to-go-to-a11y-label": "Använd {direction} för att välja: {destination}.",
+ "a11y-hint-there-is-an-item-to-the-side": "Det finns en artikel till {direction}.",
+ "a11y-hint-there-is-an-item-below": "Det finns en artikel nedan.",
+ "a11y-hint-there-is-an-item-above": "Det finns en artikel ovan.",
+ "carousel-item-a11y-label": "”{item}” i gruppen ”{group}”.",
+ "carousel-item-without-title-a11y-label": "Ej namngiven film",
+ "settings-screen-a11y-theme-section-hint": "Temaval",
+ "settings-screen-a11y-label-theme-variant": "{themeName} tema",
+ "settings-screen-a11y-label-theme-variant-hint": "Klicka för att ändra tema till {themeName}.",
+ "a11y-hint-there-is-a-movie-list-above": "Ovan finns en filmlista.",
+ "a11y-hint-there-is-a-movie-list-below": "Nedan finns en filmlista.",
+ "menu-wrapper-item-home-label": "Hem",
+ "menu-wrapper-item-settings-label": "Inställningar",
+ "login-title-form": "Inloggningsformulär",
+ "email-field-a11y-label": "E-postfält. Värde: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/tr/strings.puff.json b/assets/text/tr/strings.puff.json
new file mode 100644
index 0000000..00742be
--- /dev/null
+++ b/assets/text/tr/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "Kapat",
+ "common-save": "Kaydet",
+ "common-play": "Oynat",
+ "common-resume": "Sürdür",
+ "english": "İngilizce",
+ "polish": "Polonya",
+ "login-title": "Giriş yap",
+ "login-password-label": "Parola",
+ "login-password-error": "Parola en az 8 karakter ve en fazla 20 karakter içermelidir",
+ "login-email-label": "E-posta",
+ "login-email-error": "Lütfen uygun bir e-posta adresi belirleyin, ör. me@somemail.com",
+ "login-button": "Giriş yap",
+ "carousel-go-to-details": "Detaylara gitmek için tıklayın.",
+ "carousel-more": "Daha Fazla",
+ "carousel-no-title": "Başlıksız",
+ "details-screen-play-description-section-a11y-hint": "Film açıklaması",
+ "details-screen-no-available": "Bu video planınızda kullanılamıyor.",
+ "profile-title": "Kullanıcı Profilini Seç",
+ "profile-add-new": "Yeni profil ekle",
+ "add-profile-title": "Yeni Profil Ekle",
+ "add-profile-choose-avatar": "Avatar seç",
+ "add-profile-add-name": "İsim ekle",
+ "add-profile-form-name-label": "Ad",
+ "add-profile-form-name-error": "İsim en az 2 ve en fazla 20 karakter içermelidir",
+ "add-profile-text-of": "/",
+ "settings": "Ayarlar",
+ "settings-app-version": "Uygulama sürümü",
+ "settings-theme": "Tema",
+ "settings-profile": "Profil",
+ "settings-language": "Dil",
+ "settings-current-locale": "Geçerli dil",
+ "settings-change-language": "Dili Değiştir",
+ "light": "Açık",
+ "dark": "Karanlık",
+ "settings-log-out": "Çıkış yap",
+ "video-player-caption-placeholder-na": "Yok",
+ "video-player-no-captions": "Alt yazı yok",
+ "video-player-switch-text-track-to": "Metin parçasını şu olarak değiştir:",
+ "loading": "Yükleniyor",
+ "favorites": "Favoriler",
+ "favorites-search-team": "Takım Ara",
+ "favorites-search-team-input": "Arama girişi",
+ "favorites-sort": "Sırala",
+ "favorites-sort-ascending": "Takımları artan şekilde sırala",
+ "favorites-sort-descending": "Takımları azalan şekilde sırala",
+ "favorites-sort-favorites": "Takımları favorilere göre sırala",
+ "favorites-options-list": "Favoriler listesi",
+ "progress-bar-a11y-label-indeterminate": "Belirsiz ilerleme",
+ "avatar-a11y-label": " avatarı",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "{profileName} olarak giriş yapmak için tıklayın.",
+ "a11y-hint-direction-left": "sol",
+ "a11y-hint-direction-right": "sağ",
+ "a11y-hint-direction-up": "yukarı",
+ "a11y-hint-direction-down": "down",
+ "a11y-hint-use-direction-select-item": "{item} öğesini seçmek için {direction} yönünü kullanın.",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "Uygulama menüsü,",
+ "menu-wrapper-a11y-label-menu-name": "Uygulama menüsü",
+ "menu-wrapper-a11y-label-profile-avatar": "Profil avatarı",
+ "menu-wrapper-a11y-hint-profile-avatar": "Profil seçim ekranına gitmek için tıklayın.",
+ "close-menu-button-a11y-label": "Uygulama menüsünü kapat",
+ "menu-item-use-direction-to-go-to-a11y-label": "{destination} öğesini seçmek için {direction} yönünü kullanın.",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction} yönünde bir öğe vardır.",
+ "a11y-hint-there-is-an-item-below": "Aşağıda bir öğe vardır.",
+ "a11y-hint-there-is-an-item-above": "Yukarıda bir öğe vardır.",
+ "carousel-item-a11y-label": "'{group}' grubunda '{item}'.",
+ "carousel-item-without-title-a11y-label": "Adsız film",
+ "settings-screen-a11y-theme-section-hint": "Tema seçimi",
+ "settings-screen-a11y-label-theme-variant": "{themeName} teması",
+ "settings-screen-a11y-label-theme-variant-hint": "Temayı {themeName} olarak değiştirmek için tıklayın.",
+ "a11y-hint-there-is-a-movie-list-above": "Yukarıda bir film listesi vardır.",
+ "a11y-hint-there-is-a-movie-list-below": "Yukarıda bir film listesi vardır.",
+ "menu-wrapper-item-home-label": "Anasayfa",
+ "menu-wrapper-item-settings-label": "Ayarlar",
+ "login-title-form": "Giriş yapma formu",
+ "email-field-a11y-label": "E-posta alanı. Değer: {email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/zh-CN/strings.puff.json b/assets/text/zh-CN/strings.puff.json
new file mode 100644
index 0000000..dd642c1
--- /dev/null
+++ b/assets/text/zh-CN/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "关闭",
+ "common-save": "保存",
+ "common-play": "播放",
+ "common-resume": "继续",
+ "english": "英语",
+ "polish": "波兰语",
+ "login-title": "登录",
+ "login-password-label": "密码",
+ "login-password-error": "密码必须包含 8-20 个字符",
+ "login-email-label": "电子邮件",
+ "login-email-error": "请设置一个正确的电子邮件地址,例如 me@somemail.com",
+ "login-button": "登录",
+ "carousel-go-to-details": "点击进入详情。",
+ "carousel-more": "更多",
+ "carousel-no-title": "无标题",
+ "details-screen-play-description-section-a11y-hint": "电影描述",
+ "details-screen-no-available": "您的套餐不提供此视频。",
+ "profile-title": "选择用户个人资料",
+ "profile-add-new": "添加新个人资料",
+ "add-profile-title": "添加新个人资料",
+ "add-profile-choose-avatar": "选择头像",
+ "add-profile-add-name": "添加名称",
+ "add-profile-form-name-label": "名称",
+ "add-profile-form-name-error": "名称必须包含 2-20 个字符",
+ "add-profile-text-of": "关于",
+ "settings": "设置",
+ "settings-app-version": "应用版本",
+ "settings-theme": "主题",
+ "settings-profile": "个人资料",
+ "settings-language": "语言",
+ "settings-current-locale": "当前区域",
+ "settings-change-language": "更改语言",
+ "light": "浅色",
+ "dark": "深色",
+ "settings-log-out": "注销",
+ "video-player-caption-placeholder-na": "不适用",
+ "video-player-no-captions": "无字幕",
+ "video-player-switch-text-track-to": "切换文本轨道为",
+ "loading": "正在加载",
+ "favorites": "我的最爱",
+ "favorites-search-team": "搜索球队",
+ "favorites-search-team-input": "搜索输入",
+ "favorites-sort": "排序",
+ "favorites-sort-ascending": "球队升序排序",
+ "favorites-sort-descending": "球队降序排序",
+ "favorites-sort-favorites": "按“我的最爱”进行球队排序",
+ "favorites-options-list": "“我的最爱”列表",
+ "progress-bar-a11y-label-indeterminate": "不确定进度",
+ "avatar-a11y-label": "头像",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "点击以 {profileName} 的身份登录。",
+ "a11y-hint-direction-left": "向左",
+ "a11y-hint-direction-right": "向右",
+ "a11y-hint-direction-up": "向上",
+ "a11y-hint-direction-down": "向下",
+ "a11y-hint-use-direction-select-item": "使用{direction}选择{item}。",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "应用菜单,",
+ "menu-wrapper-a11y-label-menu-name": "应用菜单",
+ "menu-wrapper-a11y-label-profile-avatar": "个人资料头像",
+ "menu-wrapper-a11y-hint-profile-avatar": "点击前往个人资料选择屏幕。",
+ "close-menu-button-a11y-label": "关闭应用菜单",
+ "menu-item-use-direction-to-go-to-a11y-label": "使用{direction}选择:{destination}。",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction}有一件商品。",
+ "a11y-hint-there-is-an-item-below": "下方有一件商品。",
+ "a11y-hint-there-is-an-item-above": "上方有一件商品。",
+ "carousel-item-a11y-label": "“{item}”在群组“{group}”中。",
+ "carousel-item-without-title-a11y-label": "未命名电影",
+ "settings-screen-a11y-theme-section-hint": "主题选择",
+ "settings-screen-a11y-label-theme-variant": "{themeName}主题",
+ "settings-screen-a11y-label-theme-variant-hint": "点击将主题更改为 {themeName}。",
+ "a11y-hint-there-is-a-movie-list-above": "上方是电影列表。",
+ "a11y-hint-there-is-a-movie-list-below": "下方是电影列表。",
+ "menu-wrapper-item-home-label": "主页",
+ "menu-wrapper-item-settings-label": "设置",
+ "login-title-form": "登录表单",
+ "email-field-a11y-label": "电子邮件字段。值:{email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/zh-HK/strings.puff.json b/assets/text/zh-HK/strings.puff.json
new file mode 100644
index 0000000..0b0b17a
--- /dev/null
+++ b/assets/text/zh-HK/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "關閉",
+ "common-save": "儲存",
+ "common-play": "播放",
+ "common-resume": "繼續播放",
+ "english": "英文",
+ "polish": "波蘭文",
+ "login-title": "登入",
+ "login-password-label": "密碼",
+ "login-password-error": "密碼最少須有 8 個字元,最多不得超過 20 個字元",
+ "login-email-label": "電子郵件",
+ "login-email-error": "請輸入正確的電子郵件地址,例如:me@example.com",
+ "login-button": "登入",
+ "carousel-go-to-details": "按一下查閱詳細資訊。",
+ "carousel-more": "其他",
+ "carousel-no-title": "無名稱",
+ "details-screen-play-description-section-a11y-hint": "電影描述",
+ "details-screen-no-available": "此影片不適用於您的觀看方案。",
+ "profile-title": "選取個人檔案",
+ "profile-add-new": "新增個人檔案",
+ "add-profile-title": "新增個人檔案",
+ "add-profile-choose-avatar": "選取虛擬人偶",
+ "add-profile-add-name": "新增名稱",
+ "add-profile-form-name-label": "名稱",
+ "add-profile-form-name-error": "名稱最少 2 個字元,最多 20 個字元",
+ "add-profile-text-of": "/",
+ "settings": "設定",
+ "settings-app-version": "應用程式版本",
+ "settings-theme": "主題",
+ "settings-profile": "個人檔案",
+ "settings-language": "語言",
+ "settings-current-locale": "目前語系",
+ "settings-change-language": "變更語言",
+ "light": "淺色",
+ "dark": "深色",
+ "settings-log-out": "登出",
+ "video-player-caption-placeholder-na": "不適用",
+ "video-player-no-captions": "無字幕",
+ "video-player-switch-text-track-to": "切換字幕語言為",
+ "loading": "載入中",
+ "favorites": "我的最愛",
+ "favorites-search-team": "搜尋團隊",
+ "favorites-search-team-input": "搜尋輸入",
+ "favorites-sort": "排序",
+ "favorites-sort-ascending": "按升序排列隊伍清單",
+ "favorites-sort-descending": "按降序排列隊伍清單",
+ "favorites-sort-favorites": "按我的最愛排序隊伍",
+ "favorites-options-list": "我的最愛清單",
+ "progress-bar-a11y-label-indeterminate": "進度未定",
+ "avatar-a11y-label": "虛擬人偶",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "按一下以{profileName}的身份登入。",
+ "a11y-hint-direction-left": "左",
+ "a11y-hint-direction-right": "右",
+ "a11y-hint-direction-up": "上",
+ "a11y-hint-direction-down": "下",
+ "a11y-hint-use-direction-select-item": "使用 {direction} 來選取 {item}。",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "應用程式選單,",
+ "menu-wrapper-a11y-label-menu-name": "應用程式選單",
+ "menu-wrapper-a11y-label-profile-avatar": "個人檔案虛擬人偶",
+ "menu-wrapper-a11y-hint-profile-avatar": "按一下可移至設定檔選取畫面。",
+ "close-menu-button-a11y-label": "關閉應用程式選單",
+ "menu-item-use-direction-to-go-to-a11y-label": "利用 {direction} 來選取 {destination}。",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction} 有一個項目。",
+ "a11y-hint-there-is-an-item-below": "下面有一個項目。",
+ "a11y-hint-there-is-an-item-above": "上面有一個項目。",
+ "carousel-item-a11y-label": "「{item}」 屬於群組 「{group}」。",
+ "carousel-item-without-title-a11y-label": "未命名的電影",
+ "settings-screen-a11y-theme-section-hint": "主題選取",
+ "settings-screen-a11y-label-theme-variant": "{themeName}主題",
+ "settings-screen-a11y-label-theme-variant-hint": "按一下可將主題變更為{themeName}。",
+ "a11y-hint-there-is-a-movie-list-above": "以上是一個電影清單。",
+ "a11y-hint-there-is-a-movie-list-below": "以下是一個電影清單。",
+ "menu-wrapper-item-home-label": "首頁",
+ "menu-wrapper-item-settings-label": "設定",
+ "login-title-form": "登入表格",
+ "email-field-a11y-label": "電子郵件欄位 數值:{email}"
+ }
+}
\ No newline at end of file
diff --git a/assets/text/zh-TW/strings.puff.json b/assets/text/zh-TW/strings.puff.json
new file mode 100644
index 0000000..2cd9ca6
--- /dev/null
+++ b/assets/text/zh-TW/strings.puff.json
@@ -0,0 +1,80 @@
+{
+ "dir": "ltr",
+ "resources": {
+ "common-close": "關閉",
+ "common-save": "儲存",
+ "common-play": "播放",
+ "common-resume": "繼續",
+ "english": "英文",
+ "polish": "波蘭文",
+ "login-title": "登入",
+ "login-password-label": "密碼",
+ "login-password-error": "密碼必須介於 8 到 20 個字元",
+ "login-email-label": "電子郵件",
+ "login-email-error": "請設定正確的電子郵件地址,例如 me@somemail.com",
+ "login-button": "登入",
+ "carousel-go-to-details": "按一下以前往詳細資訊。",
+ "carousel-more": "其他",
+ "carousel-no-title": "無片名",
+ "details-screen-play-description-section-a11y-hint": "電影描述",
+ "details-screen-no-available": "您的方案不包含此影片。",
+ "profile-title": "選取使用者個人檔案",
+ "profile-add-new": "新增個人檔案",
+ "add-profile-title": "新增個人檔案",
+ "add-profile-choose-avatar": "選取虛擬人偶",
+ "add-profile-add-name": "新增名稱",
+ "add-profile-form-name-label": "名稱",
+ "add-profile-form-name-error": "名稱必須介於 2 到 20 個字元",
+ "add-profile-text-of": "/",
+ "settings": "設定",
+ "settings-app-version": "應用程式版本",
+ "settings-theme": "主題",
+ "settings-profile": "個人檔案",
+ "settings-language": "語言",
+ "settings-current-locale": "目前地區",
+ "settings-change-language": "變更語言",
+ "light": "淺色",
+ "dark": "深色",
+ "settings-log-out": "登出",
+ "video-player-caption-placeholder-na": "不適用",
+ "video-player-no-captions": "無字幕",
+ "video-player-switch-text-track-to": "將文字軌道切換為",
+ "loading": "載入中",
+ "favorites": "我的最愛",
+ "favorites-search-team": "搜尋球隊",
+ "favorites-search-team-input": "搜尋輸入源",
+ "favorites-sort": "排序",
+ "favorites-sort-ascending": "依球隊名稱升序排序",
+ "favorites-sort-descending": "依球隊名稱降序排序",
+ "favorites-sort-favorites": "依我的最愛排序球隊",
+ "favorites-options-list": "最愛清單",
+ "progress-bar-a11y-label-indeterminate": "進度不明確",
+ "avatar-a11y-label": " 虛擬人偶",
+ "select-user-profile-screen-a11y-hint-click-to-log-in-as": "按一下以使用 {profileName} 的身分登入。",
+ "a11y-hint-direction-left": "左",
+ "a11y-hint-direction-right": "右",
+ "a11y-hint-direction-up": "上",
+ "a11y-hint-direction-down": "下",
+ "a11y-hint-use-direction-select-item": "使用向{direction}鍵以選取「{item}」。",
+ "menu-wrapper-a11y-label-menu-sentence-prefix": "應用程式功能表,",
+ "menu-wrapper-a11y-label-menu-name": "應用程式功能表",
+ "menu-wrapper-a11y-label-profile-avatar": "個人檔案虛擬人偶",
+ "menu-wrapper-a11y-hint-profile-avatar": "按一下以前往個人檔案選取畫面。",
+ "close-menu-button-a11y-label": "關閉應用程式功能表",
+ "menu-item-use-direction-to-go-to-a11y-label": "使用向{direction}鍵以選取「{destination}」。",
+ "a11y-hint-there-is-an-item-to-the-side": "{direction}方有一個項目。",
+ "a11y-hint-there-is-an-item-below": "下方有一個項目。",
+ "a11y-hint-there-is-an-item-above": "上方有一個項目。",
+ "carousel-item-a11y-label": "「{group}」群組中的「{item}」。",
+ "carousel-item-without-title-a11y-label": "未命名的電影",
+ "settings-screen-a11y-theme-section-hint": "選取主題",
+ "settings-screen-a11y-label-theme-variant": "{themeName} 主題",
+ "settings-screen-a11y-label-theme-variant-hint": "按一下以將主題變更為 {themeName}。",
+ "a11y-hint-there-is-a-movie-list-above": "上方為電影清單。",
+ "a11y-hint-there-is-a-movie-list-below": "下方為電影清單。",
+ "menu-wrapper-item-home-label": "首頁",
+ "menu-wrapper-item-settings-label": "設定",
+ "login-title-form": "登入表單",
+ "email-field-a11y-label": "電子郵件地址欄位。值:{email}"
+ }
+}
\ No newline at end of file
diff --git a/babel.config.js b/babel.config.js
index 676bbd9..eb2f69c 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,21 +1,68 @@
-/*
- * Copyright (c) 2025 Amazon.com, Inc. or its affiliates. All rights reserved.
- *
- * PROPRIETARY/CONFIDENTIAL. USE IS SUBJECT TO LICENSE TERMS.
- */
-module.exports = {
- presets: [
- [
- 'module:metro-react-native-babel-preset',
- {useTransformReactJSXExperimental: true},
- ],
- ],
- plugins: [
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+const ReactCompilerConfig = {
+ target: '18'
+};
+
+const appConfigPlugin = require('./src/services/appConfig/babel/appConfigPlugin.js');
+
+const isDebug =
+ process.env['REACT_APP_DEBUG'] === '1' ||
+ process.env['REACT_APP_DEBUG'] === 'true';
+
+module.exports = (api) => {
+ const babelEnv = api.env();
+
+ // plugins common fo all environment
+ const plugins = [
+ ['babel-plugin-react-compiler', ReactCompilerConfig],
+ ['@amazon-devices/react-native-reanimated/plugin'],
[
- '@babel/plugin-transform-react-jsx',
+ 'module-resolver',
{
- runtime: 'automatic',
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ root: ['./'],
+ alias: {
+ '@Api': './src/api',
+ '@AppAssets': './src/assets',
+ '@AppComponents': './src/components',
+ '@AppScreens': './src/screens',
+ '@AppServices': './src/services',
+ '@AppUtils': './src/utils',
+ '@AppTestUtils': './src/test-utils',
+ '@AppTheme': './src/theme',
+ '@AppStore': './src/store',
+ '@AppModels': './src/models',
+ '@AppSrc': './src',
+ '@AppRoot': './',
+ },
},
],
- ],
+ appConfigPlugin(api),
+ ];
+
+ // plugins pushed only for "test" env
+ if (babelEnv === 'test') {
+ if (!isDebug) {
+ // NOTE: strip console.logs from testing enviroment if not in DEBUG mode
+ // intentionally preserving console.errors and console.warnings
+ plugins.push([
+ 'transform-remove-console',
+ { exclude: ['error', 'warn'] },
+ ]);
+ }
+ }
+
+ // plugins pushed only for "production" env
+ if (babelEnv === 'production') {
+ plugins.push('transform-remove-console');
+ }
+
+ const presets = ['module:metro-react-native-babel-preset'];
+
+ return {
+ presets,
+ plugins,
+ };
};
diff --git a/docs/images/home-screen.png b/docs/images/home-screen.png
index 0674e0e..c3e9f04 100644
Binary files a/docs/images/home-screen.png and b/docs/images/home-screen.png differ
diff --git a/get-current-commit.sh b/get-current-commit.sh
new file mode 100755
index 0000000..cd8c146
--- /dev/null
+++ b/get-current-commit.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+echo "Setting commit hash value"
+export REACT_APP_VERSION=$(git rev-parse --short HEAD)
+
+# Create .env if it doesn't exist
+[ ! -f .env ] && touch .env
+
+# Update or add REACT_APP_VERSION
+sed -i '' -e "/^REACT_APP_VERSION=/d" .env
+echo "REACT_APP_VERSION=$REACT_APP_VERSION" >> .env
+
+echo "Current commit hash:"
+echo $REACT_APP_VERSION
diff --git a/index.js b/index.js
index adf990d..4f31cac 100644
--- a/index.js
+++ b/index.js
@@ -1,15 +1,17 @@
-/*
- * Copyright (c) 2025 Amazon.com, Inc. or its affiliates. All rights reserved.
- *
- * PROPRIETARY/CONFIDENTIAL. USE IS SUBJECT TO LICENSE TERMS.
- */
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
import { AppRegistry, LogBox } from 'react-native';
-import { name as appName } from './app.json';
import { App } from './src/App';
+import { name as appName, syncSourceName } from './app.json';
+import { EpgSyncSource } from './src/epg/EpgSyncSource';
-// Temporary workaround for problem with nested text
-// not working currently.
+// This command deactivate debug logs and warnings that don't affect core functionality.
+// To see console messages as small windows at the top, comment out the line below.
LogBox.ignoreAllLogs();
AppRegistry.registerComponent(appName, () => App);
+AppRegistry.registerComponent(
+ syncSourceName,
+ () => EpgSyncSource,
+);
diff --git a/jest.config.json b/jest.config.json
index 8016922..cacc0d3 100644
--- a/jest.config.json
+++ b/jest.config.json
@@ -1,46 +1,52 @@
{
"preset": "react-native",
- "globals": {
- "ts-jest": {
- "babelConfig": true,
- "diagnostics": true
- }
- },
- "transform": {
- "^.+\\.tsx?$": "ts-jest"
- },
- "transformIgnorePatterns": [
- "node_modules/(?!(react-native|@react-native|@amazon-devices/kepler-ui-components|@amazon-devices/react-linear-gradient|@amazon-devices/react-native-kepler|@amazon-devices/lottie-react-native|iso-639-3)/)"
- ],
- "modulePathIgnorePatterns": ["src/w3cmedia/shakaplayer/dist/"],
- "testRegex": "/tst/.*\\.(test|spec)\\.(ts|tsx|js)$",
+ "testRegex": "/*/__tests__/.*\\.(test|spec)\\.(ts|tsx|js)$",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"],
"moduleNameMapper": {
"^~/(.*)$": "/src/$1"
},
-
- "coverageDirectory": "/coverage",
+ "modulePaths": ["/src"],
+ "roots": ["/src/", "/node_modules/"],
+ "collectCoverage": true,
+ "coverageReporters": ["json", "json-summary", "lcov", "text"],
"collectCoverageFrom": [
"src/**/*",
"!**/index.{ts,js,tsx}",
"!**/types.ts",
"!src/**/*.d.ts",
- "!src/w3cmedia/polyfills/**",
- "!src/w3cmedia/shakaplayer/**"
+ "!src/**/__snapshots__/*",
+ "!src/services/appConfig/babel/*",
+ "!src/**/__tests__/mocks/*",
+ "!src/test-utils/**/*",
+ "!src/api/**/*.json",
+ "!src/models/**/*.ts",
+ "!src/**/constants.ts",
+ "!src/**/types.ts",
+ "!src/**/styles.ts",
+ "!src/w3cmedia/*",
+ "!src/w3cmedia/polyfills/*",
+ "!src/w3cmedia/shakaplayer/dist/**/*",
+ "!src/w3cmedia/shakaplayer/*",
+ "src/w3cmedia/shakaplayer/getTrackVariantLabel.ts"
],
- "collectCoverage": true,
- "coverageReporters": [
- "json",
- "json-summary",
- "lcov",
- "text",
- "clover",
- "cobertura"
- ],
-
- "setupFiles": [
- "./node_modules/@amazon-devices/react-native-kepler/jest/setup.js",
- "./node_modules/react-native/jest/setup.js"
+ "testPathIgnorePatterns": ["src/w3cmedia/shakaplayer/dist"],
+ "transformIgnorePatterns": [
+ "node_modules/(?!(jest-)?react-native|@react-native|@amazon-devices/kepler-ui-components|@amazon-devices/react-native-kepler|@amazon-devices/lottie-react-native|@amazon-devices/react-navigation|@amazon-devices/react-native-reanimated|@amazon-devices/react-native-gesture-handler|@amazon-devices/react-navigation__drawer|@amazon-devices/react-linear-gradient|iso-639-3)"
],
- "setupFilesAfterEnv": ["/jest.setup.ts"]
+ "coverageDirectory": ".tmp/coverage",
+ "globals": {
+ "ts-jest": {
+ "babelConfig": true,
+ "diagnostics": true
+ }
+ },
+ "coverageThreshold": {
+ "global": {
+ "branches": 55,
+ "functions": 55,
+ "lines": 55,
+ "statements": 55
+ }
+ },
+ "setupFilesAfterEnv": ["/jest.setup.tsx"]
}
diff --git a/jest.setup.tsx b/jest.setup.tsx
new file mode 100644
index 0000000..58db957
--- /dev/null
+++ b/jest.setup.tsx
@@ -0,0 +1,316 @@
+/**
+ * NOTE: below line will cause lint error because jest-fetch-mock
+ * is added to dev-dependencies (and it should be)
+ * TODO: consider adjusting eslint config to exclude
+ * test-related files from that eslint rule
+ */
+// eslint-disable-next-line import/no-extraneous-dependencies
+import fetchMock from 'jest-fetch-mock';
+import React from 'react';
+import { type ScaledSize, View } from 'react-native';
+import 'react-native/jest/setup';
+import 'react-native-gesture-handler/jestSetup';
+
+import '@amazon-devices/react-native-gesture-handler/jestSetup';
+import '@amazon-devices/react-native-kepler/jest/setup';
+import type Icon from '@amazon-devices/react-native-vector-icons/MaterialCommunityIcons';
+import '@testing-library/jest-native/extend-expect';
+
+// Mock NativeModules before any imports
+jest.mock('react-native/Libraries/BatchedBridge/NativeModules', () => ({
+ UIManager: {
+ RCTView: () => {},
+ },
+ PlatformConstants: {
+ getConstants: () => ({
+ isTesting: true,
+ }),
+ },
+ NativeModules: {},
+}));
+
+// Mock W3C Media components
+jest.mock('@amazon-devices/react-native-w3cmedia', () => {
+ class KeplerMediaControlHandler {
+ async handlePlay() {}
+ async handlePause() {}
+ async handleStop() {}
+ async handleTogglePlayPause() {}
+ async handleStartOver() {}
+ async handleFastForward() {}
+ async handleRewind() {}
+ async handleSeek() {}
+ }
+ return {
+ KeplerMediaControlHandler,
+ VideoPlayer: jest.fn().mockImplementation(() => ({
+ play: jest.fn(),
+ pause: jest.fn(),
+ currentTime: 0,
+ duration: 100,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ })),
+ KeplerVideoSurfaceView: jest.fn(),
+ KeplerCaptionsView: jest.fn(),
+ };
+});
+
+fetchMock.enableMocks();
+
+jest.mock('@amazon-devices/react-native-screens', () => ({
+ ...jest.requireActual('@amazon-devices/react-native-screens'),
+ enableScreens: jest.fn(),
+}));
+
+jest.mock('@react-native-async-storage/async-storage', () =>
+ require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
+);
+
+jest.mock('@amazon-devices/react-native-device-info', () => ({
+ ...require('@amazon-devices/react-native-device-info/jest/react-native-device-info-mock'),
+ getDeviceType: jest.fn(() => 'TV'),
+ default: {
+ getDeviceType: jest.fn(() => 'TV'),
+ },
+}));
+
+const IconMock: typeof Icon = ({ ...props }) => ;
+
+jest.mock(
+ '@amazon-devices/react-native-vector-icons/MaterialCommunityIcons',
+ () => IconMock,
+);
+
+jest.mock(
+ '@amazon-devices/react-native-vector-icons/MaterialIcons',
+ () => IconMock,
+);
+
+// @ts-expect-error
+global.navigator = {};
+
+jest.mock('@amazon-devices/keplerscript-netmgr-lib', () =>
+ require('src/test-utils/mocks/keplerscript-netmgr-lib'),
+);
+
+jest.mock('@amazon-devices/asset-resolver-lib', () =>
+ require('src/test-utils/mocks/asset-resolver-lib'),
+);
+
+jest.mock('@amazon-devices/keplerscript-kepleri18n-lib', () =>
+ require('src/test-utils/mocks/keplerscript-kepleri18n-lib'),
+);
+
+jest.mock(
+ '@amazon-devices/react-native-kepler/Libraries/Utilities/registerGeneratedViewConfig',
+ () => () => {},
+);
+
+jest.mock('@amazon-devices/react-native-kepler', () => {
+ return {
+ __esModule: true,
+ HWEvent: jest.fn(),
+ KeplerAppStateChange: jest.fn(),
+ useCallback: jest.fn(),
+ useTVEventHandler: jest.fn((evt) => evt),
+ TVFocusGuideView: jest.fn(({ children }) => children),
+ default: jest.fn(),
+ InteractionManager: jest.requireActual(
+ '@amazon-devices/react-native-kepler/Libraries/Interaction/InteractionManager',
+ ),
+ useHideSplashScreenCallback: jest.fn(() => () => {}),
+ usePreventHideSplashScreen: jest.fn(),
+ Switch: jest.fn(),
+ useGetCurrentKeplerAppStateCallback: jest.fn(),
+ useAddKeplerAppStateListenerCallback: jest.fn().mockReturnValue(
+ jest.fn(() => ({
+ remove: () => {},
+ })),
+ ),
+ useComponentInstance: jest.fn(() => ({})),
+ };
+});
+
+// @ts-expect-error
+global.performance.reportFullyDrawn = () => {};
+
+// Mock Platform
+jest.mock('react-native/Libraries/Utilities/Platform', () => ({
+ OS: 'ios',
+ select: jest.fn((obj) => obj.ios),
+}));
+
+// Mock StyleSheet
+jest.mock('react-native/Libraries/StyleSheet/StyleSheet', () => ({
+ create: jest.fn((styles) => ({ ...styles })),
+ compose: (style1: Object, style2: Object) => ({ ...style1, ...style2 }),
+ flatten: (style: Object) => {
+ const flattenStyle = require('react-native/Libraries/StyleSheet/flattenStyle');
+ return flattenStyle(style);
+ },
+ hairlineWidth: 1,
+}));
+
+// Mock NativeEventEmitter
+jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
+ class MockNativeEventEmitter {
+ addListener = jest.fn();
+ removeListener = jest.fn();
+ emit = jest.fn();
+ }
+ return MockNativeEventEmitter;
+});
+
+const mockedDimensionsValue: ScaledSize = {
+ width: 375,
+ height: 667,
+ scale: 2,
+ fontScale: 2,
+};
+
+jest.mock('react-native/Libraries/Utilities/Dimensions', () => {
+ return {
+ default: {
+ get: () => mockedDimensionsValue,
+ },
+ };
+});
+
+const mockUseWindowDimensions = jest.fn(() => mockedDimensionsValue);
+
+jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
+ default: mockUseWindowDimensions,
+}));
+
+jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => {
+ return {
+ getEnforcing: jest.fn((name) => {
+ switch (name) {
+ case 'ImageLoader':
+ return {
+ getSize: jest.fn((_url) => Promise.resolve([320, 240])),
+ prefetchImage: jest.fn(),
+ };
+
+ default:
+ return {};
+ }
+ }),
+ get: jest.fn(),
+ };
+});
+
+jest.mock('@amazon-devices/kepler-channel', () => ({
+ ChannelServerComponent2: {
+ getOrMakeServer: jest.fn().mockReturnValue({
+ setHandlerForComponent: jest.fn(),
+ }),
+ makeChannelResponseBuilder: jest.fn().mockReturnValue({
+ channelStatus: jest.fn().mockReturnValue({
+ build: jest.fn().mockReturnValue({}),
+ }),
+ }),
+ },
+ ChannelServerComponent: {
+ channelServer: {
+ handler: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@amazon-devices/kepler-epg-provider', () => ({
+ __esModule: true,
+ ChannelDescriptorBuilder: jest.fn(),
+ IChannelDescriptor: jest.fn(),
+}));
+
+jest.mock('@amazon-devices/keplerscript-audio-lib', () => ({
+ AudioManager: jest.fn(),
+ AudioEvent: jest.fn(),
+ AudioContentType: jest.fn(),
+ AudioUsageType: jest.fn(),
+ AudioFlags: jest.fn(),
+ AudioDevice: jest.fn(),
+ AudioSampleFormat: jest.fn(),
+}));
+
+jest.mock('@amazon-devices/react-native-w3cmedia/dist/headless', () => ({
+ WebCrypto: jest.fn(),
+ WebCryptoKey: jest.fn(),
+ WebCryptoKeyPair: jest.fn(),
+ WebCryptoKeyUsage: jest.fn(),
+ WebCryptoKeyType: jest.fn(),
+ WebCryptoKeyAlgorithm: jest.fn(),
+ WebCryptoKeyOperation: jest.fn(),
+}));
+
+// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
+jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
+
+// Mock the specific useComponentState hook that's causing the issue
+jest.mock(
+ '@amazon-devices/kepler-ui-components/dist/src/common/state/useComponentState',
+ () => {
+ const mockUseComponentState = jest.fn(() => ({
+ isTV: true,
+ deviceType: 'TV',
+ }));
+ return {
+ __esModule: true,
+ default: mockUseComponentState,
+ useComponentState: mockUseComponentState,
+ };
+ },
+);
+
+// Mock Kepler Audio Library
+jest.mock('@amazon-devices/keplerscript-audio-lib', () => ({
+ AudioManager: {
+ getSupportedPlaybackConfigurationsAsync: jest.fn().mockResolvedValue([]),
+ registerAudioEventObserverAsync: jest.fn().mockResolvedValue(undefined),
+ unregisterAudioEventObserverAsync: jest.fn().mockResolvedValue(undefined),
+ },
+ AudioEvent: {
+ DEVICE_STATE_UPDATE: 'DEVICE_STATE_UPDATE',
+ },
+ AudioContentType: {
+ CONTENT_TYPE_MUSIC: 'CONTENT_TYPE_MUSIC',
+ },
+ AudioUsageType: {
+ USAGE_MEDIA: 'USAGE_MEDIA',
+ },
+ AudioFlags: {
+ FLAG_NONE: 'FLAG_NONE',
+ },
+ AudioDevice: {
+ DEVICE_DEFAULT: 'DEVICE_DEFAULT',
+ },
+ AudioSampleFormat: {
+ FORMAT_PCM_16_BIT: 'FORMAT_PCM_16_BIT',
+ FORMAT_PCM_8_BIT: 'FORMAT_PCM_8_BIT',
+ FORMAT_PCM_24_BIT: 'FORMAT_PCM_24_BIT',
+ FORMAT_PCM_32_BIT: 'FORMAT_PCM_32_BIT',
+ },
+}));
+
+// Mock kepler-player-client to prevent ES module import errors
+jest.mock('@amazon-devices/kepler-player-client', () => ({
+ PlayerClientFactory: {
+ createPlayerClient: jest.fn(),
+ },
+}));
+
+// Mock the hybrid video player layer globally
+jest.mock('src/services/videoPlayer/hybrid/useSmartVideoPlayer', () => ({
+ useSmartVideoPlayer: jest.fn(() => ({
+ state: 'INSTANTIATING',
+ videoPlayerServiceRef: { current: null },
+ key: 'test-key',
+ })),
+ useVideoPlayerTypeRecommendation: jest.fn(() => ({
+ playerType: 'REGULAR',
+ reason: 'Test environment',
+ isHeadlessAvailable: false,
+ })),
+}));
diff --git a/kpi-test-scenarios/foreground-memory.py b/kpi-test-scenarios/foreground-memory.py
new file mode 100644
index 0000000..90786ec
--- /dev/null
+++ b/kpi-test-scenarios/foreground-memory.py
@@ -0,0 +1,107 @@
+import logging
+from typing import Any, Dict, Optional, Union
+
+"""Import more if needed."""
+import time
+
+supportAppiumOptions: bool = True
+try:
+ from appium.webdriver.webdriver import AppiumOptions
+except ImportError:
+ supportAppiumOptions = False
+
+from appium import webdriver
+
+appium_url: str = "http://127.0.0.1:4723"
+options: Optional[Union[Dict[str, Any], AppiumOptions]] = None
+logger = logging.getLogger(__name__)
+
+# Please do not change the name of the class.
+class TestRunner:
+ _PRESS_DELAY: float = 0.9
+
+ def __init__(self, device_serial_number: str):
+ self.__driver: Optional[webdriver.webdriver.WebDriver] = None
+ self.__capabilities: Dict[str, Any] = {
+ "platformName": "Kepler",
+ "appium:automationName": "automation-toolkit/JSON-RPC",
+ "kepler:device": f"vda://{device_serial_number}",
+ "kepler:jsonRPCPort": 8383,
+ "newCommandTimeout": 500,
+ "appium:deviceName": device_serial_number,
+ "appURL": "com.amazondeveloper.keplersportapp.main"
+ }
+
+ # You might also define helper function to modularize
+ # functions called within the test scenario.
+ def _press_dpad(self, direction: str, interval: float):
+ """Helper function to send D-pad key press event to Kepler device.
+
+ Args:
+ Direction (str): D-pad direction key. Please refer to
+ https://developer.amazon.com/docs/kepler-tv/appium-commands.html
+ for D-pad direction keys.
+ interval (int): The time interval between each movement along the path, in unit seconds.
+ _press_dpad will wait for time difference between the interval and time lapse of
+ press event.
+ """
+ # We keep "holdDuration" to 0 (no holding) to
+ # reproduce D-pad clicking behavior by app users.
+ start: float = time.time()
+ self.__driver.execute_script(
+ "jsonrpc: injectInputKeyEvent",
+ [{"inputKeyEvent": direction , "holdDuration": 0}]
+ )
+ cmd_time: float = time.time() - start
+ if interval > cmd_time:
+ wait_time: float = interval - cmd_time
+ logger.info(f"Sleeping for {wait_time} seconds to give {interval} seconds interval between D-pad press events.")
+ time.sleep(wait_time)
+
+ def prep(self) -> None:
+ if supportAppiumOptions:
+ self.__driver = webdriver.Remote(
+ appium_url,
+ options=AppiumOptions().load_capabilities(self.__capabilities)
+ )
+ else:
+ self.__driver = webdriver.Remote(appium_url, self.__capabilities)
+
+ # wait 10 seconds for app to warm up
+ logger.info("Waiting for application to warm up for 10 seconds")
+ time.sleep(10)
+
+ # Login
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+ input_email = self.__driver.find_element(by="xpath",
+ value="(//children[@test_id='title']/../../following-sibling::children/children)[2]")
+ input_email.send_keys("test@test.com")
+ self._press_dpad("158", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+ input_password = self.__driver.find_element(by="xpath",
+ value="(//children[@test_id='title']/../../following-sibling::children/children)[2]")
+ input_password.send_keys("12345678")
+ self._press_dpad("158", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+
+ # Profile screen
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+
+ def run(self) -> None:
+ # Measure Foreground Memory
+ time.sleep(10)
\ No newline at end of file
diff --git a/kpi-test-scenarios/ui-fluidity.py b/kpi-test-scenarios/ui-fluidity.py
new file mode 100644
index 0000000..45592d9
--- /dev/null
+++ b/kpi-test-scenarios/ui-fluidity.py
@@ -0,0 +1,128 @@
+import logging
+from typing import Any, Dict, Optional, Union
+
+"""Import more if needed."""
+import time
+
+supportAppiumOptions: bool = True
+try:
+ from appium.webdriver.webdriver import AppiumOptions
+except ImportError:
+ supportAppiumOptions = False
+
+from appium import webdriver
+
+appium_url: str = "http://127.0.0.1:4723"
+options: Optional[Union[Dict[str, Any], AppiumOptions]] = None
+logger = logging.getLogger(__name__)
+
+# Please do not change the name of the class.
+class TestRunner:
+ _PRESS_DELAY: float = 0.9
+
+ def __init__(self, device_serial_number: str):
+ self.__driver: Optional[webdriver.webdriver.WebDriver] = None
+ self.__capabilities: Dict[str, Any] = {
+ "platformName": "Kepler",
+ "appium:automationName": "automation-toolkit/JSON-RPC",
+ "kepler:device": f"vda://{device_serial_number}",
+ "kepler:jsonRPCPort": 8383,
+ "newCommandTimeout": 500,
+ "appium:deviceName": device_serial_number,
+ "appURL": "com.amazondeveloper.keplersportapp.main"
+ }
+
+ # You might also define helper function to modularize
+ # functions called within the test scenario.
+ def _press_dpad(self, direction: str, interval: float):
+ """Helper function to send D-pad key press event to Kepler device.
+
+ Args:
+ Direction (str): D-pad direction key. Please refer to
+ https://developer.amazon.com/docs/kepler-tv/appium-commands.html
+ for D-pad direction keys.
+ interval (int): The time interval between each movement along the path, in unit seconds.
+ _press_dpad will wait for time difference between the interval and time lapse of
+ press event.
+ """
+ # We keep "holdDuration" to 0 (no holding) to
+ # reproduce D-pad clicking behavior by app users.
+ start: float = time.time()
+ self.__driver.execute_script(
+ "jsonrpc: injectInputKeyEvent",
+ [{"inputKeyEvent": direction , "holdDuration": 0}]
+ )
+ cmd_time: float = time.time() - start
+ if interval > cmd_time:
+ wait_time: float = interval - cmd_time
+ logger.info(f"Sleeping for {wait_time} seconds to give {interval} seconds interval between D-pad press events.")
+ time.sleep(wait_time)
+
+ def prep(self) -> None:
+ if supportAppiumOptions:
+ self.__driver = webdriver.Remote(
+ appium_url,
+ options=AppiumOptions().load_capabilities(self.__capabilities)
+ )
+ else:
+ self.__driver = webdriver.Remote(appium_url, self.__capabilities)
+
+ # wait 10 seconds for app to warm up
+ logger.info("Waiting for application to warm up for 10 seconds")
+ time.sleep(10)
+
+ # Login
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+ input_email = self.__driver.find_element(by="xpath",
+ value="(//children[@test_id='title']/../../following-sibling::children/children)[2]")
+ input_email.send_keys("test@test.com")
+ self._press_dpad("158", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+ input_password = self.__driver.find_element(by="xpath",
+ value="(//children[@test_id='title']/../../following-sibling::children/children)[2]")
+ input_password.send_keys("12345678")
+ self._press_dpad("158", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+
+ # Profile screen
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+
+
+ def run(self) -> None:
+ # Measure UI Fluidity by moving around Home screen Carousels
+ self._press_dpad("106", TestRunner._PRESS_DELAY)
+ self._press_dpad("106", TestRunner._PRESS_DELAY)
+ self._press_dpad("106", TestRunner._PRESS_DELAY)
+ self._press_dpad("106", TestRunner._PRESS_DELAY)
+ self._press_dpad("106", TestRunner._PRESS_DELAY)
+ self._press_dpad("105", TestRunner._PRESS_DELAY)
+ self._press_dpad("105", TestRunner._PRESS_DELAY)
+ self._press_dpad("105", TestRunner._PRESS_DELAY)
+ self._press_dpad("105", TestRunner._PRESS_DELAY)
+ self._press_dpad("105", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("103", TestRunner._PRESS_DELAY)
+ self._press_dpad("103", TestRunner._PRESS_DELAY)
+ self._press_dpad("103", TestRunner._PRESS_DELAY)
+ self._press_dpad("103", TestRunner._PRESS_DELAY)
+ self._press_dpad("103", TestRunner._PRESS_DELAY)
+
\ No newline at end of file
diff --git a/kpi-test-scenarios/video-fluidity.py b/kpi-test-scenarios/video-fluidity.py
new file mode 100644
index 0000000..f732a76
--- /dev/null
+++ b/kpi-test-scenarios/video-fluidity.py
@@ -0,0 +1,123 @@
+import logging
+from typing import Any, Dict, Optional, Union
+
+"""Import more if needed."""
+import time
+
+supportAppiumOptions: bool = True
+try:
+ from appium.webdriver.webdriver import AppiumOptions
+except ImportError:
+ supportAppiumOptions = False
+
+from appium import webdriver
+
+appium_url: str = "http://127.0.0.1:4723"
+options: Optional[Union[Dict[str, Any], AppiumOptions]] = None
+logger = logging.getLogger(__name__)
+
+# Please do not change the name of the class.
+class TestRunner:
+ _PRESS_DELAY: float = 0.9
+
+ def __init__(self, device_serial_number: str):
+ self.__driver: Optional[webdriver.webdriver.WebDriver] = None
+ self.__capabilities: Dict[str, Any] = {
+ "platformName": "Kepler",
+ "appium:automationName": "automation-toolkit/JSON-RPC",
+ "kepler:device": f"vda://{device_serial_number}",
+ "kepler:jsonRPCPort": 8383,
+ "newCommandTimeout": 500,
+ "appium:deviceName": device_serial_number,
+ "appURL": "com.amazondeveloper.keplersportapp.main"
+ }
+
+ # You might also define helper function to modularize
+ # functions called within the test scenario.
+ def _press_dpad(self, direction: str, interval: float):
+ """Helper function to send D-pad key press event to Kepler device.
+
+ Args:
+ Direction (str): D-pad direction key. Please refer to
+ https://developer.amazon.com/docs/kepler-tv/appium-commands.html
+ for D-pad direction keys.
+ interval (int): The time interval between each movement along the path, in unit seconds.
+ _press_dpad will wait for time difference between the interval and time lapse of
+ press event.
+ """
+ # We keep "holdDuration" to 0 (no holding) to
+ # reproduce D-pad clicking behavior by app users.
+ start: float = time.time()
+ self.__driver.execute_script(
+ "jsonrpc: injectInputKeyEvent",
+ [{"inputKeyEvent": direction , "holdDuration": 0}]
+ )
+ cmd_time: float = time.time() - start
+ if interval > cmd_time:
+ wait_time: float = interval - cmd_time
+ logger.info(f"Sleeping for {wait_time} seconds to give {interval} seconds interval between D-pad press events.")
+ time.sleep(wait_time)
+
+ def _close_app():
+ """Subprocess.run wrapper with logging and timout."""
+ command = " ".join("vda", "shell", "vlcm", "terminate-app", "--pkg-id", "com.amazondeveloper.keplersportapp", "--force")
+ logger.debug(f"running command:{command}, with timeout={timeout}, kwargs={kwargs}")
+ t_zero = time.monotonic()
+ ret = subprocess.run(args, capture_output=True, text=True, **kwargs) # pylint: disable=subprocess-run-check
+ took = time.monotonic() - t_zero
+ logger.debug(f"Command return code: {ret.returncode}\nstderr: {ret.stderr}\nstdout: {ret.stdout}\nfook: {took}")
+
+ def prep(self) -> None:
+ if supportAppiumOptions:
+ self.__driver = webdriver.Remote(
+ appium_url,
+ options=AppiumOptions().load_capabilities(self.__capabilities)
+ )
+ else:
+ self.__driver = webdriver.Remote(appium_url, self.__capabilities)
+
+ # wait 10 seconds for app to warm up
+ logger.info("Waiting for application to warm up for 10 seconds")
+ time.sleep(10)
+
+ # Login
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+ input_email = self.__driver.find_element(by="xpath",
+ value="(//children[@test_id='title']/../../following-sibling::children/children)[2]")
+ input_email.send_keys("test@test.com")
+ self._press_dpad("158", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+ input_password = self.__driver.find_element(by="xpath",
+ value="(//children[@test_id='title']/../../following-sibling::children/children)[2]")
+ input_password.send_keys("12345678")
+ self._press_dpad("158", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("108", TestRunner._PRESS_DELAY)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+
+ # Profile screen
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+
+ time.sleep(2)
+ self.__driver.get_window_rect() # workaround for element not found issue
+ time.sleep(2)
+
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+ time.sleep(2)
+ self._press_dpad("96", TestRunner._PRESS_DELAY)
+
+
+ def run(self) -> None:
+ # Measure Video Fluidity
+
+ # Wait 1 minute to measure video streaming fluidity in the app.
+ time.sleep(60)
\ No newline at end of file
diff --git a/lefthook.yml b/lefthook.yml
new file mode 100644
index 0000000..25eac09
--- /dev/null
+++ b/lefthook.yml
@@ -0,0 +1,21 @@
+pre-push:
+ parallel: true
+ commands:
+ lint:
+ run: npm run lint
+ fail_text: Try run `npm run lint:fix` and/or `npm run prettier` to cover issue or address them manually.
+ typecheck:
+ run: npm run typecheck
+
+pre-commit:
+ parallel: true
+ commands:
+ lint:
+ glob: 'src/**/*.{ts,tsx}'
+ exclude: '.d.ts'
+ run: npm run lint-check {staged_files}
+ fail_text: Try run `npm run lint:fix` to cover issue or address them manually.
+ types:
+ glob: '*.{js,ts,jsx,tsx}'
+ exclude: '(^|/)(jest\.\w+)\.ts$'
+ run: npm run typecheck
diff --git a/localesSync.config.js b/localesSync.config.js
new file mode 100644
index 0000000..647878b
--- /dev/null
+++ b/localesSync.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ primaryLanguage: 'en-US',
+ secondaryLanguages: ['pl'],
+ localesFolder: './assets/text',
+ spaces: 2,
+};
diff --git a/manifest.toml b/manifest.toml
index 11b8fbb..2464f63 100644
--- a/manifest.toml
+++ b/manifest.toml
@@ -1,79 +1,186 @@
-#
-# Copyright (c) 2025 Amazon.com, Inc. or its affiliates. All rights reserved.
-#
-# PROPRIETARY/CONFIDENTIAL. USE IS SUBJECT TO LICENSE TERMS.
-#
+#
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: MIT-0
+#
schema-version = 1
[package]
-title = "Vega Audio Sample App"
-version = "2.22.0"
-id = "com.amazondeveloper.kepleraudioreferenceapp"
-icon = "@image/audio-app-dark.png"
+title = "Vega Sports Sample App"
+version = "0.22.0"
+id = "com.amazondeveloper.keplersportapp"
+icon = "@image/SportsApp.png"
-## The [components] section declares the application components in the package, including interactive, service, and task components.
[components]
[[components.interactive]]
-id = "com.amazondeveloper.kepleraudioreferenceapp.main"
+id = "com.amazondeveloper.keplersportapp.main"
runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0"
categories = ["com.amazon.category.main", "com.amazon.category.kepler.media"]
launch-type = "singleton"
-## The [needs] section declares the system capabilities that the application requires to function on a Vega device. This information helps Vega package manager and Amazon Appstore to ensure the application is installed on the supported devices. Carefully declare the application's needs for your desired device targeting. You can choose a combination of needs and wants to maximize your device reach, while providing enhanced features on applicable devices.
-[needs]
-[[needs.module]]
-id = "/com.amazon.kepler.media.control.server@IMediaControlServerComponentAsync"
+[[components.interactive]]
+id = "com.amazondeveloper.keplersportapp.sync_source"
+launch-type = "singleton"
+runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0"
+
+[[components.service]]
+id = "com.amazondeveloper.keplersportapp.interface.provider"
+categories = ["com.amazon.category.kepler.media"]
+launch-type = "singleton"
+runtime-module = "/com.amazon.kepler.headless.runtime.loader_2@IKeplerScript_2_0"
+
+[[components.service]]
+id = "com.amazondeveloper.keplersportapp.service"
+runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0"
+launch-type = "singleton"
+
+[[components.task]]
+id = "com.amazondeveloper.keplersportapp.onInstallOrUpdateTask"
+runtime-module = "/com.amazon.kepler.headless.runtime.loader_2@IKeplerScript_2_0"
+
+[[components.task]]
+id = "com.amazondeveloper.keplersportapp.epgSyncTask"
+runtime-module = "/com.amazon.kepler.headless.runtime.loader_2@IKeplerScript_2_0"
+
+[processes]
+[[processes.group]]
+component-ids = [
+ "com.amazondeveloper.keplersportapp.main",
+ "com.amazondeveloper.keplersportapp.service",
+]
+
+[[processes.group]]
+component-ids = ["com.amazondeveloper.keplersportapp.interface.provider"]
+
+[[processes.group]]
+component-ids = ["com.amazondeveloper.keplersportapp.onInstallOrUpdateTask"]
+
+[[processes.group]]
+component-ids = ["com.amazondeveloper.keplersportapp.epgSyncTask"]
-## The [wants] section declares the capabilities that are optional for the application to function on a Vega device. Handle graceful fallbacks in your application code if a feature in this list is unavailable on a device.
[wants]
[[wants.service]]
-id = "com.amazon.gipc.uuid.*"
+id = "com.amazondeveloper.keplersportapp.service"
[[wants.service]]
-id = "com.amazon.media.server"
+id = "com.amazon.alexa.datastore.service"
-[[wants.service]]
-id = "com.amazon.media.playersession.service"
+[[wants.service]]
+id = "com.amazon.inputmethod.service"
+
+[[wants.service]]
+id = "com.amazon.inputd.service"
+
+[[wants.service]]
+id = "com.amazon.network.service"
[[wants.service]]
id = "com.amazon.mediametrics.service"
+[[wants.service]]
+id = "com.amazon.media.server"
+
+[[wants.service]]
+id = "com.amazon.gipc.uuid.*"
+
+[[wants.service]]
+id = "com.amazon.media.playersession.service"
+
[[wants.service]]
id = "com.amazon.audio.stream"
-[[wants.service]]
+[[wants.service]]
id = "com.amazon.audio.control"
-[[wants.service]]
+[[wants.service]]
id = "com.amazon.audio.system"
[[wants.service]]
id = "com.amazon.mediabuffer.service"
-
+
[[wants.service]]
id = "com.amazon.mediatransform.service"
+[[wants.service]]
+id = "com.amazon.drm.key"
+
+[[wants.service]]
+id = "com.amazon.drm.crypto"
+
+[[wants.privilege]]
+id = "com.amazon.devconf.privilege.accessibility"
+
+[needs]
+[[needs.privilege]]
+id = "com.amazon.network.privilege.net-info"
+
+[[needs.privilege]]
+id = "com.amazon.kepler.tv.privilege.data_provider"
+
+[[needs.privilege]]
+id = "com.amazon.audio.privilege.microphone.access"
+
+[tasks]
+[[tasks.work]]
+component-id = "com.amazondeveloper.keplersportapp.onInstallOrUpdateTask"
+mode = "install"
-## The [offers] section declares the capabilities that an application package offers to other application packages installed on a Vega OS system. Offers is an optional section.
[offers]
[[offers.service]]
+id = "com.amazondeveloper.keplersportapp.service"
+
+[[offers.service]]
+id = "com.amazondeveloper.keplersportapp.interface.provider"
+[[offers.service]]
id = "com.amazon.gipc.uuid.*"
-#The [[extras]] section contains key-value pairs to describe custom meta-data associated with the package or a component in the package. Extras are optional.
+[[offers.interaction]]
+id = "com.amazondeveloper.keplersportapp.main"
+
+[[offers.module]]
+id = "/com.amazondeveloper.keplersportapp.main@IMod1"
+includes-messages = ["pkg://com.amazondeveloper.keplersportapp.main"]
+
+[[offers.message-target]]
+uses-component = "com.amazondeveloper.keplersportapp.sync_source"
+uris = ["livetv://sync_source"]
+
+[[message]]
+uri = "pkg://com.amazondeveloper.keplersportapp.main"
+sender-privileges = ["*"] # anyone can send this message
+receiver-privileges = ["self"] # only our package can receive this message
+
[[extras]]
key = "interface.provider"
-component-id = "com.amazondeveloper.kepleraudioreferenceapp.main"
+component-id = "com.amazondeveloper.keplersportapp.main"
+## KMC
[[extras.value.application.interface]]
interface_name = "com.amazon.kepler.media.IMediaPlaybackServer"
command_options = [
- "Play",
- "Pause",
- "StartOver",
- "Previous",
- "Next",
- "SkipForward",
- "SkipBackward",
+ "Play",
+ "Pause",
+ "StartOver",
+ "Previous",
+ "Next",
+ "SkipForward",
+ "SkipBackward",
]
attribute_options = ["AudioAdvanceMuted"]
features = ["AdvancedSeek", "VariableSpeed", "AudioTracks", "TextTracks"]
+
+[extras.value.application]
+[[extras.value.application.interface]]
+interface_name = "com.amazon.kepler.media.IChannelServer"
+features = ["ChannelList"]
+
+[extras.value.application.interface.override_command_component]
+ChangeChannel = "com.amazondeveloper.keplersportapp.main"
+ChangeChannelByNumber = "com.amazondeveloper.keplersportapp.main"
+SkipChannel = "com.amazondeveloper.keplersportapp.main"
+
+[extras.value.application.interface.static-values]
+ChannelList = []
+
+[application]
+relaunch_policy = "none"
+preserve_state = true
diff --git a/metro.config.js b/metro.config.js
index 63ebecc..b83e368 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -1,19 +1,29 @@
-/*
- * Copyright (c) 2025 Amazon.com, Inc. or its affiliates. All rights reserved.
- *
- * PROPRIETARY/CONFIDENTIAL. USE IS SUBJECT TO LICENSE TERMS.
- */
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
-/** * Metro configuration for React Native
- * Metro configuration
- * https://github.com/facebook/react-native
- * https://reactnative.dev/docs/metro
- *
- *
- * @format
- * @type {import('metro-config').MetroConfig}
- */
-const config = {};
+const crypto = require('crypto');
+const fs = require('fs');
+
+function hashFileContent(filePath, hashName = 'sha256') {
+ if (!fs.existsSync(filePath)) {
+ return;
+ } else {
+ const hash = crypto.createHash(hashName);
+ hash.update(fs.readFileSync(filePath));
+ return hash.digest('hex');
+ }
+}
+
+const cacheVersion = hashFileContent('.env');
+
+/**
++ * Metro configuration
++ * https://facebook.github.io/metro/docs/configuration
+ *
++ * @type {import('metro-config').MetroConfig}
+ */
+const config = { cacheVersion };
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
diff --git a/package.json b/package.json
index 287a86b..19ef17f 100644
--- a/package.json
+++ b/package.json
@@ -1,107 +1,137 @@
{
- "name": "@amazon-devices/kepleraudioreferenceapp",
- "version": "2.22.0",
+ "name": "@amazon-devices/keplersportapp",
+ "version": "0.22.0",
"files": [
"dist"
],
"scripts": {
- "clean": "rm -rf build bundle kepler-build generated logs dist node_modules package-lock.json coverage shaka-setup/shaka-player",
+ "clean": "rm -rf node_modules buildinfo.json dist bvm package-lock.json shaka-setup/shaka-player",
"postinstall": "cd shaka-setup && ./build.sh && ./copyOutputs.sh",
- "build": "npm-run-all compile lint:fix test",
+ "start": "react-native start",
+ "test": "jest --colors",
+ "test:watch": "jest --colors --watchAll",
"test:snapshot": "jest --colors --updateSnapshot",
- "start": "node node_modules/react-native/local-cli/cli.js start",
- "test": "jest --colors --coverage",
- "lint": "eslint src --ext .ts,.tsx && eslint tst --ext .ts,.tsx",
- "lint:fix": "eslint src --ext .ts,.tsx --fix && eslint tst --ext .ts,.tsx --fix",
+ "lint-check": "eslint",
+ "lint": "npm run lint-check -- src --ext .ts,.tsx $*",
+ "lint:fix": "npm run lint -- --fix",
+ "prettier": "prettier --write './src/**/*.{ts,js,jsx,tsx}'",
"compile": "tsc -p tsconfig.json",
- "app:install": "kepler device install-app --dir .",
- "app:launch": "kepler device launch-app",
- "app:build:debug": "run-s build:debug && npm run app:install && npm run app:launch -- -b Debug",
- "app:build": "run-s build:release && npm run app:install && npm run app:launch -- -b Release",
- "create-bundle-dirs:debug": "mkdir -p build/lib/rn-bundles/Debug/",
- "bundle-kepler:debug": "react-native bundle --platform kepler --entry-file index.js --bundle-output build/lib/rn-bundles/Debug/index.bundle --assets-dest build/lib/rn-bundles/Debug/ --sourcemap-output build/lib/rn-bundles/Debug/index.bundle.map",
- "hermes-kepler:debug": "react-native hermes-kepler --bundle-path build/lib/rn-bundles/Debug/index.bundle --out build/lib/rn-bundles/Debug/index.hermes.bundle",
- "build:js:debug": "run-s create-bundle-dirs:debug bundle-kepler:debug hermes-kepler:debug",
- "create-bundle-dirs:release": "mkdir -p build/lib/rn-bundles/Release/ && mkdirp build/private/debugging/Release/",
- "bundle-kepler:release": "react-native bundle --platform kepler --entry-file index.js --dev false --bundle-output build/lib/rn-bundles/Release/index.bundle --assets-dest build/lib/rn-bundles/Release/ --sourcemap-output build/private/debugging/Release/index.bundle.map",
- "hermes-kepler:release": "react-native hermes-kepler --bundle-path build/lib/rn-bundles/Release/index.bundle --out build/lib/rn-bundles/Release/index.hermes.bundle",
- "build:js:release": "run-s create-bundle-dirs:release bundle-kepler:release hermes-kepler:release",
- "build:native:debug": "kepler build -b Debug",
- "build:debug": "run-s build:js:debug build:native:debug",
- "build:native:release": "kepler build -b Release",
- "build:release": "run-s build:js:release build:native:release",
- "build:app": "run-s build build:debug build:release",
- "build:native": "kepler build -b Debug -b Release",
- "build:module": "run-s build build:native",
- "prepack": "run-s build:module"
+ "typecheck": "tsc --noEmit",
+ "code:check": "npm-run-all lint test typecheck",
+ "build": "npm run compile",
+ "build:release": "react-native build-kepler --build-type Release --reset-cache",
+ "build:debug": "react-native build-kepler --build-type Debug",
+ "build:app": "bash scripts/get-current-commit.sh && npm-run-all test build:release build:debug",
+ "release": "npm-run-all code:check build build:app",
+ "kepler:restart-app": "kepler device launch-app --device Simulator --appName com.amazondeveloper.keplersportapp.main",
+ "kepler:run:aarch64": "./scripts/get-current-commit.sh && kepler run-kepler build/aarch64-debug/keplersportapp_aarch64.vpkg com.amazondeveloper.keplersportapp.main -s",
+ "kepler:run:aarch64:release": "./scripts/get-current-commit.sh && kepler run-kepler build/aarch64-release/keplersportapp_aarch64.vpkg com.amazondeveloper.keplersportapp.main -s",
+ "kepler:run:x64": "./scripts/get-current-commit.sh && kepler run-kepler build/x86_64-debug/keplersportapp_x86_64.vpkg com.amazondeveloper.keplersportapp.main -s",
+ "kepler:simulator-screen-reader-enable": "kepler exec vda shell vdcm set \"com.amazon.devconf/system/accessibility/VoiceViewEnabled\" \"ENABLED\"",
+ "kepler:simulator-screen-reader-disable": "kepler exec vda shell vdcm set \"com.amazon.devconf/system/accessibility/VoiceViewEnabled\" \"DISABLED\"",
+ "kepler:simulator-ports": "kepler device start-port-forwarding --device Simulator -p 8081 --forward false && kepler device start-port-forwarding --device Simulator -p 8097 --forward false",
+ "i18n:sync": "npx i18next-locales-sync -c ./localesSync.config.js"
},
"dependencies": {
+ "@amazon-devices/headless-task-manager": "~1.2.4",
+ "@amazon-devices/kepler-player-server": "^2.2.1",
+ "@amazon-devices/kepler-player-client": "^2.2.1",
+ "@amazon-devices/keplerscript-turbomodule-api": "^1.0.0",
+ "@amazon-devices/react-native-kepler": "^2.0.0",
+ "@amazon-devices/react-native-w3cmedia": "^2.1.80",
+ "@amazon-devices/asset-resolver-lib": "^1.0.0",
+ "@amazon-devices/kepler-channel": "^1.0.6",
+ "@amazon-devices/kepler-epg-provider": "^1.7.8",
+ "@amazon-devices/kepler-epg-sync-scheduler": "^1.2.12",
+ "@amazon-devices/kepler-performance-api": "^0.1.0",
"@amazon-devices/kepler-media-controls": "^1.0.0",
"@amazon-devices/kepler-media-types": "^1.0.0",
- "@amazon-devices/keplerscript-react-native-reanimated": "~2.0.0",
- "@amazon-devices/react-native-kepler": "^2.0.0",
- "@amazon-devices/react-native-safe-area-context": "~2.0.0",
+ "@amazon-devices/kepler-ui-components": "^2.0.2",
+ "@amazon-devices/keplerscript-audio-lib": "^2.0.6",
+ "@amazon-devices/keplerscript-kepleri18n-lib": "^1.0.0",
+ "@amazon-devices/keplerscript-netmgr-lib": "^2.0.2",
+ "@amazon-devices/react-linear-gradient": "~2.0.0",
+ "@amazon-devices/react-native-device-info": "^2.0.0",
"@amazon-devices/react-native-screens": "~2.0.0",
- "@amazon-devices/react-native-w3cmedia": "~2.1.0",
- "@amazon-devices/react-navigation__core": "~2.0.0",
- "@amazon-devices/react-navigation__drawer": "~2.0.0",
- "@amazon-devices/react-navigation__native": "~2.0.0",
- "@amazon-devices/react-navigation__stack": "~2.0.0",
- "@amazon-devices/react-native-vector-icons": "~2.0.0",
- "@amazon-devices/kepler-ui-components": "^2.0.0",
+ "@amazon-devices/react-native-vector-icons": "^2.0.0",
+ "@amazon-devices/react-navigation__core": "~7.0.0",
+ "@amazon-devices/react-navigation__devtools": "~7.0.0",
+ "@amazon-devices/react-navigation__drawer": "~7.0.0",
+ "@amazon-devices/react-navigation__native": "~7.0.0",
+ "@amazon-devices/react-navigation__routers": "~7.0.0",
+ "@amazon-devices/react-navigation__stack": "~7.0.0",
+ "@amazon-devices/react-native-reanimated": "^2.0.0",
+ "@react-native-async-storage/async-storage": "npm:@amazon-devices/react-native-async-storage__async-storage@~2.0.0",
+ "@tanstack/react-query": "~5.0.0",
"base-64": "^1.0.0",
- "lodash": "^4.17.21",
- "moment": "~2.30.1",
+ "date-fns": "^4.1.0",
+ "email-validator": "^2.0.4",
+ "eslint-plugin-amzn-a11y": "file:plugins/eslint-plugin-amzn-a11y",
+ "fastestsmallesttextencoderdecoder": "^1.0.22",
+ "lodash.get": "^4.4.2",
+ "patch-package": "^8.0.0",
"react": "18.2.0",
+ "react-compiler-runtime": "^19.0.0-beta-714736e-20250131",
"react-native": "0.72.0",
"react-native-gesture-handler": "npm:@amazon-devices/react-native-gesture-handler@~2.0.0",
- "web-streams-polyfill": "^3.2.1",
- "xmldom": "^0.6.0"
- },
- "kepler": {
- "projectType": "application",
- "appName": "kepleraudioreferenceapp",
- "targets": [
- "tv"
- ],
- "os": [
- "vega"
- ]
+ "underscore": "^1.13.7",
+ "xmldom": "^0.6.0",
+ "zustand": "^5.0.2"
},
"devDependencies": {
"@amazon-devices/kepler-cli-platform": "^0",
- "@babel/core": "^7.22.8",
- "@babel/runtime": "^7.22.8",
- "@react-native-community/eslint-config": "^3.0.1",
- "@react-native/eslint-config": "^0.72.2",
- "@trivago/prettier-plugin-sort-imports": "^4.3.0",
- "prettier-plugin-organize-imports": "^3.2.4",
+ "@babel/plugin-proposal-decorators": "^7.25.9",
+ "@callstack/eslint-config": "^14.2.0",
+ "@react-native-community/cli": "17.0.1",
+ "@react-native/eslint-config": "0.72.2",
"@react-native/metro-config": "^0.72.6",
- "@testing-library/react-native": "^11.5.4",
- "@types/jest": "^28.0.0",
- "@types/lodash": "^4.14.196",
+ "@testing-library/jest-native": "^5.4.3",
+ "@testing-library/react-native": "^12.9.0",
+ "@total-typescript/ts-reset": "^0.6.1",
+ "@tsconfig/react-native": "^3.0.5",
+ "@types/jest": "^29.5.14",
+ "@types/lodash.get": "^4.4.9",
+ "@types/lodash.throttle": "^4.1.9",
+ "@types/react": "^18.0.24",
"@types/react-test-renderer": "^18.0.0",
- "@typescript-eslint/eslint-plugin": "^5.18.0",
- "babel-jest": "^28.0.0",
- "eslint": "^8.19.0",
- "eslint-plugin-simple-import-sort": "^5.0.3",
- "jest": "^28.0.0",
- "metro-react-native-babel-preset": "^0.76.5",
+ "@types/underscore": "^1.13.0",
+ "@types/xmldom": "^0.1.34",
+ "@typescript-eslint/eslint-plugin": "^5.62.0",
+ "@typescript-eslint/parser": "^5.62.0",
+ "babel-jest": "^29.0.0",
+ "babel-plugin-module-resolver": "^5.0.2",
+ "babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
+ "babel-plugin-transform-remove-console": "^6.9.4",
+ "eslint": "^8.57.1",
+ "eslint-import-resolver-typescript": "^3.6.3",
+ "eslint-plugin-import": "^2.30.0",
+ "eslint-plugin-prettier": "^5.2.1",
+ "eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
+ "eslint-plugin-react-native-a11y": "^3.5.1",
+ "jest": "^29.7.0",
+ "jest-fetch-mock": "^3.0.3",
+ "lefthook": "^1.7.16",
+ "metro-react-native-babel-preset": "^0.77.0",
"mustache": "^4.2.0",
"npm-run-all": "^4.1.5",
- "prettier": "^2.8.8",
+ "prettier": "^3.4.2",
+ "react-native-dotenv": "^3.4.11",
"react-test-renderer": "18.2.0",
- "ts-jest": "^28.0.0",
- "typescript": "^4.8.4"
+ "ts-jest": "^29.2.5",
+ "typescript": "5.1.6"
+ },
+ "overrides": {
+ "react-native-gesture-handler": "$react-native-gesture-handler",
+ "@react-native-async-storage/async-storage": "npm:@amazon-devices/react-native-async-storage__async-storage@~2.0.0"
},
- "resolutions": {
- "@types/react": "^18"
+ "engines": {
+ "node": ">=18"
},
- "eslintConfig": {
- "extends": "@react-native",
- "root": true,
- "ignorePatterns": [
- "src/w3cmedia/shakaplayer/dist/"
+ "kepler": {
+ "projectType": "application",
+ "appName": "KeplerSportApp",
+ "targets": [
+ "tv"
]
}
}
diff --git a/plugins/eslint-plugin-amzn-a11y/index.js b/plugins/eslint-plugin-amzn-a11y/index.js
new file mode 100644
index 0000000..011ffbc
--- /dev/null
+++ b/plugins/eslint-plugin-amzn-a11y/index.js
@@ -0,0 +1,6 @@
+module.exports = {
+ rules: {
+ 'no-inferior-a11y-props': require('./rules/no-inferior-a11y-props'),
+ 'no-missing-a11y-props': require('./rules/no-missing-a11y-props'),
+ },
+};
diff --git a/plugins/eslint-plugin-amzn-a11y/package.json b/plugins/eslint-plugin-amzn-a11y/package.json
new file mode 100644
index 0000000..62fb3af
--- /dev/null
+++ b/plugins/eslint-plugin-amzn-a11y/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@amazon-devices/eslint-plugin-amzn-a11y",
+ "version": "1.0.0",
+ "main": "index.js",
+ "license": "MIT",
+ "type": "commonjs",
+ "description": ""
+}
diff --git a/plugins/eslint-plugin-amzn-a11y/rules/no-inferior-a11y-props.js b/plugins/eslint-plugin-amzn-a11y/rules/no-inferior-a11y-props.js
new file mode 100644
index 0000000..a28ea6c
--- /dev/null
+++ b/plugins/eslint-plugin-amzn-a11y/rules/no-inferior-a11y-props.js
@@ -0,0 +1,59 @@
+const a11yReplacementPropsLUT = {
+ accessibilityLabel: 'aria-label',
+ accessibilityLabelledBy: 'aria-labelledby',
+ accessibilityLiveRegion: 'aria-live',
+ accessibilityRole: 'role',
+ accessibilityState: ['aria-checked', 'aria-disabled', 'aria-expanded'],
+ accessibilityValue: [
+ 'aria-valuemin',
+ 'aria-valuemax',
+ 'aria-valuenow',
+ 'aria-valuetext',
+ ],
+ accessibilityElementsHidden: 'aria-hidden',
+ accessibilityViewIsModal: 'aria-modal',
+};
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Enforce that inferior a11y rules are replaced with the ones that are equivalent but have precedence over them.',
+ },
+ fixable: 'code',
+ schema: [],
+ hasSuggestions: true,
+ },
+ create(context) {
+ return {
+ JSXAttribute(node) {
+ const propName = node.name.name;
+
+ if (a11yReplacementPropsLUT[propName]) {
+ const deprecated = propName,
+ preferred = a11yReplacementPropsLUT[deprecated];
+
+ const suggestions = (
+ Array.isArray(preferred) ? preferred : [preferred]
+ ).map((p) => ({
+ desc: `Replace property with '${p}'`,
+ fix: (fixer) => {
+ return fixer.replaceText(node.name, p);
+ },
+ }));
+
+ context.report({
+ node,
+ message: Array.isArray(preferred)
+ ? `Prop '${deprecated}' is inferior. Use ${preferred.map((p) => `'${p}'`).join(', ')} instead, which has precedence.`
+ : `Prop '${deprecated}' is inferior. Use '${preferred}' instead, which has precedence.`,
+ suggest: suggestions,
+ // if only one suggestion is available, then the issue is auto-fixable
+ fix: suggestions.length === 1 ? suggestions[0].fix : undefined,
+ });
+ }
+ },
+ };
+ },
+};
diff --git a/plugins/eslint-plugin-amzn-a11y/rules/no-missing-a11y-props.js b/plugins/eslint-plugin-amzn-a11y/rules/no-missing-a11y-props.js
new file mode 100644
index 0000000..3dede29
--- /dev/null
+++ b/plugins/eslint-plugin-amzn-a11y/rules/no-missing-a11y-props.js
@@ -0,0 +1,65 @@
+const interactiveComponents = [
+ // note: Button & IconButton do not need enforcement as they already define a 'role="button"' prop by default
+ 'TouchableOpacity',
+ 'SeekBar',
+ 'FocusStyleTouchableOpacity',
+ 'TouchableWithoutFeedback',
+ 'TouchableNativeFeedback',
+ 'TouchableOpacity',
+ 'TouchableHighlight',
+ 'TextInput',
+ 'Pressable',
+ 'Switch',
+];
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Enforce that interactive components have proper a11y properties.',
+ },
+ messages: {
+ missingA11yProp:
+ "Missing required a11y props for interactive component. Expected to find 'role' or 'aria-label'.",
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ JSXOpeningElement(node) {
+ if (interactiveComponents.includes(node.name.name)) {
+ const jsxAttributes = node.attributes.filter(
+ ({ type }) => type === 'JSXAttribute',
+ );
+
+ const isInteractivityDisabled = jsxAttributes.find(
+ (attribute) =>
+ attribute.name.name === 'accessible' &&
+ attribute.value?.type === 'JSXExpressionContainer' &&
+ attribute.value.expression.type === 'Literal' &&
+ attribute.value.expression.value === false,
+ );
+
+ if (!isInteractivityDisabled) {
+ const anyA11yRequiredPropPresent = jsxAttributes.some((attribute) =>
+ [
+ 'role',
+ 'aria-label',
+ 'accessibilityRole',
+ 'accessibilityLabel',
+ ].includes(attribute.name.name),
+ );
+
+ if (!anyA11yRequiredPropPresent) {
+ context.report({
+ node,
+ messageId: 'missingA11yProp',
+ });
+ }
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/reset.d.ts b/reset.d.ts
new file mode 100644
index 0000000..0e1045a
--- /dev/null
+++ b/reset.d.ts
@@ -0,0 +1,2 @@
+// https://www.totaltypescript.com/ts-reset
+import "@total-typescript/ts-reset";
diff --git a/scripts/get-current-commit.sh b/scripts/get-current-commit.sh
new file mode 100755
index 0000000..4c1b7a1
--- /dev/null
+++ b/scripts/get-current-commit.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+echo "Setting commit hash value"
+export REACT_APP_VERSION=$(git rev-parse --short HEAD)
+
+# Create .env if it doesn't exist
+[ ! -f .env ] && touch .env
+
+
+# Update or add REACT_APP_VERSION
+sed -i '' -e "/^REACT_APP_VERSION=/d" .env
+echo "REACT_APP_VERSION=$REACT_APP_VERSION" >> .env
+
+
+
+echo "Current commit hash:"
+echo $REACT_APP_VERSION
diff --git a/service.js b/service.js
new file mode 100644
index 0000000..fceb270
--- /dev/null
+++ b/service.js
@@ -0,0 +1,31 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * Headless Service Entry Point Registration
+ *
+ * This file registers the entry points for the headless video player service.
+ * The Vega CLI looks for this file when building the app with react-native build-kepler.
+ *
+ * The service runs in a separate JavaScript thread from the UI, providing improved
+ * performance for video playback.
+ */
+
+import { HeadlessEntryPointRegistry } from '@amazon-devices/headless-task-manager';
+
+import {
+ onStartService,
+ onStopService,
+} from './src/services/videoPlayer/headless/HeadlessEntryPoint';
+
+// Register the onStartService entry point
+HeadlessEntryPointRegistry.registerHeadlessEntryPoint(
+ 'com.amazondeveloper.keplersportapp.service::onStartService',
+ () => onStartService,
+);
+
+// Register the onStopService entry point
+HeadlessEntryPointRegistry.registerHeadlessEntryPoint(
+ 'com.amazondeveloper.keplersportapp.service::onStopService',
+ () => onStopService,
+);
diff --git a/shaka-setup/build.sh b/shaka-setup/build.sh
index c235f49..9af085d 100755
--- a/shaka-setup/build.sh
+++ b/shaka-setup/build.sh
@@ -96,6 +96,15 @@ tar -xzf shaka-rel-v$VERSION-r$RELEASE.tar.gz
git am --abort
}
+# Disable Google Fonts to support restricted network environments
+if [ -f "ui/controls.less" ]; then
+ # Comment out font imports to prevent network failures
+ sed 's|@import (css, inline) "https://fonts.googleapis.com/css?family=Roboto";|\n/* Roboto font disabled for network compatibility */|g' ui/controls.less | \
+ sed 's|@import (css, inline) "https://fonts.googleapis.com/icon?family=Material+Icons+Round";|\n/* Material Icons disabled for network compatibility */|g' > ui/controls.less.tmp
+ mv ui/controls.less.tmp ui/controls.less
+ echo "Font imports disabled for network-restricted environments"
+fi
+
# Build customized Shaka Player
echo "Building customized Shaka Player with Vega build system"
if ! kepler exec python build/all.py; then
diff --git a/shaka-setup/copyOutputs.sh b/shaka-setup/copyOutputs.sh
index 55ea8ec..b3721d9 100755
--- a/shaka-setup/copyOutputs.sh
+++ b/shaka-setup/copyOutputs.sh
@@ -13,13 +13,6 @@ cp -R shaka-player/shaka-rel/src/. ../src/w3cmedia/
echo "Copying built Shaka Player distribution files"
cp -R shaka-player/dist/. ../src/w3cmedia/shakaplayer/dist/
-echo "Removing test/example files"
-rm -f ../src/w3cmedia/AppNonAdaptive.tsx
-rm -f ../src/w3cmedia/AppNonAdaptiveVideo.tsx
-rm -f ../src/w3cmedia/AppPreBuffering.tsx
-rm -f ../src/w3cmedia/AppPreBufferingAds.tsx
-rm -f ../src/w3cmedia/AppPreBufferingPlaylist.tsx
-
echo "Cleaning up obsolete files"
rm -f ../src/w3cmedia/PlayerInterface.ts # obsolete file
rm -f ../src/w3cmedia/shakaplayer/dist/wrapper.js # obsolete file carrying an interpolation literal {% ... %} causing a syntax error
diff --git a/src/App.tsx b/src/App.tsx
index 29d5e0e..59d4592 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,18 +1,73 @@
-/*
- * Copyright (c) 2025 Amazon.com, Inc. or its affiliates. All rights reserved.
- *
- * PROPRIETARY/CONFIDENTIAL. USE IS SUBJECT TO LICENSE TERMS.
- */
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
-import React from 'react';
+import * as React from 'react';
+import { StrictMode, useEffect } from 'react';
+import { StyleSheet } from 'react-native';
-import NavigationStack from './navigation/NavigationStack';
-import { AudioProvider } from './store/AudioProvider';
+import { ChannelServerComponent } from '@amazon-devices/kepler-channel';
+import { GestureHandlerRootView } from '@amazon-devices/react-native-gesture-handler';
+import {
+ useHideSplashScreenCallback,
+ usePreventHideSplashScreen,
+} from '@amazon-devices/react-native-kepler';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import { AppThemeProvider, darkTheme } from '@AppTheme';
+import { REACT_APP_OVERRIDE_SPLASH_SCREEN_DURATION_MS } from '@AppServices/appConfig/processEnvs';
+import { TranslationProvider } from '@AppServices/i18n';
+import { channelTunerHandler } from '@AppSrc/epg/channelTunerHandler';
+import { MainStackNavigator } from '@AppSrc/navigators';
+import { useFullyDrawnReportingOnAppStateChange } from './hooks/useFullyDrawnReportingOnAppStateChange';
+
+const SPLASH_SCREEN_ADDITIONAL_DURATION_MS = Number(
+ REACT_APP_OVERRIDE_SPLASH_SCREEN_DURATION_MS ?? 4000,
+);
+
+const queryClient = new QueryClient();
export const App = () => {
+ const hideSplashScreen = useHideSplashScreenCallback();
+ usePreventHideSplashScreen();
+ if (__DEV__) {
+ hideSplashScreen();
+ }
+
+ useFullyDrawnReportingOnAppStateChange();
+
+ useEffect(() => {
+ // This line demonstrates how to provide a handler to a Channel server.
+ // This handler is to handle Vega Channel commands.
+ ChannelServerComponent.channelServer.handler = channelTunerHandler;
+ }, []);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ hideSplashScreen();
+ }, SPLASH_SCREEN_ADDITIONAL_DURATION_MS);
+
+ return () => {
+ clearTimeout(timeout);
+ };
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps -- Needed to only run once on mount
+
return (
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
diff --git a/src/Constants.ts b/src/Constants.ts
deleted file mode 100644
index 3a5930c..0000000
--- a/src/Constants.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * Copyright (c) 2025 Amazon.com, Inc. or its affiliates. All rights reserved.
- *
- * PROPRIETARY/CONFIDENTIAL. USE IS SUBJECT TO LICENSE TERMS.
- */
-
-/**
- * Application constants and utilities
- */
-
-import moment from 'moment';
-import { Dimensions, Platform } from 'react-native';
-import { isLargeScreen } from './utils/ScreenSizing';
-
-// Layout dimensions
-export const HomeSidebarWidth = Platform.isTV ? 350 : 200;
-
-// Image asset imports
-export const MUSIC_ICON = require('./assets/images/music-note.png');
-export const MOVIE_CARD_PLACEHOLDER_IMAGE = require('./assets/images/placeholder.png');
-export const DEFAULT_COVER_IMAGE = require('./assets/images/covers/neon-nights.webp');
-
-// Responsive font sizes based on screen size
-export const TitleFontSize = isLargeScreen ? 20 : 13;
-export const HeaderFontSize = isLargeScreen ? 26 : 18;
-
-// UI element sizes
-export const SizeNavigationButtons = 40;
-export const SizePlayshuffleButtons = 60;
-
-// Layout proportions
-export const CarouselHeight = isLargeScreen ? '30%' : '40%';
-
-// Screen dimensions for layout calculations
-export const dimension = Dimensions.get('window');
-
-// Default time display format
-export const DEFAULT_PROGRESS_TIME = '00:00';
-
-// Material Design icon names
-export const MaterialIcons = {
- PAUSE: 'pause',
- PLAY: 'play-arrow',
- CHEVRON_LEFT: 'chevron-left', // Left navigation arrow
- CHEVRON_RIGHT: 'chevron-right', // Right navigation arrow
- SHUFFLE: 'shuffle',
-};
-
-/** Formats milliseconds to MM:SS string, returns 0 for zero input */
-export const formatTime = (ms: number) => {
- if (ms === 0) {
- return 0;
- }
-
- let duration = moment.duration(ms, 'milliseconds');
- let fromMinutes = Math.floor(duration.asMinutes());
- let fromSeconds = Math.floor(duration.asSeconds() - fromMinutes * 60);
-
- // Format as MM:SS with zero-padding
- return Math.floor(duration.asSeconds()) >= 60
- ? (fromMinutes <= 9 ? '0' + fromMinutes : fromMinutes) +
- ':' +
- (fromSeconds <= 9 ? '0' + fromSeconds : fromSeconds)
- : '00:' + (fromSeconds <= 9 ? '0' + fromSeconds : fromSeconds);
-};
-
-/** Converts seconds to MM:SS format */
-export const secondsToHHMMSS = (seconds: number | string) => {
- seconds = Number(seconds);
- const min = Math.floor((seconds % 3600) / 60);
- const sec = Math.floor((seconds % 3600) % 60);
-
- // Format minutes and seconds with zero-padding
- const mins = min > 0 ? (min < 10 ? `0${min}:` : `${min}:`) : '00:';
- const second = sec > 0 ? (sec < 10 ? `0${sec}` : sec) : '00';
- return `${mins}${second}`;
-};
-
-// TV remote event key states
-export const EVENT_KEY_DOWN = 0; // Key press event
-export const EVENT_KEY_UP = 1; // Key release event
-
-// TV remote control event types
-export enum RemoteEvent {
- // Directional navigation
- UP = 'up',
- DOWN = 'down',
- RIGHT = 'right',
- LEFT = 'left',
-
- // Action buttons
- SELECT = 'select',
- MENU = 'menu',
- BACK = 'back',
-
- // Media control buttons
- PLAY_PAUSE = 'playpause',
- SKIP_BACKWARD = 'skip_backward',
- SKIP_FORWARD = 'skip_forward',
-
- // Page navigation
- PAGE_UP = 'page_up',
- PAGE_DOWN = 'page_down',
- PAGE_LEFT = 'page_left',
- PAGE_RIGHT = 'page_right',
-
- // Additional buttons
- INFO = 'info',
- MORE = 'more',
-}
-
-// Media element ready states
-export enum ReadyState {
- HAVE_NOTHING = 0, // No information available
- HAVE_METADATA = 1, // Metadata loaded
- HAVE_CURRENT_DATA = 2, // Current position data available
- HAVE_FUTURE_DATA = 3, // Current and future data available
- HAVE_ENOUGH_DATA = 4, // Enough data for playing
-}
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
new file mode 100644
index 0000000..e550257
--- /dev/null
+++ b/src/__tests__/App.test.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import 'react-native';
+
+import { render, screen } from '@testing-library/react-native';
+
+import { App } from '../App';
+
+jest.mock('@amazon-devices/kepler-channel', () => ({
+ ChannelServerComponent: {
+ channelServer: {
+ handler: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@amazon-devices/react-native-kepler', () => ({
+ useHideSplashScreenCallback: () => {
+ const callback = () => {};
+ (callback as unknown as { handler: jest.Mock }).handler = jest.fn();
+ return callback as { (): void; handler: jest.Mock };
+ },
+ usePreventHideSplashScreen: jest.fn(),
+ useGetCurrentKeplerAppStateCallback: jest.fn(() => jest.fn()),
+ useAddKeplerAppStateListenerCallback: jest.fn(() =>
+ jest.fn(() => ({ remove: jest.fn() })),
+ ),
+}));
+
+describe('App', () => {
+ const renderApp = () => render();
+
+ it('render login screen after initial App render', () => {
+ renderApp();
+
+ expect(screen.getByTestId('login')).toBeOnTheScreen();
+ });
+
+ it('initial screen renders button with login text', () => {
+ renderApp();
+
+ const header = screen.getByRole('button');
+
+ expect(header).toBeOnTheScreen();
+ expect(header).toHaveTextContent('login-button');
+ });
+});
diff --git a/src/api/README.md b/src/api/README.md
new file mode 100644
index 0000000..1399dd5
--- /dev/null
+++ b/src/api/README.md
@@ -0,0 +1,342 @@
+# API Fetchers and DTO Pattern
+
+This module is responsible for managing API requests and data transformation for the application's data models, such as `livestreams`, `documentaries`, etc. It ensures the data fetched from the API aligns with the defined data models expected by the application, enabling consistent type-checking, data parsing, and error handling.
+
+This document outlines the module's usage, the DTO pattern, and guidance on structuring additional endpoints.
+
+---
+
+## Table of Contents
+
+- [Overview](#overview)
+- [What is the DTO Pattern?](#what-is-the-dto-pattern)
+- [Folder Structure](#folder-structure)
+- [Usage](#usage)
+- [Utilities](#utilities)
+- [Static JSON files](#static-json-files)
+- [Contributing](#contributing)
+
+---
+
+## Overview
+
+The API module:
+
+- Provides fetcher methods and hooks to retrieve data from specific endpoints
+- Implements the Data Transfer Object (DTO) pattern to parse response data into the models expected by the app
+- Validates types to ensure accuracy and prevent errors
+- Simplifies access to parsed data for easy manipulation
+- Centralizes error handling for consistent API interactions
+
+This encapsulated module provides a scalable way to manage multiple API endpoints across the app.
+
+---
+
+## What is the DTO Pattern?
+
+The DTO (Data Transfer Object) pattern is used to transfer data between different parts of a program, typically between the server and client in an API. DTOs define a structured format for data that makes it easier to manage, validate, and parse responses to and from the server. In this module:
+
+1. **Definition of DTO**: A DTO represents the structure of data as received from the API, and it may vary from the app's internal data model.
+2. **Purpose of Parsing**: After data is fetched, a parsing function converts it from DTO form to the app's internal model. This ensures that the data structure aligns with what the application expects.
+3. **Error Prevention**: By defining DTOs explicitly, we can catch and handle unexpected data formats early, reducing runtime errors and bugs.
+
+The `fetchLiveStreamsApiCall` function in the code example above shows how we use the DTO pattern. After the response is fetched, `parseLiveStreamsDtoArray` parses it into the app's required model format.
+
+---
+
+## Folder Structure
+
+The folder structure is organized to clearly separate concerns related to different endpoints. Let's elaborate on the structure based on `livestream` endpoint example:
+
+```plaintext
+- api
+ - liveStreams
+ - dtos # Contains DTOs defining the structure of data received from the API
+ - staticData # Stores any static data for testing or backup purposes
+ - fetchLiveStreamsDetails.ts # Fetch function for detailed data on livestreams
+ - fetchLiveStreams.ts # Fetch function for primary livestreams endpoint
+```
+
+### Explanation of Folder Components
+
+- **`liveStreams/dtos`**: This directory contains DTO definitions and parsing functions that handle the data structure expected from the `liveStreams` endpoint. For example, the `LiveStreamDto` type and the `parseLiveStreamsDtoArray` function.
+- **`liveStreams/staticData`**: Provides default static data in JSON format to use when the API is unavailable or for testing purposes (please check [Switching Between Data Sources section in ApiClient README](../services/apiClient/README.md#switching-between-data-sources) for more details).
+- **`fetchLiveStreams.ts`**: Contains the primary function, `fetchLiveStreamsApiCall`, to fetch and parse data from the `liveStreams` endpoint with list of available `livestreams` to be presented in carousel.
+- **`fetchLiveStreamsDetails.ts`**: Additional endpoint for detailed live stream data, required e.g for Details Screen.
+
+---
+
+## Usage
+
+### Step 1: Define the DTO and Parsing Logic
+
+In `liveStreams/dtos/LiveStreamsDto.ts`, define the data transfer object. Remember to properly set `?` and apply proper parsing methods to end up with desire data structure depending on possible values and approach how to handle them in lower part in the app:
+
+```typescript
+export type LiveStreamDto = {
+ id?: string;
+ title?: string;
+ description?: string;
+ streamUrl?: string;
+ observers_count?: number;
+ start_time?: string;
+};
+
+export function parseLiveStreamsDtoArray(
+ data: LiveStreamDto[],
+): LiveStreamModel[] {
+ return data.map((dto) => ({
+ id: dto.id,
+ title: dto.title,
+ description: dto.description,
+ url: dto.streamUrl,
+ viewers: parseNumber(dto.observers_count)
+ startTime: new Date(dto.start_time),
+ }));
+}
+```
+
+### Step 2: Define the Model type
+
+In `src/models/liveStreams`, define the data model object. Remember that this will be final Model structure so you need to be sure about what value has to be here and it's type is properly parsed in Step 1. Because of that in the lower part of the app confidence about property type will be as high as possible:
+
+```typescript
+export type LiveStreamModel = {
+ id: string;
+ title: string;
+ description: string;
+ streamUrl: string;
+ viewers: number;
+ startTime: Date;
+};
+
+export function parseLiveStreamsDtoArray(
+ data: LiveStreamDto[],
+): LiveStreamModel[] {
+ return data.map((dto) => ({
+ id: dto.id,
+ title: dto.title,
+ description: dto.description,
+ url: dto.streamUrl,
+ startTime: new Date(dto.startTime),
+ }));
+}
+```
+
+### Step 3: Implement the Fetch Function
+
+In `fetchLiveStreams.ts`, combines the API fetch, error handling and parse functionality:
+
+```typescript
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import { parseLiveStreamsDtoArray, LiveStreamDto } from './dtos/LiveStreamsDto';
+import staticData from './staticData/liveStreams.json';
+
+type ResponseDto = LiveStreamDto[];
+
+const endpoint = Endpoints.LiveStreams;
+
+export const fetchLiveStreamsApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchLiveStreamsApiCall(): Resource does not exist for endpoint '${endpoint}'`,
+ );
+ default:
+ throw new Error(
+ `fetchLiveStreamsApiCall(): Failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseLiveStreamsDtoArray(response.data);
+};
+```
+
+### Step 4: Create the Custom Hook
+
+In `fetchLiveStreams.ts`, use `react-query` to create a dedicated reusable hook for given endpoint:
+
+```typescript
+import { useQuery } from '@tanstack/react-query';
+
+export const useLiveStreams = () => {
+ const query = useQuery({ queryKey: [endpoint], queryFn: fetchLiveStreamsApiCall});
+
+ return query;
+};
+```
+
+### Example Usage of the Hook
+
+Use `useLiveStreams` within a component to fetch and consume live stream related data:
+
+```typescript
+import React from 'react';
+import { useLiveStreams } from '@AppServices/api/liveStreams/fetchLiveStreams';
+
+export const LiveStreamsList = () => {
+ const { data: liveStreams, isLoading, isError } = useLiveStreams();
+
+ if (isLoading) return Loading...;
+ if (isError) return Error loading streams.;
+
+ return (
+
+ {liveStreams.map((stream) => (
+ {stream.title}
+ ))}
+
+ );
+};
+```
+
+---
+
+## Utilities
+
+Utility functions are included to parse and validate response data preserving types. Currently implemented DTO utils are:
+
+Here’s a detailed description for each `dtoUtils` function:
+
+---
+
+### `parseDtoArray`
+
+This utility function is a generic method that helps to transform an array of DTO objects into an array of app-specific models. By providing a parsing function, `parseItem`, this function converts each DTO item into the desired model type, discarding any undefined values in the result.
+
+#### Parameters
+
+- `parseItem: (dto: Dto) => Model | undefined`: A function that defines how each item in the array should be parsed or converted from a `Dto` to a `Model`. This function may return `undefined` if the parsing is unsuccessful or the item does not meet the desired criteria.
+- `dtos: Dto[] | null | undefined`: The array of DTO items that needs to be parsed. It can also be `null` or `undefined`, in which case an empty array is returned.
+
+#### Returns
+
+- `Model[]`: An array of parsed models, excluding any `undefined` values produced by the `parseItem` function.
+
+#### Example Usage
+
+```typescript
+// Assuming parseLiveStreamItem is a function that parses a single LiveStreamDto into LiveStreamModel
+const liveStreamModels = parseDtoArray(parseLiveStreamItem, liveStreamDtos);
+```
+
+---
+
+### `parseDtoRecord`
+
+This utility function is a generic method to transform an object (or "record") with DTO values into an object with app-specific models, keeping the same keys. Like `parseDtoArray`, it also filters out any entries where the parsed model is `undefined`.
+
+#### Parameters
+
+- `parseItem: (dto: Dto) => Model | undefined`: A function that defines the transformation of each DTO into a `Model`. If parsing fails or the item does not meet specific conditions, it may return `undefined`.
+- `dto: Record`: The input object, where each key corresponds to a `Dto` item that needs to be parsed.
+
+#### Returns
+
+- `Record`: An object with the same keys as the input, but containing only successfully parsed model items, with any undefined values excluded.
+
+#### Example Usage
+
+```typescript
+// Assuming parseLiveStreamItem is a function that parses a single LiveStreamDto into LiveStreamModel
+const liveStreamModelRecord = parseDtoRecord(
+ parseLiveStreamItem,
+ liveStreamDtoRecord,
+);
+```
+
+---
+
+## Static JSON files
+
+Static JSON file is commited to codebase to allow reference app to render some content without need of having API exposed. However it's possible to easily setup local instance of JSON server to serve data from local instance of it - the JSON files has to have specific structure:
+
+To create a JSON file with static data for the app’s endpoints, structure it so each endpoint has a unique key with an array of objects representing its records. Each record should align with the DTO structure defined for that endpoint. Here’s a step-by-step guide to shaping the JSON file with static data:
+
+### Structure Overview
+
+The static data JSON file should follow this format:
+
+```json
+{
+ "<>": [
+ /* array of records matching the DTO structure */
+ ]
+}
+```
+
+### Example for `liveStreams` Endpoint
+
+For example, if you have an endpoint named `liveStreams`, the static data file might look like this:
+
+```jsonc
+{
+ "liveStreams": [
+ {
+ "id": "stream1",
+ "title": "Live Music Concert",
+ "description": "A live streaming event of an outdoor music concert.",
+ "video_source": {
+ "title": "Dash",
+ "type": "hls",
+ "format": "m3u8",
+ "uri": "https://amazon.com/media1.m3u8"
+ },
+ "start_time": "2023-10-31T20:00:00Z",
+ },
+ {
+ "id": "stream2",
+ "title": "Cooking Show",
+ "description": "Recording of a cooking show with professional chefs.",
+ "video_source": {
+ "format": "mp4",
+ "title": "MP4",
+ "uri": "https://amazon.com/media2.mp4",
+ },
+ "start_time": "2023-11-01T18:00:00Z",
+ },
+ ],
+}
+```
+
+### Guidelines for Structuring Static Data
+
+1. **Define the Key as the Endpoint Name**:
+
+ - The top-level key should be the exact name of the endpoint, as defined in the app (e.g., `"liveStreams"`).
+
+2. **Use Array of Records**:
+
+ - Each endpoint key should contain an array, even if there is only one record. This array structure makes it easier to add or update records in the future.
+
+3. **Match DTO Structure for Each Record**:
+ - Ensure that each record in the array matches the properties defined and used in the DTO for that endpoint. That's the benefit of having DTO - all records which does not have proper values will be handled during parsing. Those records might be ignored or error might be thrown depending of the use case.
+ - For instance, suppose the `LiveStreamDto` has properties `id`, `title`, `description`, `video_source`, and `start_time`. If you add another field without adjusting DTO it won't be parsed. However if you affect one of the property name used in DTO - this property will be parsed to `undefined` or fallback to value defined in used parser method.
+
+---
+
+## Contributing
+
+If you are extending or modifying the **Api Module**, follow these guidelines:
+
+1. **New Folder** Create a new folder following the pattern described in this REAMDE
+
+- **New Endpoint** Create a fetch function (one per file) that calls the API and parses the data.
+- **New DTO** Define the DTO and parsing functions for the endpoint.
+- **JSON Static Data** Create JSON file with static data sollowing required structure
+- **New Hook** Optionally, expose a custom hook from files with fetcher for easy access to the data in components.
+
+2. **Testing**: Write unit tests to verify the behavior of any new functionality, especially in edge cases (e.g., throwing error or fallback to default behaviour).
+3. **Documentation**: Update README (if neccessary) to reflect any new methods, configuration options or structure.
diff --git a/src/api/auth/dtos/AuthDto.ts b/src/api/auth/dtos/AuthDto.ts
new file mode 100644
index 0000000..c9cb97d
--- /dev/null
+++ b/src/api/auth/dtos/AuthDto.ts
@@ -0,0 +1,37 @@
+import type { Account, User } from '@AppModels/auth';
+import { parseDtoArray } from '../../dtoUtils/dtoCommonUtils';
+import type { UserDto } from '../dtos/UserDto';
+import { parseUserDto } from '../dtos/UserDto';
+
+export type AuthDto = {
+ id?: string;
+ name?: string;
+ surname?: string;
+ email?: string;
+ address?: string;
+ image?: string;
+ profiles?: UserDto[];
+};
+
+export const parseAuthDto = ({
+ id,
+ image,
+ name,
+ surname,
+ profiles: profilesDto,
+ email,
+}: AuthDto): Account | undefined => {
+ if (!id) {
+ return;
+ }
+ const profiles = parseDtoArray(parseUserDto, profilesDto);
+
+ return {
+ id,
+ firstName: name ?? '',
+ lastName: surname ?? '',
+ email: email ?? '',
+ avatar: image,
+ profiles,
+ };
+};
diff --git a/src/api/auth/dtos/UserDto.ts b/src/api/auth/dtos/UserDto.ts
new file mode 100644
index 0000000..62239b8
--- /dev/null
+++ b/src/api/auth/dtos/UserDto.ts
@@ -0,0 +1,23 @@
+import type { User } from '@AppModels/auth';
+
+export type UserDto = {
+ id?: string;
+ displayName?: string;
+ image?: string;
+};
+
+export const parseUserDto = ({
+ id,
+ displayName,
+ image,
+}: UserDto): User | undefined => {
+ if (!id) {
+ return;
+ }
+
+ return {
+ id,
+ avatar: image,
+ name: displayName ?? '',
+ };
+};
diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts
new file mode 100644
index 0000000..bad11fc
--- /dev/null
+++ b/src/api/auth/index.ts
@@ -0,0 +1 @@
+export * from './signIn';
diff --git a/src/api/auth/signIn.ts b/src/api/auth/signIn.ts
new file mode 100644
index 0000000..d0819e2
--- /dev/null
+++ b/src/api/auth/signIn.ts
@@ -0,0 +1,38 @@
+import {
+ ApiClient,
+ Endpoints,
+ isSuccessResponse,
+} from '@AppServices/apiClient';
+import type { AuthDto } from './dtos/AuthDto';
+import { parseAuthDto } from './dtos/AuthDto';
+import staticData from './staticData/auth.json';
+
+export type SignInParams = {
+ email: string;
+ password: string;
+};
+
+const endpoint = Endpoints.Auth;
+export const postSignIn = async (params: SignInParams) => {
+ const response = await ApiClient.post(
+ endpoint,
+ { staticData },
+ { params },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `postSignIn(): resources does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `postSignIn(): failed to received data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseAuthDto(response.data);
+};
diff --git a/src/api/auth/staticData/auth.json b/src/api/auth/staticData/auth.json
new file mode 100644
index 0000000..83791dd
--- /dev/null
+++ b/src/api/auth/staticData/auth.json
@@ -0,0 +1,22 @@
+{
+ "auth": {
+ "id": "nmbsd-234sdc-678-jklsdf",
+ "name": "Freida",
+ "surname": "Gomam",
+ "email": "fgomam@fakemail.com",
+ "address": "Nowhere 5 USA",
+ "image": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/5p.jpg",
+ "profiles": [
+ {
+ "id": "bada55-1dl00k-4tm33",
+ "displayName": "Freida",
+ "image": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/6p.jpg"
+ },
+ {
+ "id": "l00k-1nt0-s0uL",
+ "displayName": "Jennifer",
+ "image": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/7p.jpg"
+ }
+ ]
+ }
+}
diff --git a/src/api/carouselLayout/__tests__/__snapshots__/fetchCarouselLayout.test.ts.snap b/src/api/carouselLayout/__tests__/__snapshots__/fetchCarouselLayout.test.ts.snap
new file mode 100644
index 0000000..8a92669
--- /dev/null
+++ b/src/api/carouselLayout/__tests__/__snapshots__/fetchCarouselLayout.test.ts.snap
@@ -0,0 +1,251 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchDocumentaries fetchCarouselLayoutApiCall should return parsed documentaries data on success 1`] = `
+[
+ {
+ "carouselTitle": null,
+ "carouselType": "hero",
+ "endpoint": "suggestedforyou",
+ "itemId": "e77effd2-60fd-4a75-9921-ee1596c4d248",
+ },
+ {
+ "carouselTitle": "Live TV: fresh news from events",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "fac0db1d-60ee-4ff9-9c3f-fc1ca24fa6be",
+ },
+ {
+ "carouselTitle": "Sport Teams Hall of Fame",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "cf5bafe0-023b-4ff9-9c3f-fc1ca24fa6be",
+ },
+ {
+ "carouselTitle": "Documentaries on Sport Stars",
+ "carouselType": "square",
+ "endpoint": "documentaries",
+ "itemId": "068f3841-023b-4de8-85d1-f410d1407528",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc13a87-af56-42ef-a66e-b882a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f1389-845c-476d-820b-1aa388e7bf1a4",
+ },
+ {
+ "carouselTitle": "Champions League",
+ "carouselType": "card",
+ "endpoint": "documentaries",
+ "itemId": "5d21039f-3ba5-491b-b481-324ceb742ac71",
+ },
+ {
+ "carouselTitle": "Live TV: fresh news from events",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "fac0db1d-60ee-4ff9-9c3f-fc15ca24fa6be",
+ },
+ {
+ "carouselTitle": "Sport Teams Hall of Fame",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "cf5bafe0-023b-4ff9-9c3f-6fc1ca24fa6be",
+ },
+ {
+ "carouselTitle": "Documentaries on Sport Stars",
+ "carouselType": "square",
+ "endpoint": "documentaries",
+ "itemId": "06844f3841-023b-4de8-85d1-7f410d14aa07528",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc1113a87-af56-42ef-a66e8-b8jj8a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f11389-845c-476d-8290b-1aajj88e7bf1a4",
+ },
+ {
+ "carouselTitle": "Champions League",
+ "carouselType": "card",
+ "endpoint": "documentaries",
+ "itemId": "5d221039f-3ba5-491b-b4081-32cejjb742ac71",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc132a87-af56-42ef-a66e-bgh882a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c9177f14389-845c-476d-820b-s",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc166113a87-af56-42ef-a66e8-b8jj8a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f5511389-845c-476d-8290b-1aajj88e7bf1a4",
+ },
+ {
+ "carouselTitle": "Champions League",
+ "carouselType": "card",
+ "endpoint": "documentaries",
+ "itemId": "5d22441039f-3ba5-491b-b4081-32cejjb742ac71",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc13233a87-af56-42ef-a66e-bgh882a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f1422389-845c-476d-820b-s",
+ },
+]
+`;
+
+exports[`fetchDocumentaries useCarouselLayout should return carousel layout data and no error on successful fetch 1`] = `
+[
+ {
+ "carouselTitle": null,
+ "carouselType": "hero",
+ "endpoint": "suggestedforyou",
+ "itemId": "e77effd2-60fd-4a75-9921-ee1596c4d248",
+ },
+ {
+ "carouselTitle": "Live TV: fresh news from events",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "fac0db1d-60ee-4ff9-9c3f-fc1ca24fa6be",
+ },
+ {
+ "carouselTitle": "Sport Teams Hall of Fame",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "cf5bafe0-023b-4ff9-9c3f-fc1ca24fa6be",
+ },
+ {
+ "carouselTitle": "Documentaries on Sport Stars",
+ "carouselType": "square",
+ "endpoint": "documentaries",
+ "itemId": "068f3841-023b-4de8-85d1-f410d1407528",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc13a87-af56-42ef-a66e-b882a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f1389-845c-476d-820b-1aa388e7bf1a4",
+ },
+ {
+ "carouselTitle": "Champions League",
+ "carouselType": "card",
+ "endpoint": "documentaries",
+ "itemId": "5d21039f-3ba5-491b-b481-324ceb742ac71",
+ },
+ {
+ "carouselTitle": "Live TV: fresh news from events",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "fac0db1d-60ee-4ff9-9c3f-fc15ca24fa6be",
+ },
+ {
+ "carouselTitle": "Sport Teams Hall of Fame",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "cf5bafe0-023b-4ff9-9c3f-6fc1ca24fa6be",
+ },
+ {
+ "carouselTitle": "Documentaries on Sport Stars",
+ "carouselType": "square",
+ "endpoint": "documentaries",
+ "itemId": "06844f3841-023b-4de8-85d1-7f410d14aa07528",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc1113a87-af56-42ef-a66e8-b8jj8a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f11389-845c-476d-8290b-1aajj88e7bf1a4",
+ },
+ {
+ "carouselTitle": "Champions League",
+ "carouselType": "card",
+ "endpoint": "documentaries",
+ "itemId": "5d221039f-3ba5-491b-b4081-32cejjb742ac71",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc132a87-af56-42ef-a66e-bgh882a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c9177f14389-845c-476d-820b-s",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc166113a87-af56-42ef-a66e8-b8jj8a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f5511389-845c-476d-8290b-1aajj88e7bf1a4",
+ },
+ {
+ "carouselTitle": "Champions League",
+ "carouselType": "card",
+ "endpoint": "documentaries",
+ "itemId": "5d22441039f-3ba5-491b-b4081-32cejjb742ac71",
+ },
+ {
+ "carouselTitle": "Roland Garros",
+ "carouselType": "card",
+ "endpoint": "teams",
+ "itemId": "9fc13233a87-af56-42ef-a66e-bgh882a88540cc9",
+ },
+ {
+ "carouselTitle": "Interviews with Idols",
+ "carouselType": "square",
+ "endpoint": "livestreams",
+ "itemId": "c91f1422389-845c-476d-820b-s",
+ },
+]
+`;
diff --git a/src/api/carouselLayout/__tests__/fetchCarouselLayout.test.ts b/src/api/carouselLayout/__tests__/fetchCarouselLayout.test.ts
new file mode 100644
index 0000000..f9537b9
--- /dev/null
+++ b/src/api/carouselLayout/__tests__/fetchCarouselLayout.test.ts
@@ -0,0 +1,122 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import {
+ useCarouselLayout,
+ fetchCarouselLayoutApiCall,
+} from '../fetchCarouselLayout';
+import staticData from '../staticData/carouselLayout.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchDocumentaries', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchCarouselLayoutApiCall', () => {
+ it('should return parsed documentaries data on success', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.carousellayout }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchCarouselLayoutApiCall();
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should throw error for 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 400 });
+
+ await expect(fetchCarouselLayoutApiCall()).rejects.toThrow(
+ `fetchCarouselLayoutApiCall(): resources does not exists for endpoint 'carousellayout'`,
+ );
+ });
+
+ it('should throw error for unexpected Api status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(fetchCarouselLayoutApiCall()).rejects.toThrow(
+ `Perform a request failed`,
+ );
+ });
+
+ it('should throw error for other non-200 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
+
+ await expect(fetchCarouselLayoutApiCall()).rejects.toThrow(
+ `fetchCarouselLayoutApiCall(): failed to fetch data from endpoint 'carousellayout'`,
+ );
+ });
+ });
+
+ describe('useCarouselLayout', () => {
+ it('should return carousel layout data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.carousellayout }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchCarouselLayoutApiCall(),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useCarouselLayout());
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() => useCarouselLayout());
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBe(null);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should return error when fetch fails', () => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useCarouselLayout());
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/carouselLayout/dtos/CarouselLayoutDto.ts b/src/api/carouselLayout/dtos/CarouselLayoutDto.ts
new file mode 100644
index 0000000..5072345
--- /dev/null
+++ b/src/api/carouselLayout/dtos/CarouselLayoutDto.ts
@@ -0,0 +1,62 @@
+import type { CarouselLayout } from '@AppModels/carouselLayout/CarouselLayout';
+import { CarouselType } from '@AppModels/carouselLayout/CarouselLayout';
+import { logError } from '@AppUtils/logging';
+import { isValidEnumValueTypeGuard } from '@AppUtils/typeGuards';
+import { parseEndpoint } from '../../dtoUtils/dtoAppUtils';
+import { parseDtoArray, parseString } from '../../dtoUtils/dtoCommonUtils';
+
+export type CarouselLayoutDto = {
+ id?: Maybe;
+ carouselType?: Maybe;
+ carouselTitle?: Maybe;
+ endpoint?: Maybe;
+};
+
+export const parseCarouselLayoutDto = (
+ dto: CarouselLayoutDto,
+): CarouselLayout | undefined => {
+ if (!dto.carouselType || !dto.id) {
+ return;
+ }
+
+ const endpoint = parseEndpoint(dto.endpoint);
+
+ if (!endpoint) {
+ logError(`Failed to parse endpoint: `, dto.endpoint);
+ return;
+ }
+
+ const carouselType = parseCarouselType(dto.carouselType);
+
+ if (!carouselType) {
+ logError(
+ `Unknown carouselType (${dto.carouselType}) for (${dto.id}), fallback to default: `,
+ CarouselType.Default,
+ );
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ carouselType: carouselType ?? CarouselType.Default,
+ carouselTitle: dto.carouselTitle || null,
+ endpoint,
+ };
+};
+export const parseCarouselLayoutDtoArray = (
+ dtos: CarouselLayoutDto[],
+): CarouselLayout[] => {
+ return parseDtoArray(parseCarouselLayoutDto, dtos);
+};
+
+const parseCarouselType = (
+ carouselTypeCandidate: Maybe,
+): CarouselType | undefined => {
+ const parsedCarouselType = parseString(carouselTypeCandidate);
+
+ if (parsedCarouselType) {
+ if (isValidEnumValueTypeGuard(CarouselType, parsedCarouselType)) {
+ return parsedCarouselType;
+ }
+ }
+};
diff --git a/src/api/carouselLayout/fetchCarouselLayout.ts b/src/api/carouselLayout/fetchCarouselLayout.ts
new file mode 100644
index 0000000..ae70abd
--- /dev/null
+++ b/src/api/carouselLayout/fetchCarouselLayout.ts
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { CarouselLayoutDto } from './dtos/CarouselLayoutDto';
+import { parseCarouselLayoutDtoArray } from './dtos/CarouselLayoutDto';
+import staticData from './staticData/carouselLayout.json';
+
+type ResponseDto = CarouselLayoutDto[];
+
+const endpoint = Endpoints.CarouselLayout;
+
+export const fetchCarouselLayoutApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchCarouselLayoutApiCall(): resources does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchCarouselLayoutApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseCarouselLayoutDtoArray(response.data);
+};
+
+export const useCarouselLayout = () => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchCarouselLayoutApiCall,
+ });
+
+ return query;
+};
diff --git a/src/api/carouselLayout/index.ts b/src/api/carouselLayout/index.ts
new file mode 100644
index 0000000..965599d
--- /dev/null
+++ b/src/api/carouselLayout/index.ts
@@ -0,0 +1,4 @@
+export {
+ useCarouselLayout,
+ fetchCarouselLayoutApiCall,
+} from './fetchCarouselLayout';
diff --git a/src/api/carouselLayout/staticData/carouselLayout.json b/src/api/carouselLayout/staticData/carouselLayout.json
new file mode 100644
index 0000000..594b156
--- /dev/null
+++ b/src/api/carouselLayout/staticData/carouselLayout.json
@@ -0,0 +1,124 @@
+{
+ "carousellayout": [
+ {
+ "id": "e77effd2-60fd-4a75-9921-ee1596c4d248",
+ "carouselType": "hero",
+ "carouselTitle": null,
+ "endpoint": "suggestedforyou"
+ },
+ {
+ "id": "fac0db1d-60ee-4ff9-9c3f-fc1ca24fa6be",
+ "carouselType": "square",
+ "carouselTitle": "Live TV: fresh news from events",
+ "endpoint": "livestreams"
+ },
+ {
+ "id": "cf5bafe0-023b-4ff9-9c3f-fc1ca24fa6be",
+ "carouselType": "card",
+ "carouselTitle": "Sport Teams Hall of Fame",
+ "endpoint": "teams"
+ },
+ {
+ "id": "068f3841-023b-4de8-85d1-f410d1407528",
+ "carouselType": "square",
+ "carouselTitle": "Documentaries on Sport Stars",
+ "endpoint": "documentaries"
+ },
+ {
+ "id": "9fc13a87-af56-42ef-a66e-b882a88540cc9",
+ "carouselType": "card",
+ "carouselTitle": "Roland Garros",
+ "endpoint": "teams"
+ },
+ {
+ "id": "c91f1389-845c-476d-820b-1aa388e7bf1a4",
+ "carouselType": "square",
+ "carouselTitle": "Interviews with Idols",
+ "endpoint": "livestreams"
+ },
+ {
+ "id": "5d21039f-3ba5-491b-b481-324ceb742ac71",
+ "carouselType": "card",
+ "carouselTitle": "Champions League",
+ "endpoint": "documentaries"
+ },
+ {
+ "id": "fac0db1d-60ee-4ff9-9c3f-fc15ca24fa6be",
+ "carouselType": "square",
+ "carouselTitle": "Live TV: fresh news from events",
+ "endpoint": "livestreams"
+ },
+ {
+ "id": "cf5bafe0-023b-4ff9-9c3f-6fc1ca24fa6be",
+ "carouselType": "card",
+ "carouselTitle": "Sport Teams Hall of Fame",
+ "endpoint": "teams"
+ },
+ {
+ "id": "06844f3841-023b-4de8-85d1-7f410d14aa07528",
+ "carouselType": "square",
+ "carouselTitle": "Documentaries on Sport Stars",
+ "endpoint": "documentaries"
+ },
+ {
+ "id": "9fc1113a87-af56-42ef-a66e8-b8jj8a88540cc9",
+ "carouselType": "card",
+ "carouselTitle": "Roland Garros",
+ "endpoint": "teams"
+ },
+ {
+ "id": "c91f11389-845c-476d-8290b-1aajj88e7bf1a4",
+ "carouselType": "square",
+ "carouselTitle": "Interviews with Idols",
+ "endpoint": "livestreams"
+ },
+ {
+ "id": "5d221039f-3ba5-491b-b4081-32cejjb742ac71",
+ "carouselType": "card",
+ "carouselTitle": "Champions League",
+ "endpoint": "documentaries"
+ },
+ {
+ "id": "9fc132a87-af56-42ef-a66e-bgh882a88540cc9",
+ "carouselType": "card",
+ "carouselTitle": "Roland Garros",
+ "endpoint": "teams"
+ },
+ {
+ "id": "c9177f14389-845c-476d-820b-s",
+ "carouselType": "square",
+ "carouselTitle": "Interviews with Idols",
+ "endpoint": "livestreams"
+ },
+ {
+ "id": "9fc166113a87-af56-42ef-a66e8-b8jj8a88540cc9",
+ "carouselType": "card",
+ "carouselTitle": "Roland Garros",
+ "endpoint": "teams"
+ },
+ {
+ "id": "c91f5511389-845c-476d-8290b-1aajj88e7bf1a4",
+ "carouselType": "square",
+ "carouselTitle": "Interviews with Idols",
+ "endpoint": "livestreams"
+ },
+ {
+ "id": "5d22441039f-3ba5-491b-b4081-32cejjb742ac71",
+ "carouselType": "card",
+ "carouselTitle": "Champions League",
+ "endpoint": "documentaries"
+ },
+ {
+ "id": "9fc13233a87-af56-42ef-a66e-bgh882a88540cc9",
+ "carouselType": "card",
+ "carouselTitle": "Roland Garros",
+ "endpoint": "teams"
+ },
+ {
+ "id": "c91f1422389-845c-476d-820b-s",
+ "carouselType": "square",
+ "carouselTitle": "Interviews with Idols",
+ "endpoint": "livestreams"
+ }
+ ]
+}
diff --git a/src/api/detailsLayout/dtos/DetailsLayoutDto.ts b/src/api/detailsLayout/dtos/DetailsLayoutDto.ts
new file mode 100644
index 0000000..3ae0036
--- /dev/null
+++ b/src/api/detailsLayout/dtos/DetailsLayoutDto.ts
@@ -0,0 +1,35 @@
+import type {
+ DetailsLayout,
+ DetailsScreenLayoutElements,
+} from '@AppModels/detailsLayout/DetailsLayout';
+import { parseString } from '../../dtoUtils/dtoCommonUtils';
+import { parseLayoutType, parseLayoutElements } from './utils';
+
+export type DetailsLayoutDto = {
+ id?: Maybe;
+ layout_type?: Maybe; // livestreams, documentaries, teams, suggestedForYou
+ layout_title?: Maybe;
+ layoutElements?: Maybe;
+};
+
+export const parseDetailsLayoutDto = (
+ dto: DetailsLayoutDto,
+): DetailsLayout | undefined => {
+ if (!dto.id) {
+ return;
+ }
+
+ const layoutType = parseLayoutType(dto.layout_type);
+ const layoutElements = parseLayoutElements(dto.layoutElements);
+
+ if (!layoutType) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ layoutType,
+ layoutTitle: parseString(dto.layout_title),
+ layoutElements,
+ };
+};
diff --git a/src/api/detailsLayout/dtos/DetailsLayoutListDto.ts b/src/api/detailsLayout/dtos/DetailsLayoutListDto.ts
new file mode 100644
index 0000000..89f1c0e
--- /dev/null
+++ b/src/api/detailsLayout/dtos/DetailsLayoutListDto.ts
@@ -0,0 +1,32 @@
+import { isInListTypeGuard } from '@AppUtils';
+import type { DetailsLayoutListItem } from '@AppSrc/models/detailsLayout/DetailsLayoutListItem';
+import { supportedLayoutsList } from '@AppSrc/models/detailsLayout/DetailsLayoutListItem';
+import { parseDtoArray } from '../../dtoUtils/dtoCommonUtils';
+
+export type DetailsLayoutListItemDto = {
+ id?: Maybe;
+ layout_type?: Maybe;
+};
+
+export const parseDetailsLayoutListItemDto = (
+ dto: DetailsLayoutListItemDto,
+): DetailsLayoutListItem | undefined => {
+ if (!dto.id || !dto.layout_type) {
+ return;
+ }
+
+ if (!isInListTypeGuard(dto.layout_type, supportedLayoutsList)) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ layoutType: dto.layout_type,
+ };
+};
+
+export const parseDetailsLayoutListDtoArray = (
+ dtos: DetailsLayoutListItemDto[],
+): DetailsLayoutListItem[] => {
+ return parseDtoArray(parseDetailsLayoutListItemDto, dtos);
+};
diff --git a/src/api/detailsLayout/dtos/utils.ts b/src/api/detailsLayout/dtos/utils.ts
new file mode 100644
index 0000000..7510221
--- /dev/null
+++ b/src/api/detailsLayout/dtos/utils.ts
@@ -0,0 +1,24 @@
+import { isInListTypeGuard } from '@AppUtils';
+import type { DetailsScreenLayoutElements } from '@AppSrc/models/detailsLayout/DetailsLayout';
+import { supportedLayoutsList } from '@AppSrc/models/detailsLayout/DetailsLayoutListItem';
+
+export const parseLayoutType = (layoutTypeCandidate: Maybe) => {
+ if (!layoutTypeCandidate) {
+ return;
+ }
+
+ if (isInListTypeGuard(layoutTypeCandidate, supportedLayoutsList)) {
+ return layoutTypeCandidate;
+ }
+};
+
+export const parseLayoutElements = (
+ layoutElementsCandidate: Maybe,
+): DetailsScreenLayoutElements[] => {
+ if (!layoutElementsCandidate) {
+ return [];
+ }
+
+ // TODO: temporary return layoutElementsCandidate as is
+ return layoutElementsCandidate;
+};
diff --git a/src/api/detailsLayout/fetchDetailsLayout.ts b/src/api/detailsLayout/fetchDetailsLayout.ts
new file mode 100644
index 0000000..0f2c1d0
--- /dev/null
+++ b/src/api/detailsLayout/fetchDetailsLayout.ts
@@ -0,0 +1,61 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { StaticDataStructure } from '@AppServices/apiClient/staticDataClient/types';
+import type { DetailsLayoutDto } from './dtos/DetailsLayoutDto';
+import { parseDetailsLayoutDto } from './dtos/DetailsLayoutDto';
+import staticData from './staticData/detailsLayout.json';
+
+type ResponseDto = DetailsLayoutDto;
+
+const endpoint = Endpoints.DetailsLayout;
+
+export const fetchDetailsLayoutApiCall = async (detailsLayoutId: string) => {
+ if (!detailsLayoutId) {
+ throw new Error(
+ `fetchDetailsLayoutApiCall() was used with invalid item id`,
+ );
+ }
+
+ const response = await ApiClient.get(
+ endpoint,
+ {
+ id: detailsLayoutId,
+ staticData: staticData as StaticDataStructure,
+ },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchDetailsLayoutApiCall(): resources does not exist for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchDetailsLayoutApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseDetailsLayoutDto(response.data);
+};
+
+export const useDetailsLayout = ({
+ detailsContentEndpoint,
+}: {
+ detailsContentEndpoint: Endpoints;
+}) => {
+ const query = useQuery({
+ queryKey: [endpoint, detailsContentEndpoint],
+ queryFn: () => fetchDetailsLayoutApiCall(detailsContentEndpoint),
+ });
+
+ return query;
+};
diff --git a/src/api/detailsLayout/fetchDetailsLayoutList.ts b/src/api/detailsLayout/fetchDetailsLayoutList.ts
new file mode 100644
index 0000000..2bb665d
--- /dev/null
+++ b/src/api/detailsLayout/fetchDetailsLayoutList.ts
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { DetailsLayoutListItemDto } from './dtos/DetailsLayoutListDto';
+import { parseDetailsLayoutListDtoArray } from './dtos/DetailsLayoutListDto';
+import staticData from './staticData/detailsLayoutList.json';
+
+type ResponseDto = DetailsLayoutListItemDto[];
+
+const endpoint = Endpoints.DetailsLayout;
+
+export const fetchDetailsLayoutListApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchDetailsLayoutListApiCall(): resources does not exist for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchDetailsLayoutListApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseDetailsLayoutListDtoArray(response.data);
+};
+
+export const useDetailsLayoutList = () => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchDetailsLayoutListApiCall,
+ });
+
+ return query;
+};
diff --git a/src/api/detailsLayout/index.ts b/src/api/detailsLayout/index.ts
new file mode 100644
index 0000000..441b648
--- /dev/null
+++ b/src/api/detailsLayout/index.ts
@@ -0,0 +1,8 @@
+export {
+ useDetailsLayoutList,
+ fetchDetailsLayoutListApiCall,
+} from './fetchDetailsLayoutList';
+export {
+ useDetailsLayout,
+ fetchDetailsLayoutApiCall,
+} from './fetchDetailsLayout';
diff --git a/src/api/detailsLayout/staticData/detailsLayout.json b/src/api/detailsLayout/staticData/detailsLayout.json
new file mode 100644
index 0000000..ebc8791
--- /dev/null
+++ b/src/api/detailsLayout/staticData/detailsLayout.json
@@ -0,0 +1,402 @@
+{
+ "detailslayout": [
+ {
+ "id": "documentaries",
+ "layout_type": "documentaries",
+ "layout_title": "Sports Documentaries",
+ "layoutElements": [
+ {
+ "layoutElements": [
+ {
+ "layoutElements": [
+ {
+ "id": "containera-imagetile1",
+ "description": "Explore the journey of legendary athletes.",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg"
+ },
+ "displayProps": {
+ "size": "custom",
+ "customWidth": 500,
+ "customHeight": 200
+ },
+ "targetUrl": {
+ "url": "details/documentaries"
+ },
+ "title": "Athlete Stories",
+ "elementType": "DCImageTile"
+ },
+ {
+ "id": "containera-imagetile2",
+ "description": "Read about the top stars.",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/31.jpg"
+ },
+ "displayProps": {
+ "size": "custom",
+ "customWidth": 500,
+ "customHeight": 200
+ },
+ "targetUrl": {
+ "url": "details/documentaries"
+ },
+ "title": "Top stars",
+ "elementType": "DCImageTile"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 0,
+ "flexDirection": "row"
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ },
+ {
+ "layoutElements": [
+ {
+ "description": "A closer look at iconic sports moments.",
+ "displayProps": {
+ "size": "small"
+ },
+ "id": "containera-subcontainer-imagetile1",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg"
+ },
+ "targetUrl": {
+ "url": "details/documentaries"
+ },
+ "title": "Iconic Moments",
+ "elementType": "DCImageTile"
+ },
+ {
+ "description": "Behind the scenes of championship games.",
+ "displayProps": {
+ "size": "small"
+ },
+ "id": "containera-subcontainer-imagetile2",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/33.jpg"
+ },
+ "targetUrl": {
+ "url": "details/documentaries"
+ },
+ "title": "Championship Stories",
+ "elementType": "DCImageTile"
+ },
+ {
+ "description": "The untold stories of rising stars.",
+ "displayProps": {
+ "size": "small"
+ },
+ "id": "containera-subcontainer-imagetile3",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/35.jpg"
+ },
+ "targetUrl": {
+ "url": "details/documentaries"
+ },
+ "title": "Rising Stars",
+ "elementType": "DCImageTile"
+ },
+ {
+ "description": "Team insights and player stats.",
+ "displayProps": {
+ "size": "small"
+ },
+ "id": "sectionb-tilewithimage2",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/36.jpg"
+ },
+ "targetUrl": {
+ "url": "details/teams"
+ },
+ "title": "Team Profiles",
+ "elementType": "DCImageTile"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 18,
+ "flexDirection": "row",
+ "verticalSpacing": 20
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ },
+ {
+ "elementType": "DCText",
+ "text": "Athlete stories provide an insight into the lives of sports stars beyond the game. They highlight the challenges, sacrifices, and triumphs that shape their journeys to success. From overcoming injuries to breaking records, these stories inspire fans and aspiring athletes alike. They often reveal the personal struggles and resilience that drive their passion for the sport. Athlete stories also celebrate their achievements, both on and off the field, showcasing their impact on the community. Through these narratives, we connect with the human side of sports, finding motivation and admiration in their experiences.",
+ "displayProps": {
+ "variant": "body",
+ "alignContent": "center",
+ "alignItems": "center",
+ "justifyContent": "center"
+ },
+ "id": "container-body1"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 18
+ },
+ "title": "Documentary Highlights",
+ "elementType": "DCContentContainer"
+ }
+ ]
+ },
+ {
+ "id": "livestreams",
+ "layout_type": "livestreams",
+ "layout_title": "Live Matches",
+ "layoutElements": [
+ {
+ "layoutElements": [
+ {
+ "description": "Team 1 is ready to dominate the field.",
+ "displayProps": {
+ "size": "medium"
+ },
+ "id": "containerlivestreams-imagetile1",
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/38.jpg",
+ "targetUrl": "team1.thumbnail"
+ },
+ "targetUrl": {
+ "url": "details/teams"
+ },
+ "title": "Team 1",
+ "titleTarget": "team1.name",
+ "elementType": "DCImageTile"
+ },
+ {
+ "displayProps": {
+ "variant": "headline",
+ "alignContent": "center",
+ "alignItems": "center",
+ "justifyContent": "center"
+ },
+ "id": "containerlivestreams-text1",
+ "text": "VS",
+ "elementType": "DCText"
+ },
+ {
+ "description": "Team 2 is bringing their A-game.",
+ "id": "containerlivestreams-imagetile2",
+ "displayProps": {
+ "size": "medium"
+ },
+ "image": {
+ "url": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/39.jpg",
+ "targetUrl": "team2.thumbnail"
+ },
+ "targetUrl": {
+ "url": "details/teams"
+ },
+ "title": "Team 2",
+ "titleTarget": "team2.name",
+ "elementType": "DCImageTile"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 18,
+ "flexDirection": "row"
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ },
+ {
+ "layoutElements": [
+ {
+ "displayProps": {
+ "variant": "headline"
+ },
+ "id": "containerlivestreams-text1",
+ "text": "Upcoming Match Highlights",
+ "textTarget": "headline",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "body"
+ },
+ "id": "containerlivestreams-text2",
+ "text": "Get ready for an exciting clash between two top teams. Stay tuned for live updates and in-depth analysis.",
+ "textTarget": "description",
+ "elementType": "DCText"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 50,
+ "verticalSpacing": 30
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ }
+ ]
+ },
+ {
+ "id": "suggestedforyou",
+ "layout_type": "suggestedforyou",
+ "layout_title": "Recommended for You",
+ "layoutElements": [
+ {
+ "layoutElements": [
+ {
+ "displayProps": {
+ "variant": "headline"
+ },
+ "id": "containerlivestreams-text1",
+ "text": "Curated Sports Content Just for You",
+ "elementType": "DCText"
+ }
+ ],
+ "title": null,
+ "elementType": "DCContentContainer"
+ }
+ ]
+ },
+ {
+ "id": "teams",
+ "layout_type": "teams",
+ "layout_title": "Team Profiles",
+ "layoutElements": [
+ {
+ "id": "containerteams2",
+ "layoutElements": [
+ {
+ "id": "containerteams2-1",
+ "layoutElements": [
+ {
+ "description": "",
+ "displayProps": {
+ "size": "custom",
+ "customWidth": 300,
+ "customHeight": 450
+ },
+ "id": "containerteams-imagetile1",
+ "image": {
+ "targetUrl": "thumbnail"
+ },
+ "titleTarget": "teamName",
+ "targetUrl": {
+ "url": "details/teams"
+ },
+ "elementType": "DCImageTile"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 18,
+ "flex": 0
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ },
+ {
+ "id": "containerteams2-2",
+ "layoutElements": [
+ {
+ "displayProps": {
+ "variant": "body"
+ },
+ "id": "containerteams2-text1",
+ "text": "Sport type:",
+ "title": "DCText 1",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "title",
+ "horizontalSpacing": 24
+ },
+ "id": "containerteams2-text2",
+ "text": "N/A",
+ "textTarget": "sportType",
+ "title": "DCText 2",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "body",
+ "topSpacing": 24
+ },
+ "id": "containerteams2-text3",
+ "text": "Coach:",
+ "title": "DCText 3",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "title",
+ "horizontalSpacing": 24
+ },
+ "id": "containerteams2-text4",
+ "text": "N/A",
+ "textTarget": "coachName",
+ "title": "DCText 4",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "body",
+ "topSpacing": 24
+ },
+ "id": "containerteams2-text5",
+ "text": "Home stadium:",
+ "title": "DCText 5",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "title",
+ "horizontalSpacing": 24
+ },
+ "id": "containerteams2-text6",
+ "text": "N/A",
+ "textTarget": "homeStadium",
+ "title": "DCText 6",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "body",
+ "topSpacing": 24
+ },
+ "id": "containerteams2-text7",
+ "text": "Team color:",
+ "title": "DCText 7",
+ "elementType": "DCText"
+ },
+ {
+ "displayProps": {
+ "variant": "title",
+ "horizontalSpacing": 24
+ },
+ "id": "containerteams2-text8",
+ "text": "N/A",
+ "textTarget": "teamColor",
+ "title": "DCText 8",
+ "elementType": "DCText"
+ }
+ ],
+ "displayProps": {
+ "alignSelf": "flex-start",
+ "justifyContent": "flex-start",
+ "alignContent": "flex-start"
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ }
+ ],
+ "displayProps": {
+ "horizontalSpacing": 18,
+ "verticalSpacing": 36,
+ "flexDirection": "row",
+ "alignSelf": "flex-start",
+ "justifyContent": "flex-start",
+ "alignContent": "flex-start"
+ },
+ "title": null,
+ "elementType": "DCContentContainer"
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/api/detailsLayout/staticData/detailsLayoutList.json b/src/api/detailsLayout/staticData/detailsLayoutList.json
new file mode 100644
index 0000000..d3c5d5a
--- /dev/null
+++ b/src/api/detailsLayout/staticData/detailsLayoutList.json
@@ -0,0 +1,20 @@
+{
+ "detailslayout": [
+ {
+ "id": "documentaries",
+ "layout_type": "documentaries"
+ },
+ {
+ "id": "livestreams",
+ "layout_type": "livestreams"
+ },
+ {
+ "id": "sugggestedForYou",
+ "layout_type": "sugggestedForYou"
+ },
+ {
+ "id": "teams",
+ "layout_type": "teams"
+ }
+ ]
+}
diff --git a/src/api/documentaries/__tests__/__snapshots__/fetchDocumentaries.test.ts.snap b/src/api/documentaries/__tests__/__snapshots__/fetchDocumentaries.test.ts.snap
new file mode 100644
index 0000000..8cf19ef
--- /dev/null
+++ b/src/api/documentaries/__tests__/__snapshots__/fetchDocumentaries.test.ts.snap
@@ -0,0 +1,321 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchDocumentaries fetchDocumentariesApiCall should return parsed documentaries data on success 1`] = `
+[
+ {
+ "itemId": "813f703b-f365-4dae-bbed-60a2af813dae",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/16.jpg",
+ "title": "Road to the Championship",
+ },
+ {
+ "itemId": "b1798c7c-cd4b-41a3-85dd-6be0f75fa1e5",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/17.jpg",
+ "title": "Beyond the Finish Line",
+ },
+ {
+ "itemId": "a8561206-bdb3-40ef-a3f7-23c920af45fc",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/18.jpg",
+ "title": "The Rise of a Superstar",
+ },
+ {
+ "itemId": "9c237dc0-0d91-4aaf-ac96-046ed9b8e495",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/19.jpg",
+ "title": "The Science of Winning",
+ },
+ {
+ "itemId": "65c7daeb-9e47-40cc-b61b-b1ea011859b6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/20.jpg",
+ "title": "Chasing Greatness",
+ },
+ {
+ "itemId": "3b7b4569-f755-4d80-885f-41d90eef23ae",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/21.jpg",
+ "title": "Legends of the Game",
+ },
+ {
+ "itemId": "165b6aaf-9f93-4022-9910-d3e239e628a4",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/22.jpg",
+ "title": "The Journey to Success",
+ },
+ {
+ "itemId": "8e3fe329-cd6a-4e81-a95c-8b40fb8a7f49",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/23.jpg",
+ "title": "The Evolution of Football",
+ },
+ {
+ "itemId": "097e7212-200b-4578-9bb9-8fd898203237",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/24.jpg",
+ "title": "Behind the Helmet",
+ },
+ {
+ "itemId": "d26ba3e5-7ba8-4e87-897d-df10c16b157e",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/25.jpg",
+ "title": "The Golden Era of Basketball",
+ },
+ {
+ "itemId": "0dd81ad5-8914-40c8-b641-b8ff51b67427",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/26.jpg",
+ "title": "Racing Through History",
+ },
+ {
+ "itemId": "6aff5300-6eb7-45fc-b810-3da8b2357020",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/27.jpg",
+ "title": "The Untold Stories of Sports",
+ },
+ {
+ "itemId": "afcad24f-8e58-4af2-953a-c7074d011beb",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/28.jpg",
+ "title": "The Making of a Champion",
+ },
+ {
+ "itemId": "03a73eb9-5543-4087-aecc-50b444e064a7",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/29.jpg",
+ "title": "Unstoppable: The Athlete’s Journey",
+ },
+ {
+ "itemId": "5a729bd0-805b-4ca8-b31d-438ae268498a",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/30.jpg",
+ "title": "From Grassroots to Glory",
+ },
+ {
+ "itemId": "59b79076-5504-499a-99c5-b8c085d4b53d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/31.jpg",
+ "title": "The Rivalries That Shaped Sports",
+ },
+ {
+ "itemId": "80aa0b2d-05d0-4297-8194-bfbaed28e02b",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/32.jpg",
+ "title": "Breaking Barriers in Sports",
+ },
+ {
+ "itemId": "828f58fd-d6e9-4d16-917c-f722461fc268",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/33.jpg",
+ "title": "The Art of the Perfect Play",
+ },
+ {
+ "itemId": "220324dd-66eb-4eed-b59f-586fedb31377",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/34.jpg",
+ "title": "Dynasties of the Game",
+ },
+ {
+ "itemId": "fc7ee7ed-20a5-49ff-93f0-5fb77828f02d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/35.jpg",
+ "title": "The Heart of a Champion",
+ },
+ {
+ "itemId": "8e43f8b0-fc07-4a1c-b32b-117252fd9e12",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/36.jpg",
+ "title": "Unsung Heroes of the Field",
+ },
+ {
+ "itemId": "f9c7114a-23ec-4a9e-8020-a7277e18a985",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/37.jpg",
+ "title": "The History of the Olympics",
+ },
+ {
+ "itemId": "7d6e24a6-4bc5-4154-8ada-c9b52b7cf123",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "title": "The Road to Recovery",
+ },
+ {
+ "itemId": "85670ba5-b26f-4796-9b15-f3d55928e29f",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/39.jpg",
+ "title": "Game Changers: Innovators in Sports",
+ },
+ {
+ "itemId": "4bec7063-75f9-48fe-b29d-f240cdd95ea3",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/40.jpg",
+ "title": "The Spirit of Competition",
+ },
+ {
+ "itemId": "098b2ae8-91a8-4230-9ca4-80c4fc49f706",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/41.jpg",
+ "title": "The Rise and Fall of a Legend",
+ },
+ {
+ "itemId": "e8dd0dba-e1f3-446c-83b7-069d005480c6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/42.jpg",
+ "title": "Inside the World of Extreme Sports",
+ },
+ {
+ "itemId": "d6545116-b03c-4263-89cc-79c63cc8b601",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/43.jpg",
+ "title": "The Power of Teamwork",
+ },
+ {
+ "itemId": "ff04bf53-bb6f-441a-b9fc-da88bfd55ae3",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/44.jpg",
+ "title": "The Psychology of Winning",
+ },
+ {
+ "itemId": "4c562fde-5fb5-4010-ad83-d0c60c3dccb2",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/45.jpg",
+ "title": "The Greatest Comebacks in Sports",
+ },
+ {
+ "itemId": "empty-release-date",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/46.jpg",
+ "title": "The Business of Sports",
+ },
+]
+`;
+
+exports[`fetchDocumentaries useDocumentaries should return documentaries data and no error on successful fetch 1`] = `
+[
+ {
+ "itemId": "813f703b-f365-4dae-bbed-60a2af813dae",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/16.jpg",
+ "title": "Road to the Championship",
+ },
+ {
+ "itemId": "b1798c7c-cd4b-41a3-85dd-6be0f75fa1e5",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/17.jpg",
+ "title": "Beyond the Finish Line",
+ },
+ {
+ "itemId": "a8561206-bdb3-40ef-a3f7-23c920af45fc",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/18.jpg",
+ "title": "The Rise of a Superstar",
+ },
+ {
+ "itemId": "9c237dc0-0d91-4aaf-ac96-046ed9b8e495",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/19.jpg",
+ "title": "The Science of Winning",
+ },
+ {
+ "itemId": "65c7daeb-9e47-40cc-b61b-b1ea011859b6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/20.jpg",
+ "title": "Chasing Greatness",
+ },
+ {
+ "itemId": "3b7b4569-f755-4d80-885f-41d90eef23ae",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/21.jpg",
+ "title": "Legends of the Game",
+ },
+ {
+ "itemId": "165b6aaf-9f93-4022-9910-d3e239e628a4",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/22.jpg",
+ "title": "The Journey to Success",
+ },
+ {
+ "itemId": "8e3fe329-cd6a-4e81-a95c-8b40fb8a7f49",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/23.jpg",
+ "title": "The Evolution of Football",
+ },
+ {
+ "itemId": "097e7212-200b-4578-9bb9-8fd898203237",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/24.jpg",
+ "title": "Behind the Helmet",
+ },
+ {
+ "itemId": "d26ba3e5-7ba8-4e87-897d-df10c16b157e",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/25.jpg",
+ "title": "The Golden Era of Basketball",
+ },
+ {
+ "itemId": "0dd81ad5-8914-40c8-b641-b8ff51b67427",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/26.jpg",
+ "title": "Racing Through History",
+ },
+ {
+ "itemId": "6aff5300-6eb7-45fc-b810-3da8b2357020",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/27.jpg",
+ "title": "The Untold Stories of Sports",
+ },
+ {
+ "itemId": "afcad24f-8e58-4af2-953a-c7074d011beb",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/28.jpg",
+ "title": "The Making of a Champion",
+ },
+ {
+ "itemId": "03a73eb9-5543-4087-aecc-50b444e064a7",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/29.jpg",
+ "title": "Unstoppable: The Athlete’s Journey",
+ },
+ {
+ "itemId": "5a729bd0-805b-4ca8-b31d-438ae268498a",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/30.jpg",
+ "title": "From Grassroots to Glory",
+ },
+ {
+ "itemId": "59b79076-5504-499a-99c5-b8c085d4b53d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/31.jpg",
+ "title": "The Rivalries That Shaped Sports",
+ },
+ {
+ "itemId": "80aa0b2d-05d0-4297-8194-bfbaed28e02b",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/32.jpg",
+ "title": "Breaking Barriers in Sports",
+ },
+ {
+ "itemId": "828f58fd-d6e9-4d16-917c-f722461fc268",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/33.jpg",
+ "title": "The Art of the Perfect Play",
+ },
+ {
+ "itemId": "220324dd-66eb-4eed-b59f-586fedb31377",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/34.jpg",
+ "title": "Dynasties of the Game",
+ },
+ {
+ "itemId": "fc7ee7ed-20a5-49ff-93f0-5fb77828f02d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/35.jpg",
+ "title": "The Heart of a Champion",
+ },
+ {
+ "itemId": "8e43f8b0-fc07-4a1c-b32b-117252fd9e12",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/36.jpg",
+ "title": "Unsung Heroes of the Field",
+ },
+ {
+ "itemId": "f9c7114a-23ec-4a9e-8020-a7277e18a985",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/37.jpg",
+ "title": "The History of the Olympics",
+ },
+ {
+ "itemId": "7d6e24a6-4bc5-4154-8ada-c9b52b7cf123",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "title": "The Road to Recovery",
+ },
+ {
+ "itemId": "85670ba5-b26f-4796-9b15-f3d55928e29f",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/39.jpg",
+ "title": "Game Changers: Innovators in Sports",
+ },
+ {
+ "itemId": "4bec7063-75f9-48fe-b29d-f240cdd95ea3",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/40.jpg",
+ "title": "The Spirit of Competition",
+ },
+ {
+ "itemId": "098b2ae8-91a8-4230-9ca4-80c4fc49f706",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/41.jpg",
+ "title": "The Rise and Fall of a Legend",
+ },
+ {
+ "itemId": "e8dd0dba-e1f3-446c-83b7-069d005480c6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/42.jpg",
+ "title": "Inside the World of Extreme Sports",
+ },
+ {
+ "itemId": "d6545116-b03c-4263-89cc-79c63cc8b601",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/43.jpg",
+ "title": "The Power of Teamwork",
+ },
+ {
+ "itemId": "ff04bf53-bb6f-441a-b9fc-da88bfd55ae3",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/44.jpg",
+ "title": "The Psychology of Winning",
+ },
+ {
+ "itemId": "4c562fde-5fb5-4010-ad83-d0c60c3dccb2",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/45.jpg",
+ "title": "The Greatest Comebacks in Sports",
+ },
+ {
+ "itemId": "empty-release-date",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/46.jpg",
+ "title": "The Business of Sports",
+ },
+]
+`;
diff --git a/src/api/documentaries/__tests__/__snapshots__/fetchDocumentaryDetails.test.ts.snap b/src/api/documentaries/__tests__/__snapshots__/fetchDocumentaryDetails.test.ts.snap
new file mode 100644
index 0000000..2dd3f01
--- /dev/null
+++ b/src/api/documentaries/__tests__/__snapshots__/fetchDocumentaryDetails.test.ts.snap
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchDocumentaryDetailsApiCall fetchDocumentaryDetailsApiCall should return parsed documentary details data on success 1`] = `
+{
+ "countryOfOrigin": "Latvia",
+ "description": "Phantoms vs Shadows: A Game of Stealth and Strategy! The Phantoms’ speed meets the Shadows’ cunning.
+
+Who will outplay and outlast their opponent?
+
+Watch it live now!",
+ "director": "Packston Grelik",
+ "durationMinutes": 34,
+ "genre": "Biography",
+ "itemId": "4c562fde-5fb5-4010-ad83-d0c60c3dccb2",
+ "productionCompany": "Quimba",
+ "rating": 6.8,
+ "releaseDate": 2022-12-17T04:52:43.000Z,
+ "sportType": "darts",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/45.jpg",
+ "title": "The Greatest Comebacks in Sports",
+}
+`;
+
+exports[`fetchDocumentaryDetailsApiCall fetchDocumentaryDetailsApiCall should return parsed releaseDate 1`] = `
+{
+ "countryOfOrigin": "Latvia",
+ "description": "Steelhawks vs Ironclads: A Battle of Strength! The Steelhawks’ precision takes on the Ironclads’ unyielding defense.
+
+Who will forge their way to victory?
+
+Click to find out!",
+ "director": "Packston Grelik",
+ "durationMinutes": 34,
+ "genre": "Biography",
+ "itemId": "empty-release-date",
+ "productionCompany": "Quimba",
+ "rating": 6.8,
+ "releaseDate": undefined,
+ "sportType": "darts",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/46.jpg",
+ "title": "The Business of Sports",
+}
+`;
+
+exports[`fetchDocumentaryDetailsApiCall useDocumentaryDetails should return documentary data and no error on successful fetch 1`] = `
+{
+ "countryOfOrigin": "Latvia",
+ "description": "Phantoms vs Shadows: A Game of Stealth and Strategy! The Phantoms’ speed meets the Shadows’ cunning.
+
+Who will outplay and outlast their opponent?
+
+Watch it live now!",
+ "director": "Packston Grelik",
+ "durationMinutes": 34,
+ "genre": "Biography",
+ "itemId": "4c562fde-5fb5-4010-ad83-d0c60c3dccb2",
+ "productionCompany": "Quimba",
+ "rating": 6.8,
+ "releaseDate": 2022-12-17T04:52:43.000Z,
+ "sportType": "darts",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/45.jpg",
+ "title": "The Greatest Comebacks in Sports",
+}
+`;
diff --git a/src/api/documentaries/__tests__/fetchDocumentaries.test.ts b/src/api/documentaries/__tests__/fetchDocumentaries.test.ts
new file mode 100644
index 0000000..6da9309
--- /dev/null
+++ b/src/api/documentaries/__tests__/fetchDocumentaries.test.ts
@@ -0,0 +1,122 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import {
+ useDocumentaries,
+ fetchDocumentariesApiCall,
+} from '../fetchDocumentaries';
+import staticData from '../staticData/documentaries.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchDocumentaries', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchDocumentariesApiCall', () => {
+ it('should return parsed documentaries data on success', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.documentaries }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchDocumentariesApiCall();
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should throw error for 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 400 });
+
+ await expect(fetchDocumentariesApiCall()).rejects.toThrow(
+ `fetchDocumentariesApiCall(): resources does not exist for endpoint 'documentaries'`,
+ );
+ });
+
+ it('should throw error for unexpected Api status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(fetchDocumentariesApiCall()).rejects.toThrow(
+ `Perform a request failed`,
+ );
+ });
+
+ it('should throw error for other non-200 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
+
+ await expect(fetchDocumentariesApiCall()).rejects.toThrow(
+ `fetchDocumentariesApiCall(): failed to fetch data from endpoint 'documentaries'`,
+ );
+ });
+ });
+
+ describe('useDocumentaries', () => {
+ it('should return documentaries data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.documentaries }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchDocumentariesApiCall(),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useDocumentaries());
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() => useDocumentaries());
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBe(null);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should return error when fetch fails', () => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useDocumentaries());
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/documentaries/__tests__/fetchDocumentaryDetails.test.ts b/src/api/documentaries/__tests__/fetchDocumentaryDetails.test.ts
new file mode 100644
index 0000000..6eb1484
--- /dev/null
+++ b/src/api/documentaries/__tests__/fetchDocumentaryDetails.test.ts
@@ -0,0 +1,185 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import {
+ fetchDocumentaryDetailsApiCall,
+ useDocumentaryDetails,
+} from '../fetchDocumentaryDetails';
+import staticData from '../staticData/documentaries.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchDocumentaryDetailsApiCall', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchDocumentaryDetailsApiCall', () => {
+ it('should return parsed documentary details data on success', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.documentaries }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchDocumentaryDetailsApiCall(
+ '4c562fde-5fb5-4010-ad83-d0c60c3dccb2',
+ );
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should return parsed releaseDate', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.documentaries }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchDocumentaryDetailsApiCall('empty-release-date');
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should return undefined for unexisting id', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.documentaries }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchDocumentaryDetailsApiCall('unexisting-id');
+
+ expect(result).toBeUndefined();
+ });
+
+ test.each([{ itemId: '' }, { itemId: null }, { itemId: undefined }])(
+ 'should return undefined if documentary details data has nullable id ($itemId)',
+ async ({ itemId }) => {
+ // @ts-expect-error intentionally break TS
+ await expect(fetchDocumentaryDetailsApiCall(itemId)).rejects.toThrow(
+ 'fetchDocumentaryDetailsApiCall() was used with invalid item id',
+ );
+ },
+ );
+
+ it('should throw error for 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({ livestreams: {} }), {
+ status: 400,
+ });
+
+ await expect(
+ fetchDocumentaryDetailsApiCall('4c562fde-5fb5-4010-ad83-d0c60c3dccb2'),
+ ).rejects.toThrow(
+ `fetchDocumentaryDetailsApiCall(): resource does not exists for endpoint 'documentaries'`,
+ );
+ });
+
+ it('should throw error for unexpected Api status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(
+ fetchDocumentaryDetailsApiCall('4c562fde-5fb5-4010-ad83-d0c60c3dccb2'),
+ ).rejects.toThrow(`Perform a request failed`);
+ });
+
+ it('should throw error for other non-200 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
+
+ await expect(
+ fetchDocumentaryDetailsApiCall('4c562fde-5fb5-4010-ad83-d0c60c3dccb2'),
+ ).rejects.toThrow(
+ `fetchDocumentaryDetailsApiCall(): failed to fetch data from endpoint 'documentaries'`,
+ );
+ });
+ });
+
+ describe('useDocumentaryDetails', () => {
+ it('should return documentary data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.documentaries[24] }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchDocumentaryDetailsApiCall(
+ '4c562fde-5fb5-4010-ad83-d0c60c3dccb2',
+ ),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentaryDetails({
+ documentaryId: '4c562fde-5fb5-4010-ad83-d0c60c3dccb2',
+ }),
+ );
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() =>
+ useDocumentaryDetails({
+ documentaryId: '4c562fde-5fb5-4010-ad83-d0c60c3dccb2',
+ }),
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBe(null);
+ expect(result.current.error).toBe(null);
+ });
+
+ test.each([
+ '4c562fde-5fb5-4010-ad83-d0c60c3dccb2',
+ '23232',
+ 'some string',
+ undefined,
+ null,
+ ])('should return error (case: %s)', (inputValue) => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ // @ts-expect-error intentionally break TS
+ useDocumentaryDetails({ documentaryId: inputValue }),
+ );
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/documentaries/dtos/DocumentariesDto.ts b/src/api/documentaries/dtos/DocumentariesDto.ts
new file mode 100644
index 0000000..1dbde80
--- /dev/null
+++ b/src/api/documentaries/dtos/DocumentariesDto.ts
@@ -0,0 +1,28 @@
+import type { Documentaries } from '@AppModels/documentaries/Documentaries';
+import { parseDtoArray } from '../../dtoUtils/dtoCommonUtils';
+
+export type DocumentariesDto = {
+ id?: Maybe;
+ thumbnail?: string;
+ title?: string;
+};
+
+export const parseDocumentariesDto = (
+ dto: DocumentariesDto,
+): Documentaries | undefined => {
+ if (!dto.id) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ title: dto.title,
+ thumbnail: dto.thumbnail,
+ };
+};
+
+export const parseDocumentariesDtoArray = (
+ dtos: DocumentariesDto[],
+): Documentaries[] => {
+ return parseDtoArray(parseDocumentariesDto, dtos);
+};
diff --git a/src/api/documentaries/dtos/DocumentaryDetailsDto.ts b/src/api/documentaries/dtos/DocumentaryDetailsDto.ts
new file mode 100644
index 0000000..cfcd270
--- /dev/null
+++ b/src/api/documentaries/dtos/DocumentaryDetailsDto.ts
@@ -0,0 +1,44 @@
+import type { DocumentaryDetails } from '@AppModels/documentaries/DocumentaryDetails';
+import { parseISODate } from '@AppUtils/date';
+
+export type DocumentaryDetailsDto = {
+ id?: Maybe;
+ thumbnail?: string;
+ title?: string;
+ description?: string;
+ release_date?: string;
+ duration_minutes?: number;
+ director?: string;
+ genre?: string;
+ rating?: number;
+ production_company?: string;
+ country_of_origin?: string;
+ sport_type?: string;
+};
+
+export const parseDocumentaryDetailsDto = (
+ dto: DocumentaryDetailsDto,
+): DocumentaryDetails | undefined => {
+ if (!dto?.id) {
+ return;
+ }
+
+ const releaseDate = dto.release_date
+ ? parseISODate(dto.release_date)
+ : undefined;
+
+ return {
+ itemId: dto.id,
+ thumbnail: dto.thumbnail,
+ title: dto.title,
+ description: dto.description,
+ releaseDate,
+ durationMinutes: dto.duration_minutes,
+ director: dto.director,
+ genre: dto.genre,
+ rating: dto.rating,
+ productionCompany: dto.production_company,
+ countryOfOrigin: dto.country_of_origin,
+ sportType: dto.sport_type,
+ };
+};
diff --git a/src/api/documentaries/fetchDocumentaries.ts b/src/api/documentaries/fetchDocumentaries.ts
new file mode 100644
index 0000000..497c08b
--- /dev/null
+++ b/src/api/documentaries/fetchDocumentaries.ts
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { DocumentariesDto } from './dtos/DocumentariesDto';
+import { parseDocumentariesDtoArray } from './dtos/DocumentariesDto';
+import staticData from './staticData/documentaries.json';
+
+type ResponseDto = DocumentariesDto[];
+
+const endpoint = Endpoints.Documentaries;
+
+export const fetchDocumentariesApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchDocumentariesApiCall(): resources does not exist for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchDocumentariesApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseDocumentariesDtoArray(response.data);
+};
+
+export const useDocumentaries = () => {
+ const queries = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchDocumentariesApiCall,
+ });
+
+ return queries;
+};
diff --git a/src/api/documentaries/fetchDocumentaryDetails.ts b/src/api/documentaries/fetchDocumentaryDetails.ts
new file mode 100644
index 0000000..8fdc1d8
--- /dev/null
+++ b/src/api/documentaries/fetchDocumentaryDetails.ts
@@ -0,0 +1,60 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { DocumentaryDetailsDto } from './dtos/DocumentaryDetailsDto';
+import { parseDocumentaryDetailsDto } from './dtos/DocumentaryDetailsDto';
+import staticData from './staticData/documentaries.json';
+
+type ResponseDto = DocumentaryDetailsDto;
+
+const endpoint = Endpoints.Documentaries;
+
+export const fetchDocumentaryDetailsApiCall = async (documentaryId: string) => {
+ if (!documentaryId) {
+ throw new Error(
+ `fetchDocumentaryDetailsApiCall() was used with invalid item id`,
+ );
+ }
+
+ const response = await ApiClient.get(
+ endpoint,
+ {
+ id: documentaryId,
+ staticData,
+ },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchDocumentaryDetailsApiCall(): resource does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchDocumentaryDetailsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseDocumentaryDetailsDto(response.data);
+};
+
+export const useDocumentaryDetails = ({
+ documentaryId,
+}: {
+ documentaryId: string;
+}) => {
+ const query = useQuery({
+ queryKey: [endpoint, documentaryId],
+ queryFn: () => fetchDocumentaryDetailsApiCall(documentaryId),
+ });
+
+ return query;
+};
diff --git a/src/api/documentaries/index.ts b/src/api/documentaries/index.ts
new file mode 100644
index 0000000..3a66e05
--- /dev/null
+++ b/src/api/documentaries/index.ts
@@ -0,0 +1,8 @@
+export {
+ useDocumentaries,
+ fetchDocumentariesApiCall,
+} from './fetchDocumentaries';
+export {
+ fetchDocumentaryDetailsApiCall,
+ useDocumentaryDetails,
+} from './fetchDocumentaryDetails';
diff --git a/src/api/documentaries/staticData/documentaries.json b/src/api/documentaries/staticData/documentaries.json
new file mode 100644
index 0000000..3ea7478
--- /dev/null
+++ b/src/api/documentaries/staticData/documentaries.json
@@ -0,0 +1,484 @@
+{
+ "documentaries": [
+ {
+ "title": "Road to the Championship",
+ "description": "Sharks vs Falcons: The Battle of the Season! Two fierce teams collide in an epic showdown.\n\nWho will soar to victory, and who will sink under pressure?\n\nClick to watch now!",
+ "release_date": "1/20/2019",
+ "duration_minutes": 167,
+ "director": "Nettie Brantl",
+ "genre": "Documentary",
+ "rating": 3.6,
+ "production_company": "Tagfeed",
+ "country_of_origin": "China",
+ "sport_type": "running",
+ "id": "813f703b-f365-4dae-bbed-60a2af813dae",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/16.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg"
+ },
+ {
+ "title": "Beyond the Finish Line",
+ "description": "Tigers vs Wolves: A Clash of Predators! The jungle meets the wild in this thrilling match.\n\nExpect nothing but raw power and skill on display.\n\nDon’t miss it!",
+ "release_date": "10/8/2006",
+ "duration_minutes": 31,
+ "director": "Henrik Levene",
+ "genre": "Biography",
+ "rating": 3.7,
+ "production_company": "Devshare",
+ "country_of_origin": "Indonesia",
+ "sport_type": "rugby",
+ "id": "b1798c7c-cd4b-41a3-85dd-6be0f75fa1e5",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/17.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/17.jpg"
+ },
+ {
+ "title": "The Rise of a Superstar",
+ "description": "Panthers vs Eagles: Speed Meets Precision! Two teams with everything to prove go head-to-head.\n\nWho will dominate the skies or rule the ground?\n\nWatch it live now!",
+ "release_date": "12/26/2019",
+ "duration_minutes": 19,
+ "director": "Star Hammonds",
+ "genre": "Sports",
+ "rating": 8,
+ "production_company": "Kanoodle",
+ "country_of_origin": "Canada",
+ "sport_type": "rugby",
+ "id": "a8561206-bdb3-40ef-a3f7-23c920af45fc",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/18.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/18.jpg"
+ },
+ {
+ "title": "The Science of Winning",
+ "description": "Rhinos vs Hawks: Strength vs Agility! The ultimate test of endurance and strategy.\n\nEvery play will leave you breathless. This is the game to watch!\n\nCatch the action here!",
+ "release_date": "4/16/2021",
+ "duration_minutes": 152,
+ "director": "Ario Boath",
+ "genre": "Sports",
+ "rating": 8.7,
+ "production_company": "Voonix",
+ "country_of_origin": "Indonesia",
+ "sport_type": "cycling",
+ "id": "9c237dc0-0d91-4aaf-ac96-046ed9b8e495",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/19.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/19.jpg"
+ },
+ {
+ "title": "Chasing Greatness",
+ "description": "Lions vs Bears: A Battle of Kings! The pride of the jungle takes on the might of the forest.\n\nWho will claim the crown in this epic encounter?\n\nClick to find out!",
+ "release_date": "8/17/2003",
+ "duration_minutes": 152,
+ "director": "Fania Pedreschi",
+ "genre": "Documentary",
+ "rating": 8.9,
+ "production_company": "Twinte",
+ "country_of_origin": "Portugal",
+ "sport_type": "table tennis",
+ "id": "65c7daeb-9e47-40cc-b61b-b1ea011859b6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/20.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/20.jpg"
+ },
+ {
+ "title": "Legends of the Game",
+ "description": "Vipers vs Stallions: Speed vs Power! The race to victory is on in this electrifying match.\n\nEvery second matters.\n\nWatch it now!",
+ "release_date": "12/26/2002",
+ "duration_minutes": 120,
+ "director": "Cathrin Amberson",
+ "genre": "Biography",
+ "rating": 8.6,
+ "production_company": "Eidel",
+ "country_of_origin": "China",
+ "sport_type": "archery",
+ "id": "3b7b4569-f755-4d80-885f-41d90eef23ae",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/21.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/21.jpg"
+ },
+ {
+ "title": "The Journey to Success",
+ "description": "Tigers vs Bulls: A Fight for Glory! Two teams with everything on the line clash in an unforgettable game.\n\nWill the Tigers win, or will the Bulls outsmart them?\n\nDon’t miss this!",
+ "release_date": "5/23/2014",
+ "duration_minutes": 101,
+ "director": "Eudora O'Fallowne",
+ "genre": "Sports",
+ "rating": 7.7,
+ "production_company": "Flipbug",
+ "country_of_origin": "China",
+ "sport_type": "running",
+ "id": "165b6aaf-9f93-4022-9910-d3e239e628a4",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/22.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/22.jpg"
+ },
+ {
+ "title": "The Evolution of Football",
+ "description": "Mustangs vs Jaguars: A Showdown of Speed and Skill! The field is set for an explosive match.\n\nWho will leave their mark and claim the win?\n\nWatch it live!",
+ "release_date": "6/17/2014",
+ "duration_minutes": 101,
+ "director": "Cassaundra Hercock",
+ "genre": "Documentary",
+ "rating": 5.7,
+ "production_company": "Kazio",
+ "country_of_origin": "Haiti",
+ "sport_type": "fencing",
+ "id": "8e3fe329-cd6a-4e81-a95c-8b40fb8a7f49",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/23.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/23.jpg"
+ },
+ {
+ "title": "Behind the Helmet",
+ "description": "Scorpions vs Thunder: A Storm is Brewing! The Scorpions’ sting meets the Thunder’s roar.\n\nPrepare for a game full of surprises and intensity.\n\nClick to watch now!",
+ "release_date": "4/19/2022",
+ "duration_minutes": 35,
+ "director": "Doug Wilcher",
+ "genre": "Documentary",
+ "rating": 6.5,
+ "production_company": "Flipopia",
+ "country_of_origin": "China",
+ "sport_type": "fencing",
+ "id": "097e7212-200b-4578-9bb9-8fd898203237",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/24.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/24.jpg"
+ },
+ {
+ "title": "The Golden Era of Basketball",
+ "description": "Grizzlies vs Cobras: Power vs Precision! The Grizzlies’ brute force takes on the Cobras’ deadly accuracy.\n\nWho will strike first and claim victory?\n\nFind out here!",
+ "release_date": "12/14/2009",
+ "duration_minutes": 86,
+ "director": "Donall Dudderidge",
+ "genre": "Documentary",
+ "rating": 1.9,
+ "production_company": "Meezzy",
+ "country_of_origin": "Mexico",
+ "sport_type": "volleyball",
+ "id": "d26ba3e5-7ba8-4e87-897d-df10c16b157e",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/25.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/25.jpg"
+ },
+ {
+ "title": "Racing Through History",
+ "description": "Phoenix vs Titans: A Battle of Legends! The rising Phoenix faces the unstoppable Titans.\n\nOnly one can emerge victorious in this epic clash.\n\nWatch it now!",
+ "release_date": "2/5/2016",
+ "duration_minutes": 89,
+ "director": "Freddie Faich",
+ "genre": "Sports",
+ "rating": 2.2,
+ "production_company": "Quamba",
+ "country_of_origin": "Colombia",
+ "sport_type": "rowing",
+ "id": "0dd81ad5-8914-40c8-b641-b8ff51b67427",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/26.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/26.jpg"
+ },
+ {
+ "title": "The Untold Stories of Sports",
+ "description": "Warriors vs Spartans: A Fight for Honor! Two legendary teams go head-to-head in a battle for glory.\n\nWho will stand tall when the dust settles?\n\nDon’t miss this!",
+ "release_date": "6/25/2017",
+ "duration_minutes": 74,
+ "director": "Francois Bavridge",
+ "genre": "Sports",
+ "rating": 4.9,
+ "production_company": "Linkbuzz",
+ "country_of_origin": "Serbia",
+ "sport_type": "table tennis",
+ "id": "6aff5300-6eb7-45fc-b810-3da8b2357020",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/27.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/27.jpg"
+ },
+ {
+ "title": "The Making of a Champion",
+ "description": "Gladiators vs Vikings: Strength Meets Strategy! The ultimate test of willpower and skill.\n\nWho will conquer the field and claim victory?\n\nClick to watch live!",
+ "release_date": "10/25/2022",
+ "duration_minutes": 59,
+ "director": "Lucio Dawid",
+ "genre": "Documentary",
+ "rating": 7.1,
+ "production_company": "Kwimbee",
+ "country_of_origin": "Indonesia",
+ "sport_type": "volleyball",
+ "id": "afcad24f-8e58-4af2-953a-c7074d011beb",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/28.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/28.jpg"
+ },
+ {
+ "title": "Unstoppable: The Athlete’s Journey",
+ "description": "Knights vs Pirates: A Duel of Legends! The Knights’ discipline faces the Pirates’ unpredictability.\n\nExpect drama, action, and unforgettable moments.\n\nWatch it unfold here!",
+ "release_date": "5/22/2001",
+ "duration_minutes": 169,
+ "director": "Lucienne Sincock",
+ "genre": "Sports",
+ "rating": 5.1,
+ "production_company": "Kwinu",
+ "country_of_origin": "Kosovo",
+ "sport_type": "weightlifting",
+ "id": "03a73eb9-5543-4087-aecc-50b444e064a7",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/29.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/29.jpg"
+ },
+ {
+ "title": "From Grassroots to Glory",
+ "description": "Samurai vs Ninjas: A Battle of Precision and Speed! Two iconic teams clash in a game for the ages.\n\nWho will outsmart and outplay their opponent?\n\nCatch the action now!",
+ "release_date": "2/10/2022",
+ "duration_minutes": 57,
+ "director": "Daniella Howsam",
+ "genre": "Sports",
+ "rating": 6.5,
+ "production_company": "Blogspan",
+ "country_of_origin": "Sweden",
+ "sport_type": "hockey",
+ "id": "5a729bd0-805b-4ca8-b31d-438ae268498a",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/30.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/30.jpg"
+ },
+ {
+ "title": "The Rivalries That Shaped Sports",
+ "description": "Dragonslayers vs Phoenixfire: A Mythical Showdown! The legends rise in this great battle.\n\nWho will rise and who will fall in this fiery encounter?\n\nDon’t miss it!",
+ "release_date": "11/8/2022",
+ "duration_minutes": 115,
+ "director": "Jocelin Meekins",
+ "genre": "Sports",
+ "rating": 1.7,
+ "production_company": "Riffwire",
+ "country_of_origin": "Italy",
+ "sport_type": "darts",
+ "id": "59b79076-5504-499a-99c5-b8c085d4b53d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/31.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/31.jpg"
+ },
+ {
+ "title": "Breaking Barriers in Sports",
+ "description": "Storm vs Avalanche: Nature’s Fury Unleashed! The Storm’s speed meets the Avalanche’s unstoppable force.\n\nBrace yourself for a game full of intensity and surprises.\n\nWatch it live now!",
+ "release_date": "11/12/2014",
+ "duration_minutes": 169,
+ "director": "Berget Eccleston",
+ "genre": "Sports",
+ "rating": 6.1,
+ "production_company": "Shufflester",
+ "country_of_origin": "Russia",
+ "sport_type": "running",
+ "id": "80aa0b2d-05d0-4297-8194-bfbaed28e02b",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/32.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/32.jpg"
+ },
+ {
+ "title": "The Art of the Perfect Play",
+ "description": "Blizzards vs Hurricanes: A Clash of Elements! The Blizzards’ icy precision takes on the Hurricanes’ raw power.\n\nWho will weather the storm and claim victory?\n\nClick to find out!",
+ "release_date": "1/14/2013",
+ "duration_minutes": 129,
+ "director": "Devin Tointon",
+ "genre": "Biography",
+ "rating": 7,
+ "production_company": "Realcube",
+ "country_of_origin": "Philippines",
+ "sport_type": "darts",
+ "id": "828f58fd-d6e9-4d16-917c-f722461fc268",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/33.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/33.jpg"
+ },
+ {
+ "title": "Dynasties of the Game",
+ "description": "Firehawks vs Icewolves: Heat Meets Cold! The Firehawks’ blazing offense faces the Icewolves’ cool defense.\n\nPrepare for a game that will leave you on the edge of your seat.\n\nWatch it now!",
+ "release_date": "4/12/2017",
+ "duration_minutes": 83,
+ "director": "Barbie Brinkler",
+ "genre": "Sports",
+ "rating": 9.6,
+ "production_company": "Flashdog",
+ "country_of_origin": "Poland",
+ "sport_type": "golf",
+ "id": "220324dd-66eb-4eed-b59f-586fedb31377",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/34.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/34.jpg"
+ },
+ {
+ "title": "The Heart of a Champion",
+ "description": "Crusaders vs Outlaws: A Fight for Supremacy! The Crusaders’ discipline takes on the Outlaws’ wild unpredictability.\n\nWho will emerge victorious in this thrilling encounter?\n\nDon’t miss this!",
+ "release_date": "6/14/2020",
+ "duration_minutes": 132,
+ "director": "Marleen Dunnan",
+ "genre": "Sports",
+ "rating": 1.6,
+ "production_company": "Brainbox",
+ "country_of_origin": "Indonesia",
+ "sport_type": "baseball",
+ "id": "fc7ee7ed-20a5-49ff-93f0-5fb77828f02d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/35.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/35.jpg"
+ },
+ {
+ "title": "Unsung Heroes of the Field",
+ "description": "Rangers vs Nomads: A Battle of Wanderers! Two teams with everything to prove clash in an epic game.\n\nWho will find their way to victory?\n\nCatch the action here!",
+ "release_date": "4/30/2004",
+ "duration_minutes": 54,
+ "director": "Gherardo Tallis",
+ "genre": "Sports",
+ "rating": 3.5,
+ "production_company": "Skalith",
+ "country_of_origin": "Cuba",
+ "sport_type": "snowboarding",
+ "id": "8e43f8b0-fc07-4a1c-b32b-117252fd9e12",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/36.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/36.jpg"
+ },
+ {
+ "title": "The History of the Olympics",
+ "description": "Blazers vs Thunderhawks: Speed vs Precision! The Blazers’ fiery pace takes on the Thunderhawks’ calculated strikes.\n\nWho will light up the scoreboard in this electrifying match?\n\nWatch it live now!",
+ "release_date": "3/13/2000",
+ "duration_minutes": 100,
+ "director": "James Heelis",
+ "genre": "Sports",
+ "rating": 6.8,
+ "production_company": "Kwilith",
+ "country_of_origin": "Indonesia",
+ "sport_type": "archery",
+ "id": "f9c7114a-23ec-4a9e-8020-a7277e18a985",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/37.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/37.jpg"
+ },
+ {
+ "title": "The Road to Recovery",
+ "description": "Sabers vs Cyclones: A Whirlwind of Action! The Sabers’ sharp attacks meet the Cyclones’ relentless energy.\n\nExpect tense action and breathtaking moments.\n\nClick to watch now!",
+ "release_date": "1/10/2003",
+ "duration_minutes": 63,
+ "director": "Shane Reeveley",
+ "genre": "Sports",
+ "rating": 6.2,
+ "production_company": "Cogilith",
+ "country_of_origin": "Brazil",
+ "sport_type": "rowing",
+ "id": "7d6e24a6-4bc5-4154-8ada-c9b52b7cf123",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/38.jpg"
+ },
+ {
+ "title": "Game Changers: Innovators in Sports",
+ "description": "Titans vs Spartans: A Battle of Giants! Two legendary teams collide in a fight for glory.\n\nWho will stand tall when the dust settles?\n\nDon’t miss this epic match!",
+ "release_date": "2/22/2007",
+ "duration_minutes": 148,
+ "director": "Ruprecht Ekless",
+ "genre": "Documentary",
+ "rating": 3.5,
+ "production_company": "Skidoo",
+ "country_of_origin": "China",
+ "sport_type": "badminton",
+ "id": "85670ba5-b26f-4796-9b15-f3d55928e29f",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/39.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/39.jpg"
+ },
+ {
+ "title": "The Spirit of Competition",
+ "description": "Predators vs Stormriders: A Hunt for Victory! The Predators’ cunning meets the Stormriders’ unstoppable momentum.\n\nWho will claim the win in this game?\n\nFind out here!",
+ "release_date": "2/3/2014",
+ "duration_minutes": 57,
+ "director": "Leon Stienton",
+ "genre": "Biography",
+ "rating": 4.2,
+ "production_company": "Dynabox",
+ "country_of_origin": "Thailand",
+ "sport_type": "table tennis",
+ "id": "4bec7063-75f9-48fe-b29d-f240cdd95ea3",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/40.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/40.jpg"
+ },
+ {
+ "title": "The Rise and Fall of a Legend",
+ "description": "Comets vs Meteors: A Cosmic Clash! The Comets’ speed takes on the Meteors’ raw power.\n\nPrepare for a game that’s out of this world!\n\nWatch it now!",
+ "release_date": "2/27/2015",
+ "duration_minutes": 103,
+ "director": "Linn June",
+ "genre": "Biography",
+ "rating": 3.3,
+ "production_company": "Topdrive",
+ "country_of_origin": "Indonesia",
+ "sport_type": "soccer",
+ "id": "098b2ae8-91a8-4230-9ca4-80c4fc49f706",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/41.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/41.jpg"
+ },
+ {
+ "title": "Inside the World of Extreme Sports",
+ "description": "Barracudas vs Stingrays: A Battle Beneath the Waves! The Barracudas’ agility faces the Stingrays’ precision.\n\nWho will dominate the depths and claim victory?\n\nCatch the action here!",
+ "release_date": "11/15/2005",
+ "duration_minutes": 68,
+ "director": "Edyth Alwin",
+ "genre": "Documentary",
+ "rating": 1.8,
+ "production_company": "Kwimbee",
+ "country_of_origin": "Thailand",
+ "sport_type": "skiing",
+ "id": "e8dd0dba-e1f3-446c-83b7-069d005480c6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/42.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/42.jpg"
+ },
+ {
+ "title": "The Power of Teamwork",
+ "description": "Crushers vs Defenders: Power vs Resilience! The Crushers’ brute force takes on the Defenders’ unbreakable wall.\n\nWho will break through and take the win?\n\nClick to watch live!",
+ "release_date": "20190907",
+ "duration_minutes": 171,
+ "director": "Bond Knowller",
+ "genre": "Documentary",
+ "rating": 4.7,
+ "production_company": "Oba",
+ "country_of_origin": "Sierra Leone",
+ "sport_type": "swimming",
+ "id": "d6545116-b03c-4263-89cc-79c63cc8b601",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/43.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/43.jpg"
+ },
+ {
+ "title": "The Psychology of Winning",
+ "description": "Outlaws vs Mavericks: A Wild West Showdown! The Outlaws’ unpredictability meets the Mavericks’ bold moves.\n\nExpect action and wonderful moments.\n\nDon’t miss this!",
+ "release_date": "20190907",
+ "duration_minutes": 143,
+ "director": "Pam Tart",
+ "genre": "Documentary",
+ "rating": 9.8,
+ "production_company": "Topiczoom",
+ "country_of_origin": "Brazil",
+ "sport_type": "karate",
+ "id": "ff04bf53-bb6f-441a-b9fc-da88bfd55ae3",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/44.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/44.jpg"
+ },
+ {
+ "title": "The Greatest Comebacks in Sports",
+ "description": "Phantoms vs Shadows: A Game of Stealth and Strategy! The Phantoms’ speed meets the Shadows’ cunning.\n\nWho will outplay and outlast their opponent?\n\nWatch it live now!",
+ "release_date": "2022-12-17T04:52:43Z",
+ "duration_minutes": 34,
+ "director": "Packston Grelik",
+ "genre": "Biography",
+ "rating": 6.8,
+ "production_company": "Quimba",
+ "country_of_origin": "Latvia",
+ "sport_type": "darts",
+ "id": "4c562fde-5fb5-4010-ad83-d0c60c3dccb2",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/45.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/45.jpg"
+ },
+ {
+ "title": "The Business of Sports",
+ "description": "Steelhawks vs Ironclads: A Battle of Strength! The Steelhawks’ precision takes on the Ironclads’ unyielding defense.\n\nWho will forge their way to victory?\n\nClick to find out!",
+ "release_date": "",
+ "duration_minutes": 34,
+ "director": "Packston Grelik",
+ "genre": "Biography",
+ "rating": 6.8,
+ "production_company": "Quimba",
+ "country_of_origin": "Latvia",
+ "sport_type": "darts",
+ "id": "empty-release-date",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/46.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/46.jpg"
+ },
+ {
+ "title": "The Legacy of a Champio",
+ "description": "Firestorm vs Frostfangs: Heat vs Ice! The Firestorm’s blazing offense faces the Frostfangs’ icy precision.\n\nPrepare for a game that will leave you breathless.\n\nWatch it now!",
+ "release_date": "",
+ "duration_minutes": 34,
+ "director": "Packston Grelik",
+ "genre": "Biography",
+ "rating": 6.8,
+ "production_company": "Quimba",
+ "country_of_origin": "Latvia",
+ "sport_type": "darts",
+ "id": null,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/47.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/47.jpg"
+ }
+ ]
+}
diff --git a/src/api/dtoUtils/__tests__/dtoAppUtils.test.ts b/src/api/dtoUtils/__tests__/dtoAppUtils.test.ts
new file mode 100644
index 0000000..53a2777
--- /dev/null
+++ b/src/api/dtoUtils/__tests__/dtoAppUtils.test.ts
@@ -0,0 +1,64 @@
+import { Endpoints } from '@AppServices/apiClient/types';
+import { parseEndpoint } from '../dtoAppUtils';
+
+describe('dtoAppUtils', () => {
+ describe('parseEndpoint', () => {
+ // Valid endpoint cases
+ test.each(
+ Object.entries(Endpoints).map((entry) => ({
+ endpointCandidate: entry[1],
+ expected: entry[1],
+ })),
+ )(
+ 'should return the parsed endpoint ($expected) when the input is valid ($endpointCandidate)',
+ ({ endpointCandidate, expected }) => {
+ expect(parseEndpoint(endpointCandidate)).toEqual(expected);
+ },
+ );
+
+ // Invalid endpoint cases
+ test.each([
+ { endpointCandidate: 'invalidEndpoint' },
+ { endpointCandidate: 'unknownlayout' },
+ { endpointCandidate: 'randomstring' },
+ ])(
+ 'should return undefined when the input is not in the Endpoints list ($endpointCandidate)',
+ ({ endpointCandidate }) => {
+ expect(parseEndpoint(endpointCandidate)).toBeUndefined();
+ },
+ );
+
+ // Nullable input cases
+ test.each([{ endpointCandidate: null }, { endpointCandidate: undefined }])(
+ 'should return undefined when the input is nullable ($endpointCandidate)',
+ ({ endpointCandidate }) => {
+ expect(parseEndpoint(endpointCandidate)).toBeUndefined();
+ },
+ );
+
+ // TypeScript-violating input cases
+ test.each([
+ { endpointCandidate: 123 },
+ { endpointCandidate: true },
+ { endpointCandidate: {} },
+ { endpointCandidate: [] },
+ ])(
+ 'should return undefined when the input violates TS and is not a string ($endpointCandidate)',
+ ({ endpointCandidate }) => {
+ // @ts-expect-error intentionally testing invalid TS inputs
+ expect(parseEndpoint(endpointCandidate)).toBeUndefined();
+ },
+ );
+
+ // Edge case: TypeScript valid but edge-case input
+ test.each([
+ { endpointCandidate: '', expected: undefined },
+ { endpointCandidate: ' ', expected: undefined },
+ ])(
+ 'should return undefined when the input is a string but not a valid endpoint ($endpointCandidate)',
+ ({ endpointCandidate, expected }) => {
+ expect(parseEndpoint(endpointCandidate)).toBe(expected);
+ },
+ );
+ });
+});
diff --git a/src/api/dtoUtils/__tests__/dtoCommonUtils.test.ts b/src/api/dtoUtils/__tests__/dtoCommonUtils.test.ts
new file mode 100644
index 0000000..08b3429
--- /dev/null
+++ b/src/api/dtoUtils/__tests__/dtoCommonUtils.test.ts
@@ -0,0 +1,222 @@
+import { parseDtoArray, parseDtoRecord, parseString } from '../dtoCommonUtils';
+
+describe('dtoCommonUtils', () => {
+ describe('parseDtoArray', () => {
+ const mockParseItemFunction = (dto: number) => dto * 2;
+
+ test.each([
+ { dtos: [1, 2, 3], expected: [2, 4, 6] },
+ {
+ dtos: [1, 2, 3],
+ mockParseItem: (dto: number) => (dto === 1 ? null : dto * 2),
+ expected: [4, 6],
+ },
+ ])(
+ 'should return parsed items for dtos primitives array ($dtos)',
+ ({ dtos, expected, mockParseItem = mockParseItemFunction }) => {
+ const result = parseDtoArray(mockParseItem, dtos);
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([
+ {
+ dtos: [
+ { id: 'testId', name: 'testName' },
+ { id: 'testId', name: 'testName' },
+ { name: 'testName' },
+ ],
+ mockParseItem: (dto: { id?: string; name?: string }) => dto,
+ expected: [
+ { id: 'testId', name: 'testName' },
+ { id: 'testId', name: 'testName' },
+ { name: 'testName' },
+ ],
+ },
+ {
+ dtos: [
+ { id: 'testId', name: 'testName' },
+ { id: 'testId', name: 'testName' },
+ { name: 'testName' },
+ ],
+ mockParseItem: (dto: { id?: string; name?: string }) =>
+ !dto?.id ? undefined : dto,
+ expected: [
+ { id: 'testId', name: 'testName' },
+ { id: 'testId', name: 'testName' },
+ ],
+ },
+ ])(
+ 'should return parsed items for dtos object array ($dtos)',
+ ({ dtos, expected, mockParseItem }) => {
+ const result = parseDtoArray(mockParseItem, dtos);
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([
+ {
+ dtos: [1, 2, 3],
+ returnValue: null,
+ mockParseItem() {
+ return this.returnValue;
+ },
+ expected: [],
+ },
+ {
+ dtos: [1, 2, 3],
+ returnValue: undefined,
+ mockParseItem() {
+ return this.returnValue;
+ },
+ expected: [],
+ },
+ ])(
+ 'should return empty array when parser function return nullable value ($returnValue)',
+ ({ dtos, expected, mockParseItem }) => {
+ const result = parseDtoArray(mockParseItem, dtos);
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([
+ { dtos: null, expected: [] },
+ { dtos: undefined, expected: [] },
+ ])(
+ 'should return empty array for nullable dtos input ($dtos)',
+ ({ dtos, expected }) => {
+ const result = parseDtoArray(mockParseItemFunction, dtos);
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([{ dtos: [], expected: [] }])(
+ 'should return empty array for empty dtos input ($dtos)',
+ ({ dtos, expected }) => {
+ const result = parseDtoArray(mockParseItemFunction, dtos);
+
+ expect(result).toEqual(expected);
+ },
+ );
+ });
+
+ describe('parseDtoRecord', () => {
+ const mockParseItemFunction = (dto: number) => dto * 2;
+
+ test.each([
+ { dto: { a: 1, b: 2, c: 3 }, expected: { a: 2, b: 4, c: 6 } },
+ {
+ dto: { a: 1, b: 2, c: 4 },
+ mockParseItem: (dto: number) => (dto === 1 ? null : dto * 2),
+ expected: { b: 4, c: 8 },
+ },
+ ])(
+ 'should return parsed items for dto object ($dto)',
+ ({ dto, expected, mockParseItem }) => {
+ const result = parseDtoRecord(
+ mockParseItem ?? mockParseItemFunction,
+ dto,
+ );
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([
+ {
+ dto: { a: 1, b: 2, c: 3 },
+ returnValue: undefined,
+ mockParseItem() {
+ return this.returnValue;
+ },
+ expected: {},
+ },
+ {
+ dto: { a: 1, b: 2, c: 3 },
+ returnValue: null,
+ mockParseItem() {
+ return this.returnValue;
+ },
+ expected: {},
+ },
+ ])(
+ 'should return empty object when parser function return nullable value ($returnValue)',
+ ({ dto, expected, mockParseItem }) => {
+ const result = parseDtoRecord(mockParseItem, dto);
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([
+ { dto: undefined, expected: {} },
+ { dto: null, expected: {} },
+ ])(
+ 'should return empty object for nullable dto object ($dto)',
+ ({ dto, expected }) => {
+ //@ts-expect-error intentionally break TS
+ const result = parseDtoRecord(mockParseItemFunction, dto);
+
+ expect(result).toEqual(expected);
+ },
+ );
+
+ test.each([{ dto: {}, expected: {} }])(
+ 'should return empty object for empty dto object ($dto)',
+ ({ dto, expected }) => {
+ const result = parseDtoRecord(mockParseItemFunction, dto);
+
+ expect(result).toEqual(expected);
+ },
+ );
+ });
+
+ describe('parseString', () => {
+ test.each([
+ { dtoValue: 'testString', expected: 'testString' },
+ { dtoValue: '123', expected: '123' },
+ ])(
+ 'should return the string when the input is a valid string ($dtoValue)',
+ ({ dtoValue, expected }) => {
+ expect(parseString(dtoValue)).toEqual(expected);
+ },
+ );
+
+ test.each([{ dtoValue: null }, { dtoValue: undefined }])(
+ 'should return undefined when the input is nullable ($dtoValue)',
+ ({ dtoValue }) => {
+ expect(parseString(dtoValue)).toBeUndefined();
+ },
+ );
+
+ test.each([
+ { dtoValue: 123 },
+ { dtoValue: true },
+ { dtoValue: {} },
+ { dtoValue: [] },
+ { dtoValue: Symbol('test') },
+ ])(
+ 'should return undefined when the input violates TS and is not a string ($dtoValue)',
+ ({ dtoValue }) => {
+ //@ts-expect-error intentionally break TS
+ expect(parseString(dtoValue)).toBeUndefined();
+ },
+ );
+
+ test.each([
+ { dtoValue: '', expected: '' },
+ { dtoValue: ' ', expected: ' ' },
+ { dtoValue: '\n', expected: '\n' },
+ { dtoValue: '\u0000', expected: '\u0000' },
+ ])(
+ 'should handle edge case correctly and return ($expected) when the input is ($dtoValue)',
+ ({ dtoValue, expected }) => {
+ expect(parseString(dtoValue)).toEqual(expected);
+ },
+ );
+ });
+});
diff --git a/src/api/dtoUtils/dtoAppUtils.ts b/src/api/dtoUtils/dtoAppUtils.ts
new file mode 100644
index 0000000..3925f27
--- /dev/null
+++ b/src/api/dtoUtils/dtoAppUtils.ts
@@ -0,0 +1,19 @@
+import { Endpoints } from '@AppServices/apiClient';
+import { isInListTypeGuard } from '@AppUtils/typeGuards';
+import { parseString } from './dtoCommonUtils';
+
+// ##################################################
+// # COMMON APP SPECIFIC PARSERS
+// ##################################################
+
+export const parseEndpoint = (
+ endpointCandidate: Maybe,
+): Endpoints | undefined => {
+ const parsedEndpoint = parseString(endpointCandidate);
+
+ if (parsedEndpoint) {
+ if (isInListTypeGuard(parsedEndpoint, Object.values(Endpoints))) {
+ return parsedEndpoint;
+ }
+ }
+};
diff --git a/src/api/dtoUtils/dtoCommonUtils.ts b/src/api/dtoUtils/dtoCommonUtils.ts
new file mode 100644
index 0000000..6a109a4
--- /dev/null
+++ b/src/api/dtoUtils/dtoCommonUtils.ts
@@ -0,0 +1,33 @@
+import { filterNonNull } from '@AppUtils/array';
+
+// ##################################################
+// # COMMON GENERIC PARSERS
+// ##################################################
+
+export const parseDtoArray = (
+ parseItem: (dto: Dto) => Model | undefined,
+ dtos: Dto[] | null | undefined,
+): Model[] => {
+ return filterNonNull(dtos?.map(parseItem));
+};
+
+export const parseDtoRecord = (
+ parseItem: (dto: Dto) => Model | undefined,
+ dto: Record,
+): Record => {
+ const result = Object.create(null) as Record;
+
+ Object.keys(dto ?? {}).forEach((key: string) => {
+ const selectedDto = dto[key];
+ const model = selectedDto ? parseItem(selectedDto) : undefined;
+
+ if (model != null) {
+ result[key] = model;
+ }
+ });
+
+ return result;
+};
+
+export const parseString = (value: Maybe): string | undefined =>
+ typeof value === 'string' ? value : undefined;
diff --git a/src/api/index.ts b/src/api/index.ts
new file mode 100644
index 0000000..ff8d43c
--- /dev/null
+++ b/src/api/index.ts
@@ -0,0 +1,5 @@
+export * from './liveStreams';
+export * from './suggestedForYou';
+export * from './documentaries';
+export * from './teams';
+export * from './carouselLayout';
diff --git a/src/api/liveStreams/__tests__/__snapshots__/fetchLiveStreamDetails.test.ts.snap b/src/api/liveStreams/__tests__/__snapshots__/fetchLiveStreamDetails.test.ts.snap
new file mode 100644
index 0000000..36dc8fc
--- /dev/null
+++ b/src/api/liveStreams/__tests__/__snapshots__/fetchLiveStreamDetails.test.ts.snap
@@ -0,0 +1,65 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchLiveStreamDetails fetchLiveStreamDetailsApiCall should return parsed livestream details data on success 1`] = `
+{
+ "description": "Watch the action live! From breathtaking plays to game-changing moments, this stream has it all. Feel the intensity as the players give it their all, leaving everything on the field. Every pass and every goal could change the outcome of this thrilling match.
+
+Stay tuned for every second of the excitement. The game is on, and the stakes couldn’t be higher. Whether it’s a last-minute goal or a stunning save, this is football at its finest. Don’t just watch—experience the passion, the drama, and the glory of the game.
+
+Watch to join the live stream now and be part of the action! Amazing moments are waiting for you to grasp them!",
+ "headline": "Football (Dragons vs Cougars)",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/30.jpg",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ "streamDuration": 734,
+ "streamEndDate": 2022-12-17T17:06:43.000Z,
+ "streamStartDate": 2022-12-17T04:52:43.000Z,
+ "team1": {
+ "name": "Dragons",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-01.jpg",
+ },
+ "team2": {
+ "name": "Cougars",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-02.jpg",
+ },
+ "title": "Basketball: Dragons | Cougars",
+ "videoSource": {
+ "format": "HLS",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/",
+ "title": "HLS",
+ "type": "hls",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ },
+}
+`;
+
+exports[`fetchLiveStreamDetails useLiveStreamDetails should return live streams data and no error on successful fetch 1`] = `
+{
+ "description": "Watch the action live! From breathtaking plays to game-changing moments, this stream has it all. Feel the intensity as the players give it their all, leaving everything on the field. Every pass and every goal could change the outcome of this thrilling match.
+
+Stay tuned for every second of the excitement. The game is on, and the stakes couldn’t be higher. Whether it’s a last-minute goal or a stunning save, this is football at its finest. Don’t just watch—experience the passion, the drama, and the glory of the game.
+
+Watch to join the live stream now and be part of the action! Amazing moments are waiting for you to grasp them!",
+ "headline": "Football (Dragons vs Cougars)",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/30.jpg",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ "streamDuration": 734,
+ "streamEndDate": 2022-12-17T17:06:43.000Z,
+ "streamStartDate": 2022-12-17T04:52:43.000Z,
+ "team1": {
+ "name": "Dragons",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-01.jpg",
+ },
+ "team2": {
+ "name": "Cougars",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-02.jpg",
+ },
+ "title": "Basketball: Dragons | Cougars",
+ "videoSource": {
+ "format": "HLS",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/",
+ "title": "HLS",
+ "type": "hls",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ },
+}
+`;
diff --git a/src/api/liveStreams/__tests__/__snapshots__/fetchLiveStreams.test.ts.snap b/src/api/liveStreams/__tests__/__snapshots__/fetchLiveStreams.test.ts.snap
new file mode 100644
index 0000000..1f22116
--- /dev/null
+++ b/src/api/liveStreams/__tests__/__snapshots__/fetchLiveStreams.test.ts.snap
@@ -0,0 +1,371 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchLiveStreams fetchLiveStreamsApiCall should return parsed livestreams data on success and filter out nullable id records 1`] = `
+[
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ "sport_type": "surfing",
+ "streamDate": 2022-12-17T04:52:43.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/11.jpg",
+ "title": "Live from Century's Football Match",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ "sport_type": "soccer",
+ "streamDate": 2022-06-09T11:58:23.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/12.jpg",
+ "title": "Formula 1 Discussion Panel",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "73780c3d-2f09-4ea0-b9c0-50bdf2a9ba0a",
+ "sport_type": "padel",
+ "streamDate": 2022-10-07T23:10:08.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/13.jpg",
+ "title": "Inside the Championship Studio",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "4448fb6d-f55c-4975-8b0c-14d5356d807b",
+ "sport_type": "surfing",
+ "streamDate": 2022-01-25T06:12:26.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "title": "Post-Match Analysis Desk",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "997afe05-d0fa-44c2-b62e-555062807623",
+ "sport_type": "surfing",
+ "streamDate": 2022-08-20T07:38:40.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/15.jpg",
+ "title": "Voices from the Sidelines",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "dad18f0f-d753-47b7-9393-e30630a394cd",
+ "sport_type": "skiing",
+ "streamDate": 2022-08-27T15:58:41.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/16.jpg",
+ "title": "Breaking News: Sports Edition",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "18813033-6113-4da8-ad75-2a209da6380f",
+ "sport_type": "football",
+ "streamDate": 2024-10-17T20:05:31.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/17.jpg",
+ "title": "Live Debate: Game Changers",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ "sport_type": "marathon",
+ "streamDate": 2022-08-11T07:26:11.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/18.jpg",
+ "title": "Studio Insights: Race Day",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "386e49d7-3ff1-476a-82b9-b9a61870b3b3",
+ "sport_type": "running",
+ "streamDate": 2022-04-30T05:45:24.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/19.jpg",
+ "title": "Reporter’s Corner: Match Highlights",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "9fbaaba2-b6d0-442e-83fe-529212062a4c",
+ "sport_type": "soccer",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/20.jpg",
+ "title": "On-Air with the Experts",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "c4253727-d8fe-4b22-b792-e1c79ecaa5f4",
+ "sport_type": "boxing",
+ "streamDate": undefined,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/24.jpg",
+ "title": "In-Studio with Top Analysts",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "f4a39998-4937-4bb7-82e7-90108a12a702",
+ "sport_type": "football",
+ "streamDate": undefined,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/25.jpg",
+ "title": "Game Day Reporter Spotlight",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "39abb98c-922d-4f10-b205-4b95a4d4babb",
+ "sport_type": "biking",
+ "streamDate": undefined,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/26.jpg",
+ "title": "Behind the Mic: Sports Talk",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "967d3e67-473d-4351-9c10-3727f55297ee",
+ "sport_type": "running",
+ "streamDate": 2022-08-27T15:58:41.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/27.jpg",
+ "title": "Breaking News: Sports Edition",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "074ac572-c778-4402-bd68-61438f28e10f",
+ "sport_type": "basketball",
+ "streamDate": 2024-10-17T20:05:31.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/28.jpg",
+ "title": "Live Debate: Game Changers",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "c9027268-5ef4-4663-b979-a8ae527373a1",
+ "sport_type": "baseball",
+ "streamDate": 2022-08-11T07:26:11.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/29.jpg",
+ "title": "Studio Insights: Race Day",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "7e1a09a4-1066-43d3-9ee9-0eb89ade5c18",
+ "sport_type": "land hockey",
+ "streamDate": 2022-04-30T05:45:24.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg",
+ "title": "Reporter’s Corner: Match Highlights",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "1eeadc2b-4e6f-461a-8f93-825ef54b2f48",
+ "sport_type": "tennis",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/31.jpg",
+ "title": "On-Air with the Experts",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "144509c0-424f-40e3-ad26-f1ae885e8b79",
+ "sport_type": "running",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg",
+ "title": "Pre-Game Strategy Breakdown",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "93e85028-3a15-44e1-b976-813711a6b938",
+ "sport_type": "biking",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/33.jpg",
+ "title": "Final Whistle Recap Show",
+ },
+]
+`;
+
+exports[`fetchLiveStreams useLiveStreams should return live streams data and no error on successful fetch 1`] = `
+[
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ "sport_type": "surfing",
+ "streamDate": 2022-12-17T04:52:43.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/11.jpg",
+ "title": "Live from Century's Football Match",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ "sport_type": "soccer",
+ "streamDate": 2022-06-09T11:58:23.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/12.jpg",
+ "title": "Formula 1 Discussion Panel",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "73780c3d-2f09-4ea0-b9c0-50bdf2a9ba0a",
+ "sport_type": "padel",
+ "streamDate": 2022-10-07T23:10:08.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/13.jpg",
+ "title": "Inside the Championship Studio",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "4448fb6d-f55c-4975-8b0c-14d5356d807b",
+ "sport_type": "surfing",
+ "streamDate": 2022-01-25T06:12:26.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "title": "Post-Match Analysis Desk",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "997afe05-d0fa-44c2-b62e-555062807623",
+ "sport_type": "surfing",
+ "streamDate": 2022-08-20T07:38:40.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/15.jpg",
+ "title": "Voices from the Sidelines",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "dad18f0f-d753-47b7-9393-e30630a394cd",
+ "sport_type": "skiing",
+ "streamDate": 2022-08-27T15:58:41.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/16.jpg",
+ "title": "Breaking News: Sports Edition",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "18813033-6113-4da8-ad75-2a209da6380f",
+ "sport_type": "football",
+ "streamDate": 2024-10-17T20:05:31.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/17.jpg",
+ "title": "Live Debate: Game Changers",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ "sport_type": "marathon",
+ "streamDate": 2022-08-11T07:26:11.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/18.jpg",
+ "title": "Studio Insights: Race Day",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "386e49d7-3ff1-476a-82b9-b9a61870b3b3",
+ "sport_type": "running",
+ "streamDate": 2022-04-30T05:45:24.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/19.jpg",
+ "title": "Reporter’s Corner: Match Highlights",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "9fbaaba2-b6d0-442e-83fe-529212062a4c",
+ "sport_type": "soccer",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/20.jpg",
+ "title": "On-Air with the Experts",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "c4253727-d8fe-4b22-b792-e1c79ecaa5f4",
+ "sport_type": "boxing",
+ "streamDate": undefined,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/24.jpg",
+ "title": "In-Studio with Top Analysts",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "f4a39998-4937-4bb7-82e7-90108a12a702",
+ "sport_type": "football",
+ "streamDate": undefined,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/25.jpg",
+ "title": "Game Day Reporter Spotlight",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "39abb98c-922d-4f10-b205-4b95a4d4babb",
+ "sport_type": "biking",
+ "streamDate": undefined,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/26.jpg",
+ "title": "Behind the Mic: Sports Talk",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "967d3e67-473d-4351-9c10-3727f55297ee",
+ "sport_type": "running",
+ "streamDate": 2022-08-27T15:58:41.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/27.jpg",
+ "title": "Breaking News: Sports Edition",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "074ac572-c778-4402-bd68-61438f28e10f",
+ "sport_type": "basketball",
+ "streamDate": 2024-10-17T20:05:31.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/28.jpg",
+ "title": "Live Debate: Game Changers",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "c9027268-5ef4-4663-b979-a8ae527373a1",
+ "sport_type": "baseball",
+ "streamDate": 2022-08-11T07:26:11.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/29.jpg",
+ "title": "Studio Insights: Race Day",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "7e1a09a4-1066-43d3-9ee9-0eb89ade5c18",
+ "sport_type": "land hockey",
+ "streamDate": 2022-04-30T05:45:24.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg",
+ "title": "Reporter’s Corner: Match Highlights",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "1eeadc2b-4e6f-461a-8f93-825ef54b2f48",
+ "sport_type": "tennis",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/31.jpg",
+ "title": "On-Air with the Experts",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "144509c0-424f-40e3-ad26-f1ae885e8b79",
+ "sport_type": "running",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg",
+ "title": "Pre-Game Strategy Breakdown",
+ },
+ {
+ "description": undefined,
+ "genre": undefined,
+ "itemId": "93e85028-3a15-44e1-b976-813711a6b938",
+ "sport_type": "biking",
+ "streamDate": 2022-08-04T05:43:00.000Z,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/33.jpg",
+ "title": "Final Whistle Recap Show",
+ },
+]
+`;
diff --git a/src/api/liveStreams/__tests__/fetchLiveStreamDetails.test.ts b/src/api/liveStreams/__tests__/fetchLiveStreamDetails.test.ts
new file mode 100644
index 0000000..cccf63c
--- /dev/null
+++ b/src/api/liveStreams/__tests__/fetchLiveStreamDetails.test.ts
@@ -0,0 +1,425 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import {
+ fetchLiveStreamDetailsApiCall,
+ useLiveStreamDetails,
+} from '../fetchLiveStreamDetails';
+import staticData from '../staticData/liveStreamDetails.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchLiveStreamDetails', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchLiveStreamDetailsApiCall', () => {
+ it('should return parsed livestream details data on success', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchLiveStreamDetailsApiCall(
+ '50128bae-e954-4233-8e15-cd5867a31370',
+ );
+
+ const { isOnAir, ...resultRest } = result ?? {};
+
+ // compare all fields in object expect functions
+ expect(resultRest).toMatchSnapshot();
+
+ // expect isOnAir to be function
+ expect(typeof isOnAir).toBe('function');
+ });
+
+ it('should return undefined for unexisting id', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchLiveStreamDetailsApiCall('unexisting-id');
+
+ expect(result).toBeUndefined();
+ });
+
+ test.each([{ itemId: '' }, { itemId: null }, { itemId: undefined }])(
+ 'should return undefined if livestream details data has nullable id ($itemId)',
+ async ({ itemId }) => {
+ // @ts-expect-error intentionally break TS
+ await expect(fetchLiveStreamDetailsApiCall(itemId)).rejects.toThrow(
+ 'fetchLiveStreamDetailsApiCall() was used with invalid item id',
+ );
+ },
+ );
+
+ describe('isOnAir', () => {
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ test.each([
+ { now: '2022-12-17T04:00:00.000Z', isOnAirResult: false },
+ { now: '2022-12-17T07:00:00.000Z', isOnAirResult: true },
+ { now: '2022-12-17T14:47:00.000Z', isOnAirResult: true },
+ ])(
+ 'should return $isOnAirResult if now date is $now',
+ async ({ now, isOnAirResult }) => {
+ jest.useFakeTimers().setSystemTime(new Date(now));
+
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchLiveStreamDetailsApiCall(
+ '50128bae-e954-4233-8e15-cd5867a31370',
+ );
+
+ // expect isOnAir to be function
+ expect(typeof result?.isOnAir).toBe('function');
+ expect(result?.isOnAir?.()).toBe(isOnAirResult);
+ },
+ );
+
+ test.each([
+ {
+ itemId: 'undefined-stream-date',
+ streamDateValue: undefined,
+ now: '2022-12-17T04:00:00.000Z',
+ },
+ {
+ itemId: 'empty-stream-date',
+ streamDateValue: '',
+ now: '2022-12-17T07:00:00.000Z',
+ },
+ {
+ itemId: 'null-stream-date',
+ streamDateValue: null,
+ now: '2022-12-17T14:47:00.000Z',
+ },
+ ])(
+ 'should return undefined if start or end date is nullable ($streamDateValue)',
+ async ({ now, itemId }) => {
+ jest.useFakeTimers().setSystemTime(new Date(now));
+
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchLiveStreamDetailsApiCall(itemId);
+
+ // expect isOnAir to be function
+ expect(typeof result?.isOnAir).toBe('function');
+ expect(result?.isOnAir?.()).toBeUndefined();
+ },
+ );
+
+ test.each([
+ {
+ itemId: 'null-stream-duration',
+ streamDurationValue: null,
+ now: '2022-12-17T04:00:00.000Z',
+ },
+ {
+ itemId: 'no-stream-duration',
+ streamDurationValue: undefined,
+ now: '2022-12-17T07:00:00.000Z',
+ },
+ {
+ itemId: 'zero-stream-duration',
+ streamDurationValue: 0,
+ now: '2022-12-17T14:47:00.000Z',
+ },
+ ])(
+ 'should return undefined if stream duration is nullable ($streamDurationValue)',
+ async ({ now, itemId }) => {
+ jest.useFakeTimers().setSystemTime(new Date(now));
+
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchLiveStreamDetailsApiCall(itemId);
+
+ // expect isOnAir to be function
+ expect(typeof result?.isOnAir).toBe('function');
+ expect(result?.isOnAir?.()).toBeUndefined();
+ },
+ );
+ });
+
+ it('should throw error when API responded with 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({ livestreams: {} }), {
+ status: 400,
+ });
+
+ await expect(
+ fetchLiveStreamDetailsApiCall('50128bae-e954-4233-8e15-cd5867a31370'),
+ ).rejects.toThrow(
+ `fetchLiveStreamDetailsApiCall(): resources does not exist for endpoint 'livestreams'`,
+ );
+ });
+
+ it('should throw error when API responded with unexpected status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(
+ fetchLiveStreamDetailsApiCall('50128bae-e954-4233-8e15-cd5867a31370'),
+ ).rejects.toThrow(`Perform a request failed`);
+ });
+
+ test.each([{ status: 401 }, { status: 403 }, { status: 500 }])(
+ 'should throw error when API response with $status status',
+ async ({ status }) => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status });
+
+ await expect(
+ fetchLiveStreamDetailsApiCall('50128bae-e954-4233-8e15-cd5867a31370'),
+ ).rejects.toThrow(
+ `fetchLiveStreamDetailsApiCall(): failed to fetch data from endpoint 'livestreams'`,
+ );
+ },
+ );
+ });
+
+ describe('useLiveStreamDetails', () => {
+ it('should return live streams data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams[0] }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchLiveStreamDetailsApiCall(
+ '50128bae-e954-4233-8e15-cd5867a31370',
+ ),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useLiveStreamDetails({
+ liveStreamId: '50128bae-e954-4233-8e15-cd5867a31370',
+ }),
+ );
+
+ const { isOnAir, ...resultRest } = result.current.data ?? {};
+
+ // test hook state
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+
+ // test hook return data shape
+ expect(resultRest).toMatchSnapshot();
+
+ // expect isOnAir to be function
+ expect(typeof isOnAir).toBe('function');
+ });
+
+ describe('isOnAir', () => {
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ test.each([
+ { now: '2022-12-17T04:00:00.000Z', isOnAirResult: false },
+ { now: '2022-12-17T07:00:00.000Z', isOnAirResult: true },
+ { now: '2022-12-17T14:47:00.000Z', isOnAirResult: true },
+ ])(
+ 'should return $isOnAirResult if now date is $now',
+ async ({ now, isOnAirResult }) => {
+ jest.useFakeTimers().setSystemTime(new Date(now));
+
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams[0] }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchLiveStreamDetailsApiCall(
+ '50128bae-e954-4233-8e15-cd5867a31370',
+ ),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useLiveStreamDetails({
+ liveStreamId: '50128bae-e954-4233-8e15-cd5867a31370',
+ }),
+ );
+
+ // expect isOnAir to be function
+ expect(typeof result.current.data?.isOnAir).toBe('function');
+ expect(result.current.data?.isOnAir?.()).toBe(isOnAirResult);
+ },
+ );
+
+ test.each([
+ {
+ itemId: 'undefined-stream-date',
+ streamDateValue: undefined,
+ now: '2022-12-17T04:00:00.000Z',
+ },
+ {
+ itemId: 'empty-stream-date',
+ streamDateValue: '',
+ now: '2022-12-17T07:00:00.000Z',
+ },
+ {
+ itemId: 'null-stream-date',
+ streamDateValue: null,
+ now: '2022-12-17T14:47:00.000Z',
+ },
+ ])(
+ 'should return undefined if start or end date is nullable ($streamDateValue)',
+ async ({ now, itemId }) => {
+ jest.useFakeTimers().setSystemTime(new Date(now));
+
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams[0] }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchLiveStreamDetailsApiCall(itemId),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useLiveStreamDetails({ liveStreamId: itemId }),
+ );
+
+ // expect isOnAir to be function
+ expect(typeof result.current.data?.isOnAir).toBe('function');
+ expect(result.current.data?.isOnAir?.()).toBeUndefined();
+ },
+ );
+
+ test.each([
+ {
+ itemId: 'null-stream-duration',
+ streamDurationValue: null,
+ now: '2022-12-17T04:00:00.000Z',
+ },
+ {
+ itemId: 'no-stream-duration',
+ streamDurationValue: undefined,
+ now: '2022-12-17T07:00:00.000Z',
+ },
+ {
+ itemId: 'zero-stream-duration',
+ streamDurationValue: 0,
+ now: '2022-12-17T14:47:00.000Z',
+ },
+ ])(
+ 'should return undefined if stream duration is nullable ($streamDurationValue)',
+ async ({ now, itemId }) => {
+ jest.useFakeTimers().setSystemTime(new Date(now));
+
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams[0] }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchLiveStreamDetailsApiCall(itemId),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useLiveStreamDetails({ liveStreamId: itemId }),
+ );
+
+ // expect isOnAir to be function
+ expect(typeof result.current.data?.isOnAir).toBe('function');
+ expect(result?.current?.data?.isOnAir?.()).toBeUndefined();
+ },
+ );
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: undefined,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() =>
+ useLiveStreamDetails({
+ liveStreamId: '50128bae-e954-4233-8e15-cd5867a31370',
+ }),
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBeUndefined();
+ expect(result.current.error).toBe(null);
+ });
+
+ test.each([
+ '50128bae-e954-4233-8e15-cd5867a31370',
+ '23232',
+ 'some string',
+ undefined,
+ null,
+ ])('should return error (case: %s)', (inputValue) => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: undefined,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ // @ts-expect-error intentionally break TS
+ useLiveStreamDetails({ liveStreamId: inputValue }),
+ );
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(undefined);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/liveStreams/__tests__/fetchLiveStreams.test.ts b/src/api/liveStreams/__tests__/fetchLiveStreams.test.ts
new file mode 100644
index 0000000..050f41f
--- /dev/null
+++ b/src/api/liveStreams/__tests__/fetchLiveStreams.test.ts
@@ -0,0 +1,123 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import { fetchLiveStreamsApiCall, useLiveStreams } from '../fetchLiveStreams';
+import staticData from '../staticData/liveStreams.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchLiveStreams', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchLiveStreamsApiCall', () => {
+ it('should return parsed livestreams data on success and filter out nullable id records', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchLiveStreamsApiCall();
+
+ expect(result).toMatchSnapshot();
+ expect(result).toHaveLength(20);
+ });
+
+ it('should throw error when API responded with 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 400 });
+
+ await expect(fetchLiveStreamsApiCall()).rejects.toThrow(
+ `fetchLiveStreamsApiCall(): resources does not exist for endpoint 'livestreams'`,
+ );
+ });
+
+ it('should throw error when API responded with unexpected status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(fetchLiveStreamsApiCall()).rejects.toThrow(
+ `Perform a request failed`,
+ );
+ });
+
+ test.each([{ status: 401 }, { status: 403 }, { status: 500 }])(
+ 'should throw error when API response with $status status',
+ async ({ status }) => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status });
+
+ await expect(fetchLiveStreamsApiCall()).rejects.toThrow(
+ `fetchLiveStreamsApiCall(): failed to fetch data from endpoint 'livestreams'`,
+ );
+ },
+ );
+ });
+
+ describe('useLiveStreams', () => {
+ it('should return live streams data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.livestreams }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchLiveStreamsApiCall(),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useLiveStreams());
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: undefined,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() => useLiveStreams());
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBeUndefined();
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should return error when fetch fails', () => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: undefined,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useLiveStreams());
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBeUndefined();
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/liveStreams/dtos/LiveStreamDetails.ts b/src/api/liveStreams/dtos/LiveStreamDetails.ts
new file mode 100644
index 0000000..206e495
--- /dev/null
+++ b/src/api/liveStreams/dtos/LiveStreamDetails.ts
@@ -0,0 +1,69 @@
+import type { LiveStreamDetails } from '@AppModels/liveStreams/LiveStreamDetails';
+import type { VideoSource } from '@AppServices/videoPlayer';
+import {
+ parseISODate,
+ calculateEndDate,
+ isNowBetweenDates,
+} from '@AppUtils/date';
+import { parseNumber } from '@AppUtils/number';
+
+export type LiveStreamDetailsDto = {
+ id?: Maybe;
+ title?: string;
+ sport_type?: string;
+ location?: string;
+ stream_date?: Maybe;
+ team1?: string;
+ team1_thumbnail?: string;
+ team2?: string;
+ team2_thumbnail?: string;
+ video_source?: VideoSource;
+ stream_duration?: Maybe;
+ description?: string;
+ thumbnail?: string;
+ imageCover?: string;
+};
+
+export const parseLiveStreamDetailsDto = (
+ dto: LiveStreamDetailsDto,
+): LiveStreamDetails | undefined => {
+ if (!dto?.id) {
+ return;
+ }
+
+ const streamStartDate = dto.stream_date
+ ? parseISODate(dto.stream_date)
+ : undefined;
+ const streamDuration = parseNumber(dto.stream_duration);
+ const streamEndDate =
+ streamStartDate && streamDuration
+ ? calculateEndDate(streamStartDate, streamDuration)
+ : undefined;
+
+ return {
+ itemId: dto.id,
+ title: dto.title,
+ streamStartDate: dto.stream_date
+ ? parseISODate(dto.stream_date)
+ : undefined,
+ streamEndDate,
+ streamDuration,
+ team1: {
+ name: dto.team1,
+ thumbnail: dto.team1_thumbnail,
+ },
+ team2: {
+ name: dto.team2,
+ thumbnail: dto.team2_thumbnail,
+ },
+ headline: `${dto.sport_type} (${dto.team1} vs ${dto.team2})`,
+ description: dto.description,
+ videoSource: dto.video_source,
+ imageCover: dto.imageCover,
+ isOnAir() {
+ if (this.streamStartDate && this.streamEndDate) {
+ return isNowBetweenDates(this.streamStartDate, this.streamEndDate);
+ }
+ },
+ };
+};
diff --git a/src/api/liveStreams/dtos/LiveStreamsDto.ts b/src/api/liveStreams/dtos/LiveStreamsDto.ts
new file mode 100644
index 0000000..49bca61
--- /dev/null
+++ b/src/api/liveStreams/dtos/LiveStreamsDto.ts
@@ -0,0 +1,37 @@
+import type { LiveStream } from '@AppModels/liveStreams/LiveStreams';
+import { parseISODate } from '@AppUtils/date';
+import { parseDtoArray } from '../../dtoUtils/dtoCommonUtils';
+
+export type LiveStreamDto = {
+ id?: Maybe;
+ title?: string;
+ stream_date?: Maybe;
+ thumbnail?: string;
+ sport_type?: string;
+ genre?: string;
+ description?: string;
+};
+
+export const parseLiveStreamsDto = (
+ dto: LiveStreamDto,
+): LiveStream | undefined => {
+ if (!dto.id) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ title: dto.title,
+ streamDate: dto.stream_date ? parseISODate(dto.stream_date) : undefined,
+ thumbnail: dto.thumbnail,
+ sport_type: dto.sport_type,
+ genre: dto.genre,
+ description: dto.description,
+ };
+};
+
+export const parseLiveStreamsDtoArray = (
+ dtos: LiveStreamDto[],
+): LiveStream[] => {
+ return parseDtoArray(parseLiveStreamsDto, dtos);
+};
diff --git a/src/api/liveStreams/fetchLiveStreamDetails.ts b/src/api/liveStreams/fetchLiveStreamDetails.ts
new file mode 100644
index 0000000..75721bc
--- /dev/null
+++ b/src/api/liveStreams/fetchLiveStreamDetails.ts
@@ -0,0 +1,61 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { StaticDataStructure } from '@AppServices/apiClient/staticDataClient/types';
+import type { LiveStreamDetailsDto } from './dtos/LiveStreamDetails';
+import { parseLiveStreamDetailsDto } from './dtos/LiveStreamDetails';
+import staticData from './staticData/liveStreamDetails.json';
+
+type ResponseDto = LiveStreamDetailsDto;
+
+const endpoint = Endpoints.LiveStreams;
+
+export const fetchLiveStreamDetailsApiCall = async (liveStreamId: string) => {
+ if (!liveStreamId) {
+ throw new Error(
+ `fetchLiveStreamDetailsApiCall() was used with invalid item id`,
+ );
+ }
+
+ const response = await ApiClient.get(
+ endpoint,
+ {
+ id: liveStreamId,
+ staticData: staticData as StaticDataStructure,
+ },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchLiveStreamDetailsApiCall(): resources does not exist for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchLiveStreamDetailsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseLiveStreamDetailsDto(response.data);
+};
+
+export const useLiveStreamDetails = ({
+ liveStreamId,
+}: {
+ liveStreamId: string;
+}) => {
+ const query = useQuery({
+ queryKey: [endpoint, liveStreamId],
+ queryFn: () => fetchLiveStreamDetailsApiCall(liveStreamId),
+ });
+
+ return query;
+};
diff --git a/src/api/liveStreams/fetchLiveStreams.ts b/src/api/liveStreams/fetchLiveStreams.ts
new file mode 100644
index 0000000..677f008
--- /dev/null
+++ b/src/api/liveStreams/fetchLiveStreams.ts
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { LiveStreamDto } from './dtos/LiveStreamsDto';
+import { parseLiveStreamsDtoArray } from './dtos/LiveStreamsDto';
+import staticData from './staticData/liveStreams.json';
+
+type ResponseDto = LiveStreamDto[];
+
+const endpoint = Endpoints.LiveStreams;
+
+export const fetchLiveStreamsApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchLiveStreamsApiCall(): resources does not exist for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchLiveStreamsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseLiveStreamsDtoArray(response.data);
+};
+
+export const useLiveStreams = () => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchLiveStreamsApiCall,
+ });
+
+ return query;
+};
diff --git a/src/api/liveStreams/index.ts b/src/api/liveStreams/index.ts
new file mode 100644
index 0000000..1ba90a8
--- /dev/null
+++ b/src/api/liveStreams/index.ts
@@ -0,0 +1,5 @@
+export { useLiveStreams, fetchLiveStreamsApiCall } from './fetchLiveStreams';
+export {
+ useLiveStreamDetails,
+ fetchLiveStreamDetailsApiCall,
+} from './fetchLiveStreamDetails';
diff --git a/src/api/liveStreams/staticData/liveStreamDetails.json b/src/api/liveStreams/staticData/liveStreamDetails.json
new file mode 100644
index 0000000..d922ac9
--- /dev/null
+++ b/src/api/liveStreams/staticData/liveStreamDetails.json
@@ -0,0 +1,902 @@
+{
+ "livestreams": [
+ {
+ "id": "50128bae-e954-4233-8e15-cd5867a31370",
+ "sport_type": "Football",
+ "stream_date": "2022-12-17T04:52:43Z",
+ "location": "Birmingham",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-01.jpg",
+ "team2": "Cougars",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-02.jpg",
+ "viewer_count": 594680,
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/30.jpg",
+ "description": "Watch the action live! From breathtaking plays to game-changing moments, this stream has it all. Feel the intensity as the players give it their all, leaving everything on the field. Every pass and every goal could change the outcome of this thrilling match.\n\nStay tuned for every second of the excitement. The game is on, and the stakes couldn’t be higher. Whether it’s a last-minute goal or a stunning save, this is football at its finest. Don’t just watch—experience the passion, the drama, and the glory of the game.\n\nWatch to join the live stream now and be part of the action! Amazing moments are waiting for you to grasp them!",
+ "title": "Basketball: Dragons | Cougars",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/30.jpg"
+ },
+ {
+ "id": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ "sport_type": "Motocross",
+ "stream_date": "2022-06-09T11:58:23Z",
+ "location": "San Luis",
+ "team1": "Hawks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/11.jpeg",
+ "team2": "Bears",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/12.jpeg",
+ "viewer_count": 85851,
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/sintel-the-movie/720p/sintel-the-movie.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/4.jpg",
+ "description": "Relive the highlights in stunning detail! From jaw-dropping jumps to heart-stopping landings, this footage captures the raw energy of motocross like never before. Feel the adrenaline as riders push their limits, defying gravity and conquering impossible tracks. The roar of the engines and the thrill of the stunts will leave you breathless.\n\nDon't miss the best stunts and most daring moments. It’s not just a race—it’s a showcase of determination and talent. Watch as the riders take on the toughest challenges and deliver unforgettable performances. This is motocross at its most electrifying.\n\nWatch to play now and experience the thrill!",
+ "title": "Soccer: Hawks | Bears",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/31.jpg"
+ },
+ {
+ "id": "73780c3d-2f09-4ea0-b9c0-50bdf2a9ba0a",
+ "sport_type": "Triathlon",
+ "stream_date": "2022-10-07T23:10:08Z",
+ "location": "Nantes",
+ "team1": "Tigers",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/13.jpeg",
+ "team2": "Wolves",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/14.jpeg",
+ "viewer_count": 961095,
+ "stream_duration": 634.584,
+ "video_source": {
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/glass-half/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/31.jpg",
+ "description": "Catch the best moments of the race in this quick recap! Witness the grit, determination, and endurance of the world's top athletes as they swim, bike, and run their way to glory. Every second is packed with action and emotion, showcasing the incredible effort it takes to compete at this level. From the starting line to the final sprint, this is a journey of pure athleticism.\n\nFast, thrilling, and action-packed, this footage is perfect for when you need your sports fix on the go. Relive the triumphs and challenges of this incredible event. Feel the tension as competitors push through exhaustion and give everything they’ve got.\n\nWatch now and feel the inspiration!",
+ "title": "Football: Tigers | Wolves",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/32.jpg"
+ },
+ {
+ "id": "4448fb6d-f55c-4975-8b0c-14d5356d807b",
+ "sport_type": "Baseball",
+ "stream_date": "2022-01-25T06:12:26Z",
+ "location": "Guérande",
+ "team1": "Sharks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/15.jpeg",
+ "team2": "Tigers",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/16.jpeg",
+ "viewer_count": 606287,
+ "stream_duration": 72.71,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/elephants-dream/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/32.jpg",
+ "description": "Stream the game live and never miss a moment! The energy, the passion, the thrill—it's all happening now as the players step up to the plate. Feel the tension rise with every pitch and the excitement explode with every home run. The crack of the bat, the roar of the crowd, and the race to the bases will keep you on the edge of your seat.\n\nGet ready for edge-of-your-seat action as the teams battle it out for victory. This is baseball at its finest, and you won't want to miss a single inning. From clutch plays to game-changing moments, this is the sport you love, live and unfiltered. Grab your snacks, settle in, and enjoy the ultimate baseball experience.\n\nWatch to stream live and join the excitement!",
+ "title": "Basketball: Sharks | Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/33.jpg"
+ },
+ {
+ "id": "997afe05-d0fa-44c2-b62e-555062807623",
+ "sport_type": "Soccer",
+ "stream_date": "2022-08-20T07:38:40Z",
+ "location": "Shancheng",
+ "team1": "Tigers",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/17.jpeg",
+ "team2": "Sharks",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/18.jpeg",
+ "viewer_count": 40427,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/tears-of-steel/720p/tears-of-steel.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/33.jpg",
+ "description": "Experience the game like never before! This video brings you closer to the action with every play, every goal, and every breathtaking moment. Feel the intensity as the players push themselves to the limit, delivering performances that will leave you in awe. The precision, the skill, and the passion are all captured in stunning detail.\n\nFrom epic goals to jaw-dropping saves, it's all here in one place. Relive the match in stunning detail. Whether you missed the game or want to relive the best moments, this video has you covered. It’s not just a highlight reel—it’s a front-row seat to the action.\n\nWatch the highlights now to be part of the action!",
+ "title": "Soccer: Tigers | Sharks",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/34.jpg"
+ },
+ {
+ "id": "dad18f0f-d753-47b7-9393-e30630a394cd",
+ "sport_type": "Racing",
+ "stream_date": "2022-08-27T15:58:41Z",
+ "location": "Velyka Bilozerka",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/19.jpeg",
+ "team2": "Sharks",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/20.jpeg",
+ "viewer_count": 884950,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/cosmos-laundromat/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/34.jpg",
+ "description": "Quick, sharp, and full of action—this video delivers the best plays in seconds! Feel the roar of the engines and the thrill of the race as drivers tackle rugged terrain and impossible obstacles. The dust flies, the tires spin, and the adrenaline surges as competitors push their vehicles to the limit. This is offroad racing at its most intense.\n\nPerfect for catching up on the game's top moments anytime, anywhere. Watch as drivers navigate treacherous tracks, defy gravity, and deliver jaw-dropping performances. Every turn, every jump, and every finish line is packed with excitement. If you love speed, power, and adventure, this is the video for you.\n\nWatch to watch and experience the rush!",
+ "title": "Football: Dragons | Sharks",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/35.jpg"
+ },
+ {
+ "id": "18813033-6113-4da8-ad75-2a209da6380f",
+ "sport_type": "Basketball",
+ "stream_date": "2024-10-17T20:05:31Z",
+ "location": "Jāndiāla Sher Khān",
+ "team1": "Hawks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/21.jpeg",
+ "team2": "Cougars",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/22.jpeg",
+ "viewer_count": 307003,
+ "stream_duration": 475.77,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/sintel-the-movie/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/35.jpg",
+ "description": "Basketball Hall of Fame\nLive sports, live energy, live excitement! Celebrate the legends of basketball and relive the moments that made history. From iconic plays to unforgettable highlights, this is a tribute to the greats of the game. Feel the passion, the skill, and the determination that define basketball at its highest level.\n\nStream the game now and feel the adrenaline rush as you witness the passion and skill that define basketball. This is more than a game—it's a legacy. Watch as the legends of the court are honored and their greatest moments are relived.",
+ "title": "Basketball: Hawks | Cougars",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/36.jpg"
+ },
+ {
+ "id": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ "sport_type": "basketball",
+ "stream_date": "2022-08-11T07:26:11Z",
+ "location": "Francisco Beltrão",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/25.jpeg",
+ "team2": "Falcons",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/23.jpeg",
+ "viewer_count": 361318,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/glass-half/720p/glass-half.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/36.jpg",
+ "title": "Football: Dragons | Falcons",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/37.jpg"
+ },
+ {
+ "id": "386e49d7-3ff1-476a-82b9-b9a61870b3b3",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-04-30T05:45:24Z",
+ "location": "Amgalang",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/24.jpeg",
+ "team2": "Panthers",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/25.jpeg",
+ "viewer_count": 48363,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/elephants-dream/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/37.jpg",
+ "title": "Football: Dragons | Panthers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/38.jpg"
+ },
+ {
+ "id": "9fbaaba2-b6d0-442e-83fe-529212062a4c",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/tears-of-steel/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "title": "Basketball: Gongfang | Lions",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/39.jpg"
+ },
+ {
+ "id": "",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/cosmos-laundromat/720p/cosmos-laundromat.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/39.jpg",
+ "title": "Soccer: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/40.jpg"
+ },
+ {
+ "id": null,
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/sintel-the-movie/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/40.jpg",
+ "title": "Football: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/41.jpg"
+ },
+ {
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/glass-half/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/41.jpg",
+ "title": "Basketball: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/42.jpg"
+ },
+ {
+ "id": "undefined-stream-date",
+ "sport_type": "Volleyball",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/elephants-dream/720p/elephants-dream.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/42.jpg",
+ "title": "Soccer: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/43.jpg"
+ },
+ {
+ "id": "empty-stream-date",
+ "sport_type": "Volleyball",
+ "stream_date": "",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/tears-of-steel/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/45.jpg",
+ "title": "Football: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/44.jpg"
+ },
+ {
+ "id": "null-stream-date",
+ "sport_type": "Volleyball",
+ "stream_date": null,
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/47.jpg",
+ "title": "Basketball: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/45.jpg"
+ },
+ {
+ "id": "null-stream-duration",
+ "sport_type": "Volleyball",
+ "stream_date": null,
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": null,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/sintel-the-movie/720p/sintel-the-movie.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/48.jpg",
+ "title": "Soccer: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/46.jpg"
+ },
+ {
+ "id": "no-stream-duration",
+ "sport_type": "Volleyball",
+ "stream_date": null,
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/glass-half/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/49.jpg",
+ "title": "Football: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/47.jpg"
+ },
+ {
+ "id": "zero-stream-duration",
+ "sport_type": "Volleyball",
+ "stream_date": null,
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 0,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/elephants-dream/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/50.jpg",
+ "title": "Basketball: Amber Lions vs Orange Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/48.jpg"
+ },
+ {
+ "id": "c4253727-d8fe-4b22-b792-e1c79ecaa5f4",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/tears-of-steel/720p/tears-of-steel.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "title": "Basketball: Gongfang | Lions",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/49.jpg"
+ },
+ {
+ "id": "50128bae-e954-4233-8e15-cd5867a31370",
+ "sport_type": "Football",
+ "stream_date": "2022-12-17T04:52:43Z",
+ "location": "Birmingham",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-01.jpg",
+ "team2": "Cougars",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-02.jpg",
+ "viewer_count": 594680,
+ "stream_duration": 734,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/cosmos-laundromat/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/30.jpg",
+ "description": "Watch the action live! From breathtaking plays to game-changing moments, this stream has it all. Feel the intensity as the players give it their all, leaving everything on the field. Every pass and every goal could change the outcome of this thrilling match.\n\nStay tuned for every second of the excitement. The game is on, and the stakes couldn’t be higher. Whether it’s a last-minute goal or a stunning save, this is football at its finest. Don’t just watch—experience the passion, the drama, and the glory of the game.\n\nWatch to join the live stream now and be part of the action! Amazing moments are waiting for you to grasp them!",
+ "title": "Basketball: Dragons | Cougars",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/50.jpg"
+ },
+ {
+ "id": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ "sport_type": "Motocross",
+ "stream_date": "2022-06-09T11:58:23Z",
+ "location": "San Luis",
+ "team1": "Hawks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/11.jpeg",
+ "team2": "Bears",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/12.jpeg",
+ "viewer_count": 85851,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/sintel-the-movie/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/4.jpg",
+ "description": "Relive the highlights in stunning detail! From jaw-dropping jumps to heart-stopping landings, this footage captures the raw energy of motocross like never before. Feel the adrenaline as riders push their limits, defying gravity and conquering impossible tracks. The roar of the engines and the thrill of the stunts will leave you breathless.\n\nDon't miss the best stunts and most daring moments. It’s not just a race—it’s a showcase of determination and talent. Watch as the riders take on the toughest challenges and deliver unforgettable performances. This is motocross at its most electrifying.\n\nWatch to play now and experience the thrill!",
+ "title": "Soccer: Hawks | Bears",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/51.jpg"
+ },
+ {
+ "id": "73780c3d-2f09-4ea0-b9c0-50bdf2a9ba0a",
+ "sport_type": "Triathlon",
+ "stream_date": "2022-10-07T23:10:08Z",
+ "location": "Nantes",
+ "team1": "Tigers",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/13.jpeg",
+ "team2": "Wolves",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/14.jpeg",
+ "viewer_count": 961095,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/glass-half/720p/glass-half.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/31.jpg",
+ "description": "Catch the best moments of the race in this quick recap! Witness the grit, determination, and endurance of the world's top athletes as they swim, bike, and run their way to glory. Every second is packed with action and emotion, showcasing the incredible effort it takes to compete at this level. From the starting line to the final sprint, this is a journey of pure athleticism.\n\nFast, thrilling, and action-packed, this footage is perfect for when you need your sports fix on the go. Relive the triumphs and challenges of this incredible event. Feel the tension as competitors push through exhaustion and give everything they’ve got.\n\nWatch now and feel the inspiration!",
+ "title": "Football: Tigers | Wolves",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/52.jpg"
+ },
+ {
+ "id": "4448fb6d-f55c-4975-8b0c-14d5356d807b",
+ "sport_type": "Baseball",
+ "stream_date": "2022-01-25T06:12:26Z",
+ "location": "Guérande",
+ "team1": "Sharks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/15.jpeg",
+ "team2": "Tigers",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/16.jpeg",
+ "viewer_count": 606287,
+ "stream_duration": 72.71,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/elephants-dream/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/32.jpg",
+ "description": "Stream the game live and never miss a moment! The energy, the passion, the thrill—it's all happening now as the players step up to the plate. Feel the tension rise with every pitch and the excitement explode with every home run. The crack of the bat, the roar of the crowd, and the race to the bases will keep you on the edge of your seat.\n\nGet ready for edge-of-your-seat action as the teams battle it out for victory. This is baseball at its finest, and you won't want to miss a single inning. From clutch plays to game-changing moments, this is the sport you love, live and unfiltered. Grab your snacks, settle in, and enjoy the ultimate baseball experience.\n\nWatch to stream live and join the excitement!",
+ "title": "Basketball: Sharks | Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/53.jpg"
+ },
+ {
+ "id": "997afe05-d0fa-44c2-b62e-555062807623",
+ "sport_type": "Soccer",
+ "stream_date": "2022-08-20T07:38:40Z",
+ "location": "Shancheng",
+ "team1": "Tigers",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/17.jpeg",
+ "team2": "Sharks",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/18.jpeg",
+ "viewer_count": 40427,
+ "stream_duration": 394.75,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/tears-of-steel/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/33.jpg",
+ "description": "Experience the game like never before! This video brings you closer to the action with every play, every goal, and every breathtaking moment. Feel the intensity as the players push themselves to the limit, delivering performances that will leave you in awe. The precision, the skill, and the passion are all captured in stunning detail.\n\nFrom epic goals to jaw-dropping saves, it's all here in one place. Relive the match in stunning detail. Whether you missed the game or want to relive the best moments, this video has you covered. It’s not just a highlight reel—it’s a front-row seat to the action.\n\nWatch the highlights now to be part of the action!",
+ "title": "Soccer: Tigers | Sharks",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/54.jpg"
+ },
+ {
+ "id": "dad18f0f-d753-47b7-9393-e30630a394cd",
+ "sport_type": "Racing",
+ "stream_date": "2022-08-27T15:58:41Z",
+ "location": "Velyka Bilozerka",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/19.jpeg",
+ "team2": "Sharks",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/20.jpeg",
+ "viewer_count": 884950,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/cosmos-laundromat/720p/cosmos-laundromat.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/34.jpg",
+ "description": "Quick, sharp, and full of action—this video delivers the best plays in seconds! Feel the roar of the engines and the thrill of the race as drivers tackle rugged terrain and impossible obstacles. The dust flies, the tires spin, and the adrenaline surges as competitors push their vehicles to the limit. This is offroad racing at its most intense.\n\nPerfect for catching up on the game's top moments anytime, anywhere. Watch as drivers navigate treacherous tracks, defy gravity, and deliver jaw-dropping performances. Every turn, every jump, and every finish line is packed with excitement. If you love speed, power, and adventure, this is the video for you.\n\nWatch to watch and experience the rush!",
+ "title": "Football: Dragons | Sharks",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/55.jpg"
+ },
+ {
+ "id": "18813033-6113-4da8-ad75-2a209da6380f",
+ "sport_type": "Basketball",
+ "stream_date": "2024-10-17T20:05:31Z",
+ "location": "Jāndiāla Sher Khān",
+ "team1": "Hawks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/21.jpeg",
+ "team2": "Cougars",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/22.jpeg",
+ "viewer_count": 307003,
+ "stream_duration": 475.77,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/sintel-the-movie/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/35.jpg",
+ "description": "Basketball Hall of Fame\nLive sports, live energy, live excitement! Celebrate the legends of basketball and relive the moments that made history. From iconic plays to unforgettable highlights, this is a tribute to the greats of the game. Feel the passion, the skill, and the determination that define basketball at its highest level.\n\nStream the game now and feel the adrenaline rush as you witness the passion and skill that define basketball. This is more than a game—it's a legacy. Watch as the legends of the court are honored and their greatest moments are relived.",
+ "title": "Basketball: Hawks | Cougars",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/56.jpg"
+ },
+ {
+ "id": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ "sport_type": "basketball",
+ "stream_date": "2022-08-11T07:26:11Z",
+ "location": "Francisco Beltrão",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/22.jpeg",
+ "team2": "Falcons",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/23.jpeg",
+ "viewer_count": 361318,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/glass-half/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/36.jpg",
+ "title": "Football: Dragons | Falcons",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/57.jpg"
+ },
+ {
+ "id": "386e49d7-3ff1-476a-82b9-b9a61870b3b3",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-04-30T05:45:24Z",
+ "location": "Amgalang",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/24.jpeg",
+ "team2": "Panthers",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/25.jpeg",
+ "viewer_count": 48363,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/elephants-dream/720p/elephants-dream.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/37.jpg",
+ "title": "Football: Dragons | Panthers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/58.jpg"
+ },
+ {
+ "id": "9fbaaba2-b6d0-442e-83fe-529212062a4c",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/tears-of-steel/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "title": "Basketball: Gongfang | Lions",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/59.jpg"
+ },
+ {
+ "id": "c4253727-d8fe-4b22-b792-e1c79ecaa5f4",
+ "sport_type": "Football",
+ "stream_date": "2022-12-17T04:52:43Z",
+ "location": "Birmingham",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-01.jpg",
+ "team2": "Cougars",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/thumbnails/thumbnail-02.jpg",
+ "viewer_count": 594680,
+ "stream_duration": 734,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/30.jpg",
+ "description": "Watch the action live! From breathtaking plays to game-changing moments, this stream has it all. Feel the intensity as the players give it their all, leaving everything on the field. Every pass and every goal could change the outcome of this thrilling match.\n\nStay tuned for every second of the excitement. The game is on, and the stakes couldn’t be higher. Whether it’s a last-minute goal or a stunning save, this is football at its finest. Don’t just watch—experience the passion, the drama, and the glory of the game.\n\nWatch to join the live stream now and be part of the action! Amazing moments are waiting for you to grasp them!",
+ "title": "Basketball: Dragons | Cougars",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/60.jpg"
+ },
+ {
+ "id": "f4a39998-4937-4bb7-82e7-90108a12a702",
+ "sport_type": "Motocross",
+ "stream_date": "2022-06-09T11:58:23Z",
+ "location": "San Luis",
+ "team1": "Hawks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/11.jpeg",
+ "team2": "Bears",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/12.jpeg",
+ "viewer_count": 85851,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/sintel-the-movie/720p/sintel-the-movie.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/4.jpg",
+ "description": "Relive the highlights in stunning detail! From jaw-dropping jumps to heart-stopping landings, this footage captures the raw energy of motocross like never before. Feel the adrenaline as riders push their limits, defying gravity and conquering impossible tracks. The roar of the engines and the thrill of the stunts will leave you breathless.\n\nDon't miss the best stunts and most daring moments. It’s not just a race—it’s a showcase of determination and talent. Watch as the riders take on the toughest challenges and deliver unforgettable performances. This is motocross at its most electrifying.\n\nWatch to play now and experience the thrill!",
+ "title": "Soccer: Hawks | Bears",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/61.jpg"
+ },
+ {
+ "id": "39abb98c-922d-4f10-b205-4b95a4d4babb",
+ "sport_type": "Triathlon",
+ "stream_date": "2022-10-07T23:10:08Z",
+ "location": "Nantes",
+ "team1": "Tigers",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/13.jpeg",
+ "team2": "Wolves",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/14.jpeg",
+ "viewer_count": 961095,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/glass-half/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/31.jpg",
+ "description": "Catch the best moments of the race in this quick recap! Witness the grit, determination, and endurance of the world's top athletes as they swim, bike, and run their way to glory. Every second is packed with action and emotion, showcasing the incredible effort it takes to compete at this level. From the starting line to the final sprint, this is a journey of pure athleticism.\n\nFast, thrilling, and action-packed, this footage is perfect for when you need your sports fix on the go. Relive the triumphs and challenges of this incredible event. Feel the tension as competitors push through exhaustion and give everything they’ve got.\n\nWatch now and feel the inspiration!",
+ "title": "Football: Tigers | Wolves",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/62.jpg"
+ },
+ {
+ "id": "967d3e67-473d-4351-9c10-3727f55297ee",
+ "sport_type": "Baseball",
+ "stream_date": "2022-01-25T06:12:26Z",
+ "location": "Guérande",
+ "team1": "Sharks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/15.jpeg",
+ "team2": "Tigers",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/16.jpeg",
+ "viewer_count": 606287,
+ "stream_duration": 72.71,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/elephants-dream/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/32.jpg",
+ "description": "Stream the game live and never miss a moment! The energy, the passion, the thrill—it's all happening now as the players step up to the plate. Feel the tension rise with every pitch and the excitement explode with every home run. The crack of the bat, the roar of the crowd, and the race to the bases will keep you on the edge of your seat.\n\nGet ready for edge-of-your-seat action as the teams battle it out for victory. This is baseball at its finest, and you won't want to miss a single inning. From clutch plays to game-changing moments, this is the sport you love, live and unfiltered. Grab your snacks, settle in, and enjoy the ultimate baseball experience.\n\nWatch to stream live and join the excitement!",
+ "title": "Basketball: Sharks | Tigers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/63.jpg"
+ },
+ {
+ "id": "074ac572-c778-4402-bd68-61438f28e10f",
+ "sport_type": "Soccer",
+ "stream_date": "2022-08-20T07:38:40Z",
+ "location": "Shancheng",
+ "team1": "Tigers",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/17.jpeg",
+ "team2": "Sharks",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/18.jpeg",
+ "viewer_count": 40427,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/tears-of-steel/720p/tears-of-steel.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/33.jpg",
+ "description": "Experience the game like never before! This video brings you closer to the action with every play, every goal, and every breathtaking moment. Feel the intensity as the players push themselves to the limit, delivering performances that will leave you in awe. The precision, the skill, and the passion are all captured in stunning detail.\n\nFrom epic goals to jaw-dropping saves, it's all here in one place. Relive the match in stunning detail. Whether you missed the game or want to relive the best moments, this video has you covered. It’s not just a highlight reel—it’s a front-row seat to the action.\n\nWatch the highlights now to be part of the action!",
+ "title": "Soccer: Tigers | Sharks",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/64.jpg"
+ },
+ {
+ "id": "c9027268-5ef4-4663-b979-a8ae527373a1",
+ "sport_type": "Racing",
+ "stream_date": "2022-08-27T15:58:41Z",
+ "location": "Velyka Bilozerka",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/19.jpeg",
+ "team2": "Sharks",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/20.jpeg",
+ "viewer_count": 884950,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/cosmos-laundromat/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/34.jpg",
+ "description": "Quick, sharp, and full of action—this video delivers the best plays in seconds! Feel the roar of the engines and the thrill of the race as drivers tackle rugged terrain and impossible obstacles. The dust flies, the tires spin, and the adrenaline surges as competitors push their vehicles to the limit. This is offroad racing at its most intense.\n\nPerfect for catching up on the game's top moments anytime, anywhere. Watch as drivers navigate treacherous tracks, defy gravity, and deliver jaw-dropping performances. Every turn, every jump, and every finish line is packed with excitement. If you love speed, power, and adventure, this is the video for you.\n\nWatch to watch and experience the rush!",
+ "title": "Football: Dragons | Sharks",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/65.jpg"
+ },
+ {
+ "id": "7e1a09a4-1066-43d3-9ee9-0eb89ade5c18",
+ "sport_type": "Basketball",
+ "stream_date": "2024-10-17T20:05:31Z",
+ "location": "Jāndiāla Sher Khān",
+ "team1": "Hawks",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/21.jpeg",
+ "team2": "Cougars",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/22.jpeg",
+ "viewer_count": 307003,
+ "stream_duration": 475.77,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/sintel-the-movie/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/35.jpg",
+ "description": "Basketball Hall of Fame\nLive sports, live energy, live excitement! Celebrate the legends of basketball and relive the moments that made history. From iconic plays to unforgettable highlights, this is a tribute to the greats of the game. Feel the passion, the skill, and the determination that define basketball at its highest level.\n\nStream the game now and feel the adrenaline rush as you witness the passion and skill that define basketball. This is more than a game—it's a legacy. Watch as the legends of the court are honored and their greatest moments are relived.",
+ "title": "Basketball: Hawks | Cougars",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/66.jpg"
+ },
+ {
+ "id": "1eeadc2b-4e6f-461a-8f93-825ef54b2f48",
+ "sport_type": "basketball",
+ "stream_date": "2022-08-11T07:26:11Z",
+ "location": "Francisco Beltrão",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/24.jpeg",
+ "team2": "Falcons",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/23.jpeg",
+ "viewer_count": 361318,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/glass-half/720p/glass-half.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/36.jpg",
+ "title": "Football: Dragons | Falcons",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/67.jpg"
+ },
+ {
+ "id": "144509c0-424f-40e3-ad26-f1ae885e8b79",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-04-30T05:45:24Z",
+ "location": "Amgalang",
+ "team1": "Dragons",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/24.jpeg",
+ "team2": "Panthers",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/25.jpeg",
+ "viewer_count": 48363,
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/elephants-dream/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/37.jpg",
+ "title": "Football: Dragons | Panthers",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/68.jpg"
+ },
+ {
+ "id": "93e85028-3a15-44e1-b976-813711a6b938",
+ "sport_type": "Volleyball",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "location": "Gongfang",
+ "team1": "Eagles",
+ "team1_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/26.jpeg",
+ "team2": "Lions",
+ "team2_thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/27.jpeg",
+ "viewer_count": 905426,
+ "stream_duration": 1.48,
+ "video_source": {
+ "type": "hls",
+ "format": "HLS",
+ "title": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/sintel-the-movie/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/300x450/38.jpg",
+ "title": "Basketball: Gongfang | Lions",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/69.jpg"
+ }
+ ]
+}
diff --git a/src/api/liveStreams/staticData/liveStreams.json b/src/api/liveStreams/staticData/liveStreams.json
new file mode 100644
index 0000000..448afff
--- /dev/null
+++ b/src/api/liveStreams/staticData/liveStreams.json
@@ -0,0 +1,143 @@
+{
+ "livestreams": [
+ {
+ "id": "50128bae-e954-4233-8e15-cd5867a31370",
+ "stream_date": "2022-12-17T04:52:43Z",
+ "title": "Live from Century's Football Match",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/11.jpg",
+ "sport_type": "surfing"
+ },
+ {
+ "id": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ "stream_date": "2022-06-09T11:58:23Z",
+ "title": "Formula 1 Discussion Panel",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/12.jpg",
+ "sport_type": "soccer"
+ },
+ {
+ "id": "73780c3d-2f09-4ea0-b9c0-50bdf2a9ba0a",
+ "stream_date": "2022-10-07T23:10:08Z",
+ "title": "Inside the Championship Studio",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/13.jpg",
+ "sport_type": "padel"
+ },
+ {
+ "id": "4448fb6d-f55c-4975-8b0c-14d5356d807b",
+ "stream_date": "2022-01-25T06:12:26Z",
+ "title": "Post-Match Analysis Desk",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "sport_type": "surfing"
+ },
+ {
+ "id": "997afe05-d0fa-44c2-b62e-555062807623",
+ "stream_date": "2022-08-20T07:38:40Z",
+ "title": "Voices from the Sidelines",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/15.jpg",
+ "sport_type": "surfing"
+ },
+ {
+ "id": "dad18f0f-d753-47b7-9393-e30630a394cd",
+ "stream_date": "2022-08-27T15:58:41Z",
+ "title": "Breaking News: Sports Edition",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/16.jpg",
+ "sport_type": "skiing"
+ },
+ {
+ "id": "18813033-6113-4da8-ad75-2a209da6380f",
+ "stream_date": "2024-10-17T20:05:31Z",
+ "title": "Live Debate: Game Changers",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/17.jpg",
+ "sport_type": "football"
+ },
+ {
+ "id": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ "stream_date": "2022-08-11T07:26:11Z",
+ "title": "Studio Insights: Race Day",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/18.jpg",
+ "sport_type": "marathon"
+ },
+ {
+ "id": "386e49d7-3ff1-476a-82b9-b9a61870b3b3",
+ "stream_date": "2022-04-30T05:45:24Z",
+ "title": "Reporter’s Corner: Match Highlights",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/19.jpg",
+ "sport_type": "running"
+ },
+ {
+ "id": "9fbaaba2-b6d0-442e-83fe-529212062a4c",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "title": "On-Air with the Experts",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/20.jpg",
+ "sport_type": "soccer"
+ },
+ {
+ "id": "c4253727-d8fe-4b22-b792-e1c79ecaa5f4",
+ "title": "In-Studio with Top Analysts",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/24.jpg",
+ "sport_type": "boxing"
+ },
+ {
+ "id": "f4a39998-4937-4bb7-82e7-90108a12a702",
+ "stream_date": "",
+ "title": "Game Day Reporter Spotlight",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/25.jpg",
+ "sport_type": "football"
+ },
+ {
+ "id": "39abb98c-922d-4f10-b205-4b95a4d4babb",
+ "stream_date": null,
+ "title": "Behind the Mic: Sports Talk",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/26.jpg",
+ "sport_type": "biking"
+ },
+ {
+ "id": "967d3e67-473d-4351-9c10-3727f55297ee",
+ "stream_date": "2022-08-27T15:58:41Z",
+ "title": "Breaking News: Sports Edition",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/27.jpg",
+ "sport_type": "running"
+ },
+ {
+ "id": "074ac572-c778-4402-bd68-61438f28e10f",
+ "stream_date": "2024-10-17T20:05:31Z",
+ "title": "Live Debate: Game Changers",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/28.jpg",
+ "sport_type": "basketball"
+ },
+ {
+ "id": "c9027268-5ef4-4663-b979-a8ae527373a1",
+ "stream_date": "2022-08-11T07:26:11Z",
+ "title": "Studio Insights: Race Day",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/29.jpg",
+ "sport_type": "baseball"
+ },
+ {
+ "id": "7e1a09a4-1066-43d3-9ee9-0eb89ade5c18",
+ "stream_date": "2022-04-30T05:45:24Z",
+ "title": "Reporter’s Corner: Match Highlights",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg",
+ "sport_type": "land hockey"
+ },
+ {
+ "id": "1eeadc2b-4e6f-461a-8f93-825ef54b2f48",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "title": "On-Air with the Experts",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/31.jpg",
+ "sport_type": "tennis"
+ },
+ {
+ "id": "144509c0-424f-40e3-ad26-f1ae885e8b79",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "title": "Pre-Game Strategy Breakdown",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg",
+ "sport_type": "running"
+ },
+ {
+ "id": "93e85028-3a15-44e1-b976-813711a6b938",
+ "stream_date": "2022-08-04T05:43:00Z",
+ "title": "Final Whistle Recap Show",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/33.jpg",
+ "sport_type": "biking"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/api/profileAvatars/dtos/ProfileAvatarDto.ts b/src/api/profileAvatars/dtos/ProfileAvatarDto.ts
new file mode 100644
index 0000000..009b0fb
--- /dev/null
+++ b/src/api/profileAvatars/dtos/ProfileAvatarDto.ts
@@ -0,0 +1 @@
+export type ProfileAvatarDto = Maybe;
diff --git a/src/api/profileAvatars/dtos/ProfileAvatarsDto.ts b/src/api/profileAvatars/dtos/ProfileAvatarsDto.ts
new file mode 100644
index 0000000..49f6b55
--- /dev/null
+++ b/src/api/profileAvatars/dtos/ProfileAvatarsDto.ts
@@ -0,0 +1,24 @@
+import type { ProfileAvatar, ProfileAvatars } from '@AppModels/profileAvatars';
+import type { ProfileAvatarDto } from './ProfileAvatarDto';
+
+export type ProfileAvatarsDto = Maybe;
+
+export const parseProfileAvatarsDto = (
+ avatarsDto: ProfileAvatarsDto,
+): ProfileAvatars => {
+ if (!avatarsDto) {
+ return [];
+ }
+
+ return avatarsDto
+ .filter((item): item is string => !!item)
+ .map((item) => {
+ const urlParts = item!.split('/'),
+ filename = urlParts.slice(urlParts.length - 2).join('-');
+
+ return {
+ url: item,
+ name: filename,
+ } satisfies ProfileAvatar;
+ });
+};
diff --git a/src/api/profileAvatars/fetchProfileAvatars.ts b/src/api/profileAvatars/fetchProfileAvatars.ts
new file mode 100644
index 0000000..d9bf78e
--- /dev/null
+++ b/src/api/profileAvatars/fetchProfileAvatars.ts
@@ -0,0 +1,45 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { ProfileAvatarsDto } from './dtos/ProfileAvatarsDto';
+import { parseProfileAvatarsDto } from './dtos/ProfileAvatarsDto';
+import staticData from './staticData/profileAvatars.json';
+
+const endpoint = Endpoints.ProfileAvatars;
+
+export const fetchProfileAvatarsApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchProfileAvatarsApiCall(): resources does not exist for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchProfileAvatarsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseProfileAvatarsDto(response.data);
+};
+
+export const useProfileAvatars = () => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchProfileAvatarsApiCall,
+ });
+
+ return query;
+};
diff --git a/src/api/profileAvatars/index.ts b/src/api/profileAvatars/index.ts
new file mode 100644
index 0000000..c2c87f4
--- /dev/null
+++ b/src/api/profileAvatars/index.ts
@@ -0,0 +1 @@
+export * from './fetchProfileAvatars';
diff --git a/src/api/profileAvatars/staticData/profileAvatars.json b/src/api/profileAvatars/staticData/profileAvatars.json
new file mode 100644
index 0000000..a486e78
--- /dev/null
+++ b/src/api/profileAvatars/staticData/profileAvatars.json
@@ -0,0 +1,13 @@
+{
+ "profileavatars": [
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/1p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/2p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/3p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/4p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/5p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/6p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/7p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/8p.jpg",
+ "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/profiles/9p.jpg"
+ ]
+}
diff --git a/src/api/suggestedForYou/__tests__/__snapshots__/fetchSuggestedForYou.test.ts.snap b/src/api/suggestedForYou/__tests__/__snapshots__/fetchSuggestedForYou.test.ts.snap
new file mode 100644
index 0000000..6a1d566
--- /dev/null
+++ b/src/api/suggestedForYou/__tests__/__snapshots__/fetchSuggestedForYou.test.ts.snap
@@ -0,0 +1,687 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchSuggestedForYou fetchSuggestedForYouApiCall should return parsed suggested for you data on success 1`] = `
+[
+ {
+ "description": "American Vagabond is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "e9a06a8f-9d40-41b9-a8b4-38bbc67159a2",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ },
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sport_type": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/1.jpg",
+ "title": "American Vagabond",
+ },
+ {
+ "description": "Dark Horse is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "2eae733d-748a-49dc-9558-56984b4bffda",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ },
+ "network": "Devcast",
+ "rating": 0.2,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/2.jpg",
+ "title": "Dark Horse (Voksne mennesker)",
+ },
+ {
+ "description": "Big, Large and Verdone is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "d8942af8-899b-4938-9060-328562d9e186",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ },
+ "network": "Ntags",
+ "rating": 8.3,
+ "sport_type": "hockey",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/3.jpg",
+ "title": "Big, Large and Verdone",
+ },
+ {
+ "description": "Crossroads is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "9bebda7d-bd2f-4660-9217-19b986e2fdd0",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": "f2d507fe-682c-476a-9a91-56923d9e5a49",
+ },
+ "network": "Abata",
+ "rating": 2.8,
+ "sport_type": "cycling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/4.jpg",
+ "title": "Crossroads",
+ },
+ {
+ "description": "Onechanbara - Zombie Bikini Squad (a.k.a. Oneechanbara: The Movie) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "7c61e824-e4e5-41a3-a0bf-7fcc16b3a297",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": "fdd82ada-af3f-4fe8-a90b-254877082e97",
+ },
+ "network": "Kaymbo",
+ "rating": 1.3,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/5.jpg",
+ "title": "Onechanbara - Zombie Bikini Squad",
+ },
+ {
+ "description": "Marathon Star is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "empty-air-date",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ },
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sport_type": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/6.jpg",
+ "title": "Marathon Star",
+ },
+ {
+ "description": "Snowball Effect: The Story of 'Clerks' is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "02c0c212-14bd-49c9-b20e-f2e0a723b785",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": "be00b94a-e12a-48c4-9615-23c3478f1bd0",
+ },
+ "network": "Eayo",
+ "rating": 4.9,
+ "sport_type": "table tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/7.jpg",
+ "title": "Snowball Effect: The Story of 'Clerks'",
+ },
+ {
+ "description": "Best Man Holiday, The is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "2f51a147-2640-4293-990a-1b29ee6f03c8",
+ "network": "Agimba",
+ "rating": 0.1,
+ "sport_type": "table tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/8.jpg",
+ "title": "Best Man Holiday, The",
+ },
+ {
+ "description": "Lilla Jönssonligan och Cornflakeskuppen is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "610185ba-76ac-4b2a-80bf-bd361a8bd500",
+ "network": "Vimbo",
+ "rating": 7.8,
+ "sport_type": "swimming",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/9.jpg",
+ "title": "Lilla Jönssonligan och Cornflakeskuppen",
+ },
+ {
+ "description": "Dragonwyck is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "203f7115-c974-414d-ae4b-c1e3ca22dcb3",
+ "network": "Yakidoo",
+ "rating": 3.3,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "title": "Dragonwyck",
+ },
+ {
+ "description": "Ultimate Accessory,The (100% cachemire) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "462a558a-db32-410a-9058-6cbebe034af9",
+ "network": "Dabtype",
+ "rating": 3.2,
+ "sport_type": "hockey",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/11.jpg",
+ "title": "Ultimate Accessory,The (100% cachemire)",
+ },
+ {
+ "description": "Best Years of Our Lives, The wrestling experience of a lifetime and more to come is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "d3d504c1-234a-40f3-bf43-3bb6b4a733c7",
+ "network": "Demimbu",
+ "rating": 0.1,
+ "sport_type": "wrestling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "title": "Best Years of Our Lives, The wrestling experience of a lifetime and more to come",
+ },
+ {
+ "description": "Hit Man is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "8b71bddf-ec6c-457a-9739-df1db64c2e13",
+ "network": "Trilia",
+ "rating": 5.1,
+ "sport_type": "tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/13.jpg",
+ "title": "Hit Man",
+ },
+ {
+ "description": "21-87 is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "cb5c4125-76d8-467c-b29b-547d8d50f385",
+ "network": "Zooveo",
+ "rating": 3.8,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "title": "21-87",
+ },
+ {
+ "description": "Herr Lehmann is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "fe34b5e1-cfd2-447f-86d9-e30047d57fd1",
+ "network": "Topicshots",
+ "rating": 9.4,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/15.jpg",
+ "title": "Herr Lehmann",
+ },
+ {
+ "description": "Fruitvale Station is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "22d2ac50-2fc9-4bcc-ab28-98712ecc9b73",
+ "network": "Skimia",
+ "rating": 8,
+ "sport_type": "cricket",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "title": "Fruitvale Station",
+ },
+ {
+ "description": "L: Change the World is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "8c700362-8853-427c-b15d-f77e0aed7046",
+ "network": "Yadel",
+ "rating": 1,
+ "sport_type": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/17.jpg",
+ "title": "L: Change the World",
+ },
+ {
+ "description": "Blue Chips is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "85da08a3-42e0-4a49-afbf-f73dbc950306",
+ "network": "Trudeo",
+ "rating": 1.5,
+ "sport_type": "golf",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/18.jpg",
+ "title": "Blue Chips",
+ },
+ {
+ "description": "Hell Is Sold Out is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "6cb24e00-8d40-413b-a174-444753a0a0ac",
+ "network": "Yakitri",
+ "rating": 4.5,
+ "sport_type": "skiing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/19.jpg",
+ "title": "Hell Is Sold Out",
+ },
+ {
+ "description": "Mary of Scotland is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "20628492-2281-471d-810d-c0919f84c5fa",
+ "network": "Miboo",
+ "rating": 5,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/20.jpg",
+ "title": "Bonobo, The",
+ },
+ {
+ "description": "Reckless Moment, The is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "2036f260-1823-46dd-bd77-c2ebad65dc35",
+ "network": "Gabcube",
+ "rating": 3.4,
+ "sport_type": "soccer",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/21.jpg",
+ "title": "Mary of Scotland",
+ },
+ {
+ "description": "Cotton Comes to Harlem is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "a402d466-813d-4201-930f-3da10207bc0e",
+ "network": "Brainlounge",
+ "rating": 7.7,
+ "sport_type": "badminton",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/22.jpg",
+ "title": "Reckless Moment, The",
+ },
+ {
+ "description": "Turtles Can Fly (Lakposhtha hâm parvaz mikonand) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "ed6d3b3d-6489-459d-8c1e-40b64e49aac2",
+ "network": "Buzzshare",
+ "rating": 8.4,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/23.jpg",
+ "title": "Cotton Comes to Harlem",
+ },
+ {
+ "description": "Doc Savage: The Man of Bronze is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "650cbdce-db08-458e-9cca-1161c9473cb8",
+ "network": "Rhycero",
+ "rating": 5,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/24.jpg",
+ "title": "Turtles Can Fly (Lakposhtha hâm parvaz mikonand)",
+ },
+ {
+ "description": "Beginners is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "d592b40c-c393-4483-804d-985e24483bcc",
+ "network": "Wikido",
+ "rating": 7.2,
+ "sport_type": "cricket",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/25.jpg",
+ "title": "Doc Savage: The Man of Bronze",
+ },
+ {
+ "description": "The Last of the Mohicans is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "17080c05-bc1d-435b-896b-bce5b50127da",
+ "network": "Kwideo",
+ "rating": 6.2,
+ "sport_type": "tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/26.jpg",
+ "title": "Beginners",
+ },
+ {
+ "description": "Overnighters, The running experience of a lifetime and more to come is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "fbb0c569-5778-4a56-9f96-7c11ea7d3ee2",
+ "network": "Pixope",
+ "rating": 4,
+ "sport_type": "running",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/27.jpg",
+ "title": "Overnighters, The running experience of a lifetime and more to come",
+ },
+ {
+ "description": "Revenge of the Nerds II: Nerds in Paradise is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "e6f2d844-2770-4d94-a657-560edb6ad38c",
+ "network": "Realblab",
+ "rating": 5.2,
+ "sport_type": "cycling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "title": "Honest Man: The Life of R. Budd Dwyer",
+ },
+ {
+ "description": "Vesku from Finland (Vesku) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "f818fd4f-eb90-496e-8e10-b094cd1cf94b",
+ "network": "Riffpath",
+ "rating": 9.7,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "title": "Revenge of the Nerds II: Nerds in Paradise",
+ },
+ {
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "1cfbd089-9dbb-4b10-884c-ab0f4e823dce",
+ "network": "Skyba",
+ "rating": 6.4,
+ "sport_type": "wrestling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "title": "Vesku from Finland (Vesku)",
+ },
+ {
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "405c1699-57aa-4982-8aff-9f120369dc14",
+ "network": "JumpXS",
+ "rating": 8.3,
+ "sport_type": "snowboarding",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "title": "Nightmare in Las Cruces, A",
+ },
+]
+`;
+
+exports[`fetchSuggestedForYou useSuggestedForYou should return suggestedForYou data and no error on successful fetch 1`] = `
+[
+ {
+ "description": "American Vagabond is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "e9a06a8f-9d40-41b9-a8b4-38bbc67159a2",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ },
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sport_type": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/1.jpg",
+ "title": "American Vagabond",
+ },
+ {
+ "description": "Dark Horse is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "2eae733d-748a-49dc-9558-56984b4bffda",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "d9f25760-7263-426a-b4d7-a25e14a26f03",
+ },
+ "network": "Devcast",
+ "rating": 0.2,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/2.jpg",
+ "title": "Dark Horse (Voksne mennesker)",
+ },
+ {
+ "description": "Big, Large and Verdone is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "d8942af8-899b-4938-9060-328562d9e186",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "449e40c9-1b3a-4a33-931f-5172b5abaf39",
+ },
+ "network": "Ntags",
+ "rating": 8.3,
+ "sport_type": "hockey",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/3.jpg",
+ "title": "Big, Large and Verdone",
+ },
+ {
+ "description": "Crossroads is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "9bebda7d-bd2f-4660-9217-19b986e2fdd0",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": "f2d507fe-682c-476a-9a91-56923d9e5a49",
+ },
+ "network": "Abata",
+ "rating": 2.8,
+ "sport_type": "cycling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/4.jpg",
+ "title": "Crossroads",
+ },
+ {
+ "description": "Onechanbara - Zombie Bikini Squad (a.k.a. Oneechanbara: The Movie) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "7c61e824-e4e5-41a3-a0bf-7fcc16b3a297",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": "fdd82ada-af3f-4fe8-a90b-254877082e97",
+ },
+ "network": "Kaymbo",
+ "rating": 1.3,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/5.jpg",
+ "title": "Onechanbara - Zombie Bikini Squad",
+ },
+ {
+ "description": "Marathon Star is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "empty-air-date",
+ "linkedContent": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370",
+ },
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sport_type": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/6.jpg",
+ "title": "Marathon Star",
+ },
+ {
+ "description": "Snowball Effect: The Story of 'Clerks' is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "02c0c212-14bd-49c9-b20e-f2e0a723b785",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": "be00b94a-e12a-48c4-9615-23c3478f1bd0",
+ },
+ "network": "Eayo",
+ "rating": 4.9,
+ "sport_type": "table tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/7.jpg",
+ "title": "Snowball Effect: The Story of 'Clerks'",
+ },
+ {
+ "description": "Best Man Holiday, The is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "2f51a147-2640-4293-990a-1b29ee6f03c8",
+ "network": "Agimba",
+ "rating": 0.1,
+ "sport_type": "table tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/8.jpg",
+ "title": "Best Man Holiday, The",
+ },
+ {
+ "description": "Lilla Jönssonligan och Cornflakeskuppen is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "610185ba-76ac-4b2a-80bf-bd361a8bd500",
+ "network": "Vimbo",
+ "rating": 7.8,
+ "sport_type": "swimming",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/9.jpg",
+ "title": "Lilla Jönssonligan och Cornflakeskuppen",
+ },
+ {
+ "description": "Dragonwyck is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "203f7115-c974-414d-ae4b-c1e3ca22dcb3",
+ "network": "Yakidoo",
+ "rating": 3.3,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "title": "Dragonwyck",
+ },
+ {
+ "description": "Ultimate Accessory,The (100% cachemire) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "462a558a-db32-410a-9058-6cbebe034af9",
+ "network": "Dabtype",
+ "rating": 3.2,
+ "sport_type": "hockey",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/11.jpg",
+ "title": "Ultimate Accessory,The (100% cachemire)",
+ },
+ {
+ "description": "Best Years of Our Lives, The wrestling experience of a lifetime and more to come is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "d3d504c1-234a-40f3-bf43-3bb6b4a733c7",
+ "network": "Demimbu",
+ "rating": 0.1,
+ "sport_type": "wrestling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "title": "Best Years of Our Lives, The wrestling experience of a lifetime and more to come",
+ },
+ {
+ "description": "Hit Man is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "8b71bddf-ec6c-457a-9739-df1db64c2e13",
+ "network": "Trilia",
+ "rating": 5.1,
+ "sport_type": "tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/13.jpg",
+ "title": "Hit Man",
+ },
+ {
+ "description": "21-87 is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "cb5c4125-76d8-467c-b29b-547d8d50f385",
+ "network": "Zooveo",
+ "rating": 3.8,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "title": "21-87",
+ },
+ {
+ "description": "Herr Lehmann is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "fe34b5e1-cfd2-447f-86d9-e30047d57fd1",
+ "network": "Topicshots",
+ "rating": 9.4,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/15.jpg",
+ "title": "Herr Lehmann",
+ },
+ {
+ "description": "Fruitvale Station is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "22d2ac50-2fc9-4bcc-ab28-98712ecc9b73",
+ "network": "Skimia",
+ "rating": 8,
+ "sport_type": "cricket",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "title": "Fruitvale Station",
+ },
+ {
+ "description": "L: Change the World is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "8c700362-8853-427c-b15d-f77e0aed7046",
+ "network": "Yadel",
+ "rating": 1,
+ "sport_type": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/17.jpg",
+ "title": "L: Change the World",
+ },
+ {
+ "description": "Blue Chips is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "85da08a3-42e0-4a49-afbf-f73dbc950306",
+ "network": "Trudeo",
+ "rating": 1.5,
+ "sport_type": "golf",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/18.jpg",
+ "title": "Blue Chips",
+ },
+ {
+ "description": "Hell Is Sold Out is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "6cb24e00-8d40-413b-a174-444753a0a0ac",
+ "network": "Yakitri",
+ "rating": 4.5,
+ "sport_type": "skiing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/19.jpg",
+ "title": "Hell Is Sold Out",
+ },
+ {
+ "description": "Mary of Scotland is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "20628492-2281-471d-810d-c0919f84c5fa",
+ "network": "Miboo",
+ "rating": 5,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/20.jpg",
+ "title": "Bonobo, The",
+ },
+ {
+ "description": "Reckless Moment, The is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "2036f260-1823-46dd-bd77-c2ebad65dc35",
+ "network": "Gabcube",
+ "rating": 3.4,
+ "sport_type": "soccer",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/21.jpg",
+ "title": "Mary of Scotland",
+ },
+ {
+ "description": "Cotton Comes to Harlem is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "a402d466-813d-4201-930f-3da10207bc0e",
+ "network": "Brainlounge",
+ "rating": 7.7,
+ "sport_type": "badminton",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/22.jpg",
+ "title": "Reckless Moment, The",
+ },
+ {
+ "description": "Turtles Can Fly (Lakposhtha hâm parvaz mikonand) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "ed6d3b3d-6489-459d-8c1e-40b64e49aac2",
+ "network": "Buzzshare",
+ "rating": 8.4,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/23.jpg",
+ "title": "Cotton Comes to Harlem",
+ },
+ {
+ "description": "Doc Savage: The Man of Bronze is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "650cbdce-db08-458e-9cca-1161c9473cb8",
+ "network": "Rhycero",
+ "rating": 5,
+ "sport_type": "boxing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/24.jpg",
+ "title": "Turtles Can Fly (Lakposhtha hâm parvaz mikonand)",
+ },
+ {
+ "description": "Beginners is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "d592b40c-c393-4483-804d-985e24483bcc",
+ "network": "Wikido",
+ "rating": 7.2,
+ "sport_type": "cricket",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/25.jpg",
+ "title": "Doc Savage: The Man of Bronze",
+ },
+ {
+ "description": "The Last of the Mohicans is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Reality",
+ "itemId": "17080c05-bc1d-435b-896b-bce5b50127da",
+ "network": "Kwideo",
+ "rating": 6.2,
+ "sport_type": "tennis",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/26.jpg",
+ "title": "Beginners",
+ },
+ {
+ "description": "Overnighters, The running experience of a lifetime and more to come is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "fbb0c569-5778-4a56-9f96-7c11ea7d3ee2",
+ "network": "Pixope",
+ "rating": 4,
+ "sport_type": "running",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/27.jpg",
+ "title": "Overnighters, The running experience of a lifetime and more to come",
+ },
+ {
+ "description": "Revenge of the Nerds II: Nerds in Paradise is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "e6f2d844-2770-4d94-a657-560edb6ad38c",
+ "network": "Realblab",
+ "rating": 5.2,
+ "sport_type": "cycling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "title": "Honest Man: The Life of R. Budd Dwyer",
+ },
+ {
+ "description": "Vesku from Finland (Vesku) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "f818fd4f-eb90-496e-8e10-b094cd1cf94b",
+ "network": "Riffpath",
+ "rating": 9.7,
+ "sport_type": "surfing",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "title": "Revenge of the Nerds II: Nerds in Paradise",
+ },
+ {
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "1cfbd089-9dbb-4b10-884c-ab0f4e823dce",
+ "network": "Skyba",
+ "rating": 6.4,
+ "sport_type": "wrestling",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "title": "Vesku from Finland (Vesku)",
+ },
+ {
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Sports",
+ "itemId": "405c1699-57aa-4982-8aff-9f120369dc14",
+ "network": "JumpXS",
+ "rating": 8.3,
+ "sport_type": "snowboarding",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "title": "Nightmare in Las Cruces, A",
+ },
+]
+`;
diff --git a/src/api/suggestedForYou/__tests__/__snapshots__/fetchSuggestedForYouDetails.test.ts.snap b/src/api/suggestedForYou/__tests__/__snapshots__/fetchSuggestedForYouDetails.test.ts.snap
new file mode 100644
index 0000000..9582bbd
--- /dev/null
+++ b/src/api/suggestedForYou/__tests__/__snapshots__/fetchSuggestedForYouDetails.test.ts.snap
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchSuggestedForYouDetailsApiCall fetchSuggestedForYouDetailsApiCall should return parsed airDate 1`] = `
+{
+ "airDate": undefined,
+ "description": "Marathon Star is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "empty-air-date",
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sportType": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/6.jpg",
+ "title": "Marathon Star",
+ "videoSource": {
+ "format": "DASH",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/",
+ "title": "DASH",
+ "type": "dash",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/cosmos-laundromat/manifest.mpd",
+ },
+}
+`;
+
+exports[`fetchSuggestedForYouDetailsApiCall fetchSuggestedForYouDetailsApiCall should return parsed suggested for you data on success 1`] = `
+{
+ "airDate": 2020-11-14T04:52:43.000Z,
+ "description": "American Vagabond is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "e9a06a8f-9d40-41b9-a8b4-38bbc67159a2",
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sportType": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/1.jpg",
+ "title": "American Vagabond",
+ "videoSource": {
+ "format": "HLS",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/",
+ "title": "HLS",
+ "type": "hls",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ },
+}
+`;
+
+exports[`fetchSuggestedForYouDetailsApiCall useSuggestedForYouDetails should return suggestedForYou data and no error on successful fetch 1`] = `
+{
+ "airDate": 2020-11-14T04:52:43.000Z,
+ "description": "American Vagabond is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "genre": "Documentary",
+ "itemId": "e9a06a8f-9d40-41b9-a8b4-38bbc67159a2",
+ "network": "Bluejam",
+ "rating": 2.7,
+ "sportType": "volleyball",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/1.jpg",
+ "title": "American Vagabond",
+ "videoSource": {
+ "format": "HLS",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/",
+ "title": "HLS",
+ "type": "hls",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ },
+}
+`;
diff --git a/src/api/suggestedForYou/__tests__/fetchSuggestedForYou.test.ts b/src/api/suggestedForYou/__tests__/fetchSuggestedForYou.test.ts
new file mode 100644
index 0000000..f96bea0
--- /dev/null
+++ b/src/api/suggestedForYou/__tests__/fetchSuggestedForYou.test.ts
@@ -0,0 +1,122 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import {
+ useSuggestedForYou,
+ fetchSuggestedForYouApiCall,
+} from '../fetchSuggestedForYou';
+import staticData from '../staticData/suggestedForYou.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchSuggestedForYou', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchSuggestedForYouApiCall', () => {
+ it('should return parsed suggested for you data on success', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.suggestedforyou }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchSuggestedForYouApiCall();
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should throw error for 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 400 });
+
+ await expect(fetchSuggestedForYouApiCall()).rejects.toThrow(
+ `fetchSuggestedForYouApiCall(): resources does not exists for endpoint 'suggestedforyou'`,
+ );
+ });
+
+ it('should throw error for unexpected Api status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(fetchSuggestedForYouApiCall()).rejects.toThrow(
+ `Perform a request failed`,
+ );
+ });
+
+ it('should throw error for other non-200 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
+
+ await expect(fetchSuggestedForYouApiCall()).rejects.toThrow(
+ `fetchSuggestedForYouApiCall(): failed to fetch data from endpoint 'suggestedforyou'`,
+ );
+ });
+ });
+
+ describe('useSuggestedForYou', () => {
+ it('should return suggestedForYou data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.suggestedforyou }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchSuggestedForYouApiCall(),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useSuggestedForYou());
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() => useSuggestedForYou());
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBe(null);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should return error when fetch fails', () => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useSuggestedForYou());
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/suggestedForYou/__tests__/fetchSuggestedForYouDetails.test.ts b/src/api/suggestedForYou/__tests__/fetchSuggestedForYouDetails.test.ts
new file mode 100644
index 0000000..0bfc2b7
--- /dev/null
+++ b/src/api/suggestedForYou/__tests__/fetchSuggestedForYouDetails.test.ts
@@ -0,0 +1,193 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import {
+ fetchSuggestedForYouDetailsApiCall,
+ useSuggestedForYouDetails,
+} from '../fetchSuggestedForYouDetails';
+import staticData from '../staticData/suggestedForYou.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockRQ = useQuery as jest.Mock;
+
+describe('fetchSuggestedForYouDetailsApiCall', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchSuggestedForYouDetailsApiCall', () => {
+ it('should return parsed suggested for you data on success', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.suggestedforyou }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchSuggestedForYouDetailsApiCall(
+ 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ );
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should return parsed airDate', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.suggestedforyou }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchSuggestedForYouDetailsApiCall('empty-air-date');
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should return undefined for unexisting id', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.suggestedforyou }),
+ {
+ status: 200,
+ },
+ );
+
+ const result = await fetchSuggestedForYouDetailsApiCall('unexisting-id');
+
+ expect(result).toBeUndefined();
+ });
+
+ test.each([{ itemId: '' }, { itemId: null }, { itemId: undefined }])(
+ 'should return undefined if suggested for you details data has nullable id ($itemId)',
+ async ({ itemId }) => {
+ await expect(
+ // @ts-expect-error intentionally break TS
+ fetchSuggestedForYouDetailsApiCall(itemId),
+ ).rejects.toThrow(
+ 'fetchSuggestedForYouDetailsApiCall() was used with invalid item id',
+ );
+ },
+ );
+
+ it('should throw error for 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({ suggestedforyou: {} }), {
+ status: 400,
+ });
+
+ await expect(
+ fetchSuggestedForYouDetailsApiCall(
+ 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ ),
+ ).rejects.toThrow(
+ `fetchSuggestedForYouDetailsApiCall(): resources does not exists for endpoint 'suggestedforyou'`,
+ );
+ });
+
+ it('should throw error for unexpected Api status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(
+ fetchSuggestedForYouDetailsApiCall(
+ 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ ),
+ ).rejects.toThrow(`Perform a request failed`);
+ });
+
+ it('should throw error for other non-200 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
+
+ await expect(
+ fetchSuggestedForYouDetailsApiCall(
+ 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ ),
+ ).rejects.toThrow(
+ `fetchSuggestedForYouDetailsApiCall(): failed to fetch data from endpoint 'suggestedforyou'`,
+ );
+ });
+ });
+
+ describe('useSuggestedForYouDetails', () => {
+ it('should return suggestedForYou data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(
+ JSON.stringify({ data: staticData.suggestedforyou[0] }),
+ {
+ status: 200,
+ },
+ );
+
+ mockRQ.mockReturnValueOnce({
+ data: await fetchSuggestedForYouDetailsApiCall(
+ 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ ),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useSuggestedForYouDetails({
+ suggestedForYouContentId: 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ }),
+ );
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() =>
+ useSuggestedForYouDetails({
+ suggestedForYouContentId: 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ }),
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBe(null);
+ expect(result.current.error).toBe(null);
+ });
+
+ test.each([
+ 'e9a06a8f-9d40-41b9-a8b4-38bbc67159a2',
+ '23232',
+ 'some string',
+ undefined,
+ null,
+ ])('should return error (case: %s)', (inputValue) => {
+ const mockError = new Error('Network error');
+
+ mockRQ.mockReturnValueOnce({
+ data: null,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ // @ts-expect-error intentionally break TS
+ useSuggestedForYouDetails({ suggestedForYouContentId: inputValue }),
+ );
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/suggestedForYou/dtos/SuggestedForYouDetails.ts b/src/api/suggestedForYou/dtos/SuggestedForYouDetails.ts
new file mode 100644
index 0000000..4562fb8
--- /dev/null
+++ b/src/api/suggestedForYou/dtos/SuggestedForYouDetails.ts
@@ -0,0 +1,37 @@
+import type { SuggestedForYouContentDetails } from '@AppModels/suggestedForYou/SuggestedForYouDetails';
+import type { VideoSource } from '@AppServices/videoPlayer';
+import { parseISODate } from '@AppUtils/date';
+
+export type SuggestedForYouDetailsDto = {
+ id?: Maybe;
+ show_name?: string;
+ thumbnail?: string;
+ video_source?: VideoSource;
+ network?: string;
+ air_date?: string;
+ genre?: string;
+ rating?: number;
+ sport_type?: string;
+ description?: string;
+};
+
+export const parseSuggestedForYouDetailsDto = (
+ dto: SuggestedForYouDetailsDto,
+): SuggestedForYouContentDetails | undefined => {
+ if (!dto?.id) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ title: dto.show_name,
+ sportType: dto.sport_type,
+ thumbnail: dto.thumbnail,
+ network: dto.network,
+ airDate: dto.air_date ? parseISODate(dto.air_date) : undefined,
+ videoSource: dto.video_source,
+ genre: dto.genre,
+ rating: dto.rating,
+ description: dto.description,
+ };
+};
diff --git a/src/api/suggestedForYou/dtos/SuggestedForYouDto.ts b/src/api/suggestedForYou/dtos/SuggestedForYouDto.ts
new file mode 100644
index 0000000..be347d0
--- /dev/null
+++ b/src/api/suggestedForYou/dtos/SuggestedForYouDto.ts
@@ -0,0 +1,68 @@
+import type { SuggestedForYou } from '@AppModels/suggestedForYou/SuggestedForYou';
+import { parseEndpoint } from '../../dtoUtils/dtoAppUtils';
+import { parseDtoArray, parseString } from '../../dtoUtils/dtoCommonUtils';
+
+export type SuggestedForYouDto = {
+ id?: Maybe;
+ show_name?: string;
+ thumbnail?: string;
+ linked_content?: {
+ endpoint: string;
+ itemId: string;
+ };
+ rating?: number;
+ network?: string;
+ sport_type?: string;
+ genre?: string;
+ description?: string;
+};
+
+export const parseSuggestedForYouDto = (
+ dto: SuggestedForYouDto,
+): SuggestedForYou | undefined => {
+ if (!dto.id) {
+ return;
+ }
+
+ const linkedContent = parseLinkedContent(dto.linked_content);
+
+ const parsedSuggestedForYou: SuggestedForYou = {
+ itemId: dto.id,
+ title: dto.show_name,
+ thumbnail: dto.thumbnail,
+ rating: dto.rating,
+ network: dto.network,
+ sport_type: dto.sport_type,
+ genre: dto.genre,
+ description: dto.description,
+ };
+
+ if (linkedContent) {
+ parsedSuggestedForYou.linkedContent = linkedContent;
+ }
+
+ return parsedSuggestedForYou;
+};
+
+export const parseSuggestedForYouDtoArray = (
+ dtos: SuggestedForYouDto[],
+): SuggestedForYou[] => {
+ return parseDtoArray(parseSuggestedForYouDto, dtos);
+};
+
+const parseLinkedContent = (
+ linkedContent: SuggestedForYouDto['linked_content'],
+) => {
+ if (!linkedContent) {
+ return;
+ }
+
+ const endpoint = parseEndpoint(linkedContent.endpoint);
+ const itemId = linkedContent.itemId
+ ? parseString(linkedContent.itemId)
+ : undefined;
+
+ if (endpoint && itemId) {
+ return { endpoint, itemId };
+ }
+};
diff --git a/src/api/suggestedForYou/fetchSuggestedForYou.ts b/src/api/suggestedForYou/fetchSuggestedForYou.ts
new file mode 100644
index 0000000..326a99e
--- /dev/null
+++ b/src/api/suggestedForYou/fetchSuggestedForYou.ts
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { SuggestedForYouDto } from './dtos/SuggestedForYouDto';
+import { parseSuggestedForYouDtoArray } from './dtos/SuggestedForYouDto';
+import staticData from './staticData/suggestedForYou.json';
+
+type ResponseDto = SuggestedForYouDto[];
+
+const endpoint = Endpoints.SuggestedForYou;
+
+export const fetchSuggestedForYouApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchSuggestedForYouApiCall(): resources does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchSuggestedForYouApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseSuggestedForYouDtoArray(response.data);
+};
+
+export const useSuggestedForYou = () => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchSuggestedForYouApiCall,
+ });
+
+ return query;
+};
diff --git a/src/api/suggestedForYou/fetchSuggestedForYouDetails.ts b/src/api/suggestedForYou/fetchSuggestedForYouDetails.ts
new file mode 100644
index 0000000..99069b3
--- /dev/null
+++ b/src/api/suggestedForYou/fetchSuggestedForYouDetails.ts
@@ -0,0 +1,63 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { StaticDataStructure } from '@AppServices/apiClient/staticDataClient/types';
+import type { SuggestedForYouDetailsDto } from './dtos/SuggestedForYouDetails';
+import { parseSuggestedForYouDetailsDto } from './dtos/SuggestedForYouDetails';
+import staticData from './staticData/suggestedForYou.json';
+
+type ResponseDto = SuggestedForYouDetailsDto;
+
+const endpoint = Endpoints.SuggestedForYou;
+
+export const fetchSuggestedForYouDetailsApiCall = async (
+ suggestedForYouContentId: string,
+) => {
+ if (!suggestedForYouContentId) {
+ throw new Error(
+ `fetchSuggestedForYouDetailsApiCall() was used with invalid item id`,
+ );
+ }
+
+ const response = await ApiClient.get(
+ endpoint,
+ {
+ id: suggestedForYouContentId,
+ staticData: staticData as StaticDataStructure,
+ },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchSuggestedForYouDetailsApiCall(): resources does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchSuggestedForYouDetailsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseSuggestedForYouDetailsDto(response.data);
+};
+
+export const useSuggestedForYouDetails = ({
+ suggestedForYouContentId,
+}: {
+ suggestedForYouContentId: string;
+}) => {
+ const query = useQuery({
+ queryKey: [endpoint, suggestedForYouContentId],
+ queryFn: () => fetchSuggestedForYouDetailsApiCall(suggestedForYouContentId),
+ });
+
+ return query;
+};
diff --git a/src/api/suggestedForYou/index.ts b/src/api/suggestedForYou/index.ts
new file mode 100644
index 0000000..372ca76
--- /dev/null
+++ b/src/api/suggestedForYou/index.ts
@@ -0,0 +1,8 @@
+export {
+ useSuggestedForYou,
+ fetchSuggestedForYouApiCall,
+} from './fetchSuggestedForYou';
+export {
+ useSuggestedForYouDetails,
+ fetchSuggestedForYouDetailsApiCall,
+} from './fetchSuggestedForYouDetails';
diff --git a/src/api/suggestedForYou/staticData/suggestedForYou.json b/src/api/suggestedForYou/staticData/suggestedForYou.json
new file mode 100644
index 0000000..7ac0314
--- /dev/null
+++ b/src/api/suggestedForYou/staticData/suggestedForYou.json
@@ -0,0 +1,665 @@
+{
+ "suggestedforyou": [
+ {
+ "show_name": "American Vagabond",
+ "network": "Bluejam",
+ "air_date": "2020-11-14T04:52:43Z",
+ "genre": "Documentary",
+ "rating": 2.7,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/1.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "sport_type": "volleyball",
+ "id": "e9a06a8f-9d40-41b9-a8b4-38bbc67159a2",
+ "linked_content": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370"
+ },
+ "description": "American Vagabond is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Dark Horse (Voksne mennesker)",
+ "network": "Devcast",
+ "air_date": "11/21/2021",
+ "genre": "Documentary",
+ "rating": 0.2,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/2.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/sintel-the-movie/720p/sintel-the-movie.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "sport_type": "boxing",
+ "id": "2eae733d-748a-49dc-9558-56984b4bffda",
+ "linked_content": {
+ "endpoint": "livestreams",
+ "itemId": "d9f25760-7263-426a-b4d7-a25e14a26f03"
+ },
+ "description": "Dark Horse is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Big, Large and Verdone",
+ "network": "Ntags",
+ "air_date": "8/26/2020",
+ "genre": "Documentary",
+ "rating": 8.3,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/3.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/glass-half/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "sport_type": "hockey",
+ "id": "d8942af8-899b-4938-9060-328562d9e186",
+ "linked_content": {
+ "endpoint": "livestreams",
+ "itemId": "449e40c9-1b3a-4a33-931f-5172b5abaf39"
+ },
+ "description": "Big, Large and Verdone is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Crossroads",
+ "network": "Abata",
+ "air_date": "8/16/2021",
+ "genre": "Reality",
+ "rating": 2.8,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/4.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/elephants-dream/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "sport_type": "cycling",
+ "id": "9bebda7d-bd2f-4660-9217-19b986e2fdd0",
+ "linked_content": {
+ "endpoint": "teams",
+ "itemId": "f2d507fe-682c-476a-9a91-56923d9e5a49"
+ },
+ "description": "Crossroads is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Onechanbara - Zombie Bikini Squad",
+ "network": "Kaymbo",
+ "air_date": "6/19/2020",
+ "genre": "Reality",
+ "rating": 1.3,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/5.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/tears-of-steel/720p/tears-of-steel.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "sport_type": "boxing",
+ "id": "7c61e824-e4e5-41a3-a0bf-7fcc16b3a297",
+ "linked_content": {
+ "endpoint": "teams",
+ "itemId": "fdd82ada-af3f-4fe8-a90b-254877082e97"
+ },
+ "description": "Onechanbara - Zombie Bikini Squad (a.k.a. Oneechanbara: The Movie) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Marathon Star",
+ "network": "Bluejam",
+ "air_date": "",
+ "genre": "Documentary",
+ "rating": 2.7,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/6.jpg",
+ "sport_type": "volleyball",
+ "id": "empty-air-date",
+ "linked_content": {
+ "endpoint": "livestreams",
+ "itemId": "50128bae-e954-4233-8e15-cd5867a31370"
+ },
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/cosmos-laundromat/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "description": "Marathon Star is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Snowball Effect: The Story of 'Clerks'",
+ "network": "Eayo",
+ "air_date": "9/4/2021",
+ "genre": "Documentary",
+ "rating": 4.9,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/7.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/sintel-the-movie/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "sport_type": "table tennis",
+ "id": "02c0c212-14bd-49c9-b20e-f2e0a723b785",
+ "linked_content": {
+ "endpoint": "teams",
+ "itemId": "be00b94a-e12a-48c4-9615-23c3478f1bd0"
+ },
+ "description": "Snowball Effect: The Story of 'Clerks' is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Best Man Holiday, The",
+ "network": "Agimba",
+ "air_date": "4/25/2020",
+ "genre": "Reality",
+ "rating": 0.1,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/8.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/glass-half/720p/glass-half.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "sport_type": "table tennis",
+ "id": "2f51a147-2640-4293-990a-1b29ee6f03c8",
+ "linkedContent": {
+ "endpoint": "teams"
+ },
+ "description": "Best Man Holiday, The is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Lilla Jönssonligan och Cornflakeskuppen",
+ "network": "Vimbo",
+ "air_date": "3/25/2021",
+ "genre": "Documentary",
+ "rating": 7.8,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/9.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/elephants-dream/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "sport_type": "swimming",
+ "id": "610185ba-76ac-4b2a-80bf-bd361a8bd500",
+ "linkedContent": {
+ "endpoint": "teams",
+ "itemId": ""
+ },
+ "description": "Lilla Jönssonligan och Cornflakeskuppen is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Dragonwyck",
+ "network": "Yakidoo",
+ "air_date": "4/21/2020",
+ "genre": "Reality",
+ "rating": 3.3,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/tears-of-steel/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "sport_type": "surfing",
+ "id": "203f7115-c974-414d-ae4b-c1e3ca22dcb3",
+ "description": "Dragonwyck is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Ultimate Accessory,The (100% cachemire)",
+ "network": "Dabtype",
+ "air_date": "1/29/2020",
+ "genre": "Reality",
+ "rating": 3.2,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/11.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/cosmos-laundromat/720p/cosmos-laundromat.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "sport_type": "hockey",
+ "id": "462a558a-db32-410a-9058-6cbebe034af9",
+ "description": "Ultimate Accessory,The (100% cachemire) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Best Years of Our Lives, The wrestling experience of a lifetime and more to come",
+ "network": "Demimbu",
+ "air_date": "4/28/2021",
+ "genre": "Documentary",
+ "rating": 0.1,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/sintel-the-movie/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "sport_type": "wrestling",
+ "id": "d3d504c1-234a-40f3-bf43-3bb6b4a733c7",
+ "description": "Best Years of Our Lives, The wrestling experience of a lifetime and more to come is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Hit Man",
+ "network": "Trilia",
+ "air_date": "10/18/2021",
+ "genre": "Reality",
+ "rating": 5.1,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/13.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/glass-half/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "sport_type": "tennis",
+ "id": "8b71bddf-ec6c-457a-9739-df1db64c2e13",
+ "description": "Hit Man is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "21-87",
+ "network": "Zooveo",
+ "air_date": "6/7/2021",
+ "genre": "Sports",
+ "rating": 3.8,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/elephants-dream/720p/elephants-dream.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "sport_type": "surfing",
+ "id": "cb5c4125-76d8-467c-b29b-547d8d50f385",
+ "description": "21-87 is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Herr Lehmann",
+ "network": "Topicshots",
+ "air_date": "3/13/2020",
+ "genre": "Sports",
+ "rating": 9.4,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/15.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/tears-of-steel/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "sport_type": "surfing",
+ "id": "fe34b5e1-cfd2-447f-86d9-e30047d57fd1",
+ "description": "Herr Lehmann is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Fruitvale Station",
+ "network": "Skimia",
+ "air_date": "9/30/2021",
+ "genre": "Sports",
+ "rating": 8,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "sport_type": "cricket",
+ "id": "22d2ac50-2fc9-4bcc-ab28-98712ecc9b73",
+ "description": "Fruitvale Station is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "L: Change the World",
+ "network": "Yadel",
+ "air_date": "10/31/2020",
+ "genre": "Reality",
+ "rating": 1,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/17.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/sintel-the-movie/720p/sintel-the-movie.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "sport_type": "volleyball",
+ "id": "8c700362-8853-427c-b15d-f77e0aed7046",
+ "description": "L: Change the World is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Blue Chips",
+ "network": "Trudeo",
+ "air_date": "11/9/2021",
+ "genre": "Reality",
+ "rating": 1.5,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/18.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/glass-half/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "sport_type": "golf",
+ "id": "85da08a3-42e0-4a49-afbf-f73dbc950306",
+ "description": "Blue Chips is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Hell Is Sold Out",
+ "network": "Yakitri",
+ "air_date": "5/17/2021",
+ "genre": "Documentary",
+ "rating": 4.5,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/19.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/elephants-dream/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "sport_type": "skiing",
+ "id": "6cb24e00-8d40-413b-a174-444753a0a0ac",
+ "description": "Hell Is Sold Out is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Bonobo, The",
+ "network": "Miboo",
+ "air_date": "12/26/2021",
+ "genre": "Sports",
+ "rating": 5,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/20.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/tears-of-steel/720p/tears-of-steel.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "sport_type": "boxing",
+ "id": "20628492-2281-471d-810d-c0919f84c5fa",
+ "description": "Mary of Scotland is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Mary of Scotland",
+ "network": "Gabcube",
+ "air_date": "7/24/2021",
+ "genre": "Documentary",
+ "rating": 3.4,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/21.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/cosmos-laundromat/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "sport_type": "soccer",
+ "id": "2036f260-1823-46dd-bd77-c2ebad65dc35",
+ "description": "Reckless Moment, The is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Reckless Moment, The",
+ "network": "Brainlounge",
+ "air_date": "4/13/2021",
+ "genre": "Documentary",
+ "rating": 7.7,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/22.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/sintel-the-movie/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "sport_type": "badminton",
+ "id": "a402d466-813d-4201-930f-3da10207bc0e",
+ "description": "Cotton Comes to Harlem is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Cotton Comes to Harlem",
+ "network": "Buzzshare",
+ "air_date": "2/28/2020",
+ "genre": "Reality",
+ "rating": 8.4,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/23.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/glass-half/720p/glass-half.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "sport_type": "boxing",
+ "id": "ed6d3b3d-6489-459d-8c1e-40b64e49aac2",
+ "description": "Turtles Can Fly (Lakposhtha hâm parvaz mikonand) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Turtles Can Fly (Lakposhtha hâm parvaz mikonand)",
+ "network": "Rhycero",
+ "air_date": "8/18/2020",
+ "genre": "Documentary",
+ "rating": 5,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/24.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/elephants-dream/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "sport_type": "boxing",
+ "id": "650cbdce-db08-458e-9cca-1161c9473cb8",
+ "description": "Doc Savage: The Man of Bronze is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Doc Savage: The Man of Bronze",
+ "network": "Wikido",
+ "air_date": "2/17/2021",
+ "genre": "Sports",
+ "rating": 7.2,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/25.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/tears-of-steel/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "sport_type": "cricket",
+ "id": "d592b40c-c393-4483-804d-985e24483bcc",
+ "description": "Beginners is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Beginners",
+ "network": "Kwideo",
+ "air_date": "8/14/2020",
+ "genre": "Reality",
+ "rating": 6.2,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/26.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/cosmos-laundromat/720p/cosmos-laundromat.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "sport_type": "tennis",
+ "id": "17080c05-bc1d-435b-896b-bce5b50127da",
+ "description": "The Last of the Mohicans is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Overnighters, The running experience of a lifetime and more to come",
+ "network": "Pixope",
+ "air_date": "6/4/2021",
+ "genre": "Documentary",
+ "rating": 4,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/27.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/sintel-the-movie/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ },
+ "sport_type": "running",
+ "id": "fbb0c569-5778-4a56-9f96-7c11ea7d3ee2",
+ "description": "Overnighters, The running experience of a lifetime and more to come is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Honest Man: The Life of R. Budd Dwyer",
+ "network": "Realblab",
+ "air_date": "5/12/2021",
+ "genre": "Documentary",
+ "rating": 5.2,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/glass-half/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ },
+ "sport_type": "cycling",
+ "id": "e6f2d844-2770-4d94-a657-560edb6ad38c",
+ "description": "Revenge of the Nerds II: Nerds in Paradise is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Revenge of the Nerds II: Nerds in Paradise",
+ "network": "Riffpath",
+ "air_date": "8/12/2020",
+ "genre": "Documentary",
+ "rating": 9.7,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/elephants-dream/720p/elephants-dream.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/elephants-dream/"
+ },
+ "sport_type": "surfing",
+ "id": "f818fd4f-eb90-496e-8e10-b094cd1cf94b",
+ "description": "Vesku from Finland (Vesku) is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Vesku from Finland (Vesku)",
+ "network": "Skyba",
+ "air_date": "8/24/2020",
+ "genre": "Sports",
+ "rating": 6.4,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "stream_duration": 734.00,
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/tears-of-steel/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/tears-of-steel/"
+ },
+ "sport_type": "wrestling",
+ "id": "1cfbd089-9dbb-4b10-884c-ab0f4e823dce",
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Nightmare in Las Cruces, A",
+ "network": "JumpXS",
+ "air_date": "5/24/2021",
+ "genre": "Sports",
+ "rating": 8.3,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "stream_duration": 634.584,
+ "video_source": {
+ "title": "HLS",
+ "type": "hls",
+ "format": "HLS",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/hls/cosmos-laundromat/master.m3u8",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/cosmos-laundromat/"
+ },
+ "sport_type": "snowboarding",
+ "id": "405c1699-57aa-4982-8aff-9f120369dc14",
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle."
+ },
+ {
+ "show_name": "Nightmare in Las Cruces, A",
+ "network": "JumpXS",
+ "air_date": "5/24/2021",
+ "genre": "Sports",
+ "rating": 8.3,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/18.jpg",
+ "stream_url": "http://phoca.cz/ut/massa/volutpat/convallis/morbi/odio.xml",
+ "sport_type": "snowboarding",
+ "id": null,
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "video_source": {
+ "title": "MP4",
+ "type": "mp4",
+ "format": "MP4",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/mp4/sintel-the-movie/720p/sintel-the-movie.mp4",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/sintel-the-movie/"
+ }
+ },
+ {
+ "show_name": "Nightmare in Las Cruces, A",
+ "network": "JumpXS",
+ "air_date": "5/24/2021",
+ "genre": "Sports",
+ "rating": 8.3,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/20.jpg",
+ "stream_url": "http://phoca.cz/ut/massa/volutpat/convallis/morbi/odio.xml",
+ "sport_type": "snowboarding",
+ "id": null,
+ "description": "Nightmare in Las Cruces, A is a documentary about the life of a man who travels the world on a motorcycle. It is a story of adventure, freedom, and the pursuit of dreams. It is a story of a man who travels the world on a motorcycle.",
+ "video_source": {
+ "title": "DASH",
+ "type": "dash",
+ "format": "DASH",
+ "uri": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/dash/glass-half/manifest.mpd",
+ "thumbnailUrl": "https://d1v0fxmwkpxbrg.cloudfront.net/videos/trickplays/glass-half/"
+ }
+ }
+ ]
+}
diff --git a/src/api/teams/__tests__/__snapshots__/fetchTeams.test.ts.snap b/src/api/teams/__tests__/__snapshots__/fetchTeams.test.ts.snap
new file mode 100644
index 0000000..a7eced8
--- /dev/null
+++ b/src/api/teams/__tests__/__snapshots__/fetchTeams.test.ts.snap
@@ -0,0 +1,507 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`fetchTeams fetchTeamsApiCall should return parsed team data on success 1`] = `
+[
+ {
+ "favorite": true,
+ "itemId": "be00b94a-e12a-48c4-9615-23c3478f1bd0",
+ "teamLogo": "https://robohash.org/iureinipsam.png?size=50x50&set=set1",
+ "teamName": "Silver Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/6.jpg",
+ "title": "Silver Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "fdd82ada-af3f-4fe8-a90b-254877082e97",
+ "teamLogo": "https://robohash.org/odiotemporeaccusamus.png?size=50x50&set=set1",
+ "teamName": "Sunset Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/8.jpg",
+ "title": "Sunset Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "635e3d02-f128-460d-a630-628fec86ef2e",
+ "teamLogo": "https://robohash.org/omnisadsequi.png?size=50x50&set=set1",
+ "teamName": "Liberty Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/10.jpg",
+ "title": "Liberty Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "f6dd98e2-d7d2-48bb-ab4d-251a0c21255a",
+ "teamLogo": "https://robohash.org/molestiaevoluptasdeserunt.png?size=50x50&set=set1",
+ "teamName": "Mountainview Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/12.jpg",
+ "title": "Mountainview Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "50d39881-48af-488a-ac97-2c137105a392",
+ "teamLogo": "https://robohash.org/animiquosquas.png?size=50x50&set=set1",
+ "teamName": "Spaceship Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "title": "Spaceship Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "50068e33-2f0a-4679-9a52-29bfee839818",
+ "teamLogo": "https://robohash.org/maximemaioreslaborum.png?size=50x50&set=set1",
+ "teamName": "Koala Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "title": "Koala Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "5ce1c7ce-b6eb-4ce4-b182-ee493d4bf8ab",
+ "teamLogo": "https://robohash.org/exercitationemdoloresdistinctio.png?size=50x50&set=set1",
+ "teamName": "Pineapple Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/16.jpg",
+ "title": "Pineapple Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "6a408906-1db0-41ec-b445-472dd5be2035",
+ "teamLogo": "https://robohash.org/etreiciendisperspiciatis.png?size=50x50&set=set1",
+ "teamName": "Rock Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/18.jpg",
+ "title": "Rock Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "aee2a69c-67e3-4e0a-81d1-9b3435185786",
+ "teamLogo": "https://robohash.org/enimquiet.png?size=50x50&set=set1",
+ "teamName": "Summer Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/20.jpg",
+ "title": "Summer Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "633354ab-e6eb-40fa-a464-a04bc2e36b3a",
+ "teamLogo": "https://robohash.org/corruptiminimaex.png?size=50x50&set=set1",
+ "teamName": "Moonwalk Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/22.jpg",
+ "title": "Moonwalk Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "f2d507fe-682c-476a-9a91-56923d9e5a49",
+ "teamLogo": "https://robohash.org/laboriosamdoloremdignissimos.png?size=50x50&set=set1",
+ "teamName": "Emerald Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/24.jpg",
+ "title": "Emerald Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "3572b444-7d87-4b41-8e96-19a48f33aa82",
+ "teamLogo": "https://robohash.org/exreprehenderitet.png?size=50x50&set=set1",
+ "teamName": "Golden Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/26.jpg",
+ "title": "Golden Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "2c6f886f-de55-4ee8-b32d-57434df1076a",
+ "teamLogo": "https://robohash.org/voluptatemetnesciunt.png?size=50x50&set=set1",
+ "teamName": "Oceanfront Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/28.jpg",
+ "title": "Oceanfront Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "f52aebc4-3813-49c0-982d-ecf5192e2210",
+ "teamLogo": "https://robohash.org/quiatotamesse.png?size=50x50&set=set1",
+ "teamName": "Oakland Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg",
+ "title": "Oakland Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "64bfdbfc-2e78-4dfc-b291-5666495e5cf6",
+ "teamLogo": "https://robohash.org/remautemdolor.png?size=50x50&set=set1",
+ "teamName": "Kapibara Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg",
+ "title": "Kapibara Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "164129cc-7d04-4aa3-807f-ace25f9cf56c",
+ "teamLogo": "https://robohash.org/evenieterrorquaerat.png?size=50x50&set=set1",
+ "teamName": "Caterpillar Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/34.jpg",
+ "title": "Caterpillar Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "f5c4bed7-deb6-4144-b1d7-8c4b8732a192",
+ "teamLogo": "https://robohash.org/reprehenderitrerumtempore.png?size=50x50&set=set1",
+ "teamName": "Rain Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/36.jpg",
+ "title": "Rain Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "77b996e5-3c89-46bb-b1b2-40952e1362e7",
+ "teamLogo": "https://robohash.org/dolordoloremlibero.png?size=50x50&set=set1",
+ "teamName": "Zebra Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/38.jpg",
+ "title": "Zebra Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "d1b38724-b542-4b40-91ab-642b1d355365",
+ "teamLogo": "https://robohash.org/noniuredoloribus.png?size=50x50&set=set1",
+ "teamName": "Fireworks Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/40.jpg",
+ "title": "Fireworks Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "1c12c40c-6f1a-4805-8d46-d2f4ad237502",
+ "teamLogo": "https://robohash.org/recusandaedolorumratione.png?size=50x50&set=set1",
+ "teamName": "Air Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/42.jpg",
+ "title": "Air Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "30a83041-daf5-4fcb-8a8b-c28a5f193d8f",
+ "teamLogo": "https://robohash.org/voluptatemquiest.png?size=50x50&set=set1",
+ "teamName": "Turquoise Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/44.jpg",
+ "title": "Turquoise Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "b65a218c-0f3f-499c-ae4c-a3e6cbed201c",
+ "teamLogo": "https://robohash.org/temporamolestiasesse.png?size=50x50&set=set1",
+ "teamName": "Mark Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/46.jpg",
+ "title": "Mark Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "8b77b926-a229-4322-8b38-38a9f210eda7",
+ "teamLogo": "https://robohash.org/rerumaliquamaut.png?size=50x50&set=set1",
+ "teamName": "Butterfly Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/48.jpg",
+ "title": "Butterfly Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "a23b5086-33ed-4ad4-9ff0-a8a5f7650fcb",
+ "teamLogo": "https://robohash.org/facilisetomnis.png?size=50x50&set=set1",
+ "teamName": "Lion Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/50.jpg",
+ "title": "Lion Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "6439a585-54be-4362-ac97-ca43d1654026",
+ "teamLogo": "https://robohash.org/etvoluptasquia.png?size=50x50&set=set1",
+ "teamName": "Cat Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/52.jpg",
+ "title": "Cat Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "ba06d75f-ae4e-4d1d-9ffe-7664a6f1412d",
+ "teamLogo": "https://robohash.org/quoculpaid.png?size=50x50&set=set1",
+ "teamName": "Comet Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/54.jpg",
+ "title": "Comet Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "00a31331-b356-4f94-aab5-b0e8a4312740",
+ "teamLogo": "https://robohash.org/sitaspernatureius.png?size=50x50&set=set1",
+ "teamName": "Sailor Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/1.jpg",
+ "title": "Sailor Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "475a0ae4-b7bc-4a4e-83af-f370c9c6ad45",
+ "teamLogo": "https://robohash.org/providentrerumeaque.png?size=50x50&set=set1",
+ "teamName": "Panda Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/3.jpg",
+ "title": "Panda Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "b49128cc-fdbb-410d-95b0-ffa48ae0c314",
+ "teamLogo": "https://robohash.org/estfacereet.png?size=50x50&set=set1",
+ "teamName": "Riverside Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/5.jpg",
+ "title": "Riverside Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "0b61fb70-65e6-4730-9cf8-36016a8de80f",
+ "teamLogo": "https://robohash.org/occaecatibeataemaxime.png?size=50x50&set=set1",
+ "teamName": "Sunflower Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/7.jpg",
+ "title": "Sunflower Team",
+ },
+ {
+ "favorite": undefined,
+ "itemId": "empty-optional-fields",
+ "teamLogo": "https://robohash.org/iureinipsam.png?size=50x50&set=set1",
+ "teamName": "Commando Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/11.jpg",
+ "title": "Commando Team",
+ },
+]
+`;
+
+exports[`fetchTeams useTeams should return all teams data and no error on successful fetch 1`] = `
+[
+ {
+ "favorite": true,
+ "itemId": "be00b94a-e12a-48c4-9615-23c3478f1bd0",
+ "teamLogo": "https://robohash.org/iureinipsam.png?size=50x50&set=set1",
+ "teamName": "Silver Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/6.jpg",
+ "title": "Silver Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "fdd82ada-af3f-4fe8-a90b-254877082e97",
+ "teamLogo": "https://robohash.org/odiotemporeaccusamus.png?size=50x50&set=set1",
+ "teamName": "Sunset Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/8.jpg",
+ "title": "Sunset Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "635e3d02-f128-460d-a630-628fec86ef2e",
+ "teamLogo": "https://robohash.org/omnisadsequi.png?size=50x50&set=set1",
+ "teamName": "Liberty Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/10.jpg",
+ "title": "Liberty Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "f6dd98e2-d7d2-48bb-ab4d-251a0c21255a",
+ "teamLogo": "https://robohash.org/molestiaevoluptasdeserunt.png?size=50x50&set=set1",
+ "teamName": "Mountainview Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/12.jpg",
+ "title": "Mountainview Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "50d39881-48af-488a-ac97-2c137105a392",
+ "teamLogo": "https://robohash.org/animiquosquas.png?size=50x50&set=set1",
+ "teamName": "Spaceship Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "title": "Spaceship Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "50068e33-2f0a-4679-9a52-29bfee839818",
+ "teamLogo": "https://robohash.org/maximemaioreslaborum.png?size=50x50&set=set1",
+ "teamName": "Koala Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "title": "Koala Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "5ce1c7ce-b6eb-4ce4-b182-ee493d4bf8ab",
+ "teamLogo": "https://robohash.org/exercitationemdoloresdistinctio.png?size=50x50&set=set1",
+ "teamName": "Pineapple Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/16.jpg",
+ "title": "Pineapple Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "6a408906-1db0-41ec-b445-472dd5be2035",
+ "teamLogo": "https://robohash.org/etreiciendisperspiciatis.png?size=50x50&set=set1",
+ "teamName": "Rock Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/18.jpg",
+ "title": "Rock Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "aee2a69c-67e3-4e0a-81d1-9b3435185786",
+ "teamLogo": "https://robohash.org/enimquiet.png?size=50x50&set=set1",
+ "teamName": "Summer Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/20.jpg",
+ "title": "Summer Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "633354ab-e6eb-40fa-a464-a04bc2e36b3a",
+ "teamLogo": "https://robohash.org/corruptiminimaex.png?size=50x50&set=set1",
+ "teamName": "Moonwalk Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/22.jpg",
+ "title": "Moonwalk Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "f2d507fe-682c-476a-9a91-56923d9e5a49",
+ "teamLogo": "https://robohash.org/laboriosamdoloremdignissimos.png?size=50x50&set=set1",
+ "teamName": "Emerald Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/24.jpg",
+ "title": "Emerald Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "3572b444-7d87-4b41-8e96-19a48f33aa82",
+ "teamLogo": "https://robohash.org/exreprehenderitet.png?size=50x50&set=set1",
+ "teamName": "Golden Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/26.jpg",
+ "title": "Golden Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "2c6f886f-de55-4ee8-b32d-57434df1076a",
+ "teamLogo": "https://robohash.org/voluptatemetnesciunt.png?size=50x50&set=set1",
+ "teamName": "Oceanfront Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/28.jpg",
+ "title": "Oceanfront Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "f52aebc4-3813-49c0-982d-ecf5192e2210",
+ "teamLogo": "https://robohash.org/quiatotamesse.png?size=50x50&set=set1",
+ "teamName": "Oakland Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg",
+ "title": "Oakland Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "64bfdbfc-2e78-4dfc-b291-5666495e5cf6",
+ "teamLogo": "https://robohash.org/remautemdolor.png?size=50x50&set=set1",
+ "teamName": "Kapibara Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg",
+ "title": "Kapibara Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "164129cc-7d04-4aa3-807f-ace25f9cf56c",
+ "teamLogo": "https://robohash.org/evenieterrorquaerat.png?size=50x50&set=set1",
+ "teamName": "Caterpillar Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/34.jpg",
+ "title": "Caterpillar Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "f5c4bed7-deb6-4144-b1d7-8c4b8732a192",
+ "teamLogo": "https://robohash.org/reprehenderitrerumtempore.png?size=50x50&set=set1",
+ "teamName": "Rain Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/36.jpg",
+ "title": "Rain Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "77b996e5-3c89-46bb-b1b2-40952e1362e7",
+ "teamLogo": "https://robohash.org/dolordoloremlibero.png?size=50x50&set=set1",
+ "teamName": "Zebra Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/38.jpg",
+ "title": "Zebra Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "d1b38724-b542-4b40-91ab-642b1d355365",
+ "teamLogo": "https://robohash.org/noniuredoloribus.png?size=50x50&set=set1",
+ "teamName": "Fireworks Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/40.jpg",
+ "title": "Fireworks Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "1c12c40c-6f1a-4805-8d46-d2f4ad237502",
+ "teamLogo": "https://robohash.org/recusandaedolorumratione.png?size=50x50&set=set1",
+ "teamName": "Air Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/42.jpg",
+ "title": "Air Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "30a83041-daf5-4fcb-8a8b-c28a5f193d8f",
+ "teamLogo": "https://robohash.org/voluptatemquiest.png?size=50x50&set=set1",
+ "teamName": "Turquoise Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/44.jpg",
+ "title": "Turquoise Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "b65a218c-0f3f-499c-ae4c-a3e6cbed201c",
+ "teamLogo": "https://robohash.org/temporamolestiasesse.png?size=50x50&set=set1",
+ "teamName": "Mark Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/46.jpg",
+ "title": "Mark Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "8b77b926-a229-4322-8b38-38a9f210eda7",
+ "teamLogo": "https://robohash.org/rerumaliquamaut.png?size=50x50&set=set1",
+ "teamName": "Butterfly Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/48.jpg",
+ "title": "Butterfly Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "a23b5086-33ed-4ad4-9ff0-a8a5f7650fcb",
+ "teamLogo": "https://robohash.org/facilisetomnis.png?size=50x50&set=set1",
+ "teamName": "Lion Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/50.jpg",
+ "title": "Lion Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "6439a585-54be-4362-ac97-ca43d1654026",
+ "teamLogo": "https://robohash.org/etvoluptasquia.png?size=50x50&set=set1",
+ "teamName": "Cat Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/52.jpg",
+ "title": "Cat Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "ba06d75f-ae4e-4d1d-9ffe-7664a6f1412d",
+ "teamLogo": "https://robohash.org/quoculpaid.png?size=50x50&set=set1",
+ "teamName": "Comet Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/54.jpg",
+ "title": "Comet Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "00a31331-b356-4f94-aab5-b0e8a4312740",
+ "teamLogo": "https://robohash.org/sitaspernatureius.png?size=50x50&set=set1",
+ "teamName": "Sailor Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/1.jpg",
+ "title": "Sailor Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "475a0ae4-b7bc-4a4e-83af-f370c9c6ad45",
+ "teamLogo": "https://robohash.org/providentrerumeaque.png?size=50x50&set=set1",
+ "teamName": "Panda Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/3.jpg",
+ "title": "Panda Team",
+ },
+ {
+ "favorite": true,
+ "itemId": "b49128cc-fdbb-410d-95b0-ffa48ae0c314",
+ "teamLogo": "https://robohash.org/estfacereet.png?size=50x50&set=set1",
+ "teamName": "Riverside Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/5.jpg",
+ "title": "Riverside Team",
+ },
+ {
+ "favorite": false,
+ "itemId": "0b61fb70-65e6-4730-9cf8-36016a8de80f",
+ "teamLogo": "https://robohash.org/occaecatibeataemaxime.png?size=50x50&set=set1",
+ "teamName": "Sunflower Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/7.jpg",
+ "title": "Sunflower Team",
+ },
+ {
+ "favorite": undefined,
+ "itemId": "empty-optional-fields",
+ "teamLogo": "https://robohash.org/iureinipsam.png?size=50x50&set=set1",
+ "teamName": "Commando Team",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/11.jpg",
+ "title": "Commando Team",
+ },
+]
+`;
diff --git a/src/api/teams/__tests__/fetchTeams.test.ts b/src/api/teams/__tests__/fetchTeams.test.ts
new file mode 100644
index 0000000..9bbf71c
--- /dev/null
+++ b/src/api/teams/__tests__/fetchTeams.test.ts
@@ -0,0 +1,113 @@
+import { useQuery } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { AppConfig } from '@AppServices/appConfig';
+import { useTeams, fetchTeamsApiCall } from '../fetchTeams';
+import staticData from '../staticData/teams.json';
+
+jest.mock('@tanstack/react-query');
+
+const mockSWR = useQuery as jest.Mock;
+
+describe('fetchTeams', () => {
+ beforeAll(() => {
+ require('jest-fetch-mock').enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ });
+
+ describe('fetchTeamsApiCall', () => {
+ it('should return parsed team data on success', async () => {
+ fetchMock.mockResponseOnce(JSON.stringify({ data: staticData.teams }), {
+ status: 200,
+ });
+
+ const result = await fetchTeamsApiCall();
+
+ expect(result).toMatchSnapshot();
+ });
+
+ it('should throw error for 400 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 400 });
+
+ await expect(fetchTeamsApiCall()).rejects.toThrow(
+ `fetchTeamsApiCall(): resources does not exists for endpoint 'teams'`,
+ );
+ });
+
+ it('should throw error for unexpected Api status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ // @ts-expect-error intentionally break TS
+ fetchMock.mockResponseOnce(null);
+
+ await expect(fetchTeamsApiCall()).rejects.toThrow(
+ `Perform a request failed`,
+ );
+ });
+
+ it('should throw error for other non-200 status', async () => {
+ jest.spyOn(AppConfig, 'isUsingStaticData').mockReturnValueOnce(false);
+
+ fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
+
+ await expect(fetchTeamsApiCall()).rejects.toThrow(
+ `fetchTeamsApiCall(): failed to fetch data from endpoint 'teams'`,
+ );
+ });
+ });
+
+ describe('useTeams', () => {
+ it('should return all teams data and no error on successful fetch', async () => {
+ fetchMock.mockResponseOnce(JSON.stringify({ data: staticData.teams }), {
+ status: 200,
+ });
+
+ mockSWR.mockReturnValueOnce({
+ data: await fetchTeamsApiCall(),
+ error: null,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useTeams());
+
+ expect(result.current.data).toMatchSnapshot();
+ expect(result.current.error).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should return isLoading as true before data loads', () => {
+ mockSWR.mockReturnValueOnce({
+ data: null,
+ error: null,
+ isLoading: true,
+ });
+
+ const { result } = renderHook(() => useTeams());
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toBe(null);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should return error when fetch fails', () => {
+ const mockError = new Error('Network error');
+
+ mockSWR.mockReturnValueOnce({
+ data: null,
+ error: mockError,
+ isLoading: false,
+ });
+
+ const { result } = renderHook(() => useTeams());
+
+ expect(result.current.error).toBe(mockError);
+ expect(result.current.data).toBe(null);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+});
diff --git a/src/api/teams/dtos/TeamDetailsDto.ts b/src/api/teams/dtos/TeamDetailsDto.ts
new file mode 100644
index 0000000..0d79007
--- /dev/null
+++ b/src/api/teams/dtos/TeamDetailsDto.ts
@@ -0,0 +1,49 @@
+import type { TeamDetails } from '@AppModels/teams/TeamDetails';
+import { parseISODate } from '@AppUtils/date';
+
+export type TeamDetailsDto = {
+ id?: Maybe;
+ team_name?: string;
+ sport_type?: string;
+ coach_name?: string;
+ team_color?: string;
+ home_stadium?: string;
+ season_start_date?: string;
+ season_end_date?: string;
+ team_logo?: string;
+ thumbnail?: string;
+ favorite?: boolean;
+ imageCover?: string | undefined;
+};
+
+export const parseTeamDetailsDto = (
+ dto: TeamDetailsDto,
+): TeamDetails | undefined => {
+ if (!dto?.id) {
+ return;
+ }
+
+ const seasonStartDate = dto.season_start_date
+ ? parseISODate(dto.season_start_date)
+ : undefined;
+
+ const seasonEndDate = dto.season_end_date
+ ? parseISODate(dto.season_end_date)
+ : undefined;
+
+ return {
+ itemId: dto.id,
+ title: dto.team_name,
+ teamName: dto.team_name,
+ sportType: dto.sport_type,
+ coachName: dto.coach_name,
+ teamColor: dto.team_color,
+ seasonStartDate,
+ seasonEndDate,
+ homeStadium: dto.home_stadium,
+ teamLogo: dto.team_logo,
+ thumbnail: dto.thumbnail,
+ favorite: dto.favorite || false,
+ imageCover: dto.imageCover || undefined,
+ };
+};
diff --git a/src/api/teams/dtos/TeamsDto.ts b/src/api/teams/dtos/TeamsDto.ts
new file mode 100644
index 0000000..c8921ac
--- /dev/null
+++ b/src/api/teams/dtos/TeamsDto.ts
@@ -0,0 +1,48 @@
+import type { Teams } from '@AppModels/teams/Teams';
+import { parseDtoArray } from '../../dtoUtils/dtoCommonUtils';
+
+export type TeamsDto = {
+ id?: Maybe;
+ team_name?: string;
+ team_logo?: string;
+ favorite?: boolean;
+ thumbnail?: string;
+};
+
+export const parseTeamsDto = (dto: TeamsDto): Teams | undefined => {
+ if (!dto.id) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ title: dto.team_name,
+ teamName: dto.team_name,
+ teamLogo: dto.team_logo,
+ favorite: dto.favorite,
+ thumbnail: dto.thumbnail,
+ };
+};
+
+export const parseTeamsDtoArray = (dtos: TeamsDto[]): Teams[] => {
+ return parseDtoArray(parseTeamsDto, dtos);
+};
+
+export const parseFavoriteTeamsDto = (dto: TeamsDto): Teams | undefined => {
+ if (!dto.id || !dto.favorite) {
+ return;
+ }
+
+ return {
+ itemId: dto.id,
+ title: dto.team_name,
+ teamName: dto.team_name,
+ teamLogo: dto.team_logo,
+ favorite: dto.favorite,
+ thumbnail: dto.thumbnail,
+ };
+};
+
+export const parseFavoriteTeamsDtoArray = (dtos: TeamsDto[]): Teams[] => {
+ return parseDtoArray(parseFavoriteTeamsDto, dtos);
+};
diff --git a/src/api/teams/fetchTeamDetails.ts b/src/api/teams/fetchTeamDetails.ts
new file mode 100644
index 0000000..a8ba84f
--- /dev/null
+++ b/src/api/teams/fetchTeamDetails.ts
@@ -0,0 +1,54 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { TeamDetailsDto } from './dtos/TeamDetailsDto';
+import { parseTeamDetailsDto } from './dtos/TeamDetailsDto';
+import staticData from './staticData/teams.json';
+
+type ResponseDto = TeamDetailsDto;
+
+const endpoint = Endpoints.Teams;
+
+export const fetchTeamDetailsApiCall = async (teamId: string) => {
+ if (!teamId) {
+ throw new Error(`fetchTeamDetailsApiCall() was used with invalid item id`);
+ }
+
+ const response = await ApiClient.get(
+ endpoint,
+ {
+ id: teamId,
+ staticData,
+ },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchTeamDetailsApiCall(): resources does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchTeamDetailsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseTeamDetailsDto(response.data);
+};
+
+export const useTeamDetails = ({ teamId }: { teamId: string }) => {
+ const query = useQuery({
+ queryKey: [endpoint, teamId],
+ queryFn: () => fetchTeamDetailsApiCall(teamId),
+ });
+
+ return query;
+};
diff --git a/src/api/teams/fetchTeams.ts b/src/api/teams/fetchTeams.ts
new file mode 100644
index 0000000..a413dbd
--- /dev/null
+++ b/src/api/teams/fetchTeams.ts
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ ApiClient,
+ isSuccessResponse,
+ Endpoints,
+} from '@AppServices/apiClient';
+import type { TeamsDto } from './dtos/TeamsDto';
+import { parseTeamsDtoArray } from './dtos/TeamsDto';
+import staticData from './staticData/teams.json';
+
+type ResponseDto = TeamsDto[];
+
+const endpoint = Endpoints.Teams;
+
+export const fetchTeamsApiCall = async () => {
+ const response = await ApiClient.get(
+ endpoint,
+ { staticData },
+ { isAuthorized: true },
+ );
+
+ if (!isSuccessResponse(response)) {
+ switch (response.status) {
+ case 400:
+ throw new Error(
+ `fetchTeamsApiCall(): resources does not exists for endpoint '${endpoint}'`,
+ );
+
+ default:
+ throw new Error(
+ `fetchTeamsApiCall(): failed to fetch data from endpoint '${endpoint}'`,
+ );
+ }
+ }
+
+ return parseTeamsDtoArray(response.data);
+};
+
+export const useTeams = () => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ queryFn: fetchTeamsApiCall,
+ });
+
+ return query;
+};
diff --git a/src/api/teams/index.ts b/src/api/teams/index.ts
new file mode 100644
index 0000000..eefd2e6
--- /dev/null
+++ b/src/api/teams/index.ts
@@ -0,0 +1,2 @@
+export { useTeams, fetchTeamsApiCall } from './fetchTeams';
+export { fetchTeamDetailsApiCall, useTeamDetails } from './fetchTeamDetails';
diff --git a/src/api/teams/staticData/teams.json b/src/api/teams/staticData/teams.json
new file mode 100644
index 0000000..30ff5da
--- /dev/null
+++ b/src/api/teams/staticData/teams.json
@@ -0,0 +1,452 @@
+{
+ "teams": [
+ {
+ "team_name": "Silver Team",
+ "sport_type": "judo",
+ "coach_name": "Konstance Lummasana",
+ "team_color": "Purple",
+ "home_stadium": "Silver City Stadium",
+ "season_start_date": "2023-07-10T04:52:43Z",
+ "season_end_date": "2023-09-17T04:52:43Z",
+ "team_logo": "https://robohash.org/iureinipsam.png?size=50x50&set=set1",
+ "id": "be00b94a-e12a-48c4-9615-23c3478f1bd0",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/6.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/6.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Sunset Team",
+ "sport_type": "football",
+ "coach_name": "Perla Shenton",
+ "team_color": "Blue",
+ "home_stadium": "Sunset Stadium",
+ "season_start_date": "1/7/2022",
+ "season_end_date": "7/6/2024",
+ "team_logo": "https://robohash.org/odiotemporeaccusamus.png?size=50x50&set=set1",
+ "id": "fdd82ada-af3f-4fe8-a90b-254877082e97",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/8.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/8.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Liberty Team",
+ "sport_type": "archery",
+ "coach_name": "Willi Dukelow",
+ "team_color": "Purple",
+ "home_stadium": "Liberty Stadium",
+ "season_start_date": "1/2/2022",
+ "season_end_date": "9/11/2023",
+ "team_logo": "https://robohash.org/omnisadsequi.png?size=50x50&set=set1",
+ "id": "635e3d02-f128-460d-a630-628fec86ef2e",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/10.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/10.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Mountainview Team",
+ "sport_type": "sailing",
+ "coach_name": "Amity Eaddy",
+ "team_color": "Green",
+ "home_stadium": "Mountainview Field",
+ "season_start_date": "1/2/2022",
+ "season_end_date": "6/11/2023",
+ "team_logo": "https://robohash.org/molestiaevoluptasdeserunt.png?size=50x50&set=set1",
+ "id": "f6dd98e2-d7d2-48bb-ab4d-251a0c21255a",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/12.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/12.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Spaceship Team",
+ "sport_type": "rowing",
+ "coach_name": "Mattie Lyvon",
+ "team_color": "Orange",
+ "home_stadium": "Silver City Stadium",
+ "season_start_date": "1/5/2022",
+ "season_end_date": "6/12/2024",
+ "team_logo": "https://robohash.org/animiquosquas.png?size=50x50&set=set1",
+ "id": "50d39881-48af-488a-ac97-2c137105a392",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Koala Team",
+ "sport_type": "table tennis",
+ "coach_name": "Laverna Muncer",
+ "team_color": "Violet",
+ "home_stadium": "Oceanfront Stadium",
+ "season_start_date": "1/10/2022",
+ "season_end_date": "12/27/2023",
+ "team_logo": "https://robohash.org/maximemaioreslaborum.png?size=50x50&set=set1",
+ "id": "50068e33-2f0a-4679-9a52-29bfee839818",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/14.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/14.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Pineapple Team",
+ "sport_type": "skateboarding",
+ "coach_name": "Marja Fevers",
+ "team_color": "Teal",
+ "home_stadium": "Oakland Arena",
+ "season_start_date": "1/8/2022",
+ "season_end_date": "10/26/2023",
+ "team_logo": "https://robohash.org/exercitationemdoloresdistinctio.png?size=50x50&set=set1",
+ "id": "5ce1c7ce-b6eb-4ce4-b182-ee493d4bf8ab",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/16.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/16.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Rock Team",
+ "sport_type": "ultimate frisbee",
+ "coach_name": "Carol Momford",
+ "team_color": "Puce",
+ "home_stadium": "Liberty Stadium",
+ "season_start_date": "1/7/2022",
+ "season_end_date": "11/19/2023",
+ "team_logo": "https://robohash.org/etreiciendisperspiciatis.png?size=50x50&set=set1",
+ "id": "6a408906-1db0-41ec-b445-472dd5be2035",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/18.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/18.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Summer Team",
+ "sport_type": "parkour",
+ "coach_name": "Dorothee Killich",
+ "team_color": "Yellow",
+ "home_stadium": "Golden Gate Field",
+ "season_start_date": "1/4/2022",
+ "season_end_date": "7/14/2024",
+ "team_logo": "https://robohash.org/enimquiet.png?size=50x50&set=set1",
+ "id": "aee2a69c-67e3-4e0a-81d1-9b3435185786",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/20.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/20.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Moonwalk Team",
+ "sport_type": "water polo",
+ "coach_name": "Stace Charman",
+ "team_color": "Orange",
+ "home_stadium": "Golden Gate Field",
+ "season_start_date": "1/9/2022",
+ "season_end_date": "1/7/2024",
+ "team_logo": "https://robohash.org/corruptiminimaex.png?size=50x50&set=set1",
+ "id": "633354ab-e6eb-40fa-a464-a04bc2e36b3a",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/22.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/22.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Emerald Team",
+ "sport_type": "climbing",
+ "coach_name": "Everett Besse",
+ "team_color": "Puce",
+ "home_stadium": "Emerald Park",
+ "season_start_date": "1/5/2022",
+ "season_end_date": "9/26/2024",
+ "team_logo": "https://robohash.org/laboriosamdoloremdignissimos.png?size=50x50&set=set1",
+ "id": "f2d507fe-682c-476a-9a91-56923d9e5a49",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/24.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/24.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Golden Team",
+ "sport_type": "karate",
+ "coach_name": "Chariot Preddle",
+ "team_color": "Red",
+ "home_stadium": "Silver City Stadium",
+ "season_start_date": "1/6/2022",
+ "season_end_date": "10/27/2023",
+ "team_logo": "https://robohash.org/exreprehenderitet.png?size=50x50&set=set1",
+ "id": "3572b444-7d87-4b41-8e96-19a48f33aa82",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/26.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/26.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Oceanfront Team",
+ "sport_type": "basketball",
+ "coach_name": "Giralda Izkovitz",
+ "team_color": "Violet",
+ "home_stadium": "Oceanfront Stadium",
+ "season_start_date": "1/5/2022",
+ "season_end_date": "1/6/2024",
+ "team_logo": "https://robohash.org/voluptatemetnesciunt.png?size=50x50&set=set1",
+ "id": "2c6f886f-de55-4ee8-b32d-57434df1076a",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/28.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/28.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Oakland Team",
+ "sport_type": "water polo",
+ "coach_name": "Sandro Dionsetto",
+ "team_color": "Violet",
+ "home_stadium": "Oakland Arena",
+ "season_start_date": "1/1/2022",
+ "season_end_date": "2/22/2023",
+ "team_logo": "https://robohash.org/quiatotamesse.png?size=50x50&set=set1",
+ "id": "f52aebc4-3813-49c0-982d-ecf5192e2210",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/30.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/30.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Kapibara Team",
+ "sport_type": "tennis",
+ "coach_name": "Augusta Fowles",
+ "team_color": "Goldenrod",
+ "home_stadium": "Oceanfront Stadium",
+ "season_start_date": "1/7/2022",
+ "season_end_date": "6/6/2023",
+ "team_logo": "https://robohash.org/remautemdolor.png?size=50x50&set=set1",
+ "id": "64bfdbfc-2e78-4dfc-b291-5666495e5cf6",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/32.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/32.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Caterpillar Team",
+ "sport_type": "golf",
+ "coach_name": "Duky Longmire",
+ "team_color": "Fuscia",
+ "home_stadium": "Oceanfront Stadium",
+ "season_start_date": "1/2/2022",
+ "season_end_date": "3/7/2024",
+ "team_logo": "https://robohash.org/evenieterrorquaerat.png?size=50x50&set=set1",
+ "id": "164129cc-7d04-4aa3-807f-ace25f9cf56c",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/34.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/34.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Rain Team",
+ "sport_type": "wrestling",
+ "coach_name": "Netty Wollaston",
+ "team_color": "Mauv",
+ "home_stadium": "Emerald Park",
+ "season_start_date": "1/9/2022",
+ "season_end_date": "4/12/2023",
+ "team_logo": "https://robohash.org/reprehenderitrerumtempore.png?size=50x50&set=set1",
+ "id": "f5c4bed7-deb6-4144-b1d7-8c4b8732a192",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/36.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/36.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Zebra Team",
+ "sport_type": "handball",
+ "coach_name": "Bambi Covill",
+ "team_color": "Yellow",
+ "home_stadium": "Golden Gate Field",
+ "season_start_date": "1/10/2022",
+ "season_end_date": "4/29/2024",
+ "team_logo": "https://robohash.org/dolordoloremlibero.png?size=50x50&set=set1",
+ "id": "77b996e5-3c89-46bb-b1b2-40952e1362e7",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/38.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/38.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Fireworks Team",
+ "sport_type": "curling",
+ "coach_name": "Kelby Longfut",
+ "team_color": "Crimson",
+ "home_stadium": "Oceanfront Stadium",
+ "season_start_date": "1/3/2022",
+ "season_end_date": "10/21/2023",
+ "team_logo": "https://robohash.org/noniuredoloribus.png?size=50x50&set=set1",
+ "id": "d1b38724-b542-4b40-91ab-642b1d355365",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/40.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/40.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Air Team",
+ "sport_type": "rowing",
+ "coach_name": "Danya Sieghart",
+ "team_color": "Aquamarine",
+ "home_stadium": "Emerald Park",
+ "season_start_date": "1/5/2022",
+ "season_end_date": "4/19/2024",
+ "team_logo": "https://robohash.org/recusandaedolorumratione.png?size=50x50&set=set1",
+ "id": "1c12c40c-6f1a-4805-8d46-d2f4ad237502",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/42.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/42.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Turquoise Team",
+ "sport_type": "soccer",
+ "coach_name": "Bentley Fideler",
+ "team_color": "Turquoise",
+ "home_stadium": "Oceanfront Stadium",
+ "season_start_date": "1/11/2022",
+ "season_end_date": "6/3/2023",
+ "team_logo": "https://robohash.org/voluptatemquiest.png?size=50x50&set=set1",
+ "id": "30a83041-daf5-4fcb-8a8b-c28a5f193d8f",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/44.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/44.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Mark Team",
+ "sport_type": "surfing",
+ "coach_name": "Ilysa Brimley",
+ "team_color": "Pink",
+ "home_stadium": "Sunset Stadium",
+ "season_start_date": "1/3/2022",
+ "season_end_date": "8/24/2024",
+ "team_logo": "https://robohash.org/temporamolestiasesse.png?size=50x50&set=set1",
+ "id": "b65a218c-0f3f-499c-ae4c-a3e6cbed201c",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/46.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/46.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Butterfly Team",
+ "sport_type": "baseball",
+ "coach_name": "Adriana Ledes",
+ "team_color": "Red",
+ "home_stadium": "Riverside Arena",
+ "season_start_date": "1/5/2022",
+ "season_end_date": "4/18/2024",
+ "team_logo": "https://robohash.org/rerumaliquamaut.png?size=50x50&set=set1",
+ "id": "8b77b926-a229-4322-8b38-38a9f210eda7",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/48.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/48.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Lion Team",
+ "sport_type": "volleyball",
+ "coach_name": "Barbara Flarity",
+ "team_color": "Blue",
+ "home_stadium": "Riverside Arena",
+ "season_start_date": "1/7/2022",
+ "season_end_date": "10/20/2023",
+ "team_logo": "https://robohash.org/facilisetomnis.png?size=50x50&set=set1",
+ "id": "a23b5086-33ed-4ad4-9ff0-a8a5f7650fcb",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/50.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/50.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Cat Team",
+ "sport_type": "climbing",
+ "coach_name": "Jeremias Saggs",
+ "team_color": "Blue",
+ "home_stadium": "Oakland Arena",
+ "season_start_date": "1/11/2022",
+ "season_end_date": "5/25/2024",
+ "team_logo": "https://robohash.org/etvoluptasquia.png?size=50x50&set=set1",
+ "id": "6439a585-54be-4362-ac97-ca43d1654026",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/52.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/52.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Comet Team",
+ "sport_type": "lacrosse",
+ "coach_name": "Diane-marie Khomich",
+ "team_color": "Maroon",
+ "home_stadium": "Riverside Arena",
+ "season_start_date": "1/1/2022",
+ "season_end_date": "12/29/2023",
+ "team_logo": "https://robohash.org/quoculpaid.png?size=50x50&set=set1",
+ "id": "ba06d75f-ae4e-4d1d-9ffe-7664a6f1412d",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/54.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/54.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Sailor Team",
+ "sport_type": "sailing",
+ "coach_name": "Sunny Pringour",
+ "team_color": "Violet",
+ "home_stadium": "Riverside Arena",
+ "season_start_date": "1/4/2022",
+ "season_end_date": "9/13/2024",
+ "team_logo": "https://robohash.org/sitaspernatureius.png?size=50x50&set=set1",
+ "id": "00a31331-b356-4f94-aab5-b0e8a4312740",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/1.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/1.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Panda Team",
+ "sport_type": "soccer",
+ "coach_name": "Althea Petschelt",
+ "team_color": "Violet",
+ "home_stadium": "Emerald Park",
+ "season_start_date": "1/1/2022",
+ "season_end_date": "5/18/2024",
+ "team_logo": "https://robohash.org/providentrerumeaque.png?size=50x50&set=set1",
+ "id": "475a0ae4-b7bc-4a4e-83af-f370c9c6ad45",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/3.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/3.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Riverside Team",
+ "sport_type": "archery",
+ "coach_name": "Les Eldritt",
+ "team_color": "Mauv",
+ "home_stadium": "Riverside Arena",
+ "season_start_date": "1/11/2022",
+ "season_end_date": "1/13/2023",
+ "team_logo": "https://robohash.org/estfacereet.png?size=50x50&set=set1",
+ "id": "b49128cc-fdbb-410d-95b0-ffa48ae0c314",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/5.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/5.jpg",
+ "favorite": true
+ },
+ {
+ "team_name": "Sunflower Team",
+ "sport_type": "basketball",
+ "coach_name": "Novelia O'Geneay",
+ "team_color": "Turquoise",
+ "home_stadium": "Liberty Stadium",
+ "season_start_date": "1/10/2022",
+ "season_end_date": "12/15/2023",
+ "team_logo": "https://robohash.org/occaecatibeataemaxime.png?size=50x50&set=set1",
+ "id": "0b61fb70-65e6-4730-9cf8-36016a8de80f",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/7.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/7.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Barrel Team",
+ "sport_type": "basketball",
+ "coach_name": "Novelia O'Geneay",
+ "team_color": "Turquoise",
+ "home_stadium": "Liberty Stadium",
+ "season_start_date": "1/10/2022",
+ "season_end_date": "12/15/2023",
+ "team_logo": "https://robohash.org/occaecatibeataemaxime.png?size=50x50&set=set1",
+ "id": null,
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/9.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/9.jpg",
+ "favorite": false
+ },
+ {
+ "team_name": "Commando Team",
+ "sport_type": "judo",
+ "coach_name": "Konstance Lummasana",
+ "team_color": "Purple",
+ "home_stadium": "Silver City Stadium",
+ "season_start_date": "",
+ "season_end_date": "",
+ "team_logo": "https://robohash.org/iureinipsam.png?size=50x50&set=set1",
+ "id": "empty-optional-fields",
+ "thumbnail": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/250x250/11.jpg",
+ "imageCover": "https://d1v0fxmwkpxbrg.cloudfront.net/sports-app/images/900x700/11.jpg",
+ "description": "test"
+ }
+ ]
+}
diff --git a/src/api/useDynamicContent.ts b/src/api/useDynamicContent.ts
new file mode 100644
index 0000000..daacd0e
--- /dev/null
+++ b/src/api/useDynamicContent.ts
@@ -0,0 +1,33 @@
+import { fetchDocumentariesApiCall } from '@Api/documentaries';
+import { fetchLiveStreamsApiCall } from '@Api/liveStreams';
+import { fetchSuggestedForYouApiCall } from '@Api/suggestedForYou';
+import { fetchTeamsApiCall } from '@Api/teams';
+import { useQuery } from '@tanstack/react-query';
+
+import type { ParsedResponseContentData } from '@AppComponents/carousels/types';
+import type { Endpoints } from '@AppServices/apiClient';
+
+const useDynamicContent = ({ endpoint }: { endpoint: Endpoints }) => {
+ const query = useQuery({
+ queryKey: [endpoint],
+ // in ideal scenario we would not have to define return type, but infer it from response after parsing and validating with zod or similar tool
+ queryFn: (): Promise => {
+ switch (endpoint) {
+ case 'teams':
+ return fetchTeamsApiCall();
+ case 'suggestedforyou':
+ return fetchSuggestedForYouApiCall();
+ case 'documentaries':
+ return fetchDocumentariesApiCall();
+ case 'livestreams':
+ return fetchLiveStreamsApiCall();
+ default:
+ throw new Error('incorrect endpoint passed');
+ }
+ },
+ });
+
+ return query;
+};
+
+export { useDynamicContent };
diff --git a/src/api/useDynamicDetailsContent.ts b/src/api/useDynamicDetailsContent.ts
new file mode 100644
index 0000000..150a951
--- /dev/null
+++ b/src/api/useDynamicDetailsContent.ts
@@ -0,0 +1,42 @@
+import { fetchDocumentaryDetailsApiCall } from '@Api/documentaries';
+import { fetchLiveStreamDetailsApiCall } from '@Api/liveStreams';
+import { fetchSuggestedForYouDetailsApiCall } from '@Api/suggestedForYou';
+import { fetchTeamDetailsApiCall } from '@Api/teams';
+import { useQuery } from '@tanstack/react-query';
+
+import type { DetailsContentData } from '@AppModels/detailsLayout/DetailsLayout';
+import type { Endpoints } from '@AppServices/apiClient';
+
+const useDynamicDetailsContent = ({
+ endpoint,
+ itemId,
+}: {
+ endpoint: Endpoints;
+ itemId?: string;
+}) => {
+ const query = useQuery({
+ queryKey: [endpoint, itemId],
+ // in ideal scenario we would not have to define return type, but infer it from response after parsing and validating with zod or similar tool
+ queryFn: itemId
+ ? (): Promise => {
+ switch (endpoint) {
+ case 'teams':
+ return fetchTeamDetailsApiCall(itemId);
+ case 'suggestedforyou':
+ return fetchSuggestedForYouDetailsApiCall(itemId);
+ case 'documentaries':
+ return fetchDocumentaryDetailsApiCall(itemId);
+ case 'livestreams':
+ return fetchLiveStreamDetailsApiCall(itemId);
+ default:
+ throw new Error('incorrect endpoint passed');
+ }
+ }
+ : undefined,
+ enabled: Boolean(itemId),
+ });
+
+ return query;
+};
+
+export { useDynamicDetailsContent };
diff --git a/src/assets/carousels/hero.jpg b/src/assets/carousels/hero.jpg
new file mode 100644
index 0000000..d169fc2
Binary files /dev/null and b/src/assets/carousels/hero.jpg differ
diff --git a/src/assets/fast_forward.png b/src/assets/fast_forward.png
new file mode 100644
index 0000000..4bf650f
Binary files /dev/null and b/src/assets/fast_forward.png differ
diff --git a/src/assets/placeholder.jpg b/src/assets/placeholder.jpg
new file mode 100644
index 0000000..27d96c4
Binary files /dev/null and b/src/assets/placeholder.jpg differ
diff --git a/src/assets/rewind.png b/src/assets/rewind.png
new file mode 100644
index 0000000..f693adf
Binary files /dev/null and b/src/assets/rewind.png differ
diff --git a/src/components/carousels/CardItemCarousel/CardItem.tsx b/src/components/carousels/CardItemCarousel/CardItem.tsx
new file mode 100644
index 0000000..94cb976
--- /dev/null
+++ b/src/components/carousels/CardItemCarousel/CardItem.tsx
@@ -0,0 +1,75 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+import type { ImageStyle } from 'react-native';
+import { View, Image, Text, StyleSheet } from 'react-native';
+
+import LinearGradient from '@amazon-devices/react-linear-gradient';
+
+import { useAppTheme, useThemedStyles } from '@AppTheme';
+import { CarouselFocusWrap } from '../CarouselFocusWrap';
+import type { CarouselItemProps } from '../types';
+import { getCardCarouselItemStyles } from './styles';
+
+export const CardItem = ({
+ carouselTitle,
+ item,
+ navigateToDetails,
+ accessibilityHint,
+ badge,
+}: CarouselItemProps & { badge?: string }) => {
+ const styles = useThemedStyles(getCardCarouselItemStyles);
+ const { colors } = useAppTheme();
+
+ const renderFunction = () => {
+ return (
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {item.title && (
+
+ {item.title}
+
+ )}
+
+
+
+
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/carousels/CardItemCarousel/CardItemsCarousel.tsx b/src/components/carousels/CardItemCarousel/CardItemsCarousel.tsx
new file mode 100644
index 0000000..e865df8
--- /dev/null
+++ b/src/components/carousels/CardItemCarousel/CardItemsCarousel.tsx
@@ -0,0 +1,103 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+
+import { useNavigation } from '@amazon-devices/react-navigation__native';
+
+import { useThemedStyles } from '@AppTheme';
+import { Carousel } from '@AppComponents/core/Carousel';
+import { HintBuilder } from '@AppServices/a11y';
+import { useTranslation } from '@AppServices/i18n';
+import { DIRECTION_PARAMETER } from '@AppServices/i18n/constants';
+import { ROUTES } from '@AppSrc/navigators/constants';
+import type {
+ CarouselContainerProps,
+ ParsedResponseContentData,
+} from '../types';
+import { CardItem } from './CardItem';
+import { getCardCarouselContainerStyles } from './styles';
+
+const keyProvider = (item: ParsedResponseContentData) =>
+ `card-carousel-item-${item.itemId}`;
+
+const itemDimensions = [
+ {
+ view: CardItem,
+ dimension: {
+ width: 300,
+ height: 450,
+ },
+ },
+];
+
+export const CardItemsCarousel = ({
+ data,
+ endpoint,
+ carouselTitle,
+ firstItemHint,
+ itemHint,
+}: CarouselContainerProps) => {
+ const styles = useThemedStyles(getCardCarouselContainerStyles);
+ const { navigate } = useNavigation();
+ const { t } = useTranslation();
+
+ const handleNavigateToDetails = (itemId: string) => {
+ navigate(ROUTES.Details, {
+ screen: 'DetailsMain',
+ params: { endpoint, itemId },
+ });
+ };
+
+ if (!data) {
+ // TO DO: Add empty component
+ return null;
+ }
+
+ return (
+ (
+
+ t('a11y-hint-there-is-an-item-to-the-side', {
+ [DIRECTION_PARAMETER]: side,
+ }),
+ ),
+ )
+ .appendHint(itemHint)
+ .appendHint(firstItemHint, { type: 'first-item', index })
+ .asString(' ')}
+ badge={index % 2 === 0 ? 'New' : ''}
+ />
+ )}
+ getItemForIndex={() => CardItem}
+ keyProvider={keyProvider}
+ itemPadding={50}
+ containerStyle={styles.containerStyles}
+ />
+ );
+};
diff --git a/src/components/carousels/CardItemCarousel/__tests__/CardItem.test.tsx b/src/components/carousels/CardItemCarousel/__tests__/CardItem.test.tsx
new file mode 100644
index 0000000..f6ac693
--- /dev/null
+++ b/src/components/carousels/CardItemCarousel/__tests__/CardItem.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+
+import { screen } from '@testing-library/react-native';
+
+import { ROUTES } from '@AppSrc/navigators/constants';
+import {
+ carouselItemExample,
+ carouselItemExampleNoImage,
+} from '@AppTestUtils/mocks/carousel-item';
+import { renderWithProviders } from '@AppTestUtils/render';
+import { CardItem } from '../CardItem';
+
+describe('CardItem', () => {
+ it('renders correctly with all props', () => {
+ renderWithProviders(
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+ expect(
+ screen.getByTestId('card-image', { includeHiddenElements: true }),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders placeholder when no image provided', () => {
+ renderWithProviders(
+ //@ts-ignore - CardItem expects thumbnail prop to be provided but we want to test the placeholder
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+ expect(
+ screen.getByTestId('card-placeholder', { includeHiddenElements: true }),
+ ).toBeOnTheScreen();
+ });
+});
diff --git a/src/components/carousels/CardItemCarousel/__tests__/CardItemsCarousel.test.tsx b/src/components/carousels/CardItemCarousel/__tests__/CardItemsCarousel.test.tsx
new file mode 100644
index 0000000..48514c0
--- /dev/null
+++ b/src/components/carousels/CardItemCarousel/__tests__/CardItemsCarousel.test.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { Animated } from 'react-native';
+
+import { screen } from '@testing-library/react-native';
+
+import { Details } from '@AppScreens/Details';
+import { Endpoints } from '@AppServices/apiClient';
+import { ROUTES } from '@AppSrc/navigators/constants';
+import { carouselItemExample } from '@AppTestUtils/mocks/carousel-item';
+import type { TestScreenItem } from '@AppTestUtils/render';
+import {
+ renderScreensWithProviders,
+ renderWithProviders,
+} from '@AppTestUtils/render';
+import { rntlUser } from '@AppTestUtils/rntlUser';
+import { CardItemsCarousel } from '../CardItemsCarousel';
+
+jest.useFakeTimers();
+
+const mockData = [
+ carouselItemExample.item,
+ carouselItemExample.item,
+ carouselItemExample.item,
+];
+
+const screens: TestScreenItem[] = [
+ {
+ component: (
+
+ ),
+ routeName: ROUTES.Home,
+ },
+ {
+ component: ,
+ routeName: ROUTES.Details,
+ },
+];
+
+const renderCarousel = (data = mockData) =>
+ renderWithProviders(
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+describe('CardItemsCarousel', () => {
+ it('renders carousel items correctly', () => {
+ renderCarousel();
+
+ expect(
+ screen.getAllByTestId('card-image', { includeHiddenElements: true }),
+ ).toHaveLength(3);
+ });
+
+ it('renders nothing when data is empty', () => {
+ // @ts-ignore
+ renderCarousel(null);
+
+ expect(
+ screen.queryByTestId('card-image', { includeHiddenElements: true }),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('navigates to Details screen when carousel item is clicked', async () => {
+ renderScreensWithProviders({ screens });
+
+ const buttons = screen.getAllByAccessibilityHint('carousel-go-to-details', {
+ exact: false,
+ });
+ expect(buttons.length).toBeGreaterThan(0);
+
+ const firstButton = buttons[0];
+ if (!firstButton) {
+ throw new Error('No button found');
+ }
+
+ await rntlUser.press(firstButton);
+
+ const element = await screen.findByText('Details');
+ expect(element).toBeOnTheScreen();
+ });
+});
diff --git a/src/components/carousels/CardItemCarousel/index.ts b/src/components/carousels/CardItemCarousel/index.ts
new file mode 100644
index 0000000..b716cad
--- /dev/null
+++ b/src/components/carousels/CardItemCarousel/index.ts
@@ -0,0 +1,6 @@
+export { CardItemsCarousel } from './CardItemsCarousel';
+export { CardItem } from './CardItem';
+export {
+ getCardCarouselItemStyles,
+ getCardCarouselContainerStyles,
+} from './styles';
diff --git a/src/components/carousels/CardItemCarousel/styles.ts b/src/components/carousels/CardItemCarousel/styles.ts
new file mode 100644
index 0000000..8f157de
--- /dev/null
+++ b/src/components/carousels/CardItemCarousel/styles.ts
@@ -0,0 +1,64 @@
+import { Dimensions, StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+
+export const getCardCarouselContainerStyles = () =>
+ StyleSheet.create({
+ containerStyles: {
+ marginLeft: 150,
+ width: Dimensions.get('window').width,
+ height: 450,
+ },
+ });
+
+export const getCardCarouselItemStyles = ({
+ colors,
+ typography,
+ isDarkTheme,
+}: AppTheme) =>
+ StyleSheet.create({
+ background: {
+ flex: 1,
+ },
+ cardOuter: {
+ backgroundColor: colors.transparent,
+ width: 300,
+ height: 450,
+ position: 'relative',
+ borderRadius: 10,
+ overflow: 'hidden',
+ },
+ bgImage: {
+ height: 450,
+ width: 300,
+ },
+ titleContainer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ zIndex: 9999,
+ },
+ title: {
+ backgroundColor: colors.transparent,
+ color: isDarkTheme ? colors.onPrimary : colors.focusPrimary,
+ fontSize: typography.size?.fontSize?.title?.sm,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ padding: 10,
+ borderRadius: 10,
+ },
+
+ badgeContainer: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ zIndex: 9999,
+ },
+ badgeText: {
+ backgroundColor: isDarkTheme ? colors.onPrimary : colors.focusPrimary,
+ color: isDarkTheme ? colors.focusPrimary : colors.onPrimary,
+ fontSize: typography.size?.fontSize?.title?.sm,
+ textAlign: 'center',
+ padding: 10,
+ },
+ });
diff --git a/src/components/carousels/CarouselFocusWrap.tsx b/src/components/carousels/CarouselFocusWrap.tsx
new file mode 100644
index 0000000..44cb3a0
--- /dev/null
+++ b/src/components/carousels/CarouselFocusWrap.tsx
@@ -0,0 +1,114 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * CarouselFocusWrap Component
+ *
+ * Reusable wrapper that adds focus management and navigation functionality to carousel items.
+ * Handles:
+ * - Focus state tracking for TV navigation
+ * - Accessibility label generation with carousel context
+ * - Navigation to detail screens with focus restoration
+ * - Integration with global carousel focus management
+ *
+ * Key Features:
+ * - Maintains focus reference for returning from detail screens
+ * - Builds contextual accessibility hints with item and carousel information
+ * - Enable navigation between related items via cross-references
+ * - Applies focus-aware styling to wrapped content
+ */
+
+import React from 'react';
+import { Pressable } from 'react-native';
+
+import { useIsFocused } from '@amazon-devices/react-navigation__core';
+
+import { useThemedStyles } from '@AppTheme';
+import { HintBuilder } from '@AppServices/a11y';
+import { useFocusState } from '@AppServices/focusGuide';
+import { useTranslation } from '@AppServices/i18n';
+import { GROUP_PARAMETER, ITEM_PARAMETER } from '@AppServices/i18n/constants';
+import type { FocusablePressableRef } from '@AppStore/useCarouselFocus';
+import { useCarouselFocus } from '@AppStore/useCarouselFocus';
+import { getCarouselFocusWrapperStyles } from './styles';
+import type { CarouselItemProps } from './types';
+
+type CarouseWrapperProps = CarouselItemProps & {
+ /** Test identifier for the wrapper */
+ testID?: string;
+ /** Render function that receives focus state and returns the item content */
+ renderChildren: (isButtonFocused: boolean) => React.ReactNode;
+};
+
+/**
+ * CarouselFocusWrap Implementation
+ *
+ * Wraps carousel items with focus management and navigation capabilities.
+ * Manages focus state, accessibility labels, and navigation to detail screens.
+ */
+export const CarouselFocusWrap = ({
+ carouselTitle,
+ testID,
+ item,
+ navigateToDetails,
+ renderChildren,
+ accessibilityHint,
+}: CarouseWrapperProps) => {
+ // Track focus state for visual feedback and accessibility
+ const {
+ handleBlur,
+ handleFocus,
+ isFocused: isButtonFocused,
+ } = useFocusState();
+
+ const styles = useThemedStyles(getCarouselFocusWrapperStyles);
+ const isScreenFocused = useIsFocused();
+
+ // Global carousel focus management for returning from detail screens
+ const { lastFocusedRef, setLastFocusedRef } = useCarouselFocus();
+ const pressableRef = React.useRef(null);
+
+ const { t } = useTranslation();
+
+ /**
+ * Handles navigation to detail screen with focus restoration.
+ * Supports both direct navigation and linked content navigation.
+ * Stores current focus reference for restoration when returning.
+ */
+ const handleNavigateToDetails = () => {
+ if (item.linkedContent) {
+ navigateToDetails?.(item.itemId, item.linkedContent);
+ } else {
+ navigateToDetails?.(item.itemId);
+ }
+ // Store reference for focus restoration when returning from details
+ setLastFocusedRef(pressableRef);
+ };
+
+ return (
+
+ {renderChildren(isButtonFocused)}
+
+ );
+};
diff --git a/src/components/carousels/HeroCarousel/Backdrop.tsx b/src/components/carousels/HeroCarousel/Backdrop.tsx
new file mode 100644
index 0000000..e5aee2a
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/Backdrop.tsx
@@ -0,0 +1,121 @@
+/**
+ * Backdrop Component
+ *
+ * Animated background component for the hero carousel that creates smooth
+ * transitions between different content images. Provides an immersive visual
+ * experience by changing the background based on the currently focused item.
+ *
+ * Key Features:
+ * - Smooth opacity transitions between background images
+ * - Gradient overlay for text readability and visual depth
+ * - Performance optimized with absolute positioning
+ * - Fallback image handling for missing thumbnails
+ * - Theme-aware gradient colors
+ * - Accessibility considerations with proper image handling
+ *
+ * Animation Behavior:
+ * - Each background image has its own animated opacity
+ * - Opacity interpolation based on carousel scroll position
+ * - Only the current item's background is visible (opacity: 1)
+ * - Adjacent items fade out smoothly (opacity: 0)
+ * - Clamped extrapolation prevents animation artifacts
+ *
+ * Visual Structure:
+ * - Background images positioned absolutely to fill container
+ * - Linear gradient overlay from transparent to theme background
+ * - Gradient provides smooth transition to content below
+ * - Images respect accessibility invert colors setting
+ *
+ * Performance Considerations:
+ * - Uses StyleSheet.absoluteFill for optimal positioning
+ * - Animated.View prevents unnecessary re-renders
+ * - Image loading is handled by React Native's Image component
+ * - Gradient colors are theme-aware and cached
+ *
+ * Example Usage:
+ * ```tsx
+ *
+ * ```
+ */
+
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+import { View, Animated, Image, StyleSheet } from 'react-native';
+
+import LinearGradient from '@amazon-devices/react-linear-gradient';
+
+import { useAppTheme, useThemedStyles } from '@AppTheme';
+import { getHeroCarouselItemStyles } from '@AppComponents/carousels/HeroCarousel/styles';
+import type { ParsedResponseContentData } from '@AppComponents/carousels/types';
+
+/**
+ * Backdrop Implementation
+ *
+ * Renders animated background images for hero carousel with smooth transitions.
+ * Creates layered background effect with gradient overlay for optimal content visibility.
+ */
+export const Backdrop = ({
+ data,
+ animatedIndex,
+}: {
+ /** Array of carousel content data containing thumbnail images */
+ data: ParsedResponseContentData[];
+ /** Animated index value for controlling background transitions */
+ animatedIndex: Animated.AnimatedDivision;
+}) => {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(getHeroCarouselItemStyles);
+
+ return (
+
+ {/* Render background image for each carousel item */}
+ {data.map((item: ParsedResponseContentData, index: number) => {
+ // Calculate opacity based on current carousel position
+ const opacity = animatedIndex.interpolate({
+ inputRange: [index - 1, index, index + 1], // Previous, current, next item
+ outputRange: [0, 1, 0], // Hidden, visible, hidden
+ extrapolate: 'clamp', // Prevent values outside 0-1 range
+ });
+
+ return (
+
+ {/* Background image with fallback */}
+
+ {/* Gradient overlay for text readability and visual depth */}
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/components/carousels/HeroCarousel/HeroCarousel.tsx b/src/components/carousels/HeroCarousel/HeroCarousel.tsx
new file mode 100644
index 0000000..97f5ea0
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/HeroCarousel.tsx
@@ -0,0 +1,262 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * HeroCarousel Component
+ *
+ * Full-width hero carousel component designed for featured content display.
+ * Provides animated backdrops, synchronized scroll effects, and full
+ * accessibility support for TV navigation.
+ *
+ * Key Features:
+ * - Animated backdrop that changes based on current item
+ * - Horizontal scrolling with snap-to-item behavior
+ * - Dynamic navigation arrows with fade animations
+ * - Comprehensive accessibility support with directional hints
+ * - Integration with app navigation for detail screens
+ * - Performance optimized with lazy rendering
+ *
+ * Animation Features:
+ * - Backdrop transitions synchronized with scroll position
+ * - Arrow opacity animations based on scroll boundaries
+ * - Smooth scroll animations with custom deceleration
+ * - Overlay fade based on page scroll position
+ * - Item-based animation index for complex transitions
+ *
+ * Accessibility:
+ * - Custom accessibility hints for first and last items
+ * - Item-specific accessibility labels with carousel context
+ * - Support for first/last item special announcements
+ * - Screen reader friendly with proper ARIA attributes
+ *
+ * Example Usage:
+ * ```tsx
+ *
+ * ```
+ */
+
+import React from 'react';
+import { View, Animated, useAnimatedValue } from 'react-native';
+
+import MaterialCommunityIcon from '@amazon-devices/react-native-vector-icons/MaterialCommunityIcons';
+import { useNavigation } from '@amazon-devices/react-navigation__core';
+
+import { useAppTheme, useThemedStyles } from '@AppTheme';
+import type { CommonCarouselItemProps } from '@AppModels/carouselLayout/CarouselLayout';
+import { HintBuilder } from '@AppServices/a11y';
+import { useTranslation } from '@AppServices/i18n';
+import { DIRECTION_PARAMETER } from '@AppServices/i18n/constants';
+import { ROUTES } from '@AppSrc/navigators/constants';
+import { ITEM_WIDTH } from '../constants';
+import type {
+ CarouselContainerProps,
+ ParsedResponseContentData,
+} from '../types';
+import { Backdrop } from './Backdrop';
+import { HeroCarouselItem } from './HeroCarouselItem';
+import { getHeroCarouselContainerStyles } from './styles';
+
+/** Key extractor for FlatList optimization */
+const keyExtractor = (item: ParsedResponseContentData) =>
+ `hero-carousel-item-${item.itemId}`;
+
+/**
+ * Props for the HeroCarouselContainer component
+ */
+// Props are defined in CarouselContainerProps type - see types.ts for detailed documentation
+
+/**
+ * HeroCarouselContainer Implementation
+ *
+ * Renders the main hero carousel with animated backdrop, navigation arrows,
+ * and scroll-synchronized effects. This is the primary hero component that manages
+ * complex animations and navigation.
+ *
+ * Performance Optimizations:
+ * - Uses getItemLayout for known dimensions (prevents layout calculations)
+ * - initialNumToRender set to 1 for faster initial load
+ * - Throttled scroll events (16ms) for 60fps animation updates
+ * - Memoized key extraction for FlatList optimization
+ */
+export const HeroCarouselContainer = ({
+ data,
+ endpoint,
+ carouselTitle,
+ firstItemHint,
+ itemHint,
+ scrollY,
+}: CarouselContainerProps) => {
+ const styles = useThemedStyles(getHeroCarouselContainerStyles);
+ const { navigate } = useNavigation();
+ const { t } = useTranslation();
+ const { typography } = useAppTheme();
+
+ // Animation setup for horizontal scrolling and backdrop effects
+ // scrollX tracks horizontal scroll position for backdrop and arrow animations
+ const scrollX = useAnimatedValue(0);
+ // animatedIndex converts scroll position to item index for backdrop transitions
+ const animatedIndex = Animated.divide(scrollX, ITEM_WIDTH);
+
+ /**
+ * Handles navigation to detail screens with support for linked content.
+ *
+ * Navigation Logic:
+ * - If linkedContent exists: Navigate to cross-referenced content
+ * - Otherwise: Navigate to the item's own detail screen
+ *
+ * This enables content discovery through related items and cross-references.
+ */
+ const handleNavigateToDetails = (
+ itemId: string,
+ linkedContent?: CommonCarouselItemProps['linkedContent'],
+ ) => {
+ if (linkedContent) {
+ // Navigate to linked content (cross-reference between related items)
+ navigate(ROUTES.Details, {
+ screen: 'DetailsMain',
+ params: {
+ endpoint: linkedContent.endpoint,
+ itemId: linkedContent.itemId,
+ },
+ });
+ } else {
+ // Navigate to item's own details using current carousel endpoint
+ navigate(ROUTES.Details, {
+ screen: 'DetailsMain',
+ params: { endpoint, itemId },
+ });
+ }
+ };
+
+ // Early return if no data - prevents rendering empty carousel
+ if (!data) {
+ // TODO: Add empty state component with proper messaging
+ return null;
+ }
+
+ return (
+
+ {/* Animated backdrop that changes based on current item */}
+
+
+ {/* Main horizontal carousel with snap-to-item behavior */}
+ (
+
+ t('a11y-hint-there-is-an-item-to-the-side', {
+ [DIRECTION_PARAMETER]: side,
+ }),
+ ),
+ )
+ .appendHint(itemHint) // General carousel navigation hint
+ .appendHint(firstItemHint, { type: 'first-item', index }) // Special hint for first item
+ .asString(' ')}
+ />
+ )}
+ horizontal={true}
+ keyExtractor={keyExtractor}
+ // Sync scroll position with animations (backdrop changes, arrow fades)
+ onScroll={Animated.event(
+ [{ nativeEvent: { contentOffset: { x: scrollX } } }],
+ { useNativeDriver: false }, // Required for layout-affecting animations
+ )}
+ scrollEventThrottle={16} // 60fps animation updates for smooth transitions
+ initialNumToRender={1} // Render only first item initially for performance
+ // Enable smooth scrolling with pre-calculated item dimensions
+ getItemLayout={(_, index) => ({
+ length: ITEM_WIDTH, // Each item is exactly ITEM_WIDTH pixels wide
+ offset: ITEM_WIDTH * index, // Calculate position without measuring
+ index,
+ })}
+ snapToInterval={ITEM_WIDTH} // Snap to each item for precise positioning
+ decelerationRate={0.3} // Smooth, controlled deceleration
+ />
+ {/* Navigation arrows with scroll-based fade animations */}
+
+ {/* Right arrow - fades out when approaching last item */}
+
+
+
+
+ {/* Left arrow - fades in after first item */}
+
+
+
+
+ {/* Overlay that dims carousel when page is scrolled (controlled by parent) */}
+
+
+ );
+};
diff --git a/src/components/carousels/HeroCarousel/HeroCarouselItem.tsx b/src/components/carousels/HeroCarousel/HeroCarouselItem.tsx
new file mode 100644
index 0000000..1def3a3
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/HeroCarouselItem.tsx
@@ -0,0 +1,176 @@
+/**
+ * HeroCarouselItem Component
+ *
+ * Individual item component for the hero carousel that displays featured content
+ * with rich animations, focus management, and detailed content information.
+ * Each item shows title, description, metadata, and a call-to-action button.
+ *
+ * Key Features:
+ * - Animated content that responds to carousel scroll position
+ * - Focus management for TV navigation with visual feedback
+ * - Rich content display (title, description, metadata, rating)
+ * - Navigation to detail screens with focus restoration
+ * - Accessibility support with proper labels and hints
+ * - Smooth opacity and transform animations based on item position
+ *
+ * Animation Behavior:
+ * - Opacity fades in/out based on current carousel position
+ * - Horizontal translation creates parallax-like effect during transitions
+ * - Focus state triggers visual styling changes
+ * - Animations are synchronized with carousel scroll via animatedIndex
+ *
+ * Focus Management:
+ * - Integrates with global carousel focus system
+ * - Maintains focus reference for returning from detail screens
+ * - First item gets initial focus by default
+ * - TV-optimized focus behavior with hasTVPreferredFocus
+ *
+ * Content Structure:
+ * - Main title with ellipsis handling
+ * - Metadata row (sport type, rating, network, genre)
+ * - Description text with line limits
+ * - "Watch Now" call-to-action button
+ *
+ * Example Usage:
+ * ```tsx
+ *
+ * ```
+ */
+
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React, { useEffect } from 'react';
+import { View, Text, Pressable, Animated } from 'react-native';
+
+import { useIsFocused } from '@amazon-devices/react-navigation__core';
+
+import { useThemedStyles } from '@AppTheme';
+import { useFocusState } from '@AppServices/focusGuide';
+import { useTranslation } from '@AppServices/i18n';
+import type { FocusablePressableRef } from '@AppStore/useCarouselFocus';
+import { useCarouselFocus } from '@AppStore/useCarouselFocus';
+import type { CarouselItemProps } from '../types';
+import { getHeroCarouselItemStyles } from './styles';
+
+/**
+ * HeroCarouselItem Implementation
+ *
+ * Renders an individual hero carousel item with animations, focus management,
+ * and rich content display. Handles navigation and maintains focus state.
+ */
+export const HeroCarouselItem = ({
+ item,
+ navigateToDetails,
+ index,
+ animatedIndex,
+}: CarouselItemProps & {
+ /** Position index of this item in the carousel */
+ index: number;
+ /** Animated value representing current carousel position for transitions */
+ animatedIndex: Animated.AnimatedDivision;
+}) => {
+ const styles = useThemedStyles(getHeroCarouselItemStyles);
+ const {
+ handleBlur,
+ handleFocus,
+ isFocused: isPressableFocused,
+ } = useFocusState();
+ const { t } = useTranslation();
+ const isScreenFocused = useIsFocused();
+
+ const { lastFocusedRef, setLastFocusedRef } = useCarouselFocus();
+ const pressableRef = React.useRef(null);
+
+ /**
+ * Handles navigation to detail screen with focus restoration support.
+ * Supports both direct navigation and linked content navigation.
+ */
+ const handleNavigateToDetails = () => {
+ if (item.linkedContent) {
+ // Navigate to linked content (cross-reference)
+ navigateToDetails?.(item.itemId, item.linkedContent);
+ } else {
+ // Navigate to item's own details
+ navigateToDetails?.(item.itemId);
+ }
+ // Store focus reference for restoration when returning
+ setLastFocusedRef(pressableRef);
+ };
+
+ // Set initial focus reference for first item when no previous focus exists
+ useEffect(() => {
+ if (lastFocusedRef.current === null && index === 0) {
+ setLastFocusedRef(pressableRef);
+ }
+ }, [lastFocusedRef, index, setLastFocusedRef, isPressableFocused]);
+
+ return (
+
+
+
+
+
+ {item.title}
+
+
+ {item.sport_type}
+ {item.rating}
+ {item.network}
+ {item.genre}
+
+
+ {item.description}
+
+
+ Watch Now
+
+
+
+
+
+ );
+};
diff --git a/src/components/carousels/HeroCarousel/__tests__/Backdrop.test.tsx b/src/components/carousels/HeroCarousel/__tests__/Backdrop.test.tsx
new file mode 100644
index 0000000..1bdbe23
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/__tests__/Backdrop.test.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { screen } from '@testing-library/react-native';
+
+import { carouselItemExample } from '@AppTestUtils/mocks/carousel-item';
+import { renderWithTheme } from '@AppTestUtils/render';
+import { Backdrop } from '../Backdrop';
+
+const heroCarouselItem = {
+ data: [
+ carouselItemExample.item,
+ carouselItemExample.item,
+ carouselItemExample.item,
+ ],
+ animatedIndex: {
+ interpolate: jest.fn(),
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ removeAllListeners: jest.fn(),
+ hasListeners: jest.fn(),
+ },
+};
+
+describe('Hero Carousel Item', () => {
+ it('renders correctly with all props', () => {
+ renderWithTheme();
+
+ expect(
+ screen.getAllByTestId('hero-image-3b7b4569-f755-4d80-885f-41d90eef23ae'),
+ ).toHaveLength(3);
+ });
+});
diff --git a/src/components/carousels/HeroCarousel/__tests__/HeroCarousel.test.tsx b/src/components/carousels/HeroCarousel/__tests__/HeroCarousel.test.tsx
new file mode 100644
index 0000000..3184d47
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/__tests__/HeroCarousel.test.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { Animated } from 'react-native';
+
+import { screen } from '@testing-library/react-native';
+
+import { Details } from '@AppScreens/Details';
+import { Endpoints } from '@AppServices/apiClient';
+import { ROUTES } from '@AppSrc/navigators/constants';
+import { carouselItemExample } from '@AppTestUtils/mocks/carousel-item';
+import type { TestScreenItem } from '@AppTestUtils/render';
+import {
+ renderScreensWithProviders,
+ renderWithProviders,
+} from '@AppTestUtils/render';
+import { rntlUser } from '@AppTestUtils/rntlUser';
+import { HeroCarouselContainer } from '../HeroCarousel';
+
+const mockData = [
+ carouselItemExample.item,
+ carouselItemExample.item,
+ carouselItemExample.item,
+];
+
+jest.useFakeTimers();
+
+const screens: TestScreenItem[] = [
+ {
+ component: (
+
+ ),
+ routeName: ROUTES.Home,
+ },
+ {
+ component: ,
+ routeName: ROUTES.Details,
+ },
+];
+
+const renderCarousel = (data = mockData) =>
+ renderWithProviders(
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+describe('HeroCarousel', () => {
+ it('renders carousel items correctly', () => {
+ renderCarousel();
+
+ expect(
+ screen.getAllByTestId('hero-image-3b7b4569-f755-4d80-885f-41d90eef23ae'),
+ ).toHaveLength(3);
+ });
+
+ it('renders nothing when data is empty', () => {
+ // @ts-ignore
+ renderCarousel([]);
+
+ expect(
+ screen.queryAllByTestId(
+ 'hero-image-3b7b4569-f755-4d80-885f-41d90eef23ae',
+ ),
+ ).toHaveLength(0);
+ });
+
+ it('navigates to Details screen when carousel item is clicked', async () => {
+ renderScreensWithProviders({ screens });
+
+ const buttons = screen.getAllByAccessibilityHint('carousel-go-to-details', {
+ exact: false,
+ });
+ expect(buttons.length).toBeGreaterThan(0);
+
+ const firstButton = buttons[0];
+ if (!firstButton) {
+ throw new Error('No button found');
+ }
+
+ await rntlUser.press(firstButton);
+
+ const element = await screen.findByText('Details');
+ expect(element).toBeOnTheScreen();
+ });
+});
diff --git a/src/components/carousels/HeroCarousel/__tests__/HeroCarouselItem.test.tsx b/src/components/carousels/HeroCarousel/__tests__/HeroCarouselItem.test.tsx
new file mode 100644
index 0000000..f077ad9
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/__tests__/HeroCarouselItem.test.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { screen } from '@testing-library/react-native';
+
+import { ROUTES } from '@AppSrc/navigators/constants';
+import { carouselItemExample } from '@AppTestUtils/mocks/carousel-item';
+import { renderWithProviders } from '@AppTestUtils/render';
+import { HeroCarouselItem } from '../HeroCarouselItem';
+
+const heroCarouselItem = {
+ ...carouselItemExample,
+ index: 0,
+ animatedIndex: {
+ interpolate: jest.fn(),
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ removeAllListeners: jest.fn(),
+ hasListeners: jest.fn(),
+ },
+ accessibilityHint: 'carousel-go-to-details',
+ carouselTitle: null,
+};
+
+describe('Hero Carousel Item', () => {
+ it('renders correctly with all props', () => {
+ renderWithProviders(, {
+ routeName: ROUTES.Home,
+ });
+
+ expect(screen.getByText('Marzana rules!')).toBeOnTheScreen();
+ });
+});
diff --git a/src/components/carousels/HeroCarousel/index.ts b/src/components/carousels/HeroCarousel/index.ts
new file mode 100644
index 0000000..5d5ff95
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/index.ts
@@ -0,0 +1,3 @@
+export { HeroCarouselContainer } from './HeroCarousel';
+export { HeroCarouselItem } from './HeroCarouselItem';
+export { getHeroCarouselContainerStyles } from './styles';
diff --git a/src/components/carousels/HeroCarousel/styles.ts b/src/components/carousels/HeroCarousel/styles.ts
new file mode 100644
index 0000000..6a2bac2
--- /dev/null
+++ b/src/components/carousels/HeroCarousel/styles.ts
@@ -0,0 +1,140 @@
+import { Dimensions, StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+import { BACKDROP_HEIGHT, ITEM_WIDTH } from '../constants';
+
+const { width, height } = Dimensions.get('window');
+
+const HERO_CAROUSEL_HEIGHT = height * 0.88;
+
+export const getHeroCarouselContainerStyles = ({ colors }: AppTheme) =>
+ StyleSheet.create({
+ listContainer: {
+ flexGrow: 1,
+ flexDirection: 'row',
+ height: HERO_CAROUSEL_HEIGHT,
+ },
+ unFocused: {
+ backgroundColor: `${colors.background}CC`,
+ width: ITEM_WIDTH,
+ height: HERO_CAROUSEL_HEIGHT,
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ },
+ arrowContainer: {
+ position: 'absolute',
+ height: HERO_CAROUSEL_HEIGHT,
+ top: 0,
+ left: 0,
+ },
+ arrow: {
+ position: 'absolute',
+ top: height * 0.35,
+ width: '20%',
+ },
+ arrowRight: {
+ left: width - 200,
+ },
+ arrowLeft: {
+ left: 10,
+ },
+ });
+
+export const getHeroCarouselItemStyles = ({
+ colors,
+ typography,
+ isDarkTheme,
+}: AppTheme) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.transparent,
+ width: ITEM_WIDTH,
+ height: HERO_CAROUSEL_HEIGHT,
+ overflow: 'visible',
+ position: 'relative',
+ },
+ background: {
+ flex: 1,
+ },
+ bgImage: {
+ flex: 1,
+ },
+ pressableContainer: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ alignItems: 'flex-start',
+ padding: 20,
+ width: ITEM_WIDTH,
+ height: '100%',
+ },
+ pressableTitle: {
+ width: '100%',
+ height: 1,
+ },
+ titleContainer: {
+ position: 'absolute',
+ top: HERO_CAROUSEL_HEIGHT * 0.55,
+ left: 100,
+ right: 0,
+ width: '45%',
+ padding: 15,
+ borderColor: colors.transparent,
+ borderWidth: 5,
+ borderRadius: 10,
+ },
+ titleContainerFocused: {},
+ infoContainer: {
+ flexDirection: 'row',
+ gap: 25,
+ paddingVertical: 20,
+ },
+ title: {
+ fontSize: typography.size?.fontSize?.title?.lg,
+ fontWeight: 'bold',
+ color: isDarkTheme ? colors.onPrimary : colors.onBackground,
+ borderRadius: 5,
+ },
+ infoBold: {
+ fontWeight: 'bold',
+ },
+ heroDetails: {
+ fontSize: 18,
+ color: isDarkTheme ? colors.onPrimary : colors.onBackground,
+ textTransform: 'capitalize',
+ },
+ heroDescription: {
+ fontSize: typography.size?.fontSize?.title?.sm,
+ color: isDarkTheme ? colors.onPrimary : colors.onBackground,
+ marginBottom: 20,
+ },
+ infoButton: {
+ width: 200,
+ height: 80,
+ backgroundColor: colors.primary,
+ borderRadius: 10,
+ padding: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 10,
+ borderWidth: 1,
+ borderColor: colors.transparent,
+ },
+ infoButtonFocused: {
+ borderColor: colors.onPrimary,
+ },
+ infoButtonText: {
+ fontSize: typography.size?.fontSize?.title?.sm,
+ color: colors.onPrimary,
+ },
+ backdropContainer: {
+ height: BACKDROP_HEIGHT,
+ width,
+ position: 'absolute',
+ },
+ backdropImage: {
+ width,
+ height: BACKDROP_HEIGHT,
+ },
+ });
diff --git a/src/components/carousels/SquareCarousel/SquareItem.tsx b/src/components/carousels/SquareCarousel/SquareItem.tsx
new file mode 100644
index 0000000..65fd1a0
--- /dev/null
+++ b/src/components/carousels/SquareCarousel/SquareItem.tsx
@@ -0,0 +1,69 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+import type { ImageStyle } from 'react-native';
+import { Text, Image, View, StyleSheet } from 'react-native';
+
+import LinearGradient from '@amazon-devices/react-linear-gradient';
+
+import { useAppTheme, useThemedStyles } from '@AppTheme';
+import { CarouselFocusWrap } from '../CarouselFocusWrap';
+import type { CarouselItemProps } from '../types';
+import { getSquareItemStyles } from './styles';
+
+export const SquareItem = ({
+ item,
+ navigateToDetails,
+ carouselTitle,
+ accessibilityHint,
+}: CarouselItemProps) => {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(getSquareItemStyles);
+
+ const renderFunction = (isFocused: boolean) => {
+ return (
+
+
+ {item.title && (
+
+ {item.sport_type}
+
+ )}
+
+
+
+
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/carousels/SquareCarousel/SquareItemsCarousel.tsx b/src/components/carousels/SquareCarousel/SquareItemsCarousel.tsx
new file mode 100644
index 0000000..3779f0b
--- /dev/null
+++ b/src/components/carousels/SquareCarousel/SquareItemsCarousel.tsx
@@ -0,0 +1,102 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+
+import { useNavigation } from '@amazon-devices/react-navigation__core';
+
+import { useThemedStyles } from '@AppTheme';
+import { Carousel } from '@AppComponents/core/Carousel';
+import { HintBuilder } from '@AppServices/a11y';
+import { useTranslation } from '@AppServices/i18n';
+import { DIRECTION_PARAMETER } from '@AppServices/i18n/constants';
+import { ROUTES } from '@AppSrc/navigators/constants';
+import type {
+ CarouselContainerProps,
+ ParsedResponseContentData,
+} from '../types';
+import { SquareItem } from './SquareItem';
+import { getSquareCarouselContainerStyles } from './styles';
+
+const keyProvider = (item: ParsedResponseContentData) =>
+ `square-carousel-item-${item.itemId}`;
+
+const itemDimensions = [
+ {
+ view: SquareItem,
+ dimension: {
+ width: 250,
+ height: 250,
+ },
+ },
+];
+
+export const SquareItemsCarousel = ({
+ data,
+ endpoint,
+ carouselTitle,
+ firstItemHint,
+ itemHint,
+}: CarouselContainerProps) => {
+ const styles = useThemedStyles(getSquareCarouselContainerStyles);
+ const { navigate } = useNavigation();
+ const { t } = useTranslation();
+
+ const handleNavigateToDetails = (itemId: string) => {
+ navigate(ROUTES.Details, {
+ screen: 'DetailsMain',
+ params: { endpoint, itemId },
+ });
+ };
+
+ if (!data) {
+ // TO DO: Add empty component
+ return null;
+ }
+
+ return (
+ (
+
+ t('a11y-hint-there-is-an-item-to-the-side', {
+ [DIRECTION_PARAMETER]: side,
+ }),
+ ),
+ )
+ .appendHint(itemHint)
+ .appendHint(firstItemHint, { type: 'first-item', index })
+ .asString(' ')}
+ />
+ )}
+ getItemForIndex={() => SquareItem}
+ keyProvider={keyProvider}
+ itemPadding={150}
+ containerStyle={styles.containerStyles}
+ />
+ );
+};
diff --git a/src/components/carousels/SquareCarousel/__tests__/SquareItem.test.tsx b/src/components/carousels/SquareCarousel/__tests__/SquareItem.test.tsx
new file mode 100644
index 0000000..5beb35f
--- /dev/null
+++ b/src/components/carousels/SquareCarousel/__tests__/SquareItem.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+
+import { screen } from '@testing-library/react-native';
+
+import { ROUTES } from '@AppSrc/navigators/constants';
+import {
+ carouselItemExample,
+ carouselItemExampleNoImage,
+} from '@AppTestUtils/mocks/carousel-item';
+import { renderWithProviders } from '@AppTestUtils/render';
+import { SquareItem } from '../SquareItem';
+
+describe('Hero Carousel Item', () => {
+ it('renders correctly with all props', () => {
+ renderWithProviders(
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+ expect(
+ screen.getByTestId('square-image', { includeHiddenElements: true }),
+ ).toBeTruthy();
+ });
+
+ it('renders placeholder when no image provided', () => {
+ renderWithProviders(
+ // @ts-ignore - CardItem expects thumbnail prop to be provided but we want to test the placeholder
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+ expect(
+ screen.getByTestId('square-placeholder', { includeHiddenElements: true }),
+ ).toBeTruthy();
+ });
+});
diff --git a/src/components/carousels/SquareCarousel/__tests__/SquareItemsCarousel.test.tsx b/src/components/carousels/SquareCarousel/__tests__/SquareItemsCarousel.test.tsx
new file mode 100644
index 0000000..7242776
--- /dev/null
+++ b/src/components/carousels/SquareCarousel/__tests__/SquareItemsCarousel.test.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { Animated } from 'react-native';
+
+import { screen } from '@testing-library/react-native';
+
+import { Details } from '@AppScreens/Details';
+import { Endpoints } from '@AppServices/apiClient';
+import { ROUTES } from '@AppSrc/navigators/constants';
+import { carouselItemExample } from '@AppTestUtils/mocks/carousel-item';
+import type { TestScreenItem } from '@AppTestUtils/render';
+import {
+ renderScreensWithProviders,
+ renderWithProviders,
+} from '@AppTestUtils/render';
+import { rntlUser } from '@AppTestUtils/rntlUser';
+import { SquareItemsCarousel } from '../SquareItemsCarousel';
+
+jest.useFakeTimers();
+
+const mockData = [
+ carouselItemExample.item,
+ carouselItemExample.item,
+ carouselItemExample.item,
+];
+
+const screens: TestScreenItem[] = [
+ {
+ component: (
+
+ ),
+ routeName: ROUTES.Home,
+ },
+ {
+ component: ,
+ routeName: ROUTES.Details,
+ },
+];
+
+const renderCarousel = (data = mockData) =>
+ renderWithProviders(
+ ,
+ { routeName: ROUTES.Home },
+ );
+
+describe('HeroCarousel', () => {
+ it('renders carousel items correctly', () => {
+ renderCarousel();
+
+ expect(
+ screen.getAllByTestId('square-image', { includeHiddenElements: true }),
+ ).toHaveLength(3);
+ });
+
+ it('renders nothing when data is empty', () => {
+ // @ts-ignore
+ renderCarousel(null);
+
+ expect(
+ screen.queryByTestId('square-image', { includeHiddenElements: true }),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('navigates to Details screen when carousel item is clicked', async () => {
+ renderScreensWithProviders({ screens });
+
+ const buttons = screen.getAllByAccessibilityHint('carousel-go-to-details', {
+ exact: false,
+ });
+ expect(buttons.length).toBeGreaterThan(0);
+
+ const firstButton = buttons[0];
+ if (!firstButton) {
+ throw new Error('No button found');
+ }
+
+ await rntlUser.press(firstButton);
+
+ const element = await screen.findByText('Details');
+ expect(element).toBeOnTheScreen();
+ });
+});
diff --git a/src/components/carousels/SquareCarousel/index.ts b/src/components/carousels/SquareCarousel/index.ts
new file mode 100644
index 0000000..d98099e
--- /dev/null
+++ b/src/components/carousels/SquareCarousel/index.ts
@@ -0,0 +1,6 @@
+export { SquareItemsCarousel } from './SquareItemsCarousel';
+export { SquareItem } from './SquareItem';
+export {
+ getSquareCarouselContainerStyles,
+ getSquareItemStyles,
+} from './styles';
diff --git a/src/components/carousels/SquareCarousel/styles.ts b/src/components/carousels/SquareCarousel/styles.ts
new file mode 100644
index 0000000..d70a1fa
--- /dev/null
+++ b/src/components/carousels/SquareCarousel/styles.ts
@@ -0,0 +1,50 @@
+import { StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+
+export const getSquareCarouselContainerStyles = () =>
+ StyleSheet.create({
+ containerStyles: {
+ marginLeft: 250,
+ height: 280,
+ },
+ });
+
+export const getSquareItemStyles = ({
+ colors,
+ typography,
+ isDarkTheme,
+}: AppTheme) =>
+ StyleSheet.create({
+ background: {
+ flex: 1,
+ },
+ cardOuter: {
+ backgroundColor: colors.transparent,
+ width: 350,
+ height: 250,
+ position: 'relative',
+ borderRadius: 10,
+ overflow: 'hidden',
+ },
+ cardOuterFocused: {
+ backgroundColor: colors.transparent,
+ },
+ titleContainer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ zIndex: 9999,
+ },
+ title: {
+ color: isDarkTheme ? colors.onPrimary : colors.focusPrimary,
+ fontSize: typography.size?.fontSize?.title?.sm,
+ padding: 10,
+ textTransform: 'capitalize',
+ fontWeight: 'bold',
+ },
+ bgImage: {
+ height: 250,
+ width: 350,
+ },
+ });
diff --git a/src/components/carousels/constants.ts b/src/components/carousels/constants.ts
new file mode 100644
index 0000000..2cb9903
--- /dev/null
+++ b/src/components/carousels/constants.ts
@@ -0,0 +1,33 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * Carousel Constants
+ *
+ * Dimension constants used across all carousel components.
+ * These values are calculated based on device screen dimensions and define
+ * the core layout measurements for consistent carousel behavior.
+ *
+ * Key Constants:
+ * - BACKDROP_HEIGHT: Height of animated backdrop (88% of screen height)
+ * - ITEM_WIDTH: Width of each carousel item (screen width minus padding)
+ *
+ * Usage:
+ * - Used by HeroCarousel for item dimensions and scroll calculations
+ * - Used by Backdrop component for proper sizing
+ * - Critical for getItemLayout performance optimization
+ *
+ * Note: These are calculated once at module load and remain constant
+ * throughout the app lifecycle for performance reasons.
+ */
+
+import { Dimensions } from 'react-native';
+
+// Get device dimensions once at module load for performance
+const { width, height } = Dimensions.get('window');
+
+/** Height of the animated backdrop - 88% of screen height leaves room for navigation */
+export const BACKDROP_HEIGHT = height * 0.88;
+
+/** Width of each carousel item - full width minus 50px padding on each side */
+export const ITEM_WIDTH = width - 100;
diff --git a/src/components/carousels/index.ts b/src/components/carousels/index.ts
new file mode 100644
index 0000000..1ccec90
--- /dev/null
+++ b/src/components/carousels/index.ts
@@ -0,0 +1,6 @@
+export type { CarouselItemProps, CarouselContainerProps } from './types';
+export { SquareItemsCarousel } from './SquareCarousel/SquareItemsCarousel';
+export { CarouselType } from '@AppModels/carouselLayout/CarouselLayout';
+export * from './HeroCarousel';
+export * from './SquareCarousel';
+export * from './CardItemCarousel';
diff --git a/src/components/carousels/styles.ts b/src/components/carousels/styles.ts
new file mode 100644
index 0000000..4675c7f
--- /dev/null
+++ b/src/components/carousels/styles.ts
@@ -0,0 +1,21 @@
+import { StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+
+export const getCarouselFocusWrapperStyles = ({
+ colors,
+ isDarkTheme,
+}: AppTheme) =>
+ StyleSheet.create({
+ wrapper: {
+ flex: 1,
+ padding: 8,
+ borderColor: colors.transparent,
+ borderWidth: 1,
+ borderRadius: 10,
+ position: 'absolute',
+ },
+ wrapperStylesFocused: {
+ borderColor: isDarkTheme ? colors.onPrimary : colors.focusPrimary,
+ },
+ });
diff --git a/src/components/carousels/types.ts b/src/components/carousels/types.ts
new file mode 100644
index 0000000..0df63b2
--- /dev/null
+++ b/src/components/carousels/types.ts
@@ -0,0 +1,73 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * Carousel Type Definitions
+ *
+ * Shared types used across all carousel components in the Kepler Sports App.
+ * Defines the data structures for different content types (sports teams, documentaries,
+ * live streams, etc.) and the props interfaces for carousel rendering and interaction.
+ */
+
+import type { Animated } from 'react-native';
+
+import type { CommonCarouselItemProps } from '@AppModels/carouselLayout/CarouselLayout';
+import type { Documentaries } from '@AppModels/documentaries/Documentaries';
+import type { LiveStream } from '@AppModels/liveStreams/LiveStreams';
+import type { SuggestedForYou } from '@AppModels/suggestedForYou/SuggestedForYou';
+import type { Teams } from '@AppModels/teams/Teams';
+import type { Endpoints } from '@AppServices/apiClient';
+
+/**
+ * Union type representing all possible content types that can be displayed in carousels.
+ * Each type corresponds to a different API endpoint and content category.
+ */
+export type ParsedResponseContentData =
+ | SuggestedForYou
+ | LiveStream
+ | Documentaries
+ | Teams;
+
+/**
+ * Base props for rendering individual carousel items.
+ * Contains the essential data needed to display any carousel item.
+ */
+type CarouselRenderItemProps = {
+ /** Content data for the carousel item */
+ item: ParsedResponseContentData;
+ /** Title of the carousel section (e.g., "Recommended for You") */
+ carouselTitle: string | null;
+};
+
+/**
+ * Extended props for interactive carousel items.
+ * Adds navigation and accessibility features to the base render props.
+ */
+export type CarouselItemProps = CarouselRenderItemProps & {
+ /** Function to navigate to item details screen */
+ navigateToDetails?: (
+ itemId: string,
+ linkedContent?: CommonCarouselItemProps['linkedContent'],
+ ) => void;
+ /** Accessibility hint for screen readers */
+ accessibilityHint?: string;
+};
+
+/**
+ * Props for carousel container components.
+ * Defines the data and configuration needed to render entire carousel sections.
+ */
+export type CarouselContainerProps = {
+ /** Array of content items to display in the carousel */
+ data: ParsedResponseContentData[];
+ /** API endpoint identifier for the content type */
+ endpoint: Endpoints;
+ /** Display title for the carousel section */
+ carouselTitle: string | null;
+ /** Animated value for scroll-based effects (parallax, fade, etc.) */
+ scrollY: Animated.Value;
+ /** Accessibility hint for the first item in the carousel */
+ firstItemHint?: string;
+ /** General accessibility hint for carousel items */
+ itemHint?: string;
+};
diff --git a/src/components/containers/CarouselsContainer/CarouselsContainer.tsx b/src/components/containers/CarouselsContainer/CarouselsContainer.tsx
new file mode 100644
index 0000000..d470adc
--- /dev/null
+++ b/src/components/containers/CarouselsContainer/CarouselsContainer.tsx
@@ -0,0 +1,122 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * CarouselsContainer Component
+ *
+ * That manages and renders all carousels in the application.
+ * This component creates a vertical scrolling container that holds multiple horizontal
+ * carousels, handling layout coordination and scroll synchronization between them.
+ *
+ * Key Features:
+ * - Vertical FlatList containing multiple horizontal carousel components
+ * - Scroll position tracking for inter-carousel animations and effects
+ * - Dynamic layout handling for different carousel types (hero, standard)
+ * - Performance optimized with memoized rendering and key extraction
+ * - Theming integration for consistent styling
+ *
+ * Architecture:
+ * - Receives carousel configuration from API via CarouselLayout array
+ * - Delegates individual carousel rendering to SingleCarouselContainer
+ * - Manages global scroll state that affects hero carousel overlay effects
+ * - Handles special layout considerations for hero carousel positioning
+ *
+ * Scroll Behavior:
+ * - Tracks vertical scroll position (scrollY) for animation coordination
+ * - Passes scroll position to child carousels for synchronized effects
+ * - Hero carousel overlay dimming is controlled by this scroll position
+ *
+ * Performance Optimizations:
+ * - Memoized renderItem function prevents unnecessary re-renders
+ * - Optimized key extraction for FlatList performance
+ * - Conditional styling based on carousel type
+ *
+ * Example Structure:
+ * ```
+ * CarouselsContainer
+ * ├── HeroCarousel (featured content)
+ * ├── MovieCarousel (continue watching)
+ * ├── MovieCarousel (recommended)
+ * └── MovieCarousel (sports highlights)
+ * ```
+ */
+
+import React, { useCallback } from 'react';
+import type { StyleProp, ViewStyle, ViewProps } from 'react-native';
+import { View, Animated, useAnimatedValue } from 'react-native';
+
+import { useThemedStyles } from '@AppTheme';
+import type { CarouselLayout } from '@AppModels/carouselLayout/CarouselLayout';
+import { SingleCarouselContainer } from './SingleCarouselContainer';
+import { getCarouselContainerStyles } from './styles';
+
+/**
+ * Props for the CarouselsContainer component
+ */
+type CarouselsContainerProps = {
+ /** Test identifier for automated testing */
+ testID: ViewProps['testID'];
+ /** Array of carousel configurations from API */
+ carouselLayout: CarouselLayout[];
+ /** Optional style overrides for container */
+ style?: StyleProp;
+};
+
+// Default test ID for consistency
+const testId = 'carouselcontainer';
+
+// Memoized key extractor for FlatList performance optimization
+const keyExtractor = (item: CarouselLayout) => `single-carousel-${item.itemId}`;
+
+export const CarouselsContainer = ({
+ testID = testId,
+ carouselLayout,
+ style,
+}: CarouselsContainerProps) => {
+ const styles = useThemedStyles(getCarouselContainerStyles);
+ const scrollY = useAnimatedValue(0);
+
+ // Memoized render function for individual carousel items
+ const renderItem = useCallback(
+ ({ item, index }: { item: CarouselLayout; index: number }) => {
+ return (
+
+ );
+ },
+ [carouselLayout.length, scrollY],
+ );
+
+ return (
+
+ {/* Vertical FlatList containing multiple horizontal carousels */}
+
+
+ );
+};
diff --git a/src/components/containers/CarouselsContainer/SingleCarouselContainer.tsx b/src/components/containers/CarouselsContainer/SingleCarouselContainer.tsx
new file mode 100644
index 0000000..6f69a0d
--- /dev/null
+++ b/src/components/containers/CarouselsContainer/SingleCarouselContainer.tsx
@@ -0,0 +1,77 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+import type { Animated } from 'react-native';
+import { Text, View } from 'react-native';
+
+import { useDynamicContent } from '@Api/useDynamicContent';
+
+import { useThemedStyles } from '@AppTheme';
+import { CarouselComponent } from '@AppComponents/containers/CarouselsContainer/utils';
+import type { CarouselLayout } from '@AppModels/carouselLayout/CarouselLayout';
+import { HintBuilder } from '@AppServices/a11y';
+import { useTranslation } from '@AppServices/i18n';
+import {
+ DIRECTION_PARAMETER,
+ DESTINATION_PARAMETER as DESTINATION_PARAMETER,
+} from '@AppServices/i18n/constants';
+import { getSingleCarouselStyles } from './styles';
+
+export const SingleCarouselContainer = ({
+ item,
+ index,
+ totalItems,
+ scrollY,
+}: {
+ item: CarouselLayout;
+ index: number;
+ totalItems: number;
+ scrollY: Animated.Value;
+}) => {
+ const styles = useThemedStyles(getSingleCarouselStyles);
+ const { t } = useTranslation();
+ const { data, isLoading } = useDynamicContent({
+ endpoint: item.endpoint,
+ });
+
+ if (!data || isLoading) {
+ return null;
+ }
+
+ const itemHint = new HintBuilder()
+ .appendHint(t('a11y-hint-there-is-a-movie-list-above'), {
+ type: 'nth-but-first-item',
+ index,
+ })
+ .appendHint(t('a11y-hint-there-is-a-movie-list-below'), {
+ type: 'nth-but-last-item',
+ index,
+ length: totalItems,
+ })
+ .asString(' ');
+
+ return (
+
+ {item.carouselTitle && (
+
+ {item.carouselTitle || t('carousel-no-title')}
+
+ )}
+
+
+ );
+};
diff --git a/src/components/containers/CarouselsContainer/index.ts b/src/components/containers/CarouselsContainer/index.ts
new file mode 100644
index 0000000..4471a3c
--- /dev/null
+++ b/src/components/containers/CarouselsContainer/index.ts
@@ -0,0 +1 @@
+export { CarouselsContainer } from './CarouselsContainer';
diff --git a/src/components/containers/CarouselsContainer/styles.ts b/src/components/containers/CarouselsContainer/styles.ts
new file mode 100644
index 0000000..c70d440
--- /dev/null
+++ b/src/components/containers/CarouselsContainer/styles.ts
@@ -0,0 +1,28 @@
+import { StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+
+export const getCarouselContainerStyles = () =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ contentContainerWithHero: {},
+ });
+
+export const getSingleCarouselStyles = ({ colors, typography }: AppTheme) =>
+ StyleSheet.create({
+ singleCarouselView: {
+ justifyContent: 'center',
+ flex: 1,
+ paddingHorizontal: 50,
+ paddingBottom: 40,
+ },
+ text: {
+ color: colors.onBackground,
+ fontSize: typography.size?.fontSize?.title?.lg,
+ fontWeight: 'bold',
+ paddingBottom: 10,
+ paddingTop: 40,
+ },
+ });
diff --git a/src/components/containers/CarouselsContainer/utils.tsx b/src/components/containers/CarouselsContainer/utils.tsx
new file mode 100644
index 0000000..effc151
--- /dev/null
+++ b/src/components/containers/CarouselsContainer/utils.tsx
@@ -0,0 +1,69 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+import type { Animated } from 'react-native';
+
+import {
+ CardItemsCarousel,
+ HeroCarouselContainer,
+ SquareItemsCarousel,
+} from '@AppComponents/carousels';
+import { CarouselType } from '@AppModels/carouselLayout/CarouselLayout';
+import type { CarouselContainerProps } from '../../carousels/types';
+
+type GetCarouselComponentProps = {
+ carouselType: CarouselType;
+ carouselTitle: string | null;
+ firstItemHint: string;
+ itemHint?: string;
+ scrollY: Animated.Value;
+} & CarouselContainerProps;
+
+export const CarouselComponent = ({
+ carouselType,
+ carouselTitle,
+ data,
+ endpoint,
+ firstItemHint,
+ itemHint,
+ scrollY,
+}: GetCarouselComponentProps) => {
+ switch (carouselType) {
+ case CarouselType.Hero:
+ return (
+
+ );
+ case CarouselType.Square:
+ return (
+
+ );
+ case CarouselType.Card:
+ return (
+
+ );
+ default:
+ return null;
+ }
+};
diff --git a/src/components/containers/ScreenContainer/ScreenContainer.tsx b/src/components/containers/ScreenContainer/ScreenContainer.tsx
new file mode 100644
index 0000000..63b9a04
--- /dev/null
+++ b/src/components/containers/ScreenContainer/ScreenContainer.tsx
@@ -0,0 +1,73 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * ScreenContainer Component
+ *
+ * Base container component used by all screens in the Kepler Sports App.
+ * Provides consistent theming, layout, and testing infrastructure across
+ * the entire application.
+ *
+ * Key Features:
+ * - Consistent theming integration with useThemedStyles
+ * - Standardized testID structure for automated testing
+ * - Flexible styling with style prop override capability
+ * - Safe area and layout handling for TV interfaces
+ *
+ * Common Use Cases:
+ * - Wrapping all main screen components (Home, Details, etc.)
+ * - Providing consistent base styling and behavior
+ * - Ensuring proper theme application across screens
+ * - Standardizing test automation selectors
+ *
+ * Example Usage:
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { StyleProp, ViewStyle, ViewProps } from 'react-native';
+import { View } from 'react-native';
+
+import { useThemedStyles } from '@AppSrc/theme';
+import { getScreenContainerStyles } from './styles';
+
+/**
+ * Props for the ScreenContainer component
+ */
+export type ScreenContainerProps = {
+ /** Test identifier for automated testing (defaults to 'screencontainer') */
+ testID: ViewProps['testID'];
+ /** Child components to render within the container */
+ children: ReactElement | ReactElement[];
+ /** Optional style overrides for custom screen layouts */
+ style?: StyleProp;
+};
+
+// Default test ID for consistency across screens
+const testId = 'screencontainer';
+
+/**
+ * ScreenContainer Implementation
+ *
+ * Renders a themed container view that serves as the base for all screens.
+ */
+export const ScreenContainer = ({
+ testID = testId,
+ style,
+ children,
+}: ScreenContainerProps) => {
+ // Apply themed styles for consistent appearance across the app
+ const styles = useThemedStyles(getScreenContainerStyles);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/containers/ScreenContainer/__tests__/ScreenContainer.test.tsx b/src/components/containers/ScreenContainer/__tests__/ScreenContainer.test.tsx
new file mode 100644
index 0000000..cf43477
--- /dev/null
+++ b/src/components/containers/ScreenContainer/__tests__/ScreenContainer.test.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react';
+import 'react-native';
+import { View } from 'react-native';
+
+import { screen } from '@testing-library/react-native';
+
+import { renderWithTheme } from '@AppTestUtils/render';
+import { ScreenContainer } from '../ScreenContainer';
+
+describe('ScreenContainer', () => {
+ const renderScreen = (testId?: string) =>
+ renderWithTheme(
+
+
+ ,
+ );
+
+ it('should render ScreenContainer correctly', () => {
+ renderScreen();
+
+ expect(screen.getByTestId('screencontainer')).toBeOnTheScreen();
+ });
+
+ it('should render ScreenContainer and pass testId correctly', () => {
+ renderScreen('screencontainer-testScreen');
+
+ expect(screen.getByTestId('screencontainer-testScreen')).toBeOnTheScreen();
+ });
+
+ it('should ScreenContainer render children correctly', () => {
+ renderScreen();
+
+ expect(screen.getByTestId('viewChild')).toBeOnTheScreen();
+ });
+
+ it('should ScreenContainer applied styles properly', () => {
+ renderScreen();
+
+ expect(screen.getByTestId('screencontainer')).toHaveStyle({
+ justifyContent: 'center',
+ });
+ });
+});
diff --git a/src/components/containers/ScreenContainer/index.ts b/src/components/containers/ScreenContainer/index.ts
new file mode 100644
index 0000000..a48b589
--- /dev/null
+++ b/src/components/containers/ScreenContainer/index.ts
@@ -0,0 +1 @@
+export { ScreenContainer } from './ScreenContainer';
diff --git a/src/components/containers/ScreenContainer/styles.ts b/src/components/containers/ScreenContainer/styles.ts
new file mode 100644
index 0000000..6baf0bd
--- /dev/null
+++ b/src/components/containers/ScreenContainer/styles.ts
@@ -0,0 +1,11 @@
+import { StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+
+export const getScreenContainerStyles = ({ colors }: AppTheme) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ });
diff --git a/src/components/containers/index.ts b/src/components/containers/index.ts
new file mode 100644
index 0000000..9e1d4f6
--- /dev/null
+++ b/src/components/containers/index.ts
@@ -0,0 +1,2 @@
+export { ScreenContainer } from './ScreenContainer';
+export { CarouselsContainer } from './CarouselsContainer';
diff --git a/src/components/core/Avatar/Avatar.tsx b/src/components/core/Avatar/Avatar.tsx
new file mode 100644
index 0000000..ee511ca
--- /dev/null
+++ b/src/components/core/Avatar/Avatar.tsx
@@ -0,0 +1,201 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * Avatar Component
+ *
+ * Flexible avatar component for user profiles with:
+ * - Dynamic sizing and circular/rounded rectangle shapes
+ * - Focus state management for TV navigation
+ * - Image fallback to placeholder text (first letter of name)
+ * - Comprehensive accessibility support with auto-generated labels
+ * - Custom styling for focused/unfocused states
+ * - Optional label display below avatar
+ *
+ * Focus Management:
+ * - Tracks focus state internally using useFocusState hook
+ * - Applies different styles when focused for TV navigation
+ * - Supports TV preferred focus for initial focus management
+ *
+ * Accessibility Features:
+ * - Auto-generates aria-label from placeholder name if not provided
+ * - Hides decorative elements from screen readers
+ * - Proper button role for interactive avatars
+ */
+
+import React, { forwardRef, useMemo } from 'react';
+import type {
+ AccessibilityProps,
+ PressableProps,
+ TextStyle,
+ ViewStyle,
+} from 'react-native';
+import { Pressable, View } from 'react-native';
+
+import { useThemedStyles } from '@AppTheme';
+import { useFocusState } from '@AppServices/focusGuide';
+import { useTranslation } from '@AppServices/i18n';
+import { PROFILE_NAME_PARAMETER } from '@AppServices/i18n/constants';
+import { Text } from '../Text';
+import { AvatarImage } from './AvatarImage';
+import { getAvatarStyles } from './styles';
+
+export type AvatarRef = View;
+
+export type AvatarProps = {
+ /** Avatar size in pixels (width and height) */
+ size?: number;
+ /** Optional image URL for avatar */
+ image?: string;
+ /** Name used for fallback text and accessibility label */
+ placeholder: string;
+ /** Press handler for interactive avatars */
+ onPress?: PressableProps['onPress'];
+ /** Whether this avatar should receive initial TV focus */
+ hasTVPreferredFocus?: boolean;
+ /** Focus event handler */
+ onFocus?: () => void;
+ /** Blur event handler */
+ onBlur?: () => void;
+ /** Whether to render as circle (true) or rounded rectangle (false) */
+ isCircle?: boolean;
+ /** Optional label text displayed below avatar */
+ label?: string;
+ /** Custom styles for avatar wrapper */
+ wrapperStyles?: ViewStyle;
+ /** Custom styles applied when avatar is focused */
+ wrapperStylesFocused?: ViewStyle;
+ /** Custom styles for image container */
+ imageWrapperStyles?: ViewStyle;
+ /** Custom styles for label text */
+ labelStyles?: TextStyle;
+ /** Test identifier */
+ testID?: string;
+} & AccessibilityProps;
+
+/**
+ * Avatar Component Implementation
+ *
+ * Renders a user avatar with focus state management and dynamic styling.
+ * Handles image display with fallback to placeholder text, and manages
+ * focus states for TV navigation.
+ */
+export const Avatar = forwardRef(
+ (
+ {
+ size = 50,
+ image,
+ placeholder,
+ onPress,
+ hasTVPreferredFocus = false,
+ onFocus,
+ onBlur,
+ isCircle = false,
+ label,
+ imageWrapperStyles,
+ labelStyles,
+ wrapperStyles,
+ wrapperStylesFocused,
+ testID = 'avatar',
+ ...a11yProps
+ },
+ ref,
+ ) => {
+ const { t } = useTranslation();
+
+ // Manage focus state for TV navigation with custom handlers
+ const { handleBlur, handleFocus, isFocused } = useFocusState({
+ onFocus,
+ onBlur,
+ initialState: hasTVPreferredFocus,
+ });
+
+ const styles = useThemedStyles(getAvatarStyles);
+
+ // Calculate dynamic size and border radius based on shape preference
+ const wrapperSize = useMemo(
+ () => ({
+ width: size,
+ height: size,
+ borderRadius: isCircle ? size / 2 : size / 10, // Circle vs rounded rectangle
+ }),
+ [size, isCircle],
+ );
+
+ // Calculate placeholder text size proportional to avatar size
+ const placeholderStyles: Pick =
+ useMemo(() => {
+ const textSize = size / 2;
+
+ return {
+ fontSize: textSize,
+ lineHeight: textSize,
+ };
+ }, [size]);
+
+ return (
+
+ {/* Image container with focus-aware styling */}
+
+ {image ? (
+ // Display user image if available
+
+ ) : (
+ // Fallback to first letter of placeholder name
+
+ {placeholder.length && placeholder[0]
+ ? placeholder[0].toUpperCase()
+ : '-'}
+
+ )}
+
+
+ {/* Optional label below avatar */}
+ {label && (
+
+
+ {label}
+
+
+ )}
+
+ );
+ },
+);
diff --git a/src/components/core/Avatar/AvatarImage.tsx b/src/components/core/Avatar/AvatarImage.tsx
new file mode 100644
index 0000000..4570509
--- /dev/null
+++ b/src/components/core/Avatar/AvatarImage.tsx
@@ -0,0 +1,30 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+import React from 'react';
+import { type AccessibilityProps, Image } from 'react-native';
+
+import { imageStyle } from './styles';
+
+type AvatarImageProps = {
+ image: string;
+ placeholder: string;
+ borderRadius: number;
+} & Omit;
+
+export const AvatarImage = ({
+ image,
+ placeholder,
+ borderRadius,
+ ...props
+}: AvatarImageProps) => {
+ return (
+
+ );
+};
diff --git a/src/components/core/Avatar/__tests__/Avatar.test.tsx b/src/components/core/Avatar/__tests__/Avatar.test.tsx
new file mode 100644
index 0000000..260a56b
--- /dev/null
+++ b/src/components/core/Avatar/__tests__/Avatar.test.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+
+import { screen } from '@testing-library/react-native';
+
+import { renderWithTheme } from '@AppTestUtils/render';
+import { rntlUser } from '@AppTestUtils/rntlUser';
+import { Avatar } from '../Avatar';
+
+describe('', () => {
+ it('should display properly with name alias', () => {
+ renderWithTheme();
+
+ expect(
+ screen.getByText('J', { includeHiddenElements: true }),
+ ).toBeOnTheScreen();
+ });
+
+ it('should display placeholder with empty string as a name', () => {
+ renderWithTheme();
+
+ expect(
+ screen.getByText('-', { includeHiddenElements: true }),
+ ).toBeOnTheScreen();
+ });
+
+ it('should display image', () => {
+ renderWithTheme();
+
+ expect(
+ screen.getByLabelText('johny avatar', { includeHiddenElements: true }),
+ ).toBeOnTheScreen();
+ });
+
+ it('should display label', () => {
+ renderWithTheme(
+ ,
+ );
+
+ expect(
+ screen.getByText('John', { includeHiddenElements: true }),
+ ).toBeOnTheScreen();
+ });
+
+ it('should run function on click', async () => {
+ const onPresMock = jest.fn();
+
+ renderWithTheme();
+
+ await rntlUser.press(
+ screen.getByText('J', { includeHiddenElements: true }),
+ );
+
+ expect(onPresMock).toHaveBeenCalled();
+ });
+
+ it('should be focused after mount', () => {
+ renderWithTheme(
+ ,
+ );
+
+ expect(
+ screen.getByLabelText('avatar-a11y-label', {
+ includeHiddenElements: true,
+ }),
+ ).toHaveStyle({
+ borderColor: 'red',
+ });
+ });
+
+ it('should display circle variant', () => {
+ const size = 30;
+
+ renderWithTheme();
+
+ expect(
+ screen.getByTestId('avatar-image-wrapper', {
+ includeHiddenElements: true,
+ }),
+ ).toHaveStyle({
+ borderRadius: size / 2,
+ });
+ });
+});
diff --git a/src/components/core/Avatar/index.ts b/src/components/core/Avatar/index.ts
new file mode 100644
index 0000000..27700fe
--- /dev/null
+++ b/src/components/core/Avatar/index.ts
@@ -0,0 +1 @@
+export * from './Avatar';
diff --git a/src/components/core/Avatar/styles.ts b/src/components/core/Avatar/styles.ts
new file mode 100644
index 0000000..8023b51
--- /dev/null
+++ b/src/components/core/Avatar/styles.ts
@@ -0,0 +1,35 @@
+import { StyleSheet } from 'react-native';
+
+import type { AppTheme } from '@AppTheme';
+
+export const getAvatarStyles = ({ colors }: AppTheme) =>
+ StyleSheet.create({
+ wrapper: {
+ alignItems: 'center',
+ },
+ imageWrapper: {
+ backgroundColor: colors.surfaceContainer,
+ borderColor: colors.transparent,
+ borderWidth: 2,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ },
+ imageWrapperFocused: {
+ borderColor: colors.focusPrimary,
+ },
+ placeholder: {
+ fontWeight: '800',
+ },
+ placeholderFocused: {
+ color: colors.focusPrimary,
+ },
+ labelFocused: {
+ color: colors.focusPrimary,
+ },
+ });
+
+export const imageStyle = StyleSheet.create({
+ image: { width: '100%', height: '100%' },
+});
diff --git a/src/components/core/Button/Button.tsx b/src/components/core/Button/Button.tsx
new file mode 100644
index 0000000..7c9cb9f
--- /dev/null
+++ b/src/components/core/Button/Button.tsx
@@ -0,0 +1,72 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: MIT-0
+
+/**
+ * Button Component
+ *
+ * Enhanced button wrapper around Kepler UI Button with:
+ * - Advanced accessibility features for TV navigation
+ * - Automatic aria-hidden management for content when aria-label is present
+ * - Theme integration and custom styling support
+ * - Content wrapper props for flexible layout customization
+ *
+ * Accessibility Logic:
+ * - When aria-label is provided, automatically hides button content from screen readers
+ * - Maintains proper focus and disabled states for TV remote navigation
+ * - Supports custom content wrapper properties for complex button layouts
+ */
+
+import React, { forwardRef } from 'react';
+import { type View, type ViewProps } from 'react-native';
+
+import type { ButtonProps as KeplerButtonProps } from '@amazon-devices/kepler-ui-components';
+import { Button as KeplerUiButton } from '@amazon-devices/kepler-ui-components';
+
+import { useThemedStyles } from '@AppTheme';
+import { getButtonStyles } from './styles';
+
+export type ButtonRef = View;
+export type ButtonProps = KeplerButtonProps & {
+ /**
+ * Allows users to pass arbitrary props to the View wrapping the button contents (label / icon).
+ */
+ contentWrapperProps?: ViewProps;
+};
+
+/**
+ * Enhanced Button Component
+ *
+ * Wraps Kepler UI Button with accessibility enhancements and theme integration.
+ * Automatically manages aria-hidden states when aria-label is provided to prevent
+ * duplicate announcements by screen readers.
+ */
+export const Button = forwardRef(
+ ({ role = 'button', contentWrapperProps, style, ...props }, ref) => {
+ const styles = useThemedStyles(getButtonStyles);
+
+ // Accessibility logic: Hide content from screen readers when aria-label is present
+ // This prevents duplicate announcements (both label and content)
+ const shouldHideContent =
+ props['aria-hidden'] === undefined && props['aria-label'] !== undefined;
+
+ // Apply aria-hidden to content wrapper if needed for accessibility
+ const finalContentWrapperProps = shouldHideContent
+ ? { 'aria-hidden': true, ...contentWrapperProps }
+ : contentWrapperProps;
+
+ // Prepare final props with button accessibility and aria-hidden logic
+ const finalProps = finalContentWrapperProps
+ ? { ...props, contentWrapperProps: finalContentWrapperProps }
+ : props;
+
+ return (
+
+ );
+ },
+);
diff --git a/src/components/core/Button/__tests__/Button.test.tsx b/src/components/core/Button/__tests__/Button.test.tsx
new file mode 100644
index 0000000..e7e8899
--- /dev/null
+++ b/src/components/core/Button/__tests__/Button.test.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import { screen } from '@testing-library/react-native';
+
+import { Button } from '@AppComponents';
+import { renderWithTheme } from '@AppTestUtils/render';
+
+// TODO: [KEP-349] test added to increase coverage, should be replaced with proper test suite
+describe('', () => {
+ it('should display properly with provided label', () => {
+ renderWithTheme(
+