Flutter 튜토리얼 21편: 비디오 재생
요약
핵심 요지
- 문제 정의: 앱에서 비디오를 재생하려면 플랫폼별 네이티브 플레이어를 다뤄야 하고, 컨트롤 UI도 직접 만들어야 한다.
- 핵심 주장:
video_player1 패키지와chewie2를 함께 사용하면 플랫폼 차이 없이 완성된 비디오 플레이어를 구현할 수 있다. - 주요 근거:
VideoPlayerController3로 비디오를 로드하고,AspectRatio와VideoPlayer위젯으로 화면에 표시하며, Chewie가 컨트롤 UI를 제공한다. - 실무 기준: iOS 시뮬레이터에서는 에셋 비디오만 재생 가능하고, 네트워크 비디오는 실제 기기에서 테스트해야 한다.
- 한계: Linux와 Windows는 video_player를 지원하지 않는다.
문서가 설명하는 범위
- VideoPlayerController 초기화와 생명주기 관리
- 네트워크와 에셋 비디오 재생
- 재생 컨트롤 구현 (재생/일시정지, 탐색)
- Chewie로 완성된 UI 구현
읽는 시간: 12분 | 난이도: 중급
참고 자료
- Play and pause a video - Flutter 공식 비디오 재생 가이드
- video_player - 비디오 재생 플러그인
- chewie - 비디오 플레이어 UI 패키지
문제 상황
앱에서 비디오를 재생하는 것은 단순해 보이지만 여러 복잡한 문제가 있습니다.
비디오 재생의 어려움
문제 1: 플랫폼별로 다른 네이티브 플레이어 (AVPlayer, ExoPlayer)문제 2: 비디오 초기화는 비동기로 완료까지 시간 필요문제 3: 메모리 관리 - 컨트롤러를 제대로 dispose하지 않으면 메모리 누수문제 4: 재생 컨트롤 UI를 직접 만들어야 함해결 방법
Flutter의 video_player 패키지는 iOS에서 AVPlayer를, Android에서 ExoPlayer를 사용해서 비디오를 재생합니다.
통일된 API로 플랫폼 차이를 추상화합니다.
챕터 1: video_player 설정하기
Why
NOTE비디오 재생 기능을 사용하려면 먼저 패키지를 설치하고 플랫폼별 권한을 설정해야 합니다.
네트워크 비디오를 재생하려면 인터넷 접근 권한이 필요합니다.패키지 설치 → 권한 설정 → 컨트롤러 생성 → 비디오 재생
What
NOTE
video_player패키지는 iOS, Android, macOS, Web에서 비디오 재생을 지원합니다.
Linux와 Windows는 현재 지원하지 않습니다.
How
TIP패키지 설치
Terminal window flutter pub add video_playerAndroid 권한 설정 (android/app/src/main/AndroidManifest.xml)
<manifest xmlns:android="http://schemas.android.com/apk/res/android"><application ...>...</application><!-- 네트워크 비디오 재생을 위해 필요 --><uses-permission android:name="android.permission.INTERNET"/></manifest>iOS 설정 (ios/Runner/Info.plist)
<key>NSAppTransportSecurity</key><dict><key>NSAllowsArbitraryLoads</key><true/></dict>macOS 설정 (macos/Runner/DebugProfile.entitlements)
<key>com.apple.security.network.client</key><true/>
Watch out
WARNINGiOS 시뮬레이터에서는 네트워크 비디오를 재생할 수 없습니다.
에셋 비디오만 테스트 가능하며, 네트워크 비디오는 실제 기기에서 테스트해야 합니다.// 시뮬레이터에서 테스트할 때는 에셋 비디오 사용VideoPlayerController.asset('assets/videos/sample.mp4')// 실제 기기에서 네트워크 비디오 테스트VideoPlayerController.networkUrl(Uri.parse('https://...'))
결론: video_player 패키지를 설치하고 플랫폼별 권한을 설정해야 비디오를 재생할 수 있습니다.
챕터 2: VideoPlayerController 초기화
Why
NOTE비디오를 재생하려면 먼저
VideoPlayerController를 생성하고 초기화해야 합니다.
초기화는 비동기 작업이므로 완료될 때까지 기다려야 합니다.graph LR A[Controller 생성] --> B[initialize 호출] B --> C[Future 완료 대기] C --> D[비디오 재생 가능]
What
NOTE
VideoPlayerController는 비디오 소스(네트워크, 에셋, 파일)를 로드하고 재생을 제어합니다.
initialize()를 호출해서 비디오 메타데이터를 로드하고 재생 준비를 완료합니다.
How
TIP네트워크 비디오 초기화
import 'package:flutter/material.dart';import 'package:video_player/video_player.dart';class VideoScreen extends StatefulWidget {const VideoScreen({super.key});@overrideState<VideoScreen> createState() => _VideoScreenState();}class _VideoScreenState extends State<VideoScreen> {late VideoPlayerController _controller;late Future<void> _initializeVideoPlayerFuture;@overridevoid initState() {super.initState();// 네트워크 비디오 컨트롤러 생성_controller = VideoPlayerController.networkUrl(Uri.parse('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',),);// 초기화 Future 저장_initializeVideoPlayerFuture = _controller.initialize();}@overridevoid dispose() {// 반드시 dispose 호출_controller.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return const Placeholder(); // 다음 챕터에서 구현}}에셋 비디오 초기화
// pubspec.yaml에 에셋 등록 필요// flutter:// assets:// - assets/videos/_controller = VideoPlayerController.asset('assets/videos/sample.mp4');_initializeVideoPlayerFuture = _controller.initialize();파일 비디오 초기화 (웹 제외)
import 'dart:io';_controller = VideoPlayerController.file(File('/path/to/video.mp4'));_initializeVideoPlayerFuture = _controller.initialize();
Watch out
WARNING
dispose()를 호출하지 않으면 메모리 누수가 발생합니다.
특히 리스트에서 여러 비디오를 다룰 때 주의해야 합니다.@overridevoid dispose() {// 반드시 컨트롤러 해제_controller.dispose();super.dispose();}초기화 전에
play()를 호출하면 아무 일도 일어나지 않습니다. 반드시initialize()완료 후에 재생을 시작해야 합니다.
결론: VideoPlayerController를 생성하고 initialize()가 완료된 후에 비디오를 재생할 수 있습니다.
챕터 3: 비디오 화면에 표시하기
Why
NOTE초기화된 비디오를 화면에 표시하려면
VideoPlayer위젯을 사용합니다.
비디오 비율을 유지하려면AspectRatio위젯으로 감싸야 합니다.초기화 완료 확인 → AspectRatio로 비율 유지 → VideoPlayer로 표시
What
NOTE
VideoPlayer위젯은VideoPlayerController의 비디오를 화면에 렌더링합니다.
FutureBuilder를 사용해서 초기화 완료 상태를 처리합니다.
How
TIP기본 비디오 표시
@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('비디오 플레이어')),body: FutureBuilder(future: _initializeVideoPlayerFuture,builder: (context, snapshot) {if (snapshot.connectionState == ConnectionState.done) {// 초기화 완료 - 비디오 표시return AspectRatio(aspectRatio: _controller.value.aspectRatio,child: VideoPlayer(_controller),);} else {// 초기화 중 - 로딩 표시return const Center(child: CircularProgressIndicator(),);}},),);}에러 처리 추가
FutureBuilder(future: _initializeVideoPlayerFuture,builder: (context, snapshot) {if (snapshot.hasError) {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [const Icon(Icons.error, size: 48, color: Colors.red),const SizedBox(height: 16),Text('비디오 로드 실패: ${snapshot.error}'),],),);}if (snapshot.connectionState == ConnectionState.done) {return AspectRatio(aspectRatio: _controller.value.aspectRatio,child: VideoPlayer(_controller),);}return const Center(child: CircularProgressIndicator());},)전체 화면 비디오
SizedBox.expand(child: FittedBox(fit: BoxFit.cover,child: SizedBox(width: _controller.value.size.width,height: _controller.value.size.height,child: VideoPlayer(_controller),),),)
Watch out
WARNING
AspectRatio를 사용하지 않으면 비디오가 찌그러지거나 부모 크기에 맞춰 늘어납니다.// 잘못된 방법 - 비율이 깨질 수 있음VideoPlayer(_controller)// 올바른 방법 - 비율 유지AspectRatio(aspectRatio: _controller.value.aspectRatio,child: VideoPlayer(_controller),)
_controller.value.aspectRatio는 초기화 후에만 올바른 값을 반환합니다. 초기화 전에는 1.0을 반환합니다.
결론: FutureBuilder로 초기화 상태를 확인하고, AspectRatio로 비율을 유지하면서 VideoPlayer를 표시합니다.
챕터 4: 재생 컨트롤 구현
Why
NOTE사용자가 비디오를 제어하려면 재생/일시정지, 탐색, 볼륨 조절 등의 컨트롤이 필요합니다.
VideoPlayerController의 메서드로 이러한 기능을 구현합니다.play() → 재생 시작pause() → 일시정지seekTo() → 특정 위치로 이동setVolume() → 볼륨 조절setLooping() → 반복 재생
What
NOTE
VideoPlayerController는 재생 상태와 메타데이터를VideoPlayerValue로 제공합니다.
isPlaying,position,duration등의 속성으로 현재 상태를 확인할 수 있습니다.
How
TIP재생/일시정지 버튼
Scaffold(body: FutureBuilder(future: _initializeVideoPlayerFuture,builder: (context, snapshot) {if (snapshot.connectionState == ConnectionState.done) {return AspectRatio(aspectRatio: _controller.value.aspectRatio,child: VideoPlayer(_controller),);}return const Center(child: CircularProgressIndicator());},),floatingActionButton: FloatingActionButton(onPressed: () {setState(() {if (_controller.value.isPlaying) {_controller.pause();} else {_controller.play();}});},child: Icon(_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,),),)진행률 표시기와 탐색
Column(children: [AspectRatio(aspectRatio: _controller.value.aspectRatio,child: VideoPlayer(_controller),),VideoProgressIndicator(_controller,allowScrubbing: true, // 드래그로 탐색 허용colors: const VideoProgressColors(playedColor: Colors.blue,bufferedColor: Colors.grey,backgroundColor: Colors.black26,),),],)커스텀 컨트롤 바
class VideoControlBar extends StatelessWidget {final VideoPlayerController controller;const VideoControlBar({super.key, required this.controller});@overrideWidget build(BuildContext context) {return ValueListenableBuilder<VideoPlayerValue>(valueListenable: controller,builder: (context, value, child) {return Row(children: [// 재생/일시정지IconButton(icon: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),onPressed: () {value.isPlaying ? controller.pause() : controller.play();},),// 현재 시간Text(_formatDuration(value.position)),// 진행률 바Expanded(child: Slider(value: value.position.inMilliseconds.toDouble(),min: 0,max: value.duration.inMilliseconds.toDouble(),onChanged: (newValue) {controller.seekTo(Duration(milliseconds: newValue.toInt()));},),),// 전체 시간Text(_formatDuration(value.duration)),// 볼륨IconButton(icon: Icon(value.volume > 0 ? Icons.volume_up : Icons.volume_off),onPressed: () {controller.setVolume(value.volume > 0 ? 0 : 1);},),],);},);}String _formatDuration(Duration duration) {final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');return '$minutes:$seconds';}}
Watch out
WARNING
setState()를 자주 호출하면 성능이 저하됩니다.
재생 중에 매 프레임마다 위치를 업데이트하면 비효율적입니다.// 비효율적 - 매 프레임 setState_controller.addListener(() {setState(() {}); // 전체 위젯 리빌드});// 효율적 - ValueListenableBuilder 사용ValueListenableBuilder<VideoPlayerValue>(valueListenable: _controller,builder: (context, value, child) {return Text('${value.position}'); // 해당 부분만 리빌드},)
결론: VideoPlayerController의 메서드로 재생을 제어하고, ValueListenableBuilder로 효율적으로 UI를 업데이트합니다.
챕터 5: Chewie로 완성된 UI 구현
Why
NOTE직접 컨트롤 UI를 만드는 것은 시간이 많이 걸립니다.
chewie패키지는 Material/Cupertino 스타일의 완성된 비디오 플레이어 UI를 제공합니다.video_player → 비디오 재생 기능chewie → 완성된 컨트롤 UI
What
NOTEChewie는
video_player위에 구축된 UI 패키지입니다.
재생 버튼, 진행률 바, 전체 화면 전환, 자막 등을 기본 제공합니다.
How
TIP패키지 설치
Terminal window flutter pub add chewie기본 사용법
import 'package:chewie/chewie.dart';import 'package:video_player/video_player.dart';class ChewieVideoPlayer extends StatefulWidget {const ChewieVideoPlayer({super.key});@overrideState<ChewieVideoPlayer> createState() => _ChewieVideoPlayerState();}class _ChewieVideoPlayerState extends State<ChewieVideoPlayer> {late VideoPlayerController _videoPlayerController;ChewieController? _chewieController;@overridevoid initState() {super.initState();_initializePlayer();}Future<void> _initializePlayer() async {_videoPlayerController = VideoPlayerController.networkUrl(Uri.parse('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',),);await _videoPlayerController.initialize();_chewieController = ChewieController(videoPlayerController: _videoPlayerController,autoPlay: true,looping: true,);setState(() {});}@overridevoid dispose() {_videoPlayerController.dispose();_chewieController?.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('Chewie 플레이어')),body: Center(child: _chewieController != null &&_chewieController!.videoPlayerController.value.isInitialized? Chewie(controller: _chewieController!): const CircularProgressIndicator(),),);}}ChewieController 옵션
_chewieController = ChewieController(videoPlayerController: _videoPlayerController,autoPlay: false, // 자동 재생looping: true, // 반복 재생aspectRatio: 16 / 9, // 비율 고정autoInitialize: true, // 자동 초기화showControls: true, // 컨트롤 표시showOptions: true, // 설정 메뉴 표시allowFullScreen: true, // 전체 화면 허용allowMuting: true, // 음소거 허용allowPlaybackSpeedChanging: true, // 재생 속도 변경playbackSpeeds: [0.5, 1.0, 1.5, 2.0],materialProgressColors: ChewieProgressColors(playedColor: Colors.red,handleColor: Colors.red,backgroundColor: Colors.grey,bufferedColor: Colors.white54,),);
Watch out
WARNINGChewie를 사용할 때
VideoPlayerController와ChewieController둘 다 dispose해야 합니다.@overridevoid dispose() {_videoPlayerController.dispose(); // 먼저 비디오 컨트롤러_chewieController?.dispose(); // 그 다음 Chewie 컨트롤러super.dispose();}
autoInitialize: true를 설정하면 별도로initialize()를 호출할 필요가 없지만, 초기화 완료 시점을 정확히 알기 어렵습니다.
결론: Chewie를 사용하면 완성된 비디오 플레이어 UI를 쉽게 구현할 수 있습니다.
챕터 6: 자막과 추가 기능
Why
NOTE비디오에 자막을 추가하면 접근성이 향상됩니다.
Chewie는 자막, 재생 속도 변경, 커스텀 옵션 등을 지원합니다.자막 → 청각 장애인 지원, 다국어 지원재생 속도 → 학습 영상에서 유용커스텀 옵션 → 화질 선택 등
What
NOTE
Subtitle클래스로 시작 시간, 종료 시간, 텍스트를 정의합니다.
ChewieController의subtitle속성에 자막 목록을 전달합니다.
How
TIP자막 추가
_chewieController = ChewieController(videoPlayerController: _videoPlayerController,autoPlay: true,subtitle: Subtitles([Subtitle(index: 0,start: Duration.zero,end: const Duration(seconds: 5),text: '안녕하세요, Flutter 비디오 튜토리얼입니다.',),Subtitle(index: 1,start: const Duration(seconds: 5),end: const Duration(seconds: 10),text: '이 영상에서는 비디오 재생 방법을 배웁니다.',),Subtitle(index: 2,start: const Duration(seconds: 10),end: const Duration(seconds: 15),text: '시작해볼까요?',),]),showSubtitles: true, // 자막 기본 표시subtitleBuilder: (context, subtitle) {return Container(padding: const EdgeInsets.all(8),decoration: BoxDecoration(color: Colors.black54,borderRadius: BorderRadius.circular(4),),child: Text(subtitle,style: const TextStyle(color: Colors.white,fontSize: 16,),),);},);커스텀 옵션 추가
_chewieController = ChewieController(videoPlayerController: _videoPlayerController,additionalOptions: (context) {return [OptionItem(onTap: () => _showQualityDialog(context),iconData: Icons.high_quality,title: '화질 선택',),OptionItem(onTap: () => _downloadVideo(),iconData: Icons.download,title: '다운로드',),];},);SRT 파일에서 자막 로드
Future<Subtitles> _loadSubtitlesFromSrt(String srtPath) async {final srtContent = await rootBundle.loadString(srtPath);final subtitles = <Subtitle>[];final blocks = srtContent.split('\n\n');for (var i = 0; i < blocks.length; i++) {final lines = blocks[i].split('\n');if (lines.length >= 3) {final times = lines[1].split(' --> ');subtitles.add(Subtitle(index: i,start: _parseSrtTime(times[0]),end: _parseSrtTime(times[1]),text: lines.sublist(2).join('\n'),));}}return Subtitles(subtitles);}Duration _parseSrtTime(String time) {final parts = time.split(':');final seconds = parts[2].split(',');return Duration(hours: int.parse(parts[0]),minutes: int.parse(parts[1]),seconds: int.parse(seconds[0]),milliseconds: int.parse(seconds[1]),);}
Watch out
WARNING자막 타이밍이 정확하지 않으면 사용자 경험이 나빠집니다.
비디오와 자막 시간을 정확히 맞춰야 합니다.// 자막 시작/종료 시간이 겹치지 않도록 주의Subtitle(index: 0,start: Duration.zero,end: const Duration(seconds: 5), // 5초에서 끝text: '첫 번째 자막',),Subtitle(index: 1,start: const Duration(seconds: 5), // 5초에서 시작 (겹치지 않음)end: const Duration(seconds: 10),text: '두 번째 자막',),
결론: Chewie의 자막과 추가 옵션 기능을 활용하면 전문적인 비디오 플레이어를 구현할 수 있습니다.
한계
Flutter의 video_player는 대부분의 상황을 처리하지만 몇 가지 제약이 있습니다.
- 플랫폼 지원: Linux와 Windows는 지원하지 않습니다.
- DRM 미지원: 기본 패키지는 DRM 보호 콘텐츠를 재생할 수 없습니다.
- 코덱 제한: 플랫폼별로 지원하는 코덱이 다릅니다.
- 백그라운드 재생: 기본적으로 백그라운드 재생을 지원하지 않습니다.
- 스트리밍 제한: HLS/DASH 스트리밍은 플랫폼별 지원이 다릅니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!