Skip to content

Commit f96f9eb

Browse files
authored
[Release] 1.0.8 업데이트 (#23)
* fix(MyCollection): 다이어리 리스트 로딩기능 추가 (#21) * feat - 다이어리 등록 기능 (#22) * feat(AddSearchDetail): Youtube API 연동 및 AddSearchDetail 뷰, 뷰모델 정의 * feat(MyTabView): topToggle UI 네이티브로 변경 * feat(AddSearchDetail): 구간 자르기 UI 업데이트 * feat(AddSearchDetail): 유튜브 플레이어, 구간 자르기 동기화 * fix(AddSearchDetail): 구간자르기 미니맵 기능 추가 * fix(Youtube): 유튜브검색 최신 api로 업데이트 * feat(AddSearchDetail): 앨범 UI 업데이트 * feat(AddSearchDetail): 앨범 크기 및 디스크 크기 조정 * feat(AddSearchDetail): 킬링파트 구간 자르기 UI 업데이트 * refactor(AddSearchDetail): 컴포넌트 분리 작업 * feat(AddSearchField): 검색창 네이티브 UI로 업데이트 * feat(AddSearchDetail): 다이어리 추가 API 연동 및 CommentSection 추가 * feat(AddSearchDetailCommentSection): 아이콘 동적으로 변경 * feat(MyCollection): refetch 기능 추가 * feat(AddTabView): 음악 무한 스크롤 추가 * fix(MyCollection): 데이터 refetch 기능 수정 * feat(YoutubePlayer): 구간 끝나는시간 동기화 * feat(AddSearchDetail): 음악 저장 이후 검색기록 clear * feat(AddSearchDetailCommentSection): 키보드 화면 밖 터치 시 내림 * fix(MyCollection): refetch 기능 추가 * feat(AddSearchDetailTrim): 로딩 멘트 개선 및 구간자르기 UX 업데이트 * feat(1.0.8): 버전 업데이트 * feat(MyCollection): 데이터 조회 refetch 기능 강화 * feat(AddSearchDetail): 킬링파트 로고 추가 * feat(1.0.8): 버전 업데이트
2 parents faaf412 + f3a7a90 commit f96f9eb

35 files changed

Lines changed: 2820 additions & 123 deletions

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 = 6;
437+
CURRENT_PROJECT_VERSION = 9;
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.6;
462+
MARKETING_VERSION = 1.0.9;
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 = 6;
482+
CURRENT_PROJECT_VERSION = 9;
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.6;
507+
MARKETING_VERSION = 1.0.9;
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" : "killing_part_logo_diary.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"filename" : "killing_part_logo_diary 1.png",
10+
"idiom" : "universal",
11+
"scale" : "2x"
12+
},
13+
{
14+
"filename" : "killing_part_logo_diary 2.png",
15+
"idiom" : "universal",
16+
"scale" : "3x"
17+
}
18+
],
19+
"info" : {
20+
"author" : "xcode",
21+
"version" : 1
22+
}
23+
}
2.17 KB
Loading
2.17 KB
Loading
2.17 KB
Loading

KillingPart/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
<string>$(BASE_URL)</string>
99
<key>KAKAO_NATIVE_APP_KEY</key>
1010
<string>$(KAKAO_NATIVE_APP_KEY)</string>
11+
<key>MUSIC_BASE_URL</key>
12+
<string>$(MUSIC_BASE_URL)</string>
1113
<key>LSApplicationQueriesSchemes</key>
1214
<array>
1315
<string>kakaokompassauth</string>

KillingPart/KillingPartApp.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import KakaoSDKCommon
1313
struct KillingPartApp: App {
1414
init() {
1515
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" {
16-
AppFont.registerPaperlogyFonts()
1716
configureKakaoSDK()
1817
}
1918
}

KillingPart/Models/DiaryModel.swift

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
import Foundation
22

