πŸ“š Today I Learned 13회차 - Flutter λ™μ˜μƒ ν”Œλ ˆμ΄μ–΄ 🎬

7 λΆ„ μ†Œμš”

πŸ“– Flutter λ™μ˜μƒ ν”Œλ ˆμ΄μ–΄

🎯 ν•™μŠ΅ λͺ©ν‘œ

λ™μ˜μƒ ν”Œλ ˆμ΄μ–΄ 앱을 κ΅¬ν˜„ν•©λ‹ˆλ‹€. ν•Έλ“œν°μ— μ €μž₯ν•΄λ‘” λ™μ˜μƒμ„ μ„ νƒν•˜κ³  μ‹€ν–‰ν•˜κ³  컨트둀 ν•˜λŠ” κΈ°λŠ₯을 κ΅¬ν˜„ν•©λ‹ˆλ‹€.

πŸ“š ν•™μŠ΅ μˆœμ„œ

⭐ 사전 지식

  • ν™”λ©΄ νšŒμ „
  • μ‹œκ°„ λ³€ν™˜ 및 String νŒ¨λ”©

πŸ› οΈ 사전 μ€€λΉ„

  • 가상 단말에 λ™μ˜μƒ μΆ”κ°€
  • 이미지 μΆ”κ°€
  • pubspec.yaml
  • λ„€μ΄ν‹°λΈŒ μ„€μ •
  • ν”„λ‘œμ νŠΈ μ΄ˆκΈ°ν™”

πŸ’» κ΅¬ν˜„ν•˜κΈ°

  • 첫 ν™”λ©΄
  • 배경색 κ·ΈλΌλ°μ΄μ…˜
  • 파일 선택 κΈ°λŠ₯
  • ν”Œλ ˆμ΄ ν™”λ©΄
  • λ™μ˜μƒ μž¬μƒκΈ°
  • λ™μ˜μƒ 연동
  • λ™μ˜μƒ 컨트둀 λ²„νŠΌ
  • 컨트둀러 감좔기
  • νƒ€μž„μŠ€νƒ¬ν”„ μΆ”κ°€

πŸ“š μ£Όμš” λ‚΄μš©

⏰ μ‹œκ°„ λ³€ν™˜ 및 String νŒ¨λ”©

Duration의 경우 기간을 ν‘œμ‹œν•΄ μ£ΌλŠ” 클래슀인데, video_player ν”ŒλŸ¬κ·ΈμΈμ„ μ‚¬μš©ν•˜λ©΄μ„œ ν˜„μž¬ μ‹€ν–‰λ˜κ³  μžˆλŠ” μ˜μƒμ˜ μœ„μΉ˜, μ˜μƒμ˜ 총 길이 등을 Duration 클래슀둜 λ°˜ν™˜ λ°›κ²Œ λ©λ‹ˆλ‹€. ν•˜μ§€λ§Œ μ‹€μ œ μ‚¬μš©μžκ°€ μ’‹μ•„ν•˜λŠ” 데이터 νƒ€μž…μ΄ μ•„λ‹ˆκΈ° λ•Œλ¬Έμ—, 보기 쒋은 String κ°’μœΌλ‘œ λ°”κΏ”μ£Όλ©΄ μ’‹μŠ΅λ‹ˆλ‹€.

