diff --git a/SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/Contents.json new file mode 100644 index 0000000..abc88e2 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_loop_segment_active.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/ic_loop_segment_active.png b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/ic_loop_segment_active.png new file mode 100644 index 0000000..ac9aec9 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/ic_loop_segment_active.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/Contents.json new file mode 100644 index 0000000..4d15e6a --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_loop_segment_idle.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/ic_loop_segment_idle.png b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/ic_loop_segment_idle.png new file mode 100644 index 0000000..e88a42a Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/ic_loop_segment_idle.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/Contents.json new file mode 100644 index 0000000..968c1c1 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_loop_segment_start_set.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/ic_loop_segment_start_set.png b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/ic_loop_segment_start_set.png new file mode 100644 index 0000000..2703ce5 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/ic_loop_segment_start_set.png differ diff --git a/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift b/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift index c32fb10..e0280f2 100644 --- a/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift +++ b/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift @@ -13,6 +13,12 @@ import Combine import Kingfisher final class ContentPlayerPlayManager: NSObject, ObservableObject { + enum LoopState { + case idle + case waitingForEnd(start: CMTime) + case looping(start: CMTime, end: CMTime) + } + static let shared = ContentPlayerPlayManager() private let repository = ContentGenerateUrlRepository() @@ -23,8 +29,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { @Published var coverImageUrl = "" @Published var creatorProfileUrl = "" - @Published private (set) var isShowingMiniPlayer = false - @Published private (set) var isPlaying = false + @Published private(set) var isShowingMiniPlayer = false + @Published private(set) var isPlaying = false @Published var isLoading = false @Published var errorMessage = "" @@ -32,9 +38,12 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { @Published var isShowPlaylist = false @Published var playlist: [AudioContentPlaylistContent] = [] @Published var currentTime: Double = 0.0 - @Published private (set) var duration: Double = 0.0 + @Published private(set) var duration: Double = 0.0 @Published var isEditing = false + @Published private(set) var loopState: LoopState = .idle + @Published private var timeObserver: Any? + var player: AVPlayer? private var cancellables = Set() @@ -70,6 +79,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { } private func setupPlayer(with url: URL) { + stopLooping() + loopState = .idle // 기존 playerItem 관련 Combine 구독 해제 cancellables.removeAll() @@ -152,6 +163,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { func playNextContent() { stop() + stopLooping() + loopState = .idle if let content = playlistManager?.moveToNext() { generateUrl(contentId: content.id) { [unowned self] url in @@ -164,6 +177,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { func playPreviousContent() { stop() + stopLooping() + loopState = .idle if let content = playlistManager?.moveToPrevious() { generateUrl(contentId: content.id) { [unowned self] url in @@ -213,6 +228,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { func resetPlayer() { stop() + stopLooping() + loopState = .idle duration = 0 isShowingMiniPlayer = false @@ -227,6 +244,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { } func seek(to time: Double) { + stopLooping() + loopState = .idle let cmTime = CMTime(seconds: time, preferredTimescale: 600) player?.seek(to: cmTime) } @@ -244,6 +263,41 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { seek(to: min(newTimeInSeconds, durationInSeconds)) } + func toggleLoop() { + guard let currentTime = player?.currentTime() else { return } + + switch loopState { + case .idle: + loopState = .waitingForEnd(start: currentTime) + + case .waitingForEnd(let start): + let end = currentTime + loopState = .looping(start: start, end: end) + startLooping(from: start, to: end) + + case .looping: + stopLooping() + loopState = .idle + } + } + + private func startLooping(from start: CMTime, to end: CMTime) { + let interval = CMTime(seconds: 0.1, preferredTimescale: 600) + + timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] currentTime in + if currentTime >= end { + self?.player?.seek(to: start, toleranceBefore: .zero, toleranceAfter: .zero) + } + } + } + + private func stopLooping() { + if let observer = timeObserver { + player?.removeTimeObserver(observer) + timeObserver = nil + } + } + private func generateUrl(contentId: Int, onSuccess: @escaping (String) -> Void, onFailure: @escaping () -> Void) { if contentId < 0 { onFailure() diff --git a/SodaLive/Sources/Content/Player/ContentPlayerView.swift b/SodaLive/Sources/Content/Player/ContentPlayerView.swift index 5b610d0..573a77b 100644 --- a/SodaLive/Sources/Content/Player/ContentPlayerView.swift +++ b/SodaLive/Sources/Content/Player/ContentPlayerView.swift @@ -163,6 +163,21 @@ struct ContentPlayerView: View { .padding(.vertical, 21) HStack(spacing: 0) { + Image({ + switch playerManager.loopState { + case .waitingForEnd: + "ic_loop_segment_start_set" + + case .looping: + "ic_loop_segment_active" + + default: + "ic_loop_segment_idle" + } + }()) + .padding(5) + .onTapGesture { playerManager.toggleLoop() } + Spacer() Image("ic_playlist")