From 592b014941177e8b157d79f521d6a235f36653c5 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 1 Apr 2025 11:51:24 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B5=AC=EA=B0=84=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contents.json | 21 ++++++ .../ic_loop_segment_active.png | Bin 0 -> 1127 bytes .../Contents.json | 21 ++++++ .../ic_loop_segment_idle.png | Bin 0 -> 1034 bytes .../Contents.json | 21 ++++++ .../ic_loop_segment_start_set.png | Bin 0 -> 1165 bytes .../Player/ContentPlayerPlayManager.swift | 60 +++++++++++++++++- .../Content/Player/ContentPlayerView.swift | 15 +++++ 8 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_loop_segment_active.imageset/ic_loop_segment_active.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_loop_segment_idle.imageset/ic_loop_segment_idle.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_loop_segment_start_set.imageset/ic_loop_segment_start_set.png 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 0000000000000000000000000000000000000000..ac9aec9552378f485f92adc6d6b96c3eccb5b511 GIT binary patch literal 1127 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di49tH#T^vIy7~jtF^}FpLa{RuJmvrc=DRUM) zWJ$d_%d5rJK0theu!*hXLzWil3n0d+MNJzHPF=MnNZi-o!J@H|OEl2tvbmqku|7vW zV`0Y%`;!kOeRBSPwmZ(Iz|n+)Iy?^~FjZK)&+gf*{_D;e&xp_2Pn``EUKekk;Pg){ zDCb{<*7pNO=U8iY$%b&vdB6Pm$F^(x_Uq+0F@Cl_UVR|N^!>+4pWU7xUCg_IgZ=me z&Q&u%@7cg3+{SsaVw>=qCf7OKCn6r^K9gUvM(Z`p?B_yBh9XI3_pV5&eEynj@%VLu z;BqE6rc6CG36*`d=N9sbS+c)aX(66}f7+>kXPP&(HG1`LRh=C_Czw&PS+rKidE?S( zmZ;}U7RTa`xTr>i&)1&U6!_^8&233uu}zb-^;9Z?rwyt;F*X?;&RQYb2wJ8T)fM6^^36j)Y|MG!Ide+wJG6|w+ezE|NH5! zb?=d1aozVH$`MC8xs#8^`=778?Wlb3xW$Z&MuVFg=4N^Y&WhPD%W^MpZY?NZS6{g4 z-PY#jbE}J(bsijH3Vyfx$|~b?*T3q1)mIJAlS%#eIpvYWkyz~r@q#0@#YyLT-Cyp} zEDsJA-Oy4hE>{@O>8p9v@!G~U4W)~^{4bP0&eG1fvF2h@i09Nx@1=oK@X9{A<*vkxs6~h3wwGRg_`UmN5!?F`*1J1c zuDft9%a>%A@p|KuqqinXc*8F7DH11KlsH|J!UA@$!Z_=cnzBo7?|P-*tV@dA}mg6Anij z_ZCI9tP~B1jGMD}=RS!V)}1UzqOw-4J;(Cg|LHPWp$h`2Xu`(p%$CieG2b`u%c@aM zczk_#GPlIub%qnK8|AYsZ+qlE$KV$Xom8FT(HnaChMms1TJh}UrdE*O3o92xy zD&l4>EG8Ie56`sqkte=~=pA{t*grq2JHvG&NBa+kwadRnezMIg4w-8A_Iyk7yHnmQ hERF(jbY$*h`}JFL52k(SX#l#!Twj?1od0-M%v!M- z$$Qr0G5*7xP* zlpXyWKv-?-gQ9bO9e89@4>Ja9M3f+t1b2)k6v3{{H?9FInCDA$AWU z9*7}{L|F ziy=`3bOL4NH99{s`>(IBT`>f4uGrYvcsthD*K>`vc*~Eq9m|P}Dj)>ft~8yBXAv0O z7m18ZR2P-zpycHfRX_;z!@IoDEQmOC?zLq%H#eEYC-u&Y(CqIMO+dKGmUtb9aHqo` zOp?BTAFeZrg!TbRAA{K5B^ zVISA4izM?&jEN#3TqhIHZD0kt|2~q-$w()X zWLs>|Ny@#WoU{&(3=`LfOaBr>+7bb{N*AJxw4vj@N%uQ94lZ!r^04sR+)7B};Fi0m zi@sc871R?0qAcYQe{|`VHu=mn@u|9$gE*Vx*1MWaG#+|Q#mdUcrWj!E!d3UJ-4Q!C z{trL?I#Myh+=Z*|3#;fO=3R}u*VTy~cfJGhdzLj?iL*ez$?P}G1%!CjO#B#h?m7~fdCT-VpW7SMR9k^HZ zmz=-%8E2ij*VI7_wN56(%>+!&foAEowY9zw^6{|VJ)T!I6g3%Ztr&sII=1-ft`jV%!C;v+G9C^KhtntE7c z0r3nY%72aL0rwETk$`mJKqz1!6fh797zhOngaQUa0Ry3clc}{>sQXmz$ek98T0}ck zv_elR|Kkql`uaLu^k>2)I#wZs5JCtcgb+dqA!PCR2MX$kD~X=(+W-In07*qoM6N<$ Eg1hV40{{R3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2703ce554aaef66f337afc9cfa56b75fa6550cc6 GIT binary patch literal 1165 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBu9ByVV@L(#+ga!Rg&jqX-_Ke7FeNIAZ;p<+ z|EY^2!d+@^eh10f+4AUJHQ)cv_@2hd(WHQalz1hUI8^>#a!#hqeeUjWccTl$KOJA- zxccfUW3hj`n?KmR4>p^<_W9?ZY7)nGHI#3^wY2B)8~5isAFO^a_w@CGgBLGe)Y}*G zXkLHP^?)O#1|7`P3eWvymsK}r*Hmsnz=bb^n!Rtr&qxl$(DEf9y^HgG!?G1w6nV>ot&Jk$F;3Ne&t59>v^+x z7lyH>@I1Sk{P3f%z?rx8^JcQ0PB=69(OcJLTPIELN@bk;^dIA!z4OnSw|jKAf^s>F?gWlAn||-uB6!fjE{TWdP>1&9uAc4$fo!Hl9$7Wn zbrmz-A6LA%@tEFHkvliaBYlj_4;qBAOley2t~+bjLU~cyY@P=bQx-Sw@my!rG<(|N zK*#Q!1>W||%_#?&4i&DtxbsukZm~VbEhMxW4Q_gvUyFNiO4WNy%w%sd5x-*xPrkDZ z75TUzK;y~*hA^+4pU$1M<4)gKAJ6gJI6v)k-lX>~-j*WiNdbGcCwKO=p9(21DJfy% zNx0K`en-9!&#sS>0aspFZD46Lcy}oMPPl_JN1efom}v(~7kzj<`Ten}y=A`JosB!z zv91a}R(H6DC&hsM)YiD)2gA<`r)K>=y(Q??(|sP$J3t)YfQGyFq39h*40{5YOZJX z_m@Y7W%D)b{=7NOr-QqCG>*^ttU9?jG?^)^?6xJF(i9GxbyxN>7o=aXn)-}s(r?ow zo0&h(vTQk0I&*3F(IjS$NB<9(eE+ekDbhOduxS{N&ik6!$+WoDX3BCxMU!^Ozc7e6 zxNrW)bst^t>|^}!LyY})!fNey?iViiJ#`lR-{Gd!pZe|RAKnL{+z%WB4}>vG#cZa@n%e2%opE() @@ -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")