Duration duration = Duration(seconds: 192);
print(duration); // 0:03:12.000000
print(
  '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
); // 03:12
  • inMinutes κ²Œν„°λŠ” λΆ„ λ‹¨μœ„ μ‹œκ°„μ„ κ°€μ Έμ˜΅λ‹ˆλ‹€.
  • inSeconds κ²Ÿν„°λŠ” 초 λ‹¨μœ„ μ‹œκ°„μ„ κ°€μ Έμ˜€λŠ” 초 λ‹¨μœ„ μ‹œκ°„μ€ λΆ„μ˜ λͺ«μœΌλ‘œ λ„˜μ–΄κ°„ 값을 μ œμ™Έν•œ λ‚˜λ¨Έμ§€ κ°’λ§Œ 보여주면 λ©λ‹ˆλ‹€.
  • String ν΄λž˜μŠ€μ—λŠ” padLeft와 padRight ν•¨μˆ˜κ°€ μ‘΄μž¬ν•˜λŠ”λ° pad ν•¨μˆ˜λ“€μ€ String 길이λ₯Ό λ§žμΆ°μ£ΌλŠ” 역할을 ν•©λ‹ˆλ‹€.
    • μ΅œμ†Œ 길이λ₯Ό μž…λ ₯ν•˜κ³  길이가 λΆ€μ‘±ν•œ 경우 λ‘λ²ˆμ§Έ λ§€κ°œλ³€μˆ˜μ— λ“€μ–΄κ°„ 값을 μ±„μ›Œ λ„£μŠ΅λ‹ˆλ‹€.

πŸ’‘ 팁: padLeft(2, '0')λ₯Ό μ‚¬μš©ν•˜λ©΄ ν•œ 자리 숫자λ₯Ό 두 자리둜 맞좰 β€˜03’, β€˜12β€™μ²˜λŸΌ ν‘œμ‹œν•  수 μžˆμŠ΅λ‹ˆλ‹€.

🎨 배경색 κ·ΈλΌλ°μ΄μ…˜ κ΅¬ν˜„ν•˜κΈ°

BoxDecoration 클래슀λ₯Ό μ‚¬μš©ν•˜λ©΄ Container μœ„μ ―μ˜ 배경색, ν…Œλ‘λ¦¬, λͺ¨μ„œλ¦¬ λ‘₯κ·Ό 정도 λ“± μ „λ°˜μ μΈ λ””μžμΈμ„ λ³€κ²½ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

🎬 λ™μ˜μƒ μž¬μƒκΈ° κ΅¬ν˜„ν•˜κΈ°

  • AspectRatioλŠ” child λ§€κ°œλ³€μˆ˜μ— μž…λ ₯λ˜λŠ” μœ„μ ―μ˜ λΉ„μœ¨μ„ μ •ν•  수 μžˆλŠ” μœ„μ ―μž…λ‹ˆλ‹€.
  • aspectRatio λ§€κ°œλ³€μˆ˜μ— μ›ν•˜λŠ” λΉ„μœ¨μ„ μž…λ ₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
  • λΉ„μœ¨μ€ λ„ˆλΉ„/λ†’μ΄λ‘œ μž…λ ₯ν•˜λ©΄ 되며 16:9 λΉ„μœ¨μ„ μž…λ ₯ν•˜κ³  싢을 λ•ŒλŠ” 16/9λ₯Ό μž…λ ₯ν•˜λ©΄ λ©λ‹ˆλ‹€.
  • VideoPlayerControllerλ₯Ό μ„ μ–Έν•˜λ©΄ μž…λ ₯된 λ™μ˜μƒμ˜ λΉ„μœ¨μ„ value.aspectRatio κ²Œν„°λ‘œ λ°›μ•„μ˜¬ 수 μžˆμœΌλ‹ˆ 직접 λ„£μ–΄μ€¬μŠ΅λ‹ˆλ‹€.

VideoPlayerController의 λ„€μž„λ“œ μƒμ„±μž

μƒμ„±μž 이름 μ„€λͺ…
VideoPlayerController.asset μ•± λ‚΄λΆ€ assetμ—μ„œ λ™μ˜μƒ λ‘œλ“œ
VideoPlayerController.network λ„€νŠΈμ›Œν¬ URLμ—μ„œ λ™μ˜μƒ λ‘œλ“œ
VideoPlayerController.file 둜컬 νŒŒμΌμ—μ„œ λ™μ˜μƒ λ‘œλ“œ

