Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: HLS 세그먼트를 앞부분부터 잘라서 로드시킬 수 있는 커스텀 리소스 로더 구현 #330

Merged
merged 3 commits into from
Dec 14, 2023
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
26 changes: 22 additions & 4 deletions iOS/Layover/Layover.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@
19AE481C2B28C53800DD4612 /* MockSettingWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AE481B2B28C53800DD4612 /* MockSettingWorker.swift */; };
19AE48232B29D03D00DD4612 /* EditProfileInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AE481F2B29D03D00DD4612 /* EditProfileInteractorTests.swift */; };
19AE48252B29D03D00DD4612 /* EditProfilePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AE48212B29D03D00DD4612 /* EditProfilePresenterTests.swift */; };
19AE482A2B2A127E00DD4612 /* HLSAssetResourceLoaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AE48292B2A127E00DD4612 /* HLSAssetResourceLoaderDelegate.swift */; };
19AE482C2B2A1A8B00DD4612 /* HLSSliceResourceLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AE482B2B2A1A8B00DD4612 /* HLSSliceResourceLoader.swift */; };
19AE482E2B2A24C700DD4612 /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AE482D2B2A24C700DD4612 /* URL+.swift */; };
19C7AFCE2B02410F003B35F2 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C7AFCD2B02410F003B35F2 /* AuthManager.swift */; };
19C7AFD62B02584D003B35F2 /* KeychainStored.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C7AFD52B02584D003B35F2 /* KeychainStored.swift */; };
19E79AC02B0A85D0009EA9ED /* LoopingPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19E79ABF2B0A85D0009EA9ED /* LoopingPlayerView.swift */; };
Expand Down Expand Up @@ -361,6 +364,9 @@
19AE481B2B28C53800DD4612 /* MockSettingWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingWorker.swift; sourceTree = "<group>"; };
19AE481F2B29D03D00DD4612 /* EditProfileInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileInteractorTests.swift; sourceTree = "<group>"; };
19AE48212B29D03D00DD4612 /* EditProfilePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfilePresenterTests.swift; sourceTree = "<group>"; };
19AE48292B2A127E00DD4612 /* HLSAssetResourceLoaderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HLSAssetResourceLoaderDelegate.swift; sourceTree = "<group>"; };
19AE482B2B2A1A8B00DD4612 /* HLSSliceResourceLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HLSSliceResourceLoader.swift; sourceTree = "<group>"; };
19AE482D2B2A24C700DD4612 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = "<group>"; };
19C7AFCD2B02410F003B35F2 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
19C7AFD52B02584D003B35F2 /* KeychainStored.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStored.swift; sourceTree = "<group>"; };
19E79ABF2B0A85D0009EA9ED /* LoopingPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopingPlayerView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -646,8 +652,6 @@
194C21CE2B1DF63D00C62645 /* MockDatas */ = {
isa = PBXGroup;
children = (
194C21D32B1EEE3700C62645 /* sample.jpeg */,
194C21CF2B1DF65200C62645 /* PostList.json */,
FC4E0C122B28609C00152596 /* PostBoard.json */,
192513972B278645001533FA /* CheckSignUp.json */,
1925138D2B278645001533FA /* CheckUserName.json */,
Expand Down Expand Up @@ -778,6 +782,15 @@
path = EditProfile;
sourceTree = "<group>";
};
19AE48262B2A117600DD4612 /* HLSResourceLoader */ = {
isa = PBXGroup;
children = (
19AE482B2B2A1A8B00DD4612 /* HLSSliceResourceLoader.swift */,
19AE48292B2A127E00DD4612 /* HLSAssetResourceLoaderDelegate.swift */,
);
path = HLSResourceLoader;
sourceTree = "<group>";
};
19BB8A572B07BEE30070B922 /* UIComponents */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1080,6 +1093,7 @@
FC7E45752AFF6F5B004F155A /* Services */ = {
isa = PBXGroup;
children = (
19AE48262B2A117600DD4612 /* HLSResourceLoader */,
1972CCD02B125E8800C3C762 /* UserDefaults */,
19C7AFD42B02583C003B35F2 /* Keychain */,
FC4E0C172B28954000152596 /* Location */,
Expand All @@ -1101,10 +1115,10 @@
FC7E457B2AFF6F9D004F155A /* Scenes */ = {
isa = PBXGroup;
children = (
8321A2E72B1E1011000A12AF /* Report */,
1945520E2B03AEA400299768 /* Configurator.swift */,
836C33922B18436A00ECAFB0 /* Setting */,
19BB8A572B07BEE30070B922 /* UIComponents */,
8321A2E72B1E1011000A12AF /* Report */,
836C33922B18436A00ECAFB0 /* Setting */,
835A61962B0680FC002F22A5 /* Playback */,
FC2511A72B04DA9C004717BC /* Map */,
FCEE0FFB2B03AFAA00195BBE /* SignUpScene */,
Expand Down Expand Up @@ -1151,6 +1165,7 @@
FC767FA42B125F430088CF9B /* UIViewController+.swift */,
1972CCDE2B14C9B000C3C762 /* Notification.Name+.swift */,
19A169482B181AE300DB34C0 /* Sequence+.swift */,
19AE482D2B2A24C700DD4612 /* URL+.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -1379,6 +1394,7 @@
FC8696D32B26008B00F9A7B9 /* SettingViewController.swift in Sources */,
83C35E1E2B10923C00D8DD5C /* PlaybackCell.swift in Sources */,
FC0E80262B1A0BBB00EF56D6 /* UploadPostRouter.swift in Sources */,
19AE482A2B2A127E00DD4612 /* HLSAssetResourceLoaderDelegate.swift in Sources */,
FC2511A42B045D6C004717BC /* SignUpModels.swift in Sources */,
8321A2FD2B1E4260000A12AF /* DefaultPostManagerEndPointFactory.swift in Sources */,
FC767F932B1220CC0088CF9B /* NicknameDTO.swift in Sources */,
Expand All @@ -1394,6 +1410,7 @@
FC0E803A2B1B91C900EF56D6 /* EditTagPresenter.swift in Sources */,
836C33872B15A29600ECAFB0 /* Toast.swift in Sources */,
FC767F972B1224B80088CF9B /* IntroduceDTO.swift in Sources */,
19AE482E2B2A24C700DD4612 /* URL+.swift in Sources */,
19A169302B1776CA00DB34C0 /* TagPlayListCollectionViewCell.swift in Sources */,
FC0E80252B1A0BBB00EF56D6 /* UploadPostWorker.swift in Sources */,
1972CCD42B138E6B00C3C762 /* SignUpRouter.swift in Sources */,
Expand Down Expand Up @@ -1532,6 +1549,7 @@
1972CCCF2B12438900C3C762 /* LoginEndPointFactory.swift in Sources */,
835A61A92B0B5A31002F22A5 /* LoginConfigurator.swift in Sources */,
FC0E80432B1B934A00EF56D6 /* EditTagConfigurator.swift in Sources */,
19AE482C2B2A1A8B00DD4612 /* HLSSliceResourceLoader.swift in Sources */,
194551F72B037F2D00299768 /* LoginInteractor.swift in Sources */,
19A169382B17BCA800DB34C0 /* PostDTO.swift in Sources */,
19A1692A2B176D6E00DB34C0 /* TagPlayListConfigurator.swift in Sources */,
Expand Down
25 changes: 25 additions & 0 deletions iOS/Layover/Layover/Extensions/URL+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// URL+.swift
// Layover
//
// Created by 김인환 on 12/14/23.
// Copyright © 2023 CodeBomber. All rights reserved.
//

import Foundation

extension URL {
func changeScheme(to: String) -> URL {
var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
components?.scheme = to
return components?.url ?? self
}

var customHLS_URL: URL {
changeScheme(to: "lhls")
}

var originHLS_URL: URL {
changeScheme(to: "https")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ final class HomeCarouselCollectionViewCell: UICollectionViewCell {

func setVideo(url: URL, loopingAt time: TimeInterval) {
loopingPlayerView.disable()
loopingPlayerView.prepareVideo(with: url, loopStart: time, duration: 3.0)
loopingPlayerView.prepareVideo(with: url,
assetResourceLoaderDelegate: HLSAssetResourceLoaderDelegate(resourceLoader: HLSSliceResourceLoader()),
loopStart: time,
duration: 3.0)
loopingPlayerView.player?.isMuted = true
}

Expand Down
1 change: 0 additions & 1 deletion iOS/Layover/Layover/Scenes/Home/HomeConfigurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ final class HomeConfigurator: Configurator {
let router = HomeRouter()
let presenter = HomePresenter()
let interactor = HomeInteractor()
// let homeWorker = MockHomeWorker()
let homeWorker = HomeWorker()
let videoFileWorker = VideoFileWorker()

Expand Down
7 changes: 4 additions & 3 deletions iOS/Layover/Layover/Scenes/Home/HomeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,14 @@ final class HomeViewController: BaseViewController {
view.subviews.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }

NSLayoutConstraint.activate([
uploadButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
uploadButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),

carouselCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
carouselCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
carouselCollectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 42),
carouselCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -109),
carouselCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -41),

uploadButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
uploadButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
])
}

Expand Down
5 changes: 4 additions & 1 deletion iOS/Layover/Layover/Scenes/Profile/ProfileInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ final class ProfileInteractor: ProfileBusinessLogic, ProfileDataStore {

private var fetchPostsPage = 1
private var canFetchMorePosts = true
private var isMyProfile: Bool {
profileId == nil
}

// MARK: - DataStore

Expand Down Expand Up @@ -109,6 +112,7 @@ final class ProfileInteractor: ProfileBusinessLogic, ProfileDataStore {

var responsePosts = [Models.DisplayedPost]()
for post in fetchedPosts {
if !isMyProfile && post.board.status != .complete { continue }
guard let thumbnailURL = post.board.thumbnailImageURL,
let profileImageData = await userWorker?.fetchImageData(with: thumbnailURL) else {
responsePosts.append(.init(id: post.board.identifier, thumbnailImageData: nil, status: post.board.status))
Expand All @@ -125,5 +129,4 @@ final class ProfileInteractor: ProfileBusinessLogic, ProfileDataStore {
playbackStartIndex = request.startIndex
presenter?.presentPostDetail(with: Models.ShowPostDetail.Response())
}

}
39 changes: 28 additions & 11 deletions iOS/Layover/Layover/Scenes/UIComponents/LoopingPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ final class LoopingPlayerView: UIView {
player?.timeControlStatus == .playing
}

private var assetResourceLoaderDelegate: AVAssetResourceLoaderDelegate?

// MARK: - View Life Cycle

override func layoutSubviews() {
Expand All @@ -45,8 +47,23 @@ final class LoopingPlayerView: UIView {
self.player = player
}

func prepareVideo(with url: URL, loopStart: TimeInterval, duration: TimeInterval) {
let playerItem = AVPlayerItem(url: url)
func prepareVideo(with url: URL,
assetResourceLoaderDelegate: AVAssetResourceLoaderDelegate? = nil,
loopStart: TimeInterval,
duration: TimeInterval) {
let asset: AVURLAsset
if let assetResourceLoaderDelegate {
self.assetResourceLoaderDelegate = assetResourceLoaderDelegate
asset = AVURLAsset(url: url.customHLS_URL)
asset.resourceLoader.setDelegate(assetResourceLoaderDelegate,
queue: DispatchQueue.global(qos: .utility))
Task {
try await asset.load(.isPlayable, .duration)
}
} else {
asset = AVURLAsset(url: url)
}
let playerItem = AVPlayerItem(asset: asset)
let player = AVQueuePlayer()
looper = AVPlayerLooper(player: player,
templateItem: playerItem,
Expand Down Expand Up @@ -75,12 +92,12 @@ final class LoopingPlayerView: UIView {
}
}

#Preview {
let view = LoopingPlayerView()
view.prepareVideo(with: URL(string: "http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8")!,
timeRange: .init(start: .zero,
end: .init(seconds: 3.0, preferredTimescale: CMTimeScale(1.0))))
view.play()
view.player?.isMuted = true
return view
}
//#Preview {
// let view = LoopingPlayerView()
// view.prepareVideo(with: URL(string: "http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8")!,
// timeRange: .init(start: .zero,
// end: .init(seconds: 3.0, preferredTimescale: CMTimeScale(1.0))))
// view.play()
// view.player?.isMuted = true
// return view
//}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// HLSAssetResourceLoaderDelegate.swift
// Layover
//
// Created by 김인환 on 12/14/23.
// Copyright © 2023 CodeBomber. All rights reserved.
//

import Foundation
import AVFoundation

class HLSAssetResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {

// MARK: - Properties

let resourceLoader: ResourceLoader

// MARK: - Initializer

init(resourceLoader: ResourceLoader) {
self.resourceLoader = resourceLoader
}

// MARK: - Delegate Methods

func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
loadRequestedResource(loadingRequest)
}

func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool {
loadRequestedResource(renewalRequest)
}

// MARK: - Methods

// 공통으로 처리
func loadRequestedResource(_ loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let url = loadingRequest.request.url?.originHLS_URL else { return false }

if url.pathExtension.contains("ts") { // ts 파일은 리디렉션 시킨다.
loadingRequest.redirect = URLRequest(url: url)
loadingRequest.response = HTTPURLResponse(url: url,
statusCode: 302,
httpVersion: nil,
headerFields: nil)
loadingRequest.finishLoading()
} else {
Task {
guard let data = await resourceLoader.loadResource(from: url) else {
loadingRequest.finishLoading(with: NSError(domain: "Failed to load resource from \(url.absoluteString)",
code: 0,
userInfo: nil))
return
}

loadingRequest.dataRequest?.respond(with: data)
loadingRequest.finishLoading()
}
}

return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// HLSResourceLoader.swift
// Layover
//
// Created by 김인환 on 12/14/23.
// Copyright © 2023 CodeBomber. All rights reserved.
//

import Foundation
import OSLog

protocol ResourceLoader {
func loadResource(from url: URL) async -> Data?
}

// 앞부분부터 원하는 duration만큼 잘라서 load시켜주는 Resource Loader
final class HLSSliceResourceLoader: ResourceLoader {

enum M3U8Tag: String {
case extm3u = "#EXTM3U" // m3u8 파일의 시작
case extend = "#EXT-X-ENDLIST" // 마지막 태그
case extinf = "#EXTINF:" // 재생시간 -> 미디어 m3u8 파일에 포함
case extxstreaminf = "#EXT-X-STREAM-INF" // 마스터 m3u8 파일
}

// MARK: - Properties

private let session: URLSession

// MARK: - Initializer

init(session: URLSession = URLSession(configuration: .default)) {
self.session = session
}

// MARK: - ResourceLoader

func loadResource(from url: URL) async -> Data? {
let urlRequest = URLRequest(url: url.originHLS_URL) // 원래 url scheme 으로 변경

guard let (data, response) = try? await session.data(for: urlRequest),
let httpResponse = response as? HTTPURLResponse,
(200...399) ~= httpResponse.statusCode else {
os_log(.error, log: .data, "Failed to load resource from %{public}@", url.absoluteString)
return nil
}

guard let m3u8Playlist = String(data: data, encoding: .utf8) else {
os_log(.error, log: .data, "Failed to decode data to String")
return nil
}

guard isMediaM3U8(m3u8Playlist) else { return data }
return sliceM3U8Playlist(m3u8Playlist, duration: 4).data(using: .utf8) ?? data // 3초보다는 약간 여유있게 잡는다.
}

// MARK: - Methods

private func isMediaM3U8(_ m3u8Playlist: String) -> Bool {
m3u8Playlist.contains(M3U8Tag.extinf.rawValue)
}

// m3u8 미디어 플레이리스트를 받아서 duration만큼 잘라서 반환
private func sliceM3U8Playlist(_ m3u8Playlist: String, duration: TimeInterval) -> String {
var duration = duration
var playlist = m3u8Playlist.components(separatedBy: M3U8Tag.extinf.rawValue)
.compactMap {
if $0.contains(M3U8Tag.extm3u.rawValue) { return $0 } // 시작부분
else if let tsDuration = $0.components(separatedBy: ",").compactMap({ Double($0) }).first,
duration > .zero {
duration -= tsDuration
return $0
} else {
return nil
}
}.joined(separator: M3U8Tag.extinf.rawValue)
Comment on lines +68 to +76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pdf로만 봤었는데 m3u8파일 내부를 까서 시작 부분부터 재생할 duration까지만 ts 세그먼트를 합치는 고런 로직이군요.. 좋습니당

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아요 간단한...3초 3겹살...


if !playlist.contains(M3U8Tag.extend.rawValue) {
playlist.append("\n\(M3U8Tag.extend.rawValue)")
}

return playlist
}
Comment on lines +64 to +83
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}