3-
enum DiaryScope: String, Decodable {
3+
enum DiaryScope: String, Codable, CaseIterable, Identifiable {
44
case `public` = "PUBLIC"
55
case `private` = "PRIVATE"
66
case killingPart = "KILLING_PART"
7+
8+
var id: String { rawValue }
9+
10+
var addSearchDetailDisplayName: String {
11+
switch self {
12+
case .private:
13+
return "전체 비공개"
14+
case .killingPart:
15+
return "킬링파트만 공개"
16+
case .public:
17+
return "전체 공개"
18+
}
19+
}
720
}
821

922
struct DiaryFeedModel: Decodable, Identifiable {
@@ -31,7 +44,18 @@ struct DiaryFeedModel: Decodable, Identifiable {
3144
var id: Int { diaryId }
3245

3346
var albumImageURL: URL? {
34-
URL(string: albumImageUrl)
47+
let trimmed = albumImageUrl.trimmingCharacters(in: .whitespacesAndNewlines)
48+
guard !trimmed.isEmpty else { return nil }
49+
50+
if let parsed = URL(string: trimmed), parsed.scheme != nil {
51+
return parsed
52+
}
53+
54+
if trimmed.hasPrefix("//"), let parsed = URL(string: "https:\(trimmed)") {
55+
return parsed
56+
}
57+
58+
return URL(string: "https://\(trimmed)")
3559
}
3660
}
3761

@@ -46,3 +70,21 @@ struct MyDiaryFeedsResponse: Decodable {
4670
let content: [DiaryFeedModel]
4771
let page: DiaryFeedPageModel
4872
}
73+
74+
struct DiaryCreateRequest: Encodable {
75+
let artist: String
76+
let musicTitle: String
77+
let albumImageUrl: String
78+
let videoUrl: String
79+
let scope: DiaryScope
80+
let content: String
81+
let duration: String
82+
let totalDuration: String
83+
let start: String
84+
let end: String
85+
}
86+
87+
struct DiaryCreateResult {
88+
let diaryId: Int?
89+
let location: String?
90+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import Foundation
2+
3+
struct YoutubeSearchRequest: Encodable {
4+
let title: String
5+
let artist: String
6+
}
7+
8+
struct YoutubeVideo: Identifiable, Decodable, Equatable {
9+
let id: String
10+
let title: String
11+
let duration: Double
12+
private let urlString: String?
13+
14+
enum CodingKeys: String, CodingKey {
15+
case id
16+
case title
17+
case duration
18+
case url
19+
}
20+
21+
init(from decoder: Decoder) throws {
22+
let container = try decoder.container(keyedBy: CodingKeys.self)
23+
title = try container.decode(String.self, forKey: .title)
24+
25+
if let numericDuration = try? container.decode(Double.self, forKey: .duration) {
26+
duration = numericDuration
27+
} else {
28+
let durationText = try container.decode(String.self, forKey: .duration)
29+
guard let parsedDuration = Self.parseDuration(durationText) else {
30+
throw DecodingError.dataCorruptedError(
31+
forKey: .duration,
32+
in: container,
33+
debugDescription: "Unsupported duration format: \(durationText)"
34+
)
35+
}
36+
duration = parsedDuration
37+
}
38+
39+
urlString = try? container.decodeIfPresent(String.self, forKey: .url)
40+
41+
if
42+
let decodedID = (try? container.decodeIfPresent(String.self, forKey: .id))?
43+
.trimmingCharacters(in: .whitespacesAndNewlines),
44+
!decodedID.isEmpty
45+
{
46+
id = decodedID
47+
} else if let urlString, let extractedVideoID = Self.extractVideoID(from: urlString) {
48+
id = extractedVideoID
49+
} else {
50+
throw DecodingError.dataCorruptedError(
51+
forKey: .id,
52+
in: container,
53+
debugDescription: "Missing youtube video id."
54+
)
55+
}
56+
}
57+
58+
var thumbnailURL: URL? {
59+
guard let videoID = normalizedVideoID else {
60+
return nil
61+
}
62+
return URL(string: "https://i.ytimg.com/vi/\(videoID)/hqdefault.jpg")
63+
}
64+
65+
var embedURL: URL? {
66+
if let urlString, let embedURL = URL(string: urlString) {
67+
return embedURL
68+
}
69+
70+
guard let videoID = normalizedVideoID else {
71+
return nil
72+
}
73+
74+
return URL(string: "https://www.youtube.com/embed/\(videoID)?playsinline=1")
75+
}
76+
77+
private var normalizedVideoID: String? {
78+
if let extractedFromID = Self.extractVideoID(from: id) {
79+
return extractedFromID
80+
}
81+
82+
if !id.isEmpty, !id.contains("http"), !id.contains("/") {
83+
return id
84+
}
85+
86+
if let urlString, let extractedFromURL = Self.extractVideoID(from: urlString) {
87+
return extractedFromURL
88+
}
89+
90+
return nil
91+
}
92+
93+
private static func extractVideoID(from value: String) -> String? {
94+
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
95+
guard !trimmedValue.isEmpty else {
96+
return nil
97+
}
98+
99+
guard let components = URLComponents(string: trimmedValue) else {
100+
return nil
101+
}
102+
103+
let pathComponents = components.path.split(separator: "/").map(String.init)
104+
if let embedIndex = pathComponents.firstIndex(of: "embed"),
105+
pathComponents.indices.contains(embedIndex + 1) {
106+
let candidate = pathComponents[embedIndex + 1]
107+
if !candidate.isEmpty {
108+
return candidate
109+
}
110+
}
111+
112+
if
113+
let host = components.host?.lowercased(),
114+
host.contains("youtu.be"),
115+
let firstPath = pathComponents.first,
116+
!firstPath.isEmpty
117+
{
118+
return firstPath
119+
}
120+
121+
if let watchVideoID = components.queryItems?.first(where: { $0.name == "v" })?.value,
122+
!watchVideoID.isEmpty {
123+
return watchVideoID
124+
}
125+
126+
if components.scheme == nil, components.host == nil, !trimmedValue.contains("/") {
127+
return trimmedValue
128+
}
129+
130+
return nil
131+
}
132+
133+
private static let durationRegex = try? NSRegularExpression(
134+
pattern: #"^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$"#
135+
)
136+
137+
private static func parseDuration(_ value: String) -> Double? {
138+
guard let regex = durationRegex else {
139+
return nil
140+
}
141+
142+
let range = NSRange(value.startIndex..<value.endIndex, in: value)
143+
guard let match = regex.firstMatch(in: value, options: [], range: range) else {
144+
return nil
145+
}
146+
147+
func component(at index: Int) -> Double {
148+
let componentRange = match.range(at: index)
149+
guard
150+
componentRange.location != NSNotFound,
151+
let swiftRange = Range(componentRange, in: value),
152+
let parsed = Double(value[swiftRange])
153+
else {
154+
return 0
155+
}
156+
return parsed
157+
}
158+
159+
return component(at: 1) * 3600 + component(at: 2) * 60 + component(at: 3)
160+
}
161+
}

KillingPart/Services/APIClient.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,31 @@ struct APIRequest {
1515
var requiresAuthorization: Bool
1616
var headers: [String: String]
1717
var body: Data?
18+
var baseURL: URL?
1819

1920
init(
2021
path: String,
2122
method: HTTPMethod,
2223
queryItems: [URLQueryItem] = [],
2324
requiresAuthorization: Bool = false,
2425
headers: [String: String] = [:],
25-
body: Data? = nil
26+
body: Data? = nil,
27+
baseURL: URL? = nil
2628
) {
2729
self.path = path
2830
self.method = method
2931
self.queryItems = queryItems
3032
self.requiresAuthorization = requiresAuthorization
3133
self.headers = headers
3234
self.body = body
35+
self.baseURL = baseURL
3336
}
3437
}
3538

3639
protocol APIClienting {
3740
func request(_ request: APIRequest) async throws
3841
func request<T: Decodable>(_ request: APIRequest, responseType: T.Type) async throws -> T
42+
func requestWithResponse(_ request: APIRequest) async throws -> HTTPURLResponse
3943
}
4044

4145
enum APIClientError: LocalizedError {
@@ -101,6 +105,17 @@ final class APIClient: APIClienting {
101105
}
102106
}
103107

108+
func requestWithResponse(_ request: APIRequest) async throws -> HTTPURLResponse {
109+
let (data, response) = try await execute(request, allowTokenRefresh: true)
110+
guard (200..<300).contains(response.statusCode) else {
111+
throw APIClientError.serverError(
112+
statusCode: response.statusCode,
113+
message: responseMessage(from: data)
114+
)
115+
}
116+
return response
117+
}
118+
104119
private func execute(
105120
_ request: APIRequest,
106121
allowTokenRefresh: Bool
@@ -122,8 +137,13 @@ final class APIClient: APIClienting {
122137
}
123138

124139
private func buildRequest(from request: APIRequest) throws -> URLRequest {
140+
let requestBaseURL = request.baseURL ?? APIConfiguration.baseURL
125141
var urlRequest = URLRequest(
126-
url: APIConfiguration.endpoint(path: request.path, queryItems: request.queryItems)
142+
url: APIConfiguration.endpoint(
143+
baseURL: requestBaseURL,
144+
path: request.path,
145+
queryItems: request.queryItems
146+
)
127147
)
128148
urlRequest.httpMethod = request.method.rawValue
129149
urlRequest.httpBody = request.body
@@ -167,6 +187,9 @@ final class APIClient: APIClienting {
167187

168188
return (data, httpResponse)
169189
} catch {
190+
if isRequestCancelled(error) {
191+
throw error
192+
}
170193
print("[Network][Error] request failed: \(error.localizedDescription)")
171194
throw error
172195
}
@@ -298,6 +321,15 @@ final class APIClient: APIClienting {
298321
let suffix = token.suffix(4)
299322
return "\(prefix)...\(suffix)"
300323
}
324+
325+
private func isRequestCancelled(_ error: Error) -> Bool {
326+
if error is CancellationError {
327+
return true
328+
}
329+
330+
let nsError = error as NSError
331+
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
332+
}
301333
}
302334

303335
private struct APIErrorMessageResponse: Decodable {

0 commit comments

Comments
 (0)