🎚️ Slider μœ„μ ― λ™μ˜μƒκ³Ό μ—°λ™ν•˜κΈ°

  • Stack μœ„μ ―μ€ 기본적으둜 children μœ„μ ―λ“€μ„ 정쀑앙에 μœ„μΉ˜μ‹œν‚΅λ‹ˆλ‹€.
  • λ§Œμ•½ Stack λ‚΄λΆ€μ˜ νŠΉμ • μœ„μΉ˜μ— μœ„μ ―μ„ μœ„μΉ˜μ‹œν‚€κ³  μ‹Άλ‹€λ©΄ Positioned μœ„μ ―μ„ μ‚¬μš©ν•΄μ„œ μœ„μΉ˜λ₯Ό μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
  • bottom: 0 β†’ Stack의 κ°€μž₯ μ•„λž˜μ— μžμ‹μ„ μœ„μΉ˜
  • μ™Όμͺ½κ³Ό 였λ₯Έμͺ½ λκΉŒμ§€ Slider μœ„μ ―μ΄ λŠ˜μ–΄λ‚˜κ²Œ ν•˜λ €κ³  left와 right λ§€κ°œλ³€μˆ˜ λͺ¨λ‘μ— 0 값을 μ£Όλ©΄ μ™Όμͺ½λΆ€ν„° 0ν”½μ…€ 그리고 였λ₯Έμͺ½λΆ€ν„° 0ν”½μ…€κΉŒμ§€ μœ„μΉ˜ μ‹œν‚€λΌλŠ” μ˜λ―Έκ°€ λ˜λ―€λ‘œ Slider μœ„μ ―μ„ κ°€λ‘œ μ „μ²΄λ‘œ 늘릴 수 μžˆμŠ΅λ‹ˆλ‹€.
  • Slider μœ„μ ―μ˜ min 값은 항상 0μž…λ‹ˆλ‹€ (0μ΄ˆλΆ€ν„° μ‹œμž‘μ„ ν•΄μ•Όν•˜κΈ° λ•Œλ¬Έμ—).
  • μ΅œλŒ“κ°’μ€ λ™μ˜μƒμ˜ μž¬μƒ 길이λ₯Ό 초 λ‹¨μœ„λ‘œ λ³€ν™˜ν•˜λ©΄ λ©λ‹ˆλ‹€.

πŸ’‘ μ£Όμš” λ©”μ„œλ“œ

  • videoPlayerController.seekTo() ν•¨μˆ˜λŠ” λ™μ˜μƒμ˜ μž¬μƒ μœ„μΉ˜λ₯Ό νŠΉμ • μœ„μΉ˜λ‘œ 이동해 μ€λ‹ˆλ‹€.
  • videoPlayerController!.value.position.inSeconds κ²Œν„°λ₯Ό μ‹€ν–‰ν•˜λ©΄ ν˜„μž¬ λ™μ˜μƒμ΄ μ‹€ν–‰λ˜λŠ” μœ„μΉ˜ 값을 받을 수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ€” Q) Player와 State의 ν•„λ“œμ˜ μœ„μΉ˜κ°€ μ„œλ‘œ λ‹€λ₯Έ μƒνƒœμΈλ° μ™œ μ΄λ ‡κ²Œ λ‚˜λˆ μ„œ μ •μ˜ ν•˜λŠ” 게 μ’‹μ„κΉŒ?

ν”ŒλŸ¬ν„°μ—μ„œ StatefulWidgetκ³Ό State 객체의 ν•„λ“œλ₯Ό λ‚˜λˆ„μ–΄ μ •μ˜ν•˜λŠ” 것은 μ„±λŠ₯ μ΅œμ ν™”μ™€ 데이터 κ΄€λ¦¬μ˜ λͺ…ν™•μ„± λ•Œλ¬Έμž…λ‹ˆλ‹€. μ΄λŠ” ν”ŒλŸ¬ν„° ν”„λ ˆμž„μ›Œν¬μ˜ 핡심적인 λ™μž‘ 방식과 κΉŠμ€ 관련이 μžˆμŠ΅λ‹ˆλ‹€.

κ°„λ‹¨νžˆ μš”μ•½ν•˜λ©΄, Widget은 β€˜μ„€μ •(Configuration)’이고 StateλŠ” β€˜μƒνƒœ(State)’ κ·Έ 자체λ₯Ό μ €μž₯ν•˜κΈ° λ•Œλ¬Έμž…λ‹ˆλ‹€.

