Skip to content

Commit 57d6d25

Browse files
authored
[Release] 1.0.16 업데이트 (#38)
* fix - QA 반영 (#34) * fix(diary): videoUrl 필드 유튜브 id로 변경 * fix: 백그라운드 상태 복귀 시 유튜브 영상 재생 이슈해결 * feat - 킬링파트 재생 페이지 작업 (#37) * feat(PlayKillingPartView): 킬링파트 재생 페이지 UI 업데이트 * feat(PlayKillingPartView): UI 디테일 수정 * feat(PlayKillingPart): 플레이리스트 뷰 UI 업데이트 * feat(PlayKillingpart): 바텀 플레이어 패널 크기 및 UI 크기 상승 * fix(PlayKillingpart): 바텀플레이어 패널, 하단 네비, body 여백 대응 * feat(PlayKillingPart): 현재 재생 컨테이너 UI 업데이트 * feat(PlayKillingPart): 음악 다이어리 정렬 순서 수정 API 연동 및 드래그 핸들 UI 적용 * fix(PlayKillingPart): 잔상 제거 * fix(PlayKillingPart): 드래그 상태 반투명 적용 * fix(PlayKillingPart): 드래그앤 드롭 UX 개선 * feat(1.0.16): 버전 업데이트
2 parents 82ca74f + dd2b064 commit 57d6d25

16 files changed

Lines changed: 1448 additions & 50 deletions

File tree

KillingPart.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@
434434
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
435435
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
436436
CODE_SIGN_STYLE = Automatic;
437-
CURRENT_PROJECT_VERSION = 15;
437+
CURRENT_PROJECT_VERSION = 16;
438438
DEAD_CODE_STRIPPING = YES;
439439
DEVELOPMENT_TEAM = GQ89YG5G9R;
440440
ENABLE_APP_SANDBOX = YES;
@@ -459,7 +459,7 @@
459459
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
460460
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
461461
MACOSX_DEPLOYMENT_TARGET = 14.0;
462-
MARKETING_VERSION = 1.0.15;
462+
MARKETING_VERSION = 1.0.16;
463463
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
464464
PRODUCT_NAME = "$(TARGET_NAME)";
465465
REGISTER_APP_GROUPS = YES;
@@ -479,7 +479,7 @@
479479
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
480480
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
481481
CODE_SIGN_STYLE = Automatic;
482-
CURRENT_PROJECT_VERSION = 15;
482+
CURRENT_PROJECT_VERSION = 16;
483483
DEAD_CODE_STRIPPING = YES;
484484
DEVELOPMENT_TEAM = GQ89YG5G9R;
485485
ENABLE_APP_SANDBOX = YES;
@@ -504,7 +504,7 @@
504504
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
505505
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
506506
MACOSX_DEPLOYMENT_TARGET = 14.0;
507-
MARKETING_VERSION = 1.0.15;
507+
MARKETING_VERSION = 1.0.16;
508508
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
509509
PRODUCT_NAME = "$(TARGET_NAME)";
510510
REGISTER_APP_GROUPS = YES;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "killingpart_music_icon.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"filename" : "killingpart_music_icon 1.png",
10+
"idiom" : "universal",
11+
"scale" : "2x"
12+
},
13+
{
14+
"filename" : "killingpart_music_icon 2.png",
15+
"idiom" : "universal",
16+
"scale" : "3x"
17+
}
18+
],
19+
"info" : {
20+
"author" : "xcode",
21+
"version" : 1
22+
}
23+
}
434 Bytes
Loading
434 Bytes
Loading
434 Bytes
Loading

KillingPart/Models/DiaryModel.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,7 @@ struct DiaryUpdateRequest: Encodable {
128128
try container.encodeIfPresent(end, forKey: .end)
129129
}
130130
}
131+
132+
struct DiaryOrderUpdateRequest: Encodable {
133+
let diaryIds: [Int]
134+
}

KillingPart/Services/DiaryService.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ protocol DiaryServicing {
55
func createDiary(request: DiaryCreateRequest) async throws -> DiaryCreateResult
66
func updateDiary(diaryId: Int, request: DiaryUpdateRequest) async throws
77
func deleteDiary(diaryId: Int) async throws
8+
func updateMyDiaryOrder(request: DiaryOrderUpdateRequest) async throws
89
}
910

1011
enum DiaryServiceError: LocalizedError {
@@ -136,6 +137,30 @@ struct DiaryService: DiaryServicing {
136137
}
137138
}
138139

