0%

影片播放器

影片播放器

[TOC]

利用MVC架構製作簡易的影片播放器以及常用的控制功能。

APP截圖

  • 影片選擇列表

    利用UICollecitonView將影片由列表方式呈現。

  • 直式影片播放器
    點擊列表任一項目後可進入播放器頁面。

  • 橫式影片播放器
    將螢幕轉向或點擊最右下角全螢幕按鈕可入橫式播放器,再次點擊則返回 直式。
  • 播放器快轉/倒退特效
    點擊按鈕或快速點兩下可快進或倒轉

螢幕左方區域雙擊為倒轉,右方區域為快轉

程式碼

下方範例只挑幾個重點部分做說明,完整的範例在這裡

範例總共有幾大部分:

  • 資料格式
  • 影片列表
  • 播放器

資料格式

由於此範例不是經由API讀取資料,且資料格式為固定的,所以使用列舉來製作資料來源的格式以便影片切換。

創一個列舉並定義值

定義名稱可自行決定,這裡使用影片名稱來命名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum VideoSourceType:Int, CaseIterable {
case bigBuckBunny
case elephantDream
case forBiggerBlazes
case forBiggerEscape
case forBiggerFun
case forBiggerJoyrides
case forBiggerMeltdowns
case sintel
case subaruOutbackOnStreetAndDirt
case tearsOfSteel
case volkswagenGTIReview
case weAreGoingOnBullrun
case whatCareCanYouGetForAGrand
}

class VideoSource: NSObject {
override init() {
super.init()

}
}

遵循CaseIterable協議可以讓enum向陣列一樣使用。

定義一個協議並列出我們影片需要的幾個參數。

1
2
3
4
5
6
7
protocol VideoDetail {
var description:String { get } //影片描述
var sources:String { get } //影片來源
var subtitle:String { get } //副標題
var title:String { get } //標題
var fullImagePath:String { get } //圖片網址
}

使用extension並讓VideoSourceType遵循這個協議。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
extension VideoSourceType: VideoDetail {
var description:String {

}

var sources:String {

}

var subtitle:String {

}

var title:String {

}

var fullImagePath:String {

}
}

descriptionsourcessubtitletitle做相同的判斷,依照列舉的項目回傳對應得資訊。

以下為title的範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var title:String {
switch self {
case .bigBuckBunny:
return "Big Buck Bunny"

case .elephantDream:
return "Elephant Dream"

case .forBiggerBlazes:
return "For Bigger Blazes"

case .forBiggerEscape:
return "For Bigger Escape"

case .forBiggerFun:
return "For Bigger Fun"

case .forBiggerJoyrides:
return "For Bigger Joyrides"

case .forBiggerMeltdowns:
return "For Bigger Meltdowns"

case .sintel:
return "Sintel"

case .subaruOutbackOnStreetAndDirt:
return "Subaru Outback On Street And Dirt"

case .tearsOfSteel:
return "Tears of Steel"

case .volkswagenGTIReview:
return "Volkswagen GTI Review"

case .weAreGoingOnBullrun:
return "We Are Going On Bullrun"

case .whatCareCanYouGetForAGrand:
return "What care can you get for a grand?"

}
}

若有來自不同網域的圖片可利用switch判斷並加上對應的路徑。

範例的路徑都一樣所以回傳同一個網域即可。

1
2
3
var fullImagePath:String {
return "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/"+thumb
}

由於有遵循CaseIterable協議,若有需求可以用if else來判斷並處理例外的網域。

範例:

1
2
3
4
5
6
7
8
var fullImagePath:String {    
if self == .bigBuckBunny {
return "https://aaa.com/"+thumb
} else {
return "https://bbb.com/"+thumb
}
}

定義model

列表資料結構

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Categories:Codable {
var name:String?
var videos:[Video] = []
var count:Int {
return videos.count
}
}


extension Categories {
//定義一個函式,由輸入的索引回傳相對應的影片物件
func getVideo(index:Int) -> Video? {

if index < count {
return videos[index]
}
return nil
}
}

Codable協議:使資料類型可編碼( encodable )和可解碼 ( decodable ),用於與外部表示( 例如:JSON )相容。
例:

1
2
3
4
5
6

do {
let data = try? JSONEncoder().encode(item)
} catch {

}

如果沒有遵循Codable協議就不能使用JSONEncoder

影片的資料結構

1
2
3
4
5
6
7
struct Video:Codable {
var description:String?
var sources:String?
var subtitle:String?
var thumb:String?
var title:String?
}

在VideoSource建立一個陣列來存放影片資料,設定期存取權限為get only

存取權限(Access control) private 只有當前類別可以使用,private(set)能使其他檔案使用此參數,但無法改變其參數內容,若需要改變內容需要透過setter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

class VideoSource: NSObject {

private(set) var videos:[Video] = []

override init() {
super.init()

for sourceType in VideoSourceType.allCases {
let video = Video.init(description: sourceType.description,
sources: sourceType.sources,
subtitle: sourceType.subtitle,
thumb: sourceType.fullImagePath,
title: sourceType.title)
videos.append(video)
}
}

func getVideos() -> Categories {
return Categories.init(name: "Movies", videos: videos)
}
}

在大部份時候蘋果鼓勵使用 Struct,直到需要利用到 Class 才有的特性,像是需要使用到 Swift 的 Framework、Foundation 或是 UIkit,亦或是需要不斷的在各處繼承同一個父類別。