1️⃣ Widget: λΆˆλ³€(Immutable)ν•˜λŠ” μ„€μ • κ°’ πŸ“œ

CustomVideoPlayer ν΄λž˜μŠ€λŠ” StatefulWidget을 μƒμ†ν•©λ‹ˆλ‹€. ν”ŒλŸ¬ν„°μ—μ„œ Widget은 UI의 μ„€κ³„λ„λ‚˜ μ„€μ • κ°’μœΌλ‘œ μ·¨κΈ‰λ˜λ©°, ν•œ 번 μƒμ„±λ˜λ©΄ κ·Έ μ•ˆμ˜ λ‚΄μš©μ€ λ³€κ²½λ˜μ§€ μ•ŠλŠ” λΆˆλ³€(immutable) κ°μ²΄μž…λ‹ˆλ‹€.

  • final XFile video;: 이 μœ„μ ―μ΄ μ–΄λ–€ λΉ„λ””μ˜€λ₯Ό μž¬μƒν•΄μ•Ό ν•˜λŠ”μ§€μ— λŒ€ν•œ μ„€μ •μž…λ‹ˆλ‹€. λΆ€λͺ¨ μœ„μ ―μœΌλ‘œλΆ€ν„° μ „λ‹¬λ°›μŠ΅λ‹ˆλ‹€.
  • final GestureTapCallback onNewVideoPressed;: λ²„νŠΌμ΄ λˆŒλ Έμ„ λ•Œ μ–΄λ–€ λ™μž‘μ„ ν•΄μ•Ό ν•˜λŠ”μ§€μ— λŒ€ν•œ μ„€μ •μž…λ‹ˆλ‹€.

ν”ŒλŸ¬ν„°λŠ” 화면을 λ‹€μ‹œ 그릴 λ•Œ(rebuild), 기쑴의 μœ„μ ―μ„ μˆ˜μ •ν•˜λŠ” λŒ€μ‹  μƒˆλ‘œμš΄ μœ„μ ― μΈμŠ€ν„΄μŠ€λ₯Ό μƒμ„±ν•˜μ—¬ κ΅μ²΄ν•˜λŠ” 방식을 μ‚¬μš©ν•©λ‹ˆλ‹€. λ§Œμ•½ videoPlayerController처럼 κ³„μ†ν•΄μ„œ λ³€ν•˜λŠ” μƒνƒœ 값이 이 β€˜μ„€μ •β€™ ν΄λž˜μŠ€μ— μžˆλ‹€λ©΄, 화면이 λ‹€μ‹œ 그렀질 λ•Œλ§ˆλ‹€ μ»¨νŠΈλ‘€λŸ¬κ°€ μƒˆλ‘œ μƒμ„±λ˜λ©΄μ„œ μ΄μ „μ˜ μž¬μƒ μƒνƒœ(μž¬μƒ μœ„μΉ˜, λ³Όλ₯¨ λ“±)λ₯Ό λͺ¨λ‘ μžƒμ–΄λ²„λ¦¬κ²Œ λ©λ‹ˆλ‹€. μ΄λŠ” 맀우 λΉ„νš¨μœ¨μ μ΄κ³  μ˜λ„μΉ˜ μ•Šμ€ λ™μž‘μ„ μœ λ°œν•©λ‹ˆλ‹€.

2️⃣ State: κ³„μ†ν•΄μ„œ λ³€ν•˜κ³  μœ μ§€λ˜λŠ” μ‹€μ œ μƒνƒœ βš™οΈ

