Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions PickaView/Views/Player/CMTime+Extensions.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
112 changes: 112 additions & 0 deletions PickaView/Views/Player/FullscreenPlayerViewController.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
33 changes: 33 additions & 0 deletions PickaView/Views/Player/Player.storyboard
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Player View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" customClass="PlayerViewController" customModule="PickaView" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="16" y="25"/>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
97 changes: 97 additions & 0 deletions PickaView/Views/Player/PlayerViewController+Gestures.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
111 changes: 111 additions & 0 deletions PickaView/Views/Player/PlayerViewController+Player.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading