diff --git a/PickaView/Views/Player/CMTime+Extensions.swift b/PickaView/Views/Player/CMTime+Extensions.swift new file mode 100644 index 0000000..6760de6 --- /dev/null +++ b/PickaView/Views/Player/CMTime+Extensions.swift @@ -0,0 +1,27 @@ +// +// CMTime+Extensions.swift +// PickaView +// +// Created by junil on 6/10/25. +// + +import AVKit + +extension CMTime { + /// CMTime 값을 "HH:mm:ss" 또는 "mm:ss" 형태의 문자열로 변환 + /// + /// - Returns: 시간(초)을 "HH:mm:ss" 또는 "mm:ss" 포맷의 문자열로 반환 + func toTimeString() -> String { + let roundedSeconds = seconds.rounded() + let hours: Int = Int(roundedSeconds / 3600) + let min: Int = Int(roundedSeconds.truncatingRemainder(dividingBy: 3600) / 60) + let sec: Int = Int(roundedSeconds.truncatingRemainder(dividingBy: 60)) + + if hours > 0 { + // 1시간 이상일 때 "H:MM:SS" 포맷 반환 + return String(format: "%d:%02d:%02d", hours, min, sec) + } + // 1시간 미만일 때 "MM:SS" 포맷 반환 + return String(format: "%02d:%02d", min, sec) + } +} diff --git a/PickaView/Views/Player/FullscreenPlayerViewController.swift b/PickaView/Views/Player/FullscreenPlayerViewController.swift new file mode 100644 index 0000000..14e5d34 --- /dev/null +++ b/PickaView/Views/Player/FullscreenPlayerViewController.swift @@ -0,0 +1,112 @@ +// +// FullscreenPlayerViewController.swift +// PickaView +// +// Created by junil on 6/11/25. +// + +import UIKit +import AVKit + +/// 전체화면 영상 플레이어 뷰 컨트롤러 +class FullscreenPlayerViewController: UIViewController { + + // MARK: - Properties + + /// 영상 출력을 위한 AVPlayerLayer + var playerLayer: AVPlayerLayer? + + /// 플레이어 컨트롤 오버레이 뷰 (재생/정지, 시커 등) + var controlsOverlayView: UIView? + + /// 전체화면 dismiss 시 호출될 델리게이트 + weak var delegate: PlayerViewControllerDelegate? + + /// 중복 dismiss 방지용 플래그 + private var isDismissing = false + + /// (옵션) 전체화면 모드 여부 + private var isFullscreenMode = false + + // MARK: - Lifecycle + + /// 뷰가 메모리에 올라왔을 때 호출 + override func viewDidLoad() { + super.viewDidLoad() + print(">> 전체화면 viewDidLoad") + + view.backgroundColor = .black + + // AVPlayerLayer 추가 + if let playerLayer = playerLayer { + playerLayer.frame = view.bounds + view.layer.addSublayer(playerLayer) + } + + // 오버레이 추가 및 오토레이아웃 + if let controlsOverlayView = controlsOverlayView { + view.addSubview(controlsOverlayView) + controlsOverlayView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + controlsOverlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controlsOverlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + controlsOverlayView.topAnchor.constraint(equalTo: view.topAnchor), + controlsOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + // 스와이프 다운 제스처(뷰/오버레이에 모두 등록) + let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(handleDismiss)) + swipeDown.direction = .down + view.addGestureRecognizer(swipeDown) + controlsOverlayView?.addGestureRecognizer(swipeDown) + + // 기기 회전 감지(세로 전환 시 전체화면 닫기) + NotificationCenter.default.addObserver( + self, + selector: #selector(deviceOrientationDidChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + /// 레이아웃이 변경될 때마다 AVPlayerLayer 크기 갱신 + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + playerLayer?.frame = view.bounds + } + + // MARK: - Orientation & Dismiss + + /// 기기 방향 변경 감지 시 호출 (세로면 dismiss) + @objc + private func deviceOrientationDidChange() { + let orientation = UIDevice.current.orientation + if orientation == .portrait { + handleDismiss() + } + } + + /// 전체화면 뷰 dismiss 처리 및 델리게이트 호출 + @objc + private func handleDismiss() { + print(">> 전체화면: handleDismiss 호출") + guard !isDismissing else { return } + isDismissing = true + + // dismiss 및 delegate 전달 + dismiss(animated: true) { [weak self] in + print(">> 전체화면 dismiss 완료, delegate 호출") + self?.delegate?.didDismissFullscreen() + self?.isDismissing = false + } + } + + // MARK: - Status Bar & System Gesture + + /// 상태바 숨김 + override var prefersStatusBarHidden: Bool { true } + + /// 시스템 제스처 연기 (모든 엣지) + override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { .all } +} diff --git a/PickaView/Views/Player/Player.storyboard b/PickaView/Views/Player/Player.storyboard new file mode 100644 index 0000000..99fe663 --- /dev/null +++ b/PickaView/Views/Player/Player.storyboard @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PickaView/Views/Player/PlayerViewController+Gestures.swift b/PickaView/Views/Player/PlayerViewController+Gestures.swift new file mode 100644 index 0000000..e493f76 --- /dev/null +++ b/PickaView/Views/Player/PlayerViewController+Gestures.swift @@ -0,0 +1,97 @@ +// +// PlayerViewController+Gestures.swift +// PickaView +// +// Created by junil on 6/11/25. +// + +import UIKit + +/// PlayerViewController의 제스처 관련 확장 +extension PlayerViewController: UIGestureRecognizerDelegate { + + // MARK: - 제스처 초기화 + + /// 플레이어 오버레이 뷰에 필요한 모든 제스처를 등록합니다. + /// - 단일 탭: 컨트롤 토글 + /// - 더블 탭: 10초 앞으로/뒤로 시킹 + /// - (세로) 위로 스와이프: 전체화면 진입 + func setupGestures() { + // 기존 제스처 모두 제거 + controlsOverlayView.gestureRecognizers?.forEach { + controlsOverlayView.removeGestureRecognizer($0) + } + + // 단일 탭: 컨트롤 show/hide + let singleTap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility)) + singleTap.numberOfTapsRequired = 1 + singleTap.delegate = self + controlsOverlayView.addGestureRecognizer(singleTap) + + // 더블 탭: 10초 skip (좌/우) + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTap.numberOfTapsRequired = 2 + doubleTap.delegate = self + controlsOverlayView.addGestureRecognizer(doubleTap) + singleTap.require(toFail: doubleTap) + + // (세로모드일 때만) 위로 스와이프 → 전체화면 + if !isFullscreenMode { + let swipeUp = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeToFullscreen)) + swipeUp.direction = .up + controlsOverlayView.addGestureRecognizer(swipeUp) + } + } + + // MARK: - 제스처 핸들러 + + /// 단일 탭: 컨트롤(재생버튼, 시커 등) show/hide 토글 + @objc func toggleControlsVisibility() { + areControlsVisible.toggle() + let alpha: CGFloat = areControlsVisible ? 1.0 : 0.0 + + UIView.animate(withDuration: 0.3) { + self.playbackControlsStack.alpha = alpha + self.seekerStack.alpha = alpha + } + + if areControlsVisible && isPlaying { + scheduleControlsHide() + } else { + cancelControlsHide() + } + } + + /// 더블 탭: 왼쪽/오른쪽 10초 skip + /// - Parameter recognizer: UITapGestureRecognizer + @objc func handleDoubleTap(_ recognizer: UITapGestureRecognizer) { + let location = recognizer.location(in: controlsOverlayView) + let midX = controlsOverlayView.bounds.midX + + if location.x < midX { + seek(by: -10) + } else { + seek(by: 10) + } + } + + /// 위로 스와이프: 전체화면 진입(세로모드일 때만) + @objc func handleSwipeToFullscreen(_ gesture: UISwipeGestureRecognizer) { + guard !isFullscreenMode else { return } + let isPortrait: Bool + if let orientation = view.window?.windowScene?.interfaceOrientation { + isPortrait = orientation.isPortrait + } else { + isPortrait = UIDevice.current.orientation.isPortrait + } + guard isPortrait else { return } + presentFullscreen() + } + + // MARK: - UIGestureRecognizerDelegate + + /// UIControl(버튼, 슬라이더 등)에는 제스처 적용 X + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + return !(touch.view is UIControl) + } +} diff --git a/PickaView/Views/Player/PlayerViewController+Player.swift b/PickaView/Views/Player/PlayerViewController+Player.swift new file mode 100644 index 0000000..fe35063 --- /dev/null +++ b/PickaView/Views/Player/PlayerViewController+Player.swift @@ -0,0 +1,111 @@ +// +// PlayerViewController+Player.swift +// PickaView +// +// Created by junil on 6/11/25. +// + +import AVKit + +/// PlayerViewController의 AVPlayer 관련 확장 +extension PlayerViewController { + + // MARK: - Player 초기화 + + /// AVPlayer 및 AVPlayerLayer를 초기화하고 기본 영상으로 세팅합니다. + func setupPlayer() { + let urlString = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" + guard let videoURL = URL(string: urlString) else { + print("Error: Invalid URL string.") + return + } + + player = AVPlayer(url: videoURL) + playerLayer = AVPlayerLayer(player: player) + playerLayer?.videoGravity = .resizeAspect + + if let playerLayer = playerLayer { + videoContainerView.layer.insertSublayer(playerLayer, at: 0) + } + } + + // MARK: - Player Observer 관리 + + /// 재생 시간 관찰 및 상태 변화, 종료 알림 옵저버 추가 + func addPlayerObservers() { + guard let player = self.player else { return } + + // 1초마다 현재 시간 갱신 + timeObserverToken = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 1, preferredTimescale: 600), + queue: .main + ) { [weak self] _ in + self?.updatePlayerTime() + } + + // 재생 종료 알림 + NotificationCenter.default.addObserver( + self, + selector: #selector(playerDidFinishPlaying), + name: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem + ) + + // 플레이어 준비 상태 옵저빙 + player.currentItem?.addObserver( + self, + forKeyPath: #keyPath(AVPlayerItem.status), + options: [.new], + context: nil + ) + } + + /// AVPlayerItem의 status 옵저빙 결과 처리 + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey : Any]?, + context: UnsafeMutableRawPointer? + ) { + if keyPath == #keyPath(AVPlayerItem.status), + let statusValue = change?[.newKey] as? Int, + let status = AVPlayerItem.Status(rawValue: statusValue) { + if status == .readyToPlay { + if isPlaying { + scheduleControlsHide() + } + updatePlayerTime() + } + } + } + + // MARK: - Player Seek / Time + + /// 영상 재생 위치를 원하는 초 만큼 이동합니다. + /// - Parameter seconds: 이동할 시간(초, 양수: 앞으로, 음수: 뒤로) + func seek(by seconds: Double) { + guard let player = self.player else { return } + + let currentTime = player.currentTime() + let newTime = CMTimeGetSeconds(currentTime) + seconds + let time = CMTime(value: Int64(newTime), timescale: 1) + player.seek(to: time) + resetControlsHideTimer() + } + + /// 현재 재생 위치와 총 길이를 라벨/슬라이더에 반영합니다. + func updatePlayerTime() { + guard let player = self.player, + let currentTime = player.currentItem?.currentTime(), + let duration = player.currentItem?.duration else { return } + + let currentTimeInSeconds = CMTimeGetSeconds(currentTime) + let durationInSeconds = CMTimeGetSeconds(duration) + + if durationInSeconds.isFinite && durationInSeconds > 0 { + progressSlider.value = Float(currentTimeInSeconds / durationInSeconds) + currentTimeLabel.text = currentTime.toTimeString() + totalDurationLabel.text = duration.toTimeString() + } + } +} diff --git a/PickaView/Views/Player/PlayerViewController+UI.swift b/PickaView/Views/Player/PlayerViewController+UI.swift new file mode 100644 index 0000000..b28347d --- /dev/null +++ b/PickaView/Views/Player/PlayerViewController+UI.swift @@ -0,0 +1,185 @@ +// +// PlayerViewController+UI.swift +// PickaView +// +// Created by junil on 6/11/25. +// + +import UIKit + +/// PlayerViewController의 UI 및 레이아웃 확장 +extension PlayerViewController { + + // MARK: - Symbol Config + + /// 기본 아이콘 크기 설정 (플레이/정지 버튼) + var symbolConfig: UIImage.SymbolConfiguration { + UIImage.SymbolConfiguration(pointSize: 36, weight: .regular, scale: .large) + } + + /// 작은 아이콘 크기 설정 (앞/뒤 버튼) + var smallSymbolConfig: UIImage.SymbolConfiguration { + UIImage.SymbolConfiguration(pointSize: 26, weight: .regular, scale: .medium) + } + + // MARK: - UI 초기 세팅 + + /// UI 컴포넌트 계층 및 오토레이아웃 세팅, 더미 스크롤 컨텐츠 추가 + func setupUI() { + view.addSubview(videoContainerView) + view.addSubview(contentScrollView) + + videoContainerView.addSubview(controlsOverlayView) + controlsOverlayView.addSubview(playbackControlsStack) + controlsOverlayView.addSubview(seekerStack) + + let safeArea = view.safeAreaLayoutGuide + + // 세로/가로 레이아웃 제약 정의 + portraitConstraints = [ + videoContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + videoContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + videoContainerView.topAnchor.constraint(equalTo: safeArea.topAnchor), + videoContainerView.heightAnchor.constraint(equalTo: view.widthAnchor, multiplier: 9.0/16.0), + + contentScrollView.topAnchor.constraint(equalTo: videoContainerView.bottomAnchor), + contentScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ] + + landscapeConstraints = [ + videoContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + videoContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + videoContainerView.topAnchor.constraint(equalTo: view.topAnchor), + videoContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ] + + // 공통 오토레이아웃 (컨트롤/시커/버튼) + NSLayoutConstraint.activate([ + controlsOverlayView.topAnchor.constraint(equalTo: videoContainerView.topAnchor), + controlsOverlayView.bottomAnchor.constraint(equalTo: videoContainerView.bottomAnchor), + controlsOverlayView.leadingAnchor.constraint(equalTo: videoContainerView.leadingAnchor), + controlsOverlayView.trailingAnchor.constraint(equalTo: videoContainerView.trailingAnchor), + + playbackControlsStack.centerXAnchor.constraint(equalTo: controlsOverlayView.centerXAnchor), + playbackControlsStack.centerYAnchor.constraint(equalTo: controlsOverlayView.centerYAnchor), + + seekerStack.leadingAnchor.constraint(equalTo: controlsOverlayView.leadingAnchor, constant: 16), + seekerStack.trailingAnchor.constraint(equalTo: controlsOverlayView.trailingAnchor, constant: -16), + seekerStack.bottomAnchor.constraint(equalTo: controlsOverlayView.bottomAnchor, constant: -10) + ]) + + // 버튼 고정 크기 설정 + playPauseButton.widthAnchor.constraint(equalToConstant: 54).isActive = true + playPauseButton.heightAnchor.constraint(equalToConstant: 54).isActive = true + backwardButton.widthAnchor.constraint(equalToConstant: 44).isActive = true + backwardButton.heightAnchor.constraint(equalToConstant: 44).isActive = true + forwardButton.widthAnchor.constraint(equalToConstant: 44).isActive = true + forwardButton.heightAnchor.constraint(equalToConstant: 44).isActive = true + + addDummyContentToScrollView() + updateConstraintsForOrientation() + } + + // MARK: - 레이아웃 변경 + + /// 가로/세로 모드에 따라 제약조건 전환 + func updateConstraintsForOrientation() { + let isLandscape = UIDevice.current.orientation.isLandscape + if isFullscreenMode || isLandscape { + NSLayoutConstraint.deactivate(portraitConstraints) + NSLayoutConstraint.activate(landscapeConstraints) + contentScrollView.isHidden = true + } else { + NSLayoutConstraint.deactivate(landscapeConstraints) + NSLayoutConstraint.activate(portraitConstraints) + contentScrollView.isHidden = false + } + } + + // MARK: - UI 생성 유틸 + + /// SF Symbol을 사용한 플레이어 버튼 생성 + /// - Parameters: + /// - systemName: 아이콘 이름 + /// - useSmallConfig: 작은 버튼 여부 + /// - Returns: UIButton 인스턴스 + func createButton(systemName: String, useSmallConfig: Bool = false) -> UIButton { + let config = useSmallConfig ? smallSymbolConfig : symbolConfig + let button = UIButton(type: .system) + let image = UIImage(systemName: systemName, withConfiguration: config) + button.setImage(image, for: .normal) + button.tintColor = .white + button.translatesAutoresizingMaskIntoConstraints = false + button.layer.cornerRadius = (useSmallConfig ? 44 : 54) / 2 + button.contentHorizontalAlignment = .center + button.contentVerticalAlignment = .center + button.imageView?.contentMode = .scaleAspectFit + return button + } + + /// 재생/일시정지 버튼 이미지 상태 변경 + /// - Parameter isPlaying: 재생 중 여부 + func setPlayPauseImage(isPlaying: Bool) { + let imageName = isPlaying ? "pause.fill" : "play.fill" + let img = UIImage(systemName: imageName, withConfiguration: symbolConfig) + playPauseButton.setImage(img, for: .normal) + playPauseButton.imageEdgeInsets = isPlaying ? UIEdgeInsets(top: -8, left: -8, bottom: -8, right: -8) : .zero + } + + /// 시간 라벨 생성 (monospaced font) + /// - Parameter text: 초기 문자열 + /// - Returns: UILabel 인스턴스 + func createTimeLabel(text: String) -> UILabel { + let label = UILabel() + label.text = text + label.textColor = .white + label.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + /// 버튼 터치 다운 애니메이션 + /// - Parameters: + /// - button: 대상 버튼 + /// - completion: 애니메이션 완료 시 실행할 클로저 + func animateButtonTap(_ button: UIButton, completion: @escaping () -> Void) { + UIView.animate(withDuration: 0.1, animations: { + button.transform = CGAffineTransform(scaleX: 0.85, y: 0.85) + }) { _ in + UIView.animate(withDuration: 0.1, animations: { + button.transform = .identity + }, completion: { _ in + completion() + }) + } + } + + // MARK: - Demo용 Dummy Content + + /// 스크롤뷰에 더미 컨텐츠 추가 (예: 영상 설명) + func addDummyContentToScrollView() { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 10 + stackView.translatesAutoresizingMaskIntoConstraints = false + + for _ in 1...5 { + let dummyView = UIView() + dummyView.backgroundColor = .systemGray4 + dummyView.layer.cornerRadius = 15 + dummyView.heightAnchor.constraint(equalToConstant: 220).isActive = true + stackView.addArrangedSubview(dummyView) + } + + contentScrollView.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentScrollView.topAnchor, constant: 20), + stackView.bottomAnchor.constraint(equalTo: contentScrollView.bottomAnchor, constant: -20), + stackView.leadingAnchor.constraint(equalTo: contentScrollView.leadingAnchor, constant: 15), + stackView.trailingAnchor.constraint(equalTo: contentScrollView.trailingAnchor, constant: -15), + stackView.widthAnchor.constraint(equalTo: contentScrollView.widthAnchor, constant: -30) + ]) + } +} diff --git a/PickaView/Views/Player/PlayerViewController.swift b/PickaView/Views/Player/PlayerViewController.swift new file mode 100644 index 0000000..ed36a96 --- /dev/null +++ b/PickaView/Views/Player/PlayerViewController.swift @@ -0,0 +1,397 @@ +// +// PlayerViewController.swift +// PickaView +// +// Created by junil on 6/9/25. +// + +import UIKit +import AVKit + +// MARK: - Delegate Protocol + +/// 전체화면 모드 dismiss 시 호출되는 델리게이트 프로토콜 +protocol PlayerViewControllerDelegate: AnyObject { + func didDismissFullscreen() +} + +// MARK: - Main Player View Controller + +/// 영상 재생 및 플레이어 UI를 담당하는 뷰 컨트롤러 +class PlayerViewController: UIViewController, PlayerViewControllerDelegate { + + // MARK: - Player Properties + + /// AVPlayer 인스턴스 (영상 재생) + var player: AVPlayer? + + /// 영상 레이어(재생 화면용) + var playerLayer: AVPlayerLayer? + + /// 재생 시간 관찰 토큰 (clean-up용) + var timeObserverToken: Any? + + /// 현재 재생 상태 + var isPlaying = false + + /// 컨트롤 표시 여부 + var areControlsVisible = true + + /// 컨트롤 자동 숨김 타이머 + var controlsHideTimer: Timer? + + /// 세로 레이아웃 제약 목록 + var portraitConstraints: [NSLayoutConstraint] = [] + + /// 가로 레이아웃 제약 목록 + var landscapeConstraints: [NSLayoutConstraint] = [] + + /// 전체화면 모드 여부 + var isFullscreenMode: Bool = false + + /// 전체화면 dismiss 델리게이트 + weak var delegate: PlayerViewControllerDelegate? + + // MARK: - UI Components + + /// 재생/정지, 앞뒤 버튼 스택 + lazy var playbackControlsStack: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [backwardButton, playPauseButton, forwardButton]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .equalSpacing + stackView.spacing = 40 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + /// 시커(현재시간/슬라이더/총시간) 스택 + lazy var seekerStack: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [currentTimeLabel, progressSlider, totalDurationLabel]) + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + /// 영상 표시 영역 + let videoContainerView: UIView = { + let view = UIView() + view.backgroundColor = .black + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// 플레이어 컨트롤 오버레이 + let controlsOverlayView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// 재생/일시정지 버튼 + lazy var playPauseButton: UIButton = { + let button = createButton(systemName: "play.fill") + button.addTarget(self, action: #selector(playPauseButtonTapped), for: .touchUpInside) + return button + }() + + /// 10초 뒤로 버튼 + lazy var backwardButton: UIButton = { + let button = createButton(systemName: "10.arrow.trianglehead.counterclockwise", useSmallConfig: true) + button.addTarget(self, action: #selector(backwardButtonTapped), for: .touchUpInside) + return button + }() + + /// 10초 앞으로 버튼 + lazy var forwardButton: UIButton = { + let button = createButton(systemName: "10.arrow.trianglehead.clockwise", useSmallConfig: true) + button.addTarget(self, action: #selector(forwardButtonTapped), for: .touchUpInside) + return button + }() + + /// 현재 재생 위치 라벨 + lazy var currentTimeLabel: UILabel = { + createTimeLabel(text: "00:00") + }() + + /// 총 영상 길이 라벨 + lazy var totalDurationLabel: UILabel = { + createTimeLabel(text: "00:00") + }() + + /// 재생 위치 슬라이더 + lazy var progressSlider: UISlider = { + let slider = UISlider() + slider.minimumValue = 0 + slider.tintColor = .red + slider.thumbTintColor = .red + slider.translatesAutoresizingMaskIntoConstraints = false + slider.addTarget(self, action: #selector(sliderValueChanged(_:)), for: .valueChanged) + return slider + }() + + /// 영상 이외의 부가 정보 영역(예: 설명) + let contentScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + return scrollView + }() + + // MARK: - LifeCycle + + /// 뷰가 로드될 때 초기 세팅 + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + setupPlayer() + setupUI() + setPlayPauseImage(isPlaying: false) + setupGestures() + addPlayerObservers() + + // 기기 방향 변화 알림 등록 + NotificationCenter.default.addObserver( + self, + selector: #selector(deviceOrientationDidChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + /// 뷰가 나타날 때 방향/레이아웃 갱신 + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + updateConstraintsForOrientation() + } + + /// 뷰가 사라질 때 전체화면 delegate 호출 + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + delegate?.didDismissFullscreen() + } + + /// 뷰의 크기 변경시 AVPlayerLayer 리사이즈 + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + playerLayer?.frame = videoContainerView.bounds + } + + // MARK: - Player Controls + + /// 3초 후 컨트롤 자동 숨김 예약 + func scheduleControlsHide() { + cancelControlsHide() + controlsHideTimer = Timer.scheduledTimer( + timeInterval: 3.0, + target: self, + selector: #selector(hideControls), + userInfo: nil, + repeats: false + ) + } + + /// 컨트롤 자동 숨김 예약 취소 + func cancelControlsHide() { + controlsHideTimer?.invalidate() + controlsHideTimer = nil + } + + /// 컨트롤(버튼/시커) 숨기기 애니메이션 + @objc func hideControls() { + areControlsVisible = false + UIView.animate(withDuration: 0.3) { + self.playbackControlsStack.alpha = 0.0 + self.seekerStack.alpha = 0.0 + } + } + + /// 재생/일시정지 버튼 클릭 핸들러 + @objc func playPauseButtonTapped() { + guard let player = self.player else { return } + animateButtonTap(playPauseButton) { + self.isPlaying.toggle() + self.setPlayPauseImage(isPlaying: self.isPlaying) + + if self.isPlaying { + player.play() + self.scheduleControlsHide() + } else { + player.pause() + self.cancelControlsHide() + } + } + } + + /// 10초 뒤로 버튼 클릭 핸들러 + @objc func backwardButtonTapped() { + animateButtonSpin(backwardButton, clockwise: true) { + self.seek(by: -10) + } + } + + /// 10초 앞으로 버튼 클릭 핸들러 + @objc func forwardButtonTapped() { + animateButtonSpin(forwardButton, clockwise: false) { + self.seek(by: 10) + } + } + + /// 재생 위치 슬라이더 값 변경 핸들러 + @objc func sliderValueChanged(_ slider: UISlider) { + guard let player = self.player, let duration = player.currentItem?.duration else { return } + let totalSeconds = CMTimeGetSeconds(duration) + let value = Float64(slider.value) * totalSeconds + let seekTime = CMTime(value: Int64(value), timescale: 1) + player.seek(to: seekTime) + resetControlsHideTimer() + } + + /// 영상 재생이 끝났을 때 호출됨 (자동 초기화) + @objc func playerDidFinishPlaying() { + guard let player = self.player else { return } + isPlaying = false + let playImage = UIImage(systemName: "arrow.clockwise") + playPauseButton.setImage(playImage, for: .normal) + player.seek(to: .zero) + progressSlider.value = 0 + currentTimeLabel.text = "00:00" + } + + /// 컨트롤 자동 숨김 타이머 재설정 + func resetControlsHideTimer() { + if isPlaying { + scheduleControlsHide() + } + } + + /// 버튼을 일정 각도로 돌렸다가 복귀하는 애니메이션 + /// - Parameters: + /// - button: 애니메이션할 버튼 + /// - clockwise: 시계 방향 여부 + /// - completion: 완료 핸들러 + func animateButtonSpin(_ button: UIButton, clockwise: Bool, completion: (() -> Void)? = nil) { + button.layer.removeAllAnimations() + let angle: CGFloat = clockwise ? -CGFloat.pi / 2 : CGFloat.pi / 2 + + UIView.animateKeyframes(withDuration: 0.25, delay: 0, options: [], animations: { + UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.5) { + button.transform = CGAffineTransform(rotationAngle: angle).scaledBy(x: 0.85, y: 0.85) + } + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + button.transform = .identity + } + }, completion: { _ in + completion?() + }) + } + + // MARK: - 전체화면 진입/복귀 + + /// 전체화면 모드 진입 + func presentFullscreen() { + guard !isFullscreenMode else { return } + isFullscreenMode = true + + let fullscreenVC = FullscreenPlayerViewController() + fullscreenVC.modalPresentationStyle = .fullScreen + fullscreenVC.playerLayer = self.playerLayer + fullscreenVC.controlsOverlayView = self.controlsOverlayView + fullscreenVC.delegate = self + + // iOS 16 이상은 windowScene geometryUpdate 사용 + if #available(iOS 16.0, *) { + let windowScene = view.window?.windowScene + windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape)) { [weak self] _ in + self?.present(fullscreenVC, animated: true) + } + } else { + UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + present(fullscreenVC, animated: true) + } + } + + /// 전체화면에서 복귀(dismiss)할 때 호출 (FullScreen → 일반모드) + func didDismissFullscreen() { + isFullscreenMode = false + setNeedsUpdateOfSupportedInterfaceOrientations() + + // 화면 방향을 '세로'로 강제 설정 + if #available(iOS 16.0, *) { + let windowScene = view.window?.windowScene + windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) + } else { + UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + } + + // playerLayer, controlsOverlayView 다시 복구 + if let playerLayer = self.playerLayer { + playerLayer.removeFromSuperlayer() + videoContainerView.layer.insertSublayer(playerLayer, at: 0) + playerLayer.frame = videoContainerView.bounds + } + + controlsOverlayView.removeFromSuperview() + videoContainerView.addSubview(controlsOverlayView) + controlsOverlayView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + controlsOverlayView.topAnchor.constraint(equalTo: videoContainerView.topAnchor), + controlsOverlayView.bottomAnchor.constraint(equalTo: videoContainerView.bottomAnchor), + controlsOverlayView.leadingAnchor.constraint(equalTo: videoContainerView.leadingAnchor), + controlsOverlayView.trailingAnchor.constraint(equalTo: videoContainerView.trailingAnchor), + ]) + + updateConstraintsForOrientation() + setupGestures() + } + + // MARK: - Orientation + + /// 기기 방향 변경시 호출(가로 → 전체화면, 세로 → 복귀) + @objc func deviceOrientationDidChange() { + let orientation = UIDevice.current.orientation + if (orientation == .landscapeLeft || orientation == .landscapeRight), !isFullscreenMode { + presentFullscreen() + } else if orientation == .portrait, isFullscreenMode { + // 전체화면에서 FullscreenPlayerViewController가 알아서 내려감 + } + } + + /// (legacy) iOS 16 미만에서 방향 강제 변경 + func setOrientationLegacy(to orientation: UIInterfaceOrientation) { + if #available(iOS 16.0, *) { + if let scene = view.window?.windowScene { + let mask = UIInterfaceOrientationMask.portrait + let preferences = UIWindowScene.GeometryPreferences.iOS(interfaceOrientations: mask) + scene.requestGeometryUpdate(preferences, errorHandler: { error in + print("Orientation update error: \(error)") + }) + } + } else { + UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + } + } + + /// 현재 지원되는 화면 방향 + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return isFullscreenMode ? .landscape : .portrait + } + + /// 프리젠테이션시 기본 방향 + override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { + return isFullscreenMode ? .landscapeRight : .portrait + } + + /// 자동회전 허용 여부 + override var shouldAutorotate: Bool { true } + + // MARK: - Deinit + + /// 뷰컨트롤러 해제 시 클린업 + deinit { + if let token = timeObserverToken { player?.removeTimeObserver(token) } + NotificationCenter.default.removeObserver(self) + } +}