_CustomVideoPlayerState ν΄λž˜μŠ€λŠ” Stateλ₯Ό μƒμ†ν•©λ‹ˆλ‹€. 이 κ°μ²΄λŠ” μœ„μ ― νŠΈλ¦¬μ—μ„œ ν•œ 번 μƒμ„±λ˜λ©΄ μ‰½κ²Œ 사라지지 μ•Šκ³  계속 μœ μ§€λ©λ‹ˆλ‹€. μœ„μ ―(섀계도)이 μƒˆλ‘­κ²Œ κ΅μ²΄λ˜λ”λΌλ„, ν”ŒλŸ¬ν„°λŠ” 이 State 객체λ₯Ό μž¬μ‚¬μš©ν•˜μ—¬ μƒνƒœλ₯Ό λ³΄μ‘΄ν•©λ‹ˆλ‹€.

  • VideoPlayerController? videoPlayerController;: λΉ„λ””μ˜€μ˜ μž¬μƒ, μ •μ§€ λ“± μ‹€μ œ λΉ„λ””μ˜€λ₯Ό μ œμ–΄ν•˜κ³  ν˜„μž¬ μž¬μƒ μƒνƒœ(μœ„μΉ˜, 버퍼링 λ“±)λ₯Ό λ‹΄κ³  μžˆλŠ” μ‹€μ œ μƒνƒœ κ°μ²΄μž…λ‹ˆλ‹€. 이 값은 μ‚¬μš©μžμ˜ μΈν„°λž™μ…˜μ— 따라 계속 λ³€ν•΄μ•Ό ν•©λ‹ˆλ‹€.

initState()μ—μ„œ 컨트둀러λ₯Ό μ΄ˆκΈ°ν™”ν•˜κ³  dispose()μ—μ„œ ν•΄μ œν•˜λŠ” λ“±, 이 State κ°μ²΄λŠ” μžμ‹ λ§Œμ˜ 생λͺ…μ£ΌκΈ°(Lifecycle)λ₯Ό κ°€μ§€λ©° λ‚΄λΆ€ 데이터λ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€.

3️⃣ κ²°λ‘ : μ™œ λ‚˜λˆ„λŠ” 것이 쒋은가? βœ…

μ΄λŸ¬ν•œ 뢄리 κ΅¬μ‘°λŠ” λ‹€μŒκ³Ό 같은 핡심적인 μž₯점을 κ°€μ§‘λ‹ˆλ‹€.

  1. μ„±λŠ₯ μ΅œμ ν™” (Performance): 화면이 자주 λ‹€μ‹œ 그렀져도(build λ©”μ†Œλ“œ 호좜), VideoPlayerController처럼 무거운 κ°μ²΄λ‚˜ λ³΅μž‘ν•œ μƒνƒœ 값듀은 μƒˆλ‘œ μƒμ„±λ˜μ§€ μ•Šκ³  κ·ΈλŒ€λ‘œ μœ μ§€λ©λ‹ˆλ‹€. λΆˆν•„μš”ν•œ μ΄ˆκΈ°ν™” λΉ„μš©μ„ 막아 μ•± μ„±λŠ₯을 ν–₯μƒμ‹œν‚΅λ‹ˆλ‹€.

  2. μƒνƒœ 보쑴 (State Preservation): λΆ€λͺ¨ μœ„μ ―μ˜ ꡬ쑰 λ³€κ²½ λ“±μœΌλ‘œ 인해 CustomVideoPlayer μœ„μ ―μ΄ μƒˆλ‘œ μƒμ„±λ˜λ”λΌλ„, _CustomVideoPlayerState κ°μ²΄λŠ” μœ μ§€λ˜λ―€λ‘œ λΉ„λ””μ˜€μ˜ μž¬μƒ μœ„μΉ˜λ‚˜ μƒνƒœκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šκ³  κ·ΈλŒ€λ‘œ λ³΄μ‘΄λ©λ‹ˆλ‹€.

  3. μ½”λ“œμ˜ λͺ…ν™•μ„± (Clarity): μœ„μ ―μ˜ μ„€μ •(Configuration)κ³Ό μ‹€μ œ λ‚΄λΆ€ μƒνƒœ(State)κ°€ λͺ…ν™•ν•˜κ²Œ λΆ„λ¦¬λ˜μ–΄ μ½”λ“œμ˜ 역할을 μ΄ν•΄ν•˜κΈ° 쉽고 μœ μ§€λ³΄μˆ˜κ°€ μš©μ΄ν•΄μ§‘λ‹ˆλ‹€.

    • CustomVideoPlayer: β€œμ΄ λΉ„λ””μ˜€ 파일(video)을 κ°€μ§€κ³ , 이런 λ™μž‘(onNewVideoPressed)을 ν•˜λŠ” ν”Œλ ˆμ΄μ–΄λ₯Ό λ§Œλ“€μ–΄ μ€˜β€ λΌλŠ” μš”μ²­μ„œ.
    • _CustomVideoPlayerState: μš”μ²­μ„œμ˜ λ‚΄μš©μ„ λ°”νƒ•μœΌλ‘œ λΉ„λ””μ˜€ 컨트둀러λ₯Ό λ§Œλ“€κ³ , μ‹€μ œλ‘œ μž¬μƒν•˜κ³ , ν˜„μž¬ μž¬μƒ μ‹œκ°„μ„ κΈ°μ–΅ν•˜λŠ” λ“± λͺ¨λ“  μ‹€μ œ μž‘μ—…κ³Ό μƒνƒœλ₯Ό κ΄€λ¦¬ν•˜λŠ” 주체.

