npm package: @sgrsoft/vpe-reactnative-tv-sdk
Description: VPE React Native TV SDK for Android TV / Apple TV (tvOS)
Author: SGRSOFT
Latest version: 0.1.3

# @sgrsoft/vpe-reactnative-tv-sdk

Android TV / Apple TV(tvOS) / Fire TV 비디오 플레이어 SDK

[![Platform - Android TV](https://img.shields.io/badge/platform-Android%20TV-green.svg)](https://developer.android.com/tv)
[![Platform - tvOS](https://img.shields.io/badge/platform-tvOS-black.svg)](https://developer.apple.com/tvos/)
[![Platform - Fire TV](https://img.shields.io/badge/platform-Fire%20TV-orange.svg)](https://developer.amazon.com/apps-and-games/fire-tv)

React Native(react-native-tvos) 기반의 TV 전용 비디오 플레이어 SDK.
웹 SDK(`vpe-react-sdk`)와 동일한 API를 제공하여 플랫폼 간 일관된 개발 경험을 지원한다.

## 주요 기능

- HLS / DASH / MP4 네이티브 재생 (ExoPlayer / AVPlayer)
- DRM 지원 (Widevine + FairPlay)
- IMA 광고 (Google Interactive Media Ads)
- 플레이리스트 (자동 다음 재생)
- 자막 (VTT / SMI, EUC-KR 자동 디코딩)
- 선언적 레이아웃 커스터마이징
- 라이브 스트림 + LL-HLS 저지연 모드
- 다국어 (ko / en / ja)
- 이어보기 (onExit + initialPosition)
- D-pad / 리모컨 네비게이션
- HLS ABR 화질 선택
- 캡처 방지
- vpe-core-sdk 연동 (라이선스, MA 분석)

## 설치

```bash
# npm
npm install @sgrsoft/vpe-reactnative-tv-sdk

# yarn
yarn add @sgrsoft/vpe-reactnative-tv-sdk
```

### peerDependencies 설치

```bash
yarn add react react-native @sgrsoft/react-native-video @sgrsoft/vpe-core-sdk \
  @sgrsoft/vpe-react-native-ui @react-native-async-storage/async-storage
```

> **필수**: `react-native`는 반드시 `react-native-tvos`로 alias되어야 한다.
> ```jsonc
> // package.json
> "react-native": "npm:react-native-tvos@0.83.0-0"
> ```

### 선택적 의존성

```bash
# 캡처 방지 기능 사용 시
yarn add react-native-capture-protection
```

### iOS (tvOS)

```bash
cd ios && pod install
```

### Android TV

IMA 광고를 사용하는 경우 `android/gradle.properties`에 추가:

```properties
RNVideo_useExoplayerIMA=true
```

## 빠른 시작

```tsx
import React, { useRef } from 'react';
import { View, StyleSheet } from 'react-native';
import { VpePlayer } from '@sgrsoft/vpe-reactnative-tv-sdk';
import type { PlayerHandle } from '@sgrsoft/vpe-reactnative-tv-sdk';

function App() {
  const playerRef = useRef<PlayerHandle>(null);

  return (
    <View style={styles.container}>
      <VpePlayer
        ref={playerRef}
        accessKey="YOUR_ACCESS_KEY"
        options={{
          playlist: [{
            file: 'https://example.com/video.m3u8',
            description: { title: '영상 제목' },
          }],
          autostart: true,
        }}
        onBack={() => {
          // 뒤로가기 처리
        }}
        onEvent={(event) => {
          if (event.type === 'ready') {
            console.log('Player ready');
          }
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#000' },
});
```

## Props (PlayerProps)

| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `accessKey` | `string` | - | API 접근 키 (필수) |
| `devAppId` | `string` | - | 로컬 테스트용 앱 식별자. 자동 감지 실패 시 fallback |
| `platform` | `'pub' \| 'gov'` | `'pub'` | 플랫폼 |
| `stage` | `string` | `'prod'` | 스테이지 |
| `isDev` | `boolean` | `false` | 개발 모드 |
| `options` | `PlayerOptions` | - | 플레이어 옵션 |
| `layout` | `ControlBarLayout \| ControlBarLayoutVariant \| ControlBarLayoutResponsive` | - | 레이아웃 커스터마이징 |
| `scopeId` | `string` | - | 스코프 ID |
| `onEvent` | `(event: PlayerEvent) => void` | - | 이벤트 핸들러 |
| `onBack` | `() => void` | - | 뒤로가기 콜백 (TV 전용) |
| `onExit` | `(info: PlayerExitInfo) => void` | - | 플레이어 종료 시 재생 정보 전달 (TV 전용) |
| `initialPosition` | `number` | - | 시작 위치 (초 단위, 이어보기용, TV 전용) |
| `errorOverride` | `ReactNode \| ComponentType \| Function` | - | 커스텀 에러 UI |

## Options (PlayerOptions)

### 재생

| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `playlist` | `PlaylistItem[]` | - | 재생할 미디어 목록 |
| `autostart` | `boolean` | `true` | 자동 재생 |
| `muted` | `boolean` | `false` | 초기 음소거 |
| `repeat` | `boolean` | `false` | 반복 재생 |
| `playIndex` | `number` | `0` | 시작 재생 인덱스 |
| `token` | `string` | - | 스트림 URL 토큰 |
| `lowLatencyMode` | `boolean` | `false` | LL-HLS 저지연 모드 |

### UI

| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `controls` | `boolean` | `true` | 컨트롤바 표시 |
| `controlActiveTime` | `number` | `3000` | 컨트롤바 자동 숨김 (ms) |
| `progressBarColor` | `string` | - | 진행바 색상 |
| `controlBtn` | `object` | - | 버튼별 표시 on/off |
| `descriptionNotVisible` | `boolean` | `false` | 메타 설명 숨김 |
| `lang` | `string` | `'ko'` | UI 언어 (`ko` / `en` / `ja`) |
| `playRateSetting` | `number[]` | `[0.5, 0.75, 1, 1.5, 2]` | 재생 속도 목록 |

### 자막 / 워터마크

| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `visibleWatermark` | `boolean` | `false` | 워터마크 표시 |
| `watermarkText` | `string` | - | 워터마크 텍스트 |
| `watermarkConfig` | `object` | - | 워터마크 위치/투명도/랜덤 이동 설정 |

### 광고 / 보안

| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `ads` | `{ tagUrl: string; enabled?: boolean }` | - | IMA 광고 설정 |
| `screenRecordingPrevention` | `boolean` | `false` | 캡처/녹화 방지 (TV 전용) |

## PlaylistItem

```typescript
type PlaylistItem = {
  file?: string;          // 미디어 URL (.mp4, .m3u8, .mpd)
  poster?: string;        // 포스터 이미지 URL
  drm?: DrmConfig;        // DRM 설정
  description?: {
    title?: string;
    profile_name?: string;
    profile_image?: string;
    created_at?: string;
  };
  vtt?: Array<{           // 자막 트랙
    label?: string;       // 표시 이름 ("한국어", "English")
    src?: string;         // VTT 또는 SMI 파일 URL
    default?: boolean;    // 기본 자막 여부
  }>;
};
```

### 자막 예제

```tsx
options={{
  playlist: [{
    file: 'https://example.com/video.m3u8',
    vtt: [
      { label: '한국어', src: 'https://example.com/ko.vtt', default: true },
      { label: 'English', src: 'https://example.com/en.vtt' },
      { label: '日本語', src: 'https://example.com/ja.smi' },  // SMI도 지원
    ],
  }],
}}
```

## ref 메소드 (PlayerHandle)

```tsx
const playerRef = useRef<PlayerHandle>(null);

// 재생 제어
playerRef.current?.play();
playerRef.current?.pause();
playerRef.current?.mute();

// 시킹
playerRef.current?.currentTime(30);  // 30초로 이동

// 플레이리스트
playerRef.current?.next();
playerRef.current?.prev();
playerRef.current?.addNextSource({ file: 'https://...' });
playerRef.current?.addPrevSource({ file: 'https://...' });

// 토큰 갱신
playerRef.current?.tokenChange('NEW_TOKEN');

// 레이아웃 변경
playerRef.current?.layout({ bottom: [{ items: ['PlayBtn'], wrapper: 'Group' }] }, true);

// 컨트롤바 제어
playerRef.current?.controlBarActive();
playerRef.current?.controlBarDeactive();
playerRef.current?.uiHidden();
playerRef.current?.uiVisible();

// 재생 모드 강제 전환
playerRef.current?.changePlayMode('live');
```

| 메소드 | 설명 | TV 참고사항 |
|--------|------|------------|
| `play()` | 재생 | - |
| `pause()` | 일시정지 | - |
| `mute()` | 음소거 토글 | - |
| `prev()` / `next()` | 트랙 이동 | - |
| `currentTime(time)` | 시킹 (초) | - |
| `volume(vol)` | 볼륨 설정 | TV: no-op (시스템 볼륨 사용) |
| `layout(layout, merge)` | 레이아웃 변경 | - |
| `tokenChange(token)` | DRM 토큰 갱신 | - |
| `addNextSource(source)` | 다음 소스 추가 | - |
| `addPrevSource(source)` | 이전 소스 추가 | - |
| `uiHidden()` / `uiVisible()` | 컨트롤바 숨김/표시 | - |
| `controlBarActive()` / `controlBarDeactive()` | 컨트롤바 활성화/비활성화 | - |
| `changePlayMode(mode)` | VOD/Live 모드 전환 | - |
| `isSeeking()` | SeekBar 시킹 상태 조회 | - |
| `fullscreen()` / `pip()` | 전체화면 / PiP | TV: no-op (항상 전체화면, PiP 미지원) |

## 이벤트 (PlayerEvent)

```tsx
<VpePlayer
  onEvent={(event) => {
    console.log(event.type, event.state);
  }}
/>
```

### 이벤트 타입

| 카테고리 | 이벤트 | 설명 |
|---------|--------|------|
| **재생** | `ready`, `canplay`, `start` | 플레이어 준비/재생 시작 |
| | `play`, `playing`, `pause`, `ended` | 재생 상태 변경 |
| | `timeupdate`, `durationchange` | 시간/길이 변경 |
| **버퍼링** | `bufferingStart`, `bufferingEnd` | 버퍼링 시작/종료 |
| | `loadingStart`, `loadingEnd` | 로딩 시작/종료 |
| **시킹** | `seeking`, `seeked`, `waiting` | 시킹 상태 |
| **UI** | `controlbarActive`, `controlbarDeactive` | 컨트롤바 표시/숨김 |
| | `stateChange` | 상태 변경 |
| **트랙** | `next`, `nextTrack`, `prev`, `prevTrack` | 트랙 이동 |
| | `skipForward`, `skipBack` | ±10초 시킹 |
| | `playlistChange` | 플레이리스트 변경 |
| | `quality_change`, `volumechange` | 화질/볼륨 변경 |
| **광고** | `adStart`, `adStarted`, `adComplete` | 광고 시작/완료 |
| | `adSkip`, `adSkipped`, `adError` | 광고 스킵/에러 |
| | `adLoaded`, `adBreakStart`, `adBreakEnd` | 광고 로드/브레이크 |
| **에러** | `error` | 에러 발생 |

## DRM 설정

### Widevine (Android TV)

```tsx
options={{
  playlist: [{
    file: 'https://example.com/video.mpd',
    drm: {
      'com.widevine.alpha': {
        licenseUri: 'https://license-server.com/widevine',
        licenseRequestHeader: {
          'pallycon-customdata-v2': 'TOKEN',
        },
      },
    },
  }],
}}
```

### FairPlay (tvOS)

```tsx
options={{
  playlist: [{
    file: 'https://example.com/video.m3u8',
    drm: {
      'com.apple.fps': {
        licenseUri: 'https://license-server.com/fairplay',
        certificateUri: 'https://license-server.com/cert',
        certificateRequestHeader: {
          'pallycon-customdata-v2': 'TOKEN',
        },
      },
    },
  }],
}}
```

### react-native-video 직접 DRM 형식

```tsx
options={{
  playlist: [{
    file: 'https://example.com/video.m3u8',
    drm: {
      type: 'fairplay',
      licenseServer: 'https://license-server.com/fairplay',
      certificateUrl: 'https://license-server.com/cert',
      headers: { 'pallycon-customdata-v2': 'TOKEN' },
    },
  }],
}}
```

## IMA 광고

```tsx
<VpePlayer
  accessKey="YOUR_ACCESS_KEY"
  options={{
    playlist: [{ file: 'https://example.com/video.m3u8' }],
    ads: {
      tagUrl: 'https://pubads.g.doubleclick.net/gampad/ads?...',
      enabled: true,  // 기본: true
    },
  }}
/>
```

### 네이티브 빌드 설정 (필수)

**tvOS** — `ios/Podfile`:

```ruby
$RNVideoUseGoogleIMA = true
```

이후 `pod install` 실행.

**Android TV** — `android/gradle.properties`:

```properties
RNVideo_useExoplayerIMA=true
```

광고 재생 중에는 컨트롤바가 자동으로 숨겨지며, 네이티브 IMA SDK가 자체 UI를 렌더링한다.

## 레이아웃 커스터마이징

5개 섹션(`top`, `upper`, `center`, `lower`, `bottom`) + `seekbar`으로 구성된 선언적 레이아웃 시스템을 제공한다.

### 사용 가능한 아이템

| 아이템 | 설명 |
|--------|------|
| `PlayBtn` | 재생/일시정지/다시재생 |
| `BigPlayBtn` | 중앙 대형 재생 + 이전/다음 |
| `BackBtn` | 뒤로가기 |
| `SeekBar` | 탐색바 |
| `SkipBackBtn` / `SkipForwardBtn` | ±10초 시킹 |
| `PrevBtn` / `NextBtn` / `NextPrevBtn` | 트랙 이동 |
| `TimeBtn` / `CurrentTimeBtn` / `DurationBtn` | 시간 표시 |
| `MuteBtn` / `VolumeBtn` | 음소거/볼륨 |
| `SubtitleBtn` | 자막 메뉴 |
| `SettingBtn` | 설정 메뉴 |
| `MetaDesc` | 영상 메타정보 |
| `Blank` / `BlankBtn` | 빈 공간 |

### 커스텀 레이아웃 예제

```tsx
<VpePlayer
  accessKey="YOUR_ACCESS_KEY"
  options={{ playlist: [...] }}
  layout={{
    top: [
      { items: ['BackBtn'], wrapper: 'Group' },
      { items: ['MetaDesc'] },
      { wrapper: 'Blank', items: [] },
    ],
    center: [
      { items: ['BigPlayBtn'], align: 'center' },
    ],
    lower: [
      { wrapper: 'Blank', items: ['SeekBar'], align: 'center' },
    ],
    bottom: [
      { items: ['PlayBtn'], wrapper: 'Group' },
      { items: ['NextPrevBtn'], wrapper: 'Group' },
      { items: ['MuteBtn'], wrapper: 'Group' },
      { items: ['TimeBtn'], wrapper: 'Group' },
      { wrapper: 'Blank', items: [] },
      { items: ['SubtitleBtn', 'SettingBtn'], wrapper: 'Group' },
    ],
  }}
/>
```

### VOD / Live 변형

```tsx
layout={{
  vod: { /* VOD 전용 레이아웃 */ },
  live: { /* Live 전용 레이아웃 */ },
}}
```

### 런타임 레이아웃 변경

```tsx
// 부분 업데이트 (merge=true, 기본값)
playerRef.current?.layout({ bottom: [{ items: ['PlayBtn'], wrapper: 'Group' }] }, true);

// 전체 교체 (merge=false)
playerRef.current?.layout(newLayout, false);
```

## 라이브 스트림

라이브 스트림은 자동으로 감지된다:
1. HLS 매니페스트에서 `#EXT-X-ENDLIST` 부재 확인
2. Duration이 0 또는 Infinity
3. 재생 중 duration 변화 감지

라이브 감지 시:
- TimeBtn에 빨간 dot + "LIVE" 표시
- 사이드 메뉴에서 재생속도 메뉴 숨김
- SeekBar에서 시킹 비활성화

### LL-HLS 저지연 모드

```tsx
options={{
  lowLatencyMode: true,
  playlist: [{ file: 'https://example.com/live.m3u8' }],
}}
```

- Android TV: ExoPlayer `targetOffsetMs: 2000`
- tvOS: AVPlayer `preferredForwardBufferDuration: 2`
- 5초 이상 뒤처지면 라이브 엣지로 자동 복귀

## 이어보기 (Resume Playback)

SDK는 `onExit` 콜백으로 종료 시 재생 정보를 제공하고, `initialPosition`으로 시작 위치를 지정한다. 저장/복원 로직은 앱에서 구현한다.

```tsx
function PlayerScreen() {
  const [startPos, setStartPos] = useState<number | undefined>();

  useEffect(() => {
    // 저장된 위치 불러오기
    AsyncStorage.getItem('lastPosition').then(val => {
      if (val) setStartPos(Number(val));
    });
  }, []);

  return (
    <VpePlayer
      accessKey="YOUR_ACCESS_KEY"
      options={{ playlist: [...] }}
      initialPosition={startPos}
      onExit={(info) => {
        // info: { currentTime, duration, playIndex, sourceUri }
        AsyncStorage.setItem('lastPosition', String(info.currentTime));
      }}
    />
  );
}
```

## 에러 처리 (errorOverride)

기본 에러 UI 대신 커스텀 에러 UI를 렌더링할 수 있다.

```tsx
// 함수 패턴
<VpePlayer
  errorOverride={(info) => (
    <View>
      <Text>에러: {info.errorCode}</Text>
      <Text>{info.errorMessage}</Text>
    </View>
  )}
/>

// 컴포넌트 패턴
<VpePlayer errorOverride={MyErrorComponent} />

// ReactNode 패턴
<VpePlayer errorOverride={<Text>에러가 발생했습니다</Text>} />
```

## 플랫폼별 참고사항

### 해상도

| 플랫폼 | 보고 해상도 | 스케일 | 실제 해상도 |
|--------|-----------|--------|-----------|
| Apple TV | 1920 x 1080 | 1x | 1920 x 1080 |
| Android TV | 960 x 540 | 2x | 1920 x 1080 |

Android TV는 960x540 dp로 보고되므로 SDK 내부에서 자동 스케일 보정을 적용한다.

### 리모컨 이벤트

| 이벤트 | Apple TV | Android TV | 설명 |
|--------|---------|-----------|------|
| `select` | O | O | 확인/선택 |
| `playPause` | O | O | 재생/일시정지 |
| `up/down/left/right` | O | O | 방향키 |
| `menu` | O | X | 메뉴 (뒤로가기) |
| `play` / `pause` | X | O | 재생/일시정지 전용 |
| `rewind` / `fastForward` | X | O | 되감기/빨리감기 |

### 웹 SDK와의 차이점

| 기능 | 웹 | TV |
|------|-----|-----|
| 전체화면 | `requestFullscreen()` | 항상 전체화면 (no-op) |
| PiP | `requestPictureInPicture()` | 미지원 (no-op) |
| 볼륨 | 슬라이더 UI | 시스템 볼륨 (MuteBtn = 뮤트 토글) |
| 입력 | 마우스/키보드 | 리모컨 D-pad |
| 자막 | DOM TextTrack API | VTT/SMI 오버레이 렌더링 |
| 광고 | IMA + NAM | IMA만 지원 |

## 버전 호환성

| 항목 | 최소 | 권장 |
|------|------|------|
| react-native-tvos | 0.83.1-1 | 0.84.0-0 |
| React | 19.x | 19.2.x |
| Node.js | 22.11+ | 22.x LTS |
| tvOS | 15.1+ | 16.0+ |
| Android API | 24 (7.0) | 28+ |
| TypeScript | 5.0+ | 5.8.x |

## 라이선스

MIT