140+
func updateMyDiaryOrder(request: DiaryOrderUpdateRequest) async throws {
141+
let requestBody: Data
142+
do {
143+
requestBody = try JSONEncoder().encode(request)
144+
} catch {
145+
throw DiaryServiceError.requestEncodingFailed
146+
}
147+
148+
do {
149+
var apiRequest = APIRequest(
150+
path: "/diaries/order",
151+
method: .patch,
152+
requiresAuthorization: true,
153+
body: requestBody
154+
)
155+
apiRequest.headers["Accept"] = "application/json"
156+
apiRequest.headers["Content-Type"] = "application/json"
157+
try await apiClient.request(apiRequest)
158+
} catch {
159+
if isRequestCancelled(error) { throw error }
160+
throw mapError(error)
161+
}
162+
}
163+
139164
private func mapError(_ error: Error) -> DiaryServiceError {
140165
if let diaryServiceError = error as? DiaryServiceError {
141166
return diaryServiceError

KillingPart/ViewModels/Add/AddSearchDetailViewModel.swift

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -287,13 +287,87 @@ final class AddSearchDetailViewModel: ObservableObject {
287287
}
288288

289289
private var videoURLForSave: String? {
290-
if let embedURL = selectedVideo?.embedURL?.absoluteString {
291-
return embedURL
290+
guard let selectedVideo else { return nil }
291+
292+
if let normalizedVideoID = normalizedYouTubeVideoID(from: selectedVideo.id) {
293+
return normalizedVideoID
292294
}
293295

294-
guard let selectedVideo else { return nil }
295-
let videoID = selectedVideo.id.trimmingCharacters(in: .whitespacesAndNewlines)
296-
guard !videoID.isEmpty else { return nil }
297-
return "https://www.youtube.com/watch?v=\(videoID)"
296+
if
297+
let embedURLString = selectedVideo.embedURL?.absoluteString,
298+
let normalizedVideoID = normalizedYouTubeVideoID(from: embedURLString)
299+
{
300+
return normalizedVideoID
301+
}
302+
303+
return nil
304+
}
305+
306+
private func normalizedYouTubeVideoID(from value: String) -> String? {
307+
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
308+
guard !trimmedValue.isEmpty else { return nil }
309+
310+
if let extractedVideoID = extractYouTubeVideoID(from: trimmedValue) {
311+
return extractedVideoID
312+
}
313+
314+
if
315+
!trimmedValue.contains("/"),
316+
!trimmedValue.contains("?"),
317+
!trimmedValue.contains("&"),
318+
!trimmedValue.contains("="),
319+
!trimmedValue.contains(".")
320+
{
321+
return trimmedValue
322+
}
323+
324+
return nil
325+
}
326+
327+
private func extractYouTubeVideoID(from value: String) -> String? {
328+
guard let components = URLComponents(string: value) else {
329+
return nil
330+
}
331+
332+
let pathComponents = components.path.split(separator: "/").map(String.init)
333+
if let embedIndex = pathComponents.firstIndex(of: "embed"),
334+
pathComponents.indices.contains(embedIndex + 1) {
335+
let candidate = pathComponents[embedIndex + 1]
336+
if !candidate.isEmpty {
337+
return candidate
338+
}
339+
}
340+
341+
if let shortsIndex = pathComponents.firstIndex(of: "shorts"),
342+
pathComponents.indices.contains(shortsIndex + 1) {
343+
let candidate = pathComponents[shortsIndex + 1]
344+
if !candidate.isEmpty {
345+
return candidate
346+
}
347+
}
348+
349+
if let liveIndex = pathComponents.firstIndex(of: "live"),
350+
pathComponents.indices.contains(liveIndex + 1) {
351+
let candidate = pathComponents[liveIndex + 1]
352+
if !candidate.isEmpty {
353+
return candidate
354+
}
355+
}
356+
357+
if
358+
let host = components.host?.lowercased(),
359+
host.contains("youtu.be"),
360+
let firstPath = pathComponents.first,
361+
!firstPath.isEmpty
362+
{
363+
return firstPath
364+
}
365+
366+
if let watchVideoID = components.queryItems?.first(where: { $0.name == "v" })?.value,
367+
!watchVideoID.isEmpty {
368+
return watchVideoID
369+
}
370+
371+
return nil
298372
}
299373
}

KillingPart/ViewModels/My/MyCollection/MyCollectionViewModel.swift

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,12 @@ final class MyCollectionViewModel: ObservableObject {
238238

239239
do {
240240
let response = try await diaryService.fetchMyFeeds(page: page, size: size)
241+
let normalizedFeeds = normalizeFeedVideoURLs(in: response.content)
241242
if mode == .initial {
242-
myFeeds = response.content
243+
myFeeds = normalizedFeeds
243244
} else {
244245
let existingFeedIDs = Set(myFeeds.map(\.id))
245-
let newFeeds = response.content.filter { !existingFeedIDs.contains($0.id) }
246+
let newFeeds = normalizedFeeds.filter { !existingFeedIDs.contains($0.id) }
246247
myFeeds.append(contentsOf: newFeeds)
247248
if newFeeds.isEmpty {
248249
hasLoadedMyFeeds = true
@@ -264,6 +265,38 @@ final class MyCollectionViewModel: ObservableObject {
264265
}
265266
}
266267

268+
private func normalizeFeedVideoURLs(in feeds: [DiaryFeedModel]) -> [DiaryFeedModel] {
269+
feeds.map { feed in
270+
let normalizedVideoURL = resolvedVideoURLForPlayback(from: feed.videoUrl)
271+
guard normalizedVideoURL != feed.videoUrl else { return feed }
272+
return feed.replacingVideoURL(normalizedVideoURL)
273+
}
274+
}
275+
276+
private func resolvedVideoURLForPlayback(from rawVideoURL: String) -> String {
277+
let trimmedVideoURL = rawVideoURL.trimmingCharacters(in: .whitespacesAndNewlines)
278+
guard !trimmedVideoURL.isEmpty else { return rawVideoURL }
279+
guard isLikelyYouTubeVideoID(trimmedVideoURL) else { return trimmedVideoURL }
280+
return "https://www.youtube.com/embed/\(trimmedVideoURL)?playsinline=1"
281+
}
282+
283+
private func isLikelyYouTubeVideoID(_ value: String) -> Bool {
284+
if value.hasPrefix("//") {
285+
return false
286+
}
287+
288+
if let components = URLComponents(string: value),
289+
components.scheme != nil || components.host != nil {
290+
return false
291+
}
292+
293+
return !value.contains("/")
294+
&& !value.contains("?")
295+
&& !value.contains("&")
296+
&& !value.contains("=")
297+
&& !value.contains(".")
298+
}
299+
267300
private enum FeedLoadMode {
268301
case initial
269302
case pagination
@@ -327,3 +360,30 @@ final class MyCollectionViewModel: ObservableObject {
327360
}
328361
}
329362
}
363+
364+
private extension DiaryFeedModel {
365+
func replacingVideoURL(_ newVideoURL: String) -> DiaryFeedModel {
366+
DiaryFeedModel(
367+
diaryId: diaryId,
368+
artist: artist,
369+
musicTitle: musicTitle,
370+
albumImageUrl: albumImageUrl,
371+
content: content,
372+
videoUrl: newVideoURL,
373+
scope: scope,
374+
duration: duration,
375+
totalDuration: totalDuration,
376+
start: start,
377+
end: end,
378+
createDate: createDate,
379+
updateDate: updateDate,
380+
isLiked: isLiked,
381+
isStored: isStored,
382+
likeCount: likeCount,
383+
userId: userId,
384+
username: username,
385+
tag: tag,
386+
profileImageUrl: profileImageUrl
387+
)
388+
}
389+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import Foundation
2+
3+
@MainActor
4+
final class PlayKillingPartViewModel: ObservableObject {
5+
@Published private(set) var isEditMode = false
6+
@Published private(set) var isSavingOrder = false
7+
@Published var errorMessage: String?
8+
9+
private let diaryService: DiaryServicing
10+
11+
init(diaryService: DiaryServicing = DiaryService()) {
12+
self.diaryService = diaryService
13+
}
14+
15+
func beginEditing() {
16+
guard !isSavingOrder else { return }
17+
errorMessage = nil
18+
isEditMode = true
19+
}
20+
21+
func completeEditing(with diaryIDs: [Int]) async -> Bool {
22+
guard isEditMode else { return true }
23+
guard !isSavingOrder else { return false }
24+
25+
let hasDuplicateDiaryID = Set(diaryIDs).count != diaryIDs.count
26+
if hasDuplicateDiaryID {
27+
errorMessage = "플레이리스트 순서가 올바르지 않아요."
28+
return false
29+
}
30+
31+
isSavingOrder = true
32+
errorMessage = nil
33+
defer { isSavingOrder = false }
34+
35+
do {
36+
try await diaryService.updateMyDiaryOrder(
37+
request: DiaryOrderUpdateRequest(diaryIds: diaryIDs)
38+
)
39+
isEditMode = false
40+
return true
41+
} catch {
42+
if isRequestCancelled(error) { return false }
43+
errorMessage = resolveErrorMessage(from: error)
44+
return false
45+
}
46+
}
47+
48+
func endEditingWithoutSave() {
49+
guard !isSavingOrder else { return }
50+
errorMessage = nil
51+
isEditMode = false
52+
}
53+
54+
private func resolveErrorMessage(from error: Error) -> String {
55+
if let diaryError = error as? DiaryServiceError {
56+
return diaryError.errorDescription ?? "요청 처리에 실패했어요."
57+
}
58+
59+
if let apiError = error as? APIClientError {
60+
return apiError.errorDescription ?? "요청 처리에 실패했어요."
61+
}
62+
63+
if let localizedError = error as? LocalizedError {
64+
return localizedError.errorDescription ?? "요청 처리에 실패했어요."
65+
}
66+
67+
return "요청 처리에 실패했어요."
68+
}
69+
70+
private func isRequestCancelled(_ error: Error) -> Bool {
71+
if error is CancellationError {
72+
return true
73+
}
74+
75+
let nsError = error as NSError
76+
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
77+
}
78+
}

0 commit comments

Comments
 (0)