πŸ”„ didUpdateWidget을 μ΄μš©ν•œ λ™μ˜μƒ κ°±μ‹ 

⚠️ 주의: μƒˆλ‘œμš΄ λ™μ˜μƒμ„ 선택해도 ν™”λ©΄μ—λŠ” μƒˆλ‘œ μ„ νƒν•œ μ˜μƒμ΄ μ‹€ν–‰λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

  • κ·Έ μ΄μœ λŠ” λ™μ˜μƒμ˜ μ†ŒμŠ€λ₯Ό videoController λ³€μˆ˜λ₯Ό μΈμŠ€ν„΄μŠ€ν™” ν• λ•Œ μ„ μ–Έν–ˆλŠ”λ° ν˜„μž¬ μ½”λ“œμ—μ„œ videoController λ³€μˆ˜λŠ” initState ν•¨μˆ˜μ—μ„œλ§Œ μ„ μ–Έλ˜κΈ° λ•Œλ¬Έ
  • ν•΄κ²°μ±…: didUpdateWidget ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•΄μ„œ μƒˆλ‘œμš΄ λ™μ˜μƒμ΄ μ„ νƒλ˜μ—ˆμ„ λ•Œ videoControllerλ₯Ό μƒˆλ‘œ μƒμ„±ν•˜λ„λ‘ μ½”λ“œλ₯Ό μΆ”κ°€

didUpdateWidget의 생λͺ…μ£ΌκΈ°

  • didUpdateWidget의 생λͺ…μ£ΌκΈ°λ₯Ό 잘 생각해 보면, μœ„μ ―μ€ λ§€κ°œλ³€μˆ˜μ˜ 값이 변경될 λ•Œ 폐기되고 μƒˆλ‘œ μƒμ„±λ©λ‹ˆλ‹€.
  • κ·Έλž˜μ„œ didUpdateWidget의 첫번째 맀개 λ³€μˆ˜μ— μž…λ ₯λ˜λŠ” oldWidget은 νκΈ°λ˜λŠ” μœ„μ ―μ„ μ˜λ―Έν•©λ‹ˆλ‹€.

πŸ’‘ covariant ν‚€μ›Œλ“œ: CustomVideoPlayer 클래슀의 μƒμ†λœ 값도 ν—ˆκ°€ν•΄μ€λ‹ˆλ‹€.

πŸ’» μ‹€μŠ΅ 및 예제

πŸ“„ μ†ŒμŠ€ μ½”λ“œ

import 'package:flutter/material.dart';
import 'package:vid_player/screen/home_screen.dart';