structure 的使用定偏向儲存資料。
class 則是著重於流程控制,程式碼功能導向。

影片列表

創建一個 ViewController 並繼承 BaseViewController 來實作影片列表。

個人寫作習慣是會將專案內的 ViewController 繼承一個父類,裡面會有加入許多通用的函式,例如跳出警告視窗,設定上方導覽列等功能,可自行增加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MovieListViewController: BaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
//實作UICollecitonView
}
}

extension MovieListViewController: UICollectionViewDelegate, UICollectionViewDataSource {
//MARK: ----- UICollectionViewDelegate & UICollectionViewDataSource -----
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
//網格每一區有幾格
//在這實作代理
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
//初始化以及設定重複使用的網格
//在這實作代理
}

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
//網格被點擊後的事件
//在這實作代理
}
}

由於 UICollecitonView 為較基礎應用,故不在此篇重點,暫不贅述,完整專案請參考最下方程式碼連結。

播放器

實作一個AVPlayer的物件

1
2
3
4
5
6
7
8
9
10
11
12

class AVPlayerView: UIView {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)

}

override init(frame: CGRect) {
super.init(frame: frame)

}
}

制定幾個協議,將對其的操作透過代理模式傳給 PlayerViewController

1

基礎幾個參數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class AVPlayerView: UIView {

///代理
weak var delegate:AVPlayerViewDelegate?

///播放器
var player : AVPlayer?

///播放影片時所需定義的畫面介面
var avPlayerLayer : AVPlayerLayer!

///影片播放的元素(可以更深入取得影片細部資訊)
var playerItem:AVPlayerItem?

///是否正在播放
var isPlaying:Bool = false

///定期時間觀察者
var timeObserverToken: Any?

///目前播放的時間
var currentSecond:Float64? {
if let time = player?.currentItem?.currentTime() {
return CMTimeGetSeconds(time)
}

return nil
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)

}

override init(frame: CGRect) {
super.init(frame: frame)

}
}

AVPlayerView內製作一個列舉,方便監聽播放器狀態時使用。

1
2
3
4
5
6
7
8
9
10
11
12
class AVPlayerView: UIView {

///播放器裝態監聽的列舉
enum PlayerObserverKey:String {
case status
case loadedTimeRanges
case playbackBufferEmpty
case playbackLikelyToKeepUp
}

//----- 以下首略 -----
}

接下來開始初始化播放器,AVPlayerView 只吃一個外部參數,就是影片路徑,需要重複使用時,只需要代入不同的影片路徑。

AVPlayerItem 設定影片路徑 -> AVPlayerItem 影片播放元素給 AVPlayer 初始化 -> 交給 AVPLayerLayer 設定播放畫面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

/**
初始化播放器
- Parameter urlString: 影片路徑、網址
*/
func setPlayer(urlSting:String) {

let videoURL = URL(string: urlSting)!
playerItem = AVPlayerItem(url: videoURL)

player = AVPlayer(playerItem: playerItem)

avPlayerLayer = AVPlayerLayer(player: player)
avPlayerLayer.frame = self.bounds
self.layer.addSublayer(avPlayerLayer)
}

播放器有幾個需要監聽的事件,監聽後才能對狀態進行判斷並加以控制

1. 監聽播放結束

1
2
3
4
5
6

//監聽播放結束
NotificationCenter.default.addObserver(self,
selector: #selector(playerDidFinishPlaying),
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: nil)

selector 對應的函式

1
2
3
4
5
6
7
8
9

///影片播放完畢
@objc func playerDidFinishPlaying() {

//在這裡暫停播放器

delegate?.playerDidFinishPlaying(self)
}

2. 監聽播放器狀態

1
2
3
4
5
6
    //監聽播放器狀態
player?.currentItem?.addObserver(self,
forKeyPath: PlayerObserverKey.status.rawValue,
options: .new,
context: nil)
}

3. 緩衝,可用來獲取快取存了多少

1
2
3
4
5
//緩衝,可用來獲取快取存了多少
player?.currentItem?.addObserver(self,
forKeyPath: PlayerObserverKey.loadedTimeRanges.rawValue,
options: .new,
context: nil)

4. 緩衝不夠,停止播放

1
2
3
4
5
6
      
//緩衝不夠,停止播放
player?.currentItem?.addObserver(self,
forKeyPath: PlayerObserverKey.playbackBufferEmpty.rawValue,
options: .new,
context: nil)

5. 緩衝足夠,繼續播放

1
2
3
4
5
//緩衝足夠,手動播放
player?.currentItem?.addObserver(self,
forKeyPath: PlayerObserverKey.playbackLikelyToKeepUp.rawValue,
options: .new,
context: nil)

5. 監聽影片目前播放進度並傳給代理,提供給ViewController使用

1
2
3
4
5
6
7
8
9
10
11

///增加播放進度監聽
func addVideoObserver() {
timeObserverToken = player?.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 1), queue: .main, using: { (CMTime) in

if self.player!.currentItem?.status == .readyToPlay {
let currentTime = CMTimeGetSeconds(self.player!.currentTime())
self.delegate?.didUpdatePlayerCurrentTime(self, time: currentTime)
}
})
}

在關閉播放器的時候要記得移除 timeObserverToken,否則聲音還會持續在背景播放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

deinit {
if let timeObserverToken = timeObserverToken {
player?.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}

player = nil

if avPlayerLayer != nil {

avPlayerLayer.removeFromSuperlayer()
avPlayerLayer = nil
}
}
tags: 程式碼