void main() {
  runApp(MaterialApp(home: HomeScreen()));
}

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:vid_player/screen/custom_video_player.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  XFile? video;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: video == null ? renderEmpty() : renderVideo(),
    );
  }

  Widget renderEmpty() {
    return Container(
      width: MediaQuery.of(context).size.width,
      decoration: getBoxDecoration(),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _Logo(onTap: onNewVideoPressed),
          SizedBox(height: 30.0),
          _AppName(),
        ],
      ),
    );
  }

  BoxDecoration getBoxDecoration() {
    return BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [Color(0xFF2A3A7C), Color(0XFF000118)],
      ),
    );
  }

  Widget renderVideo() {
    return Center(
      child: CustomVideoPlayer(
        video: video!,
        onNewVideoPressed: onNewVideoPressed,
      ),
    );
  }

  void onNewVideoPressed() async {
    final video = await ImagePicker().pickVideo(source: ImageSource.gallery);

    if (video != null) {
      setState(() {
        this.video = video;
      });
    }
  }
}

class _Logo extends StatelessWidget {
  final GestureTapCallback onTap;

  const _Logo({required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Image.asset('asset/img/logo.png'),
    );
  }
}

class _AppName extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final textStyle = TextStyle(
      color: Colors.white,
      fontSize: 30.0,
      fontWeight: FontWeight.w300,
    );

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('VEDIO', style: textStyle),
        Text('Player', style: textStyle.copyWith(fontWeight: FontWeight.w700)),
      ],
    );
  }
}

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:vid_player/component/custom_icon_button.dart';
import 'package:video_player/video_player.dart';

class CustomVideoPlayer extends StatefulWidget {
  final XFile video;
  final GestureTapCallback onNewVideoPressed;

  const CustomVideoPlayer({
    super.key,
    required this.video,
    required this.onNewVideoPressed,
  });

  @override
  State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}

class _CustomVideoPlayerState extends State<CustomVideoPlayer> {
  VideoPlayerController? videoPlayerController;
  bool showControls = false;

  @override
  void initState() {
    super.initState();
    initializeController();
  }

  @override
  void dispose() {
    videoPlayerController?.removeListener(videoControllerListener);
    super.dispose();
  }

  @override
  void didUpdateWidget(covariant CustomVideoPlayer oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.video.path != widget.video.path) {
      initializeController();
    }
  }

  initializeController() async {
    final videoController = VideoPlayerController.file(File(widget.video.path));

    await videoController.initialize();

    videoController.addListener(videoControllerListener);

    setState(() {
      // ꡳ이 할당을 setState λ‚΄λΆ€μ—μ„œ ν•˜λŠ” 이유: μ΄λ ‡κ²Œ ν•΄μ•Ό buildλ₯Ό λ‹€μ‹œ ν•˜κ²Œ 되고 μ›ν•˜λŠ” 화면을 κ·Έλ €μ£ΌκΈ° λ•Œλ¬Έμ—
      videoPlayerController = videoController;
    });
  }

  void videoControllerListener() {
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    // λ™μ˜μƒ μ»¨νŠΈλ‘€λŸ¬κ°€ μ€€λΉ„ 쀑일 λ•Œ
    // 졜초둜 buildκ°€ λ˜λŠ”
    if (videoPlayerController == null) {
      return Center(child: CircularProgressIndicator());
    }
    return GestureDetector(
      onTap: () {
        setState(() {
          showControls = !showControls;
        });
      },
      child: AspectRatio(
        // λ™μ˜μƒ λΉ„μœ¨μ— λ”°λ₯Έ ν™”λ©΄ λ Œλ”λ§
        aspectRatio: videoPlayerController!.value.aspectRatio,
        child: Stack(
          children: [
            VideoPlayer(videoPlayerController!),
            if (showControls) Container(color: Colors.black.withOpacity(0.5)),
            Positioned(
              bottom: 0,
              right: 0,
              left: 0,
              child: Padding(
                padding: EdgeInsets.symmetric(horizontal: 8.0),
                child: Row(
                  children: [
                    renderTimeTextFromDuration(
                      videoPlayerController!.value.position,
                    ),
                    Expanded(
                      child: Slider(
                        value: videoPlayerController!.value.position.inSeconds
                            .toDouble(),
                        min: 0,
                        max: videoPlayerController!.value.duration.inSeconds
                            .toDouble(),
                        onChanged: (double val) {
                          videoPlayerController!.seekTo(
                            Duration(seconds: val.toInt()),
                          );
                        },
                      ),
                    ),
                    renderTimeTextFromDuration(
                      videoPlayerController!.value.duration,
                    ),
                  ],
                ),
              ),
            ),
            if (showControls)
              Align(
                alignment: Alignment.topRight,
                child: CustomIconButton(
                  onPressed: widget.onNewVideoPressed,
                  iconData: Icons.photo_camera_back,
                ),
              ),
            if (showControls)
              Align(
                alignment: Alignment.center,
                child: Row(
                  mainAxisAlignment:
                      MainAxisAlignment.spaceEvenly, // μžμ‹ μœ„μ ―μ˜ 간격을 λ™μΌν•˜κ²Œ μ„€μ •
                  children: [
                    CustomIconButton(
                      onPressed: onReversePressed,
                      iconData: Icons.rotate_left,
                    ),
                    CustomIconButton(
                      onPressed: onPlayPressed,
                      iconData: videoPlayerController!.value.isPlaying
                          ? Icons.pause
                          : Icons.play_arrow,
                    ),
                    CustomIconButton(
                      onPressed: onForwardPressed,
                      iconData: Icons.rotate_right,
                    ),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget renderTimeTextFromDuration(Duration duration) {
    return Text(
      '${duration.inMinutes.toString().padLeft(2, '0')}: ${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
      style: TextStyle(color: Colors.white),
    );
  }

  void onReversePressed() {
    final currentPosition = videoPlayerController!.value.position;

    Duration position = Duration();

    if (currentPosition.inSeconds > 3) {
      position = currentPosition - Duration(seconds: 3);
    }

    videoPlayerController!.seekTo(position);
  }

  void onForwardPressed() {
    final maxPosition = videoPlayerController!.value.duration;
    final currentPosition = videoPlayerController!.value.position;

    Duration position = maxPosition;
    if ((maxPosition - Duration(seconds: 3)).inSeconds >
        currentPosition.inSeconds) {
      position = currentPosition + Duration(seconds: 3);
    }

    videoPlayerController!.seekTo(position);
  }

  void onPlayPressed() {
    if (videoPlayerController!.value.isPlaying) {
      videoPlayerController!.pause();
    } else {
      videoPlayerController!.play();
    }
  }
}

import 'package:flutter/material.dart';

class CustomIconButton extends StatelessWidget {
  final GestureTapCallback onPressed;
  final IconData iconData;

  const CustomIconButton({
    super.key,
    required this.onPressed,
    required this.iconData,
  });

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: onPressed,
      iconSize: 30.0,
      color: Colors.white,
      icon: Icon(iconData),
    );
  }
}

πŸ“± μ‹€ν–‰ ν™”λ©΄

πŸ“ 마무리

βœ… 였늘 배운 것

  • Duration 클래슀: λ™μ˜μƒ μž¬μƒ μ‹œκ°„μ„ μ‚¬μš©μž μΉœν™”μ μΈ ν˜•νƒœλ‘œ λ³€ν™˜
  • VideoPlayerController: λ™μ˜μƒ λ‘œλ“œ 및 μž¬μƒ μ œμ–΄
  • AspectRatio: λ™μ˜μƒ λΉ„μœ¨μ— 맞좰 ν™”λ©΄ λ Œλ”λ§
  • Slider μœ„μ ―: λ™μ˜μƒ μž¬μƒ μœ„μΉ˜ ν‘œμ‹œ 및 μ œμ–΄
  • StatefulWidget vs State: μ„€μ •κ³Ό μƒνƒœλ₯Ό λΆ„λ¦¬ν•˜λŠ” μ΄μœ μ™€ μž₯점
  • didUpdateWidget: μœ„μ ― λ§€κ°œλ³€μˆ˜ λ³€κ²½ μ‹œ μƒνƒœ μ—…λ°μ΄νŠΈ

πŸš€ λ‹€μŒ κ³„νš

  • μ˜μƒ 톡화 μ•± κ΅¬ν˜„
  • WebRTC와 아고라 API ν™œμš©

λŒ“κΈ€λ‚¨κΈ°κΈ°