diff --git a/PepperoniV2.xcodeproj/project.pbxproj b/PepperoniV2.xcodeproj/project.pbxproj index 9d18ef8..5051ba8 100644 --- a/PepperoniV2.xcodeproj/project.pbxproj +++ b/PepperoniV2.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 511509D12CFFDA1C00D5065E /* JINGYG_010.mov in Resources */ = {isa = PBXBuildFile; fileRef = 511509CD2CFFDA1C00D5065E /* JINGYG_010.mov */; }; + 511509D22CFFDA1C00D5065E /* ONEPIC_010.mov in Resources */ = {isa = PBXBuildFile; fileRef = 511509CF2CFFDA1C00D5065E /* ONEPIC_010.mov */; }; + 511509D32CFFDA1C00D5065E /* JUSULH_010.mov in Resources */ = {isa = PBXBuildFile; fileRef = 511509CE2CFFDA1C00D5065E /* JUSULH_010.mov */; }; + 511509D42CFFDA1C00D5065E /* SLDUNK_010.mov in Resources */ = {isa = PBXBuildFile; fileRef = 511509D02CFFDA1C00D5065E /* SLDUNK_010.mov */; }; + 511509D62CFFDFC200D5065E /* showcase.json in Resources */ = {isa = PBXBuildFile; fileRef = 511509D52CFFDFC200D5065E /* showcase.json */; }; 515961C32CF33CD400060E73 /* FetchDataState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515961C22CF33CD300060E73 /* FetchDataState.swift */; }; 516A49252CEF98CB00D4E9DE /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 516A49242CEF98CB00D4E9DE /* GoogleService-Info.plist */; }; 516A49282CEF9AD200D4E9DE /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 516A49272CEF9AD200D4E9DE /* FirebaseAuth */; }; @@ -14,6 +19,10 @@ 516A492C2CEF9AD200D4E9DE /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 516A492B2CEF9AD200D4E9DE /* FirebaseFirestore */; }; 516A492E2CEF9AD200D4E9DE /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 516A492D2CEF9AD200D4E9DE /* FirebaseStorage */; }; 516A49312CEF9D6600D4E9DE /* FirestoreService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516A49302CEF9D6600D4E9DE /* FirestoreService.swift */; }; + 517C11262D000098004021E8 /* ONEPIC_010.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 517C11252D000098004021E8 /* ONEPIC_010.m4a */; }; + 517C11282D0000C7004021E8 /* JINGYG_010.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 517C11272D0000C7004021E8 /* JINGYG_010.m4a */; }; + 517C112B2D0001C4004021E8 /* SLDUNK_010.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 517C112A2D0001C4004021E8 /* SLDUNK_010.m4a */; }; + 517C112C2D0001C4004021E8 /* JUSULH_010.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 517C11292D0001C4004021E8 /* JUSULH_010.m4a */; }; 90129B852CEC67ED0029DAEF /* RouletteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90129B842CEC67E70029DAEF /* RouletteManager.swift */; }; 908797C42CF81AFD008EA1FA /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 908797C32CF81AFD008EA1FA /* HapticManager.swift */; }; 909C931C2CE5F08E00976538 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = 909C931B2CE5F08E00976538 /* .gitignore */; }; @@ -59,9 +68,18 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 511509CD2CFFDA1C00D5065E /* JINGYG_010.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = JINGYG_010.mov; sourceTree = ""; }; + 511509CE2CFFDA1C00D5065E /* JUSULH_010.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = JUSULH_010.mov; sourceTree = ""; }; + 511509CF2CFFDA1C00D5065E /* ONEPIC_010.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = ONEPIC_010.mov; sourceTree = ""; }; + 511509D02CFFDA1C00D5065E /* SLDUNK_010.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = SLDUNK_010.mov; sourceTree = ""; }; + 511509D52CFFDFC200D5065E /* showcase.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = showcase.json; sourceTree = ""; }; 515961C22CF33CD300060E73 /* FetchDataState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchDataState.swift; sourceTree = ""; }; 516A49242CEF98CB00D4E9DE /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 516A49302CEF9D6600D4E9DE /* FirestoreService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirestoreService.swift; sourceTree = ""; }; + 517C11252D000098004021E8 /* ONEPIC_010.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = ONEPIC_010.m4a; sourceTree = ""; }; + 517C11272D0000C7004021E8 /* JINGYG_010.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = JINGYG_010.m4a; sourceTree = ""; }; + 517C11292D0001C4004021E8 /* JUSULH_010.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = JUSULH_010.m4a; sourceTree = ""; }; + 517C112A2D0001C4004021E8 /* SLDUNK_010.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = SLDUNK_010.m4a; sourceTree = ""; }; 90129B842CEC67E70029DAEF /* RouletteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouletteManager.swift; sourceTree = ""; }; 908797C32CF81AFD008EA1FA /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; 909C93082CE5EFE400976538 /* PepperoniV2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PepperoniV2.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -222,6 +240,15 @@ BD400C992CE88EDD0052174B /* Resource */ = { isa = PBXGroup; children = ( + 511509D52CFFDFC200D5065E /* showcase.json */, + 517C11292D0001C4004021E8 /* JUSULH_010.m4a */, + 517C112A2D0001C4004021E8 /* SLDUNK_010.m4a */, + 511509CD2CFFDA1C00D5065E /* JINGYG_010.mov */, + 517C11272D0000C7004021E8 /* JINGYG_010.m4a */, + 511509CE2CFFDA1C00D5065E /* JUSULH_010.mov */, + 511509CF2CFFDA1C00D5065E /* ONEPIC_010.mov */, + 517C11252D000098004021E8 /* ONEPIC_010.m4a */, + 511509D02CFFDA1C00D5065E /* SLDUNK_010.mov */, 90A5038E2CED8B0E00D83DCC /* audioFile */, BD0453382CEE307900F83631 /* Extensions */, 909C931F2CE5F29100976538 /* Assets.xcassets */, @@ -397,8 +424,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 517C11282D0000C7004021E8 /* JINGYG_010.m4a in Resources */, + 517C112B2D0001C4004021E8 /* SLDUNK_010.m4a in Resources */, + 517C112C2D0001C4004021E8 /* JUSULH_010.m4a in Resources */, 909C93232CE5F29100976538 /* Preview Assets.xcassets in Resources */, 90A5039B2CEF12F600D83DCC /* SUIT-ExtraBold.otf in Resources */, + 517C11262D000098004021E8 /* ONEPIC_010.m4a in Resources */, 90A5039C2CEF12F600D83DCC /* SUIT-Heavy.otf in Resources */, 90A5039D2CEF12F600D83DCC /* SUIT-Light.otf in Resources */, 90A5039E2CEF12F600D83DCC /* SUIT-Regular.otf in Resources */, @@ -407,6 +438,11 @@ 90A503A12CEF12F600D83DCC /* SUIT-SemiBold.otf in Resources */, 516A49252CEF98CB00D4E9DE /* GoogleService-Info.plist in Resources */, 90A503A22CEF12F600D83DCC /* SUIT-Medium.otf in Resources */, + 511509D62CFFDFC200D5065E /* showcase.json in Resources */, + 511509D12CFFDA1C00D5065E /* JINGYG_010.mov in Resources */, + 511509D22CFFDA1C00D5065E /* ONEPIC_010.mov in Resources */, + 511509D32CFFDA1C00D5065E /* JUSULH_010.mov in Resources */, + 511509D42CFFDA1C00D5065E /* SLDUNK_010.mov in Resources */, 90A503A32CEF12F600D83DCC /* SUIT-ExtraLight.otf in Resources */, 90E2E9932CED035600857636 /* BOT006.m4a in Resources */, 90A503A82CEF174000D83DCC /* HakgyoansimUndongjangOTFL.otf in Resources */, @@ -584,7 +620,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"PepperoniV2/Preview Content\""; - DEVELOPMENT_TEAM = 5Z2TRCRXZZ; + DEVELOPMENT_TEAM = 2U3B7G3F2A; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PepperoniV2/Info.plist; @@ -623,7 +659,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"PepperoniV2/Preview Content\""; - DEVELOPMENT_TEAM = 5Z2TRCRXZZ; + DEVELOPMENT_TEAM = 2U3B7G3F2A; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PepperoniV2/Info.plist; diff --git a/PepperoniV2/App/FetchDataState.swift b/PepperoniV2/App/FetchDataState.swift index 80b27f0..2311700 100644 --- a/PepperoniV2/App/FetchDataState.swift +++ b/PepperoniV2/App/FetchDataState.swift @@ -5,10 +5,10 @@ // Created by Woowon Kang on 11/24/24. // -import Observation - -@Observable -class FetchDataState { - var isFetchingData: Bool = true - var errorMessage: String? = nil -} +//import Observation +// +//@Observable +//class FetchDataState { +// var isFetchingData: Bool = true +// var errorMessage: String? = nil +//} diff --git a/PepperoniV2/App/PepperoniV2App.swift b/PepperoniV2/App/PepperoniV2App.swift index 656f855..a17caa7 100644 --- a/PepperoniV2/App/PepperoniV2App.swift +++ b/PepperoniV2/App/PepperoniV2App.swift @@ -10,7 +10,7 @@ import SwiftData @main struct PepperoniV2App: App { - @State private var fetchDataState = FetchDataState() + //@State private var fetchDataState = FetchDataState() var modelContainer: ModelContainer = { let schema = Schema([Anime.self, AnimeQuote.self]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) @@ -20,39 +20,40 @@ struct PepperoniV2App: App { fatalError("Could not create ModelContainer: \(error)") } }() - @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + //@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { WindowGroup { ContentView() .preferredColorScheme(.dark) - .environment(fetchDataState) + //.environment(fetchDataState) .onAppear { - NotificationCenter.default.addObserver( - forName: AppDelegate.anonymousSignInCompleted, - object: nil, - queue: .main - ) { _ in - Task { - await MainActor.run { - let context = modelContainer.mainContext - Task { - do { - try await FirestoreService().fetchAnimeTitles(context: context) - fetchDataState.isFetchingData = false - } catch { - fetchDataState.errorMessage = error.localizedDescription - fetchDataState.isFetchingData = false - } - } - } - } - } - } - .onDisappear { - NotificationCenter.default.removeObserver(self, name: AppDelegate.anonymousSignInCompleted, object: nil) + // NotificationCenter.default.addObserver( + // forName: AppDelegate.anonymousSignInCompleted, + // object: nil, + // queue: .main + // ) { _ in + // Task { + // await MainActor.run { + // let context = modelContainer.mainContext + //// Task { + //// do { + //// try await FirestoreService().fetchAnimeTitles(context: context) + //// fetchDataState.isFetchingData = false + //// } catch { + //// fetchDataState.errorMessage = error.localizedDescription + //// fetchDataState.isFetchingData = false + //// } + //// } + // } + // } + // } + // } + //// .onDisappear { + //// NotificationCenter.default.removeObserver(self, name: AppDelegate.anonymousSignInCompleted, object: nil) + //// } } + .modelContainer(modelContainer) } - .modelContainer(modelContainer) } } diff --git a/PepperoniV2/Data/AnimeQuote.swift b/PepperoniV2/Data/AnimeQuote.swift index ffbd95a..fcb3fad 100644 --- a/PepperoniV2/Data/AnimeQuote.swift +++ b/PepperoniV2/Data/AnimeQuote.swift @@ -13,7 +13,7 @@ final class Anime { @Attribute(.unique) var id: String var title: String @Relationship(deleteRule: .cascade) var quotes: [AnimeQuote] - + init(id: String, title: String, quotes: [AnimeQuote] = []) { self.id = id self.title = title @@ -30,11 +30,8 @@ final class AnimeQuote { var timeMark: [Double] // 각 단어가 시작되는 타임마크 var voicingTime: Double // 말하기 Speed 채점을 위한 기준 var audioFile: String // 음성 파일 로컬 경로 - var youtubeID: String // 유튜브 영상 ID - var youtubeStartTime: Double // 유튜브 영상 시작 시간 - var youtubeEndTime: Double // 유튜브 영상 끝 시간 - - init(id: String, japanese: [String], pronunciation: [String], korean: [String], timeMark: [Double], voicingTime: Double, audioFile: String, youtubeID: String, youtubeStartTime: Double, youtubeEndTime: Double) { + + init(id: String, japanese: [String], pronunciation: [String], korean: [String], timeMark: [Double], voicingTime: Double, audioFile: String) { self.id = id self.japanese = japanese self.pronunciation = pronunciation @@ -42,254 +39,84 @@ final class AnimeQuote { self.timeMark = timeMark self.voicingTime = voicingTime self.audioFile = audioFile - self.youtubeID = youtubeID - self.youtubeStartTime = youtubeStartTime - self.youtubeEndTime = youtubeEndTime } } -extension ModelContext { - func fetch(_ modelType: T.Type) -> [T] { - do { - return try self.fetch(FetchDescriptor()) - } catch { - print("Error fetching \(T.self): \(error.localizedDescription)") - return [] - } - } -} +//extension ModelContext { +// func fetch(_ modelType: T.Type) -> [T] { +// do { +// return try self.fetch(FetchDescriptor()) +// } catch { +// print("Error fetching \(T.self): \(error.localizedDescription)") +// return [] +// } +// } +//} // TODO: 임시 더미데이터 삭제 @Observable class Dummie { let animes: [Anime] = [ Anime( - id: "anime1", - title: "진격의 거인", + id: "ONEPIC", + title: "원피스", quotes: [ AnimeQuote( id: "quote1", - japanese: ["自由はあきらめない!"], - pronunciation: ["지유와 아키라메나이!"], - korean: ["자유를 포기하지 않아!"], - timeMark: [0.0, 1.0], - voicingTime: 2.0, - audioFile: "attack_on_titan_quote1.mp3", - youtubeID: "abcd1234", - youtubeStartTime: 12.5, - youtubeEndTime: 15.5 - ), - AnimeQuote( - id: "quote2", - japanese: ["お前が決めるんだ!"], - pronunciation: ["오마에가 키메룬다!"], - korean: ["네가 결정하는 거야!"], - timeMark: [0.0, 1.5], - voicingTime: 2.5, - audioFile: "attack_on_titan_quote2.mp3", - youtubeID: "abcd1234", - youtubeStartTime: 20.0, - youtubeEndTime: 23.0 + japanese: ["海賊王に", "俺は", "なる"], + pronunciation: ["카이조쿠오오니", "오레와", "나루"], + korean: ["나는", "해적왕이", "될거야!"], + timeMark: [0.0, 2.8, 4.1], + voicingTime: 4.0, + audioFile: "ONEPIC_010.mov" ) ] ), Anime( - id: "anime2", - title: "귀멸의 칼날", + id: "JUSULH", + title: "주술회전", quotes: [ AnimeQuote( - id: "quote3", - japanese: ["全集中の呼吸!"], - pronunciation: ["젠슈우추우노 코큐우!"], - korean: ["전집중의 호흡!"], - timeMark: [0.0, 1.2], - voicingTime: 1.8, - audioFile: "demon_slayer_quote1.mp3", - youtubeID: "wxyz5678", - youtubeStartTime: 5.0, - youtubeEndTime: 6.5 - ), - AnimeQuote( - id: "quote4", - japanese: ["守るべきものを守る!"], - pronunciation: ["마모루베키 모노오 마모루!"], - korean: ["지켜야 할 것을 지킨다!"], - timeMark: [0.0, 1.8], - voicingTime: 2.2, - audioFile: "demon_slayer_quote2.mp3", - youtubeID: "wxyz5678", - youtubeStartTime: 18.0, - youtubeEndTime: 21.0 + id: "quote1", + japanese: ["領域展開", "無量空処"], + pronunciation: ["료이키텐카이", "무료오크쇼"], + korean: ["영역전개", "무량공처"], + timeMark: [0.1, 5.0], + voicingTime: 8.0, + audioFile: "JUSULH_010.mov" ) ] ), Anime( - id: "anime3", - title: "나의 히어로 아카데미아", + id: "JINGYG", + title: "진격의 거인", quotes: [ AnimeQuote( - id: "quote5", - japanese: ["君はヒーローになれる!"], - pronunciation: ["키미와 히이로-니 나레루!"], - korean: ["너는 히어로가 될 수 있어!"], - timeMark: [0.0, 1.5], - voicingTime: 2.0, - audioFile: "my_hero_academia_quote1.mp3", - youtubeID: "mnop3456", - youtubeStartTime: 10.0, - youtubeEndTime: 12.0 - ), - AnimeQuote( - id: "quote6", - japanese: ["諦めるな!"], - pronunciation: ["아키라메루나!"], - korean: ["포기하지 마!"], - timeMark: [0.0, 1.0], - voicingTime: 1.5, - audioFile: "my_hero_academia_quote2.mp3", - youtubeID: "mnop3456", - youtubeStartTime: 25.0, - youtubeEndTime: 26.5 + id: "quote1", + japanese: ["心臓を", "捧げよ"], + pronunciation: ["신조오오", "사사게요"], + korean: ["심장을", "받쳐라"], + timeMark: [0.5,1.4], + voicingTime: 3.0, + audioFile: "JINGYG_010.mov" ) ] ), Anime( - id: "anime4", - title: "원피스", + id: "SLDUNK", + title: "슬램덩크", quotes: [ AnimeQuote( - id: "quote7", - japanese: ["俺は海賊王になる!"], - pronunciation: ["오레와 카이조쿠오우니 나루!"], - korean: ["나는 해적왕이 될 거야!"], - timeMark: [0.0, 1.5], - voicingTime: 2.0, - audioFile: "one_piece_quote1.mp3", - youtubeID: "qrst6789", - youtubeStartTime: 8.0, - youtubeEndTime: 10.0 - ), - AnimeQuote( - id: "quote8", - japanese: ["仲間を助けるのが俺の仕事だ!"], - pronunciation: ["나카마오 타스케루노가 오레노 시고토다!"], - korean: ["동료를 돕는 것이 내 일이야!"], - timeMark: [0.0, 2.0], - voicingTime: 2.5, - audioFile: "one_piece_quote2.mp3", - youtubeID: "qrst6789", - youtubeStartTime: 30.0, - youtubeEndTime: 33.0 - ) - ] - ), - Anime( - id: "anime5", - title: "나루토", - quotes: [ - AnimeQuote( - id: "quote9", - japanese: ["忍道を貫く、それが俺の忍者道だ!"], - pronunciation: ["닌도오 츠라누쿠, 소레가 오레노 닌자도다!"], - korean: ["닌자의 길을 관철하는 것, 그것이 내 닌자도야!"], - timeMark: [0.0, 2.5], - voicingTime: 3.0, - audioFile: "naruto_quote1.mp3", - youtubeID: "uvwx1234", - youtubeStartTime: 15.0, - youtubeEndTime: 18.0 - ) - ] - ), - Anime( - id: "anime6", - title: "도쿄 구울", - quotes: [ - AnimeQuote( - id: "quote10", - japanese: ["世界は間違っている"], - pronunciation: ["세카이와 마치가테이루"], - korean: ["세상은 잘못되어 있어"], - timeMark: [0.0, 1.5], - voicingTime: 2.0, - audioFile: "tokyo_ghoul_quote1.mp3", - youtubeID: "yzab5678", - youtubeStartTime: 7.0, - youtubeEndTime: 9.0 - ) - ] - ), - Anime( - id: "anime7", - title: "코드 기어스", - quotes: [ - AnimeQuote( - id: "quote11", - japanese: ["世界を破壊し、世界を創造する"], - pronunciation: ["세카이오 하카이시, 세카이오 소우조우스루"], - korean: ["세계를 파괴하고, 세계를 창조한다"], - timeMark: [0.0, 2.0], - voicingTime: 2.5, - audioFile: "code_geass_quote1.mp3", - youtubeID: "cdef9012", - youtubeStartTime: 20.0, - youtubeEndTime: 22.5 - ) - ] - ), - Anime( - id: "anime8", - title: "죠죠의 기묘한 모험", - quotes: [ - AnimeQuote( - id: "quote12", - japanese: ["やれやれだぜ"], - pronunciation: ["야레야레다제"], - korean: ["참 나"], - timeMark: [0.0, 1.0], - voicingTime: 1.5, - audioFile: "jojo_quote1.mp3", - youtubeID: "ghij3456", - youtubeStartTime: 5.0, - youtubeEndTime: 6.5 - ) - ] - ), - Anime( - id: "anime9", - title: "강철의 연금술사", - quotes: [ - AnimeQuote( - id: "quote13", - japanese: ["等価交換だ"], - pronunciation: ["토우카코우칸다"], - korean: ["등가교환이다"], - timeMark: [0.0, 1.2], - voicingTime: 1.7, - audioFile: "fullmetal_alchemist_quote1.mp3", - youtubeID: "klmn7890", - youtubeStartTime: 12.0, - youtubeEndTime: 13.7 - ) - ] - ), - Anime( - id: "anime10", - title: "헌터x헌터", - quotes: [ - AnimeQuote( - id: "quote14", - japanese: ["楽しいから、やるんだ"], - pronunciation: ["타노시이카라, 야룬다"], - korean: ["즐겁기 때문에 하는 거야"], - timeMark: [0.0, 1.8], - voicingTime: 2.3, - audioFile: "hunter_x_hunter_quote1.mp3", - youtubeID: "opqr1234", - youtubeStartTime: 18.0, - youtubeEndTime: 20.3 - ) - ] + id: "quote1", + japanese: ["左手は","添える","だけ"], + pronunciation: ["히다리테와", "소에루", "다케"], + korean: ["왼손은", "거등뿐"], + timeMark: [0.2,0.8,1.1], + voicingTime: 3.0, + audioFile: "SLDUNK_010.mov" ) ] - } + ) + + ] +} diff --git a/PepperoniV2/Presentation/Components/YoutubePlayerView.swift b/PepperoniV2/Presentation/Components/YoutubePlayerView.swift index f59eeba..79dd567 100644 --- a/PepperoniV2/Presentation/Components/YoutubePlayerView.swift +++ b/PepperoniV2/Presentation/Components/YoutubePlayerView.swift @@ -6,32 +6,43 @@ // import SwiftUI -import YouTubeiOSPlayerHelper +import AVKit -struct YouTubePlayerView: UIViewRepresentable { - var videoID: String - var startTime: Int - var endTime: Int +import SwiftUI +import AVKit + +struct YouTubePlayerView: UIViewControllerRepresentable { + var fileURL: URL var replayTrigger: Bool - - func makeUIView(context: Context) -> YTPlayerView { - let playerView = YTPlayerView() - - let playerVars: [String: Any] = [ - "playsinline": 1, - "autoplay": 0, - "rel": 0, - "start": startTime, - "end": endTime - ] - - playerView.load(withVideoId: videoID, playerVars: playerVars) - return playerView + + class Coordinator { + var parent: YouTubePlayerView + + init(parent: YouTubePlayerView) { + self.parent = parent + } } - func updateUIView(_ uiView: YTPlayerView, context: Context) { + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let playerViewController = AVPlayerViewController() + let player = AVPlayer(url: fileURL) + playerViewController.player = player + playerViewController.showsPlaybackControls = true + + return playerViewController + } + + func updateUIViewController(_ playerViewController: AVPlayerViewController, context: Context) { + guard let player = playerViewController.player else { return } + if replayTrigger { - uiView.seek(toSeconds: Float(startTime), allowSeekAhead: true) + // 재생 중지 후 처음부터 재생 + player.seek(to: .zero) + player.play() } } } diff --git a/PepperoniV2/Presentation/ContentView.swift b/PepperoniV2/Presentation/ContentView.swift index 4e2c321..bac3632 100644 --- a/PepperoniV2/Presentation/ContentView.swift +++ b/PepperoniV2/Presentation/ContentView.swift @@ -12,7 +12,7 @@ struct ContentView: View { @State var gameData = GameData() @State var gameViewModel = GameViewModel() - @Environment(FetchDataState.self) var fetchDataState + //@Environment(FetchDataState.self) var fetchDataState var body: some View { NavigationStack(path: $router.route) { diff --git a/PepperoniV2/Presentation/Game/VideoPlayView.swift b/PepperoniV2/Presentation/Game/VideoPlayView.swift index a22b9cb..c6617af 100644 --- a/PepperoniV2/Presentation/Game/VideoPlayView.swift +++ b/PepperoniV2/Presentation/Game/VideoPlayView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AVKit struct VideoPlayView: View { @@ -55,15 +56,15 @@ struct VideoPlayView: View { .foregroundStyle(Color.ppMint_00) } - if let selectedQuote = gameViewModel.selectedQuote{ - YouTubePlayerView( - videoID: selectedQuote.youtubeID, - startTime: Int(selectedQuote.youtubeStartTime), - endTime: Int(selectedQuote.youtubeEndTime), - replayTrigger: replayTrigger - ) - .frame(height: 218) - .padding(.bottom, 24) + if let selectedQuote = gameViewModel.selectedQuote { + if let fileURL = Bundle.main.url(forResource: selectedQuote.audioFile, withExtension: "mov") { + VideoPlayer(player: AVPlayer(url: fileURL)) + .frame(height: 218) + .padding(.bottom, 24) + } else { + Text("영상 파일을 로드할 수 없습니다.") + .foregroundColor(.red) + } } } @@ -81,6 +82,7 @@ struct VideoPlayView: View { Button(action: { replayTrigger.toggle() + print("다시누름: \(gameViewModel.selectedQuote?.audioFile)") }) { RoundedRectangle(cornerRadius: 50) .frame(width:109, height:41) @@ -128,6 +130,7 @@ struct VideoPlayView: View { playerOnTurn = player } } + print("\(gameViewModel.selectedQuote?.korean)") } .alert(isPresented: $showAlert) { Alert( diff --git a/PepperoniV2/Presentation/GameSetting/AnimeSelect/AnimeSelectView.swift b/PepperoniV2/Presentation/GameSetting/AnimeSelect/AnimeSelectView.swift index bc481d7..2c6dcd0 100644 --- a/PepperoniV2/Presentation/GameSetting/AnimeSelect/AnimeSelectView.swift +++ b/PepperoniV2/Presentation/GameSetting/AnimeSelect/AnimeSelectView.swift @@ -11,15 +11,16 @@ import SwiftData struct AnimeSelectView: View { @Environment(\.modelContext) private var modelContext: ModelContext @Binding var isPresented: Bool - @Environment(FetchDataState.self) var fetchDataState + //@Environment(FetchDataState.self) var fetchDataState @Bindable var viewModel: AnimeSelectViewModel @Environment(GameViewModel.self) var gameViewModel @State private var searchText: String = "" @State private var isLoading = false - private let firestoreService = FirestoreService() + //private let firestoreService = FirestoreService() // SwiftData에서 Anime 데이터를 가져오기 - @Query var animes: [Anime] + //@Query var animes: [Anime] + @State var dummie = Dummie() @State private var loadingStates: [String: (isLoading: Bool, progress: Double)] = [:] @@ -35,29 +36,7 @@ struct AnimeSelectView: View { ) // MARK: -검색창 - HStack (spacing: 14){ - Image(systemName: "magnifyingglass") - .font(.system(size: 17, weight: .regular)) - - TextField( - "애니 검색", - text: $searchText, - prompt: Text("애니 검색") - .foregroundColor(Color(red: 0.47, green: 0.47, blue: 0.47)) - ) - .hakgyoansim(size: 16) - } - .padding(.horizontal, 13) - .padding(.vertical, 9) - .foregroundColor(.white) - .cornerRadius(6) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color(red: 0.47, green: 0.47, blue: 0.47), lineWidth: 1) - } - .frame(height: 40) - .padding(.horizontal) - .padding(.top, 8) + SearchBar(searchText: $searchText) DashLine() .stroke(style: .init(dash: [6])) @@ -67,40 +46,19 @@ struct AnimeSelectView: View { .padding(.horizontal, 16) // MARK: -ProgressView - if fetchDataState.isFetchingData || isLoading { - HStack { - Spacer() - ProgressView("명대사를 불러오는 중...") - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.black.opacity(0.8)) // 반투명 배경 - ) - .foregroundStyle(.white) - Spacer() - } - } // MARK: -anime 리스트 - List(Array(currentAnimes.enumerated()), id: \.element.id) { index, anime in + List(currentAnimes) { anime in AnimeRowView( anime: anime, - isSelected: viewModel.tempSelectedAnime?.id == anime.id, - isLoading: loadingStates[anime.id]?.isLoading ?? false, - progress: loadingStates[anime.id]?.progress ?? 0.0 + isSelected: viewModel.tempSelectedAnime?.id == anime.id ) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) .onTapGesture { - if !(loadingStates[anime.id]?.isLoading ?? false) { - Task { - await selectAnime(anime) // 선택된 애니 데이터 로드 - } - } + viewModel.selectAnime(anime) } - .disabled(loadingStates[anime.id]?.isLoading ?? false) // 로딩 중일 경우 비활성화 - .padding(.bottom, index == currentAnimes.count - 1 ? 60 : 0) } .listStyle(.plain) .padding(.bottom,60) @@ -113,6 +71,8 @@ struct AnimeSelectView: View { Button { if let selectedAnime = viewModel.tempSelectedAnime { gameViewModel.selectedAnime = selectedAnime // GameViewModel에 Anime 설정 + print("저장 누름: \(viewModel.tempSelectedAnime?.title)") + print("저장 누름: \(gameViewModel.selectedAnime?.title)") viewModel.saveChanges() isPresented = false } @@ -141,48 +101,86 @@ struct AnimeSelectView: View { } } - /// 현재 보여지는 애니 리스트 - private var currentAnimes: [Anime] { - let sortedAnimes = animes.sorted { $0.title.localizedCompare($1.title) == .orderedAscending } - if searchText.isEmpty { - return sortedAnimes - } else { - // 검색어 공백 제거 - let normalizedSearchText = searchText.replacingOccurrences(of: " ", with: "") - return sortedAnimes.filter { - // 애니 제목 공백 제거 - let normalizedTitle = $0.title.replacingOccurrences(of: " ", with: "") - return normalizedTitle.localizedCaseInsensitiveContains(normalizedSearchText) - } - } - } +// /// 현재 보여지는 애니 리스트 +// private var currentAnimes: [Anime] { +// let sortedAnimes = animes.sorted { $0.title.localizedCompare($1.title) == .orderedAscending } +// if searchText.isEmpty { +// return sortedAnimes +// } else { +// // 검색어 공백 제거 +// let normalizedSearchText = searchText.replacingOccurrences(of: " ", with: "") +// return sortedAnimes.filter { +// // 애니 제목 공백 제거 +// let normalizedTitle = $0.title.replacingOccurrences(of: " ", with: "") +// return normalizedTitle.localizedCaseInsensitiveContains(normalizedSearchText) +// } +// } +// } // 애니 선택 및 데이터 로드 @MainActor - private func selectAnime(_ anime: Anime) async { + private func selectAnime(_ anime: Anime) { let animeID = anime.id + print("\(animeID)") + print("\(anime)") + viewModel.selectAnime(anime) + saveAnimeToModelContext(anime) - // 이미 quotes가 있는 경우, 바로 선택 - if !anime.quotes.isEmpty { - viewModel.selectAnime(anime) - return - } - - // quotes가 비어있는 경우, 데이터를 다운로드 - loadingStates[animeID] = (isLoading: true, progress: 0.0) + } + private func saveAnimeToModelContext(_ anime: Anime) { + modelContext.insert(anime) + + // 저장 do { - try await firestoreService.fetchAnimeDetailsAndStore(context: modelContext, animeID: animeID) { progress in - DispatchQueue.main.async { - loadingStates[animeID]?.progress = progress - } - } - viewModel.selectAnime(anime) + try modelContext.save() + print("Anime successfully saved to model context: \(anime.title)") } catch { - print("Failed to load anime details: \(error.localizedDescription)") + print("Failed to save anime to model context: \(error.localizedDescription)") } - loadingStates[animeID]?.isLoading = false } + private var currentAnimes: [Anime] { + if searchText.isEmpty { + return dummie.animes + } else { + // 검색어 공백 제거 + let normalizedSearchText = searchText.replacingOccurrences(of: " ", with: "") + return dummie.animes.filter { + // 애니 제목 공백 제거 + let normalizedTitle = $0.title.replacingOccurrences(of: " ", with: "") + return normalizedTitle.localizedCaseInsensitiveContains(normalizedSearchText) + } + } + } +} +struct SearchBar: View { + @Binding var searchText: String + var body: some View { + HStack (spacing: 14){ + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .regular)) + + TextField( + "애니 검색", + text: $searchText, + prompt: Text("애니 검색") + .foregroundColor(Color(red: 0.47, green: 0.47, blue: 0.47)) + ) + .hakgyoansim(size: 16) + } + .padding(.horizontal, 13) + .padding(.vertical, 9) + .foregroundColor(.white) + .cornerRadius(6) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(red: 0.47, green: 0.47, blue: 0.47), lineWidth: 1) + } + .frame(height: 40) + .padding(.horizontal) + .padding(.top, 8) + } + } struct DashLine: Shape { @@ -197,35 +195,11 @@ struct DashLine: Shape { struct AnimeRowView: View { let anime: Anime let isSelected: Bool - let isLoading: Bool - let progress: Double - - private var needDownload: Bool { - anime.quotes.isEmpty || isLoading - } var body: some View { HStack { VStack { - if isLoading { - ZStack { - Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 4) - Circle() - .trim(from: 0, to: CGFloat(progress)) - .stroke(Color.blue, style: StrokeStyle(lineWidth: 4, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.linear, value: progress) - } - .frame(width: 32, height: 32) - .padding(.leading, 11) - } else if needDownload { - Image("NeedDownload") - .resizable() - .frame(width: 32, height: 32) - .foregroundStyle(Color.ppDarkGray_01) - .padding(.leading, 11) - } else if isSelected { + if isSelected { Image("Checkmark") .resizable() .frame(width:35, height: 32, alignment: .center) @@ -237,14 +211,14 @@ struct AnimeRowView: View { HStack { Text(anime.title) - .foregroundStyle(needDownload ? Color.ppDarkGray_01: .white) + .foregroundStyle(.white) .suit(.medium, size: 16) Spacer() } .padding(.horizontal) .frame(maxHeight: .infinity) - .background(needDownload ? LinearGradient(stops: [ + .background(!isSelected ? LinearGradient(stops: [ Gradient.Stop(color: Color.ppDarkGray_04, location: 0.0), Gradient.Stop(color: Color.ppDarkGray_04, location: 1.00), ], @@ -261,13 +235,8 @@ struct AnimeRowView: View { .frame(height: 78) .cornerRadius(10) .overlay { - if needDownload { - RoundedRectangle(cornerRadius: 10) - .stroke(Color.ppDarkGray_01, lineWidth: 2) - } else { RoundedRectangle(cornerRadius: 10) .stroke(LinearGradient.gradient3, lineWidth: 2) - } } .padding(.horizontal) diff --git a/PepperoniV2/Presentation/Helper/SpeechGrader.swift b/PepperoniV2/Presentation/Helper/SpeechGrader.swift index 61b8386..b3de1ae 100644 --- a/PepperoniV2/Presentation/Helper/SpeechGrader.swift +++ b/PepperoniV2/Presentation/Helper/SpeechGrader.swift @@ -78,99 +78,78 @@ private func levenshteinDistance(_ source: String, _ target: String) -> Int { // calculateIntonation 함수 func calculateIntonation(referenceFileName: String, comparisonFileURL: URL) -> Double { + // referenceFileName이 .mov로 끝나면 .m4a로 변환 + let m4aFileName = referenceFileName.replacingOccurrences(of: ".mov", with: ".m4a") - var referenceURL: URL? + // Bundle에서 m4a 파일의 URL 가져오기 + guard let path = Bundle.main.path(forResource: m4aFileName, ofType: "m4a") else { + print("참조 파일(m4a)을 찾을 수 없습니다: \(m4aFileName)") + return 0.0 + } - // Documents 이하 경로 추출 - if let documentRange = referenceFileName.range(of: "/Documents/") { - let relativePath = String(referenceFileName[documentRange.upperBound...]) // "CHOIAE_008.m4a" - - // 현재 앱의 Document 디렉토리 경로 가져오기 - let currentDocumentDirectory = FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask)[0] - - // 새로운 URL 생성 - referenceURL = currentDocumentDirectory.appendingPathComponent(relativePath) - - print("Updated URL: \(referenceURL)") // /var/mobile/Containers/Data/Application/NEW_UUID/Documents/CHOIAE_008.m4a - } else { - print("Invalid path: \(referenceFileName)") + let referenceURL = URL(fileURLWithPath: path) + + print("참조 파일 URL: \(referenceURL)") // 디버깅용 + + // 피치 데이터 추출 + guard let referencePitchData = extractPitchData(from: referenceURL, applyVolumeThreshold: false), // 볼륨 기준 미적용 + let comparisonPitchData = extractPitchData(from: comparisonFileURL, applyVolumeThreshold: true) else { // 볼륨 기준 적용 + print("피치 데이터를 추출할 수 없습니다.") + return 0.0 } + // 이후 코드는 기존 로직과 동일 + let validReferenceData = referencePitchData.compactMap { $0 > 0 ? $0 : nil } + let validComparisonData = comparisonPitchData.compactMap { $0 > 0 ? $0 : nil } - if let referenceURL = referenceURL { - - // 파일 존재 여부 확인 - guard FileManager.default.fileExists(atPath: referenceURL.path) else { - print("참조 파일을 찾을 수 없습니다: \(referenceURL.path)") - return 0.0 - } - guard let referencePitchData = extractPitchData(from: referenceURL, applyVolumeThreshold: false), // 볼륨 기준 미적용 - let comparisonPitchData = extractPitchData(from: comparisonFileURL, applyVolumeThreshold: true) else { // 볼륨 기준 적용 - print("피치 데이터를 추출할 수 없습니다.") - return 0.0 - } - - // 이후 코드는 동일 - let validReferenceData = referencePitchData.compactMap { $0 > 0 ? $0 : nil } - let validComparisonData = comparisonPitchData.compactMap { $0 > 0 ? $0 : nil } - - guard !validReferenceData.isEmpty, !validComparisonData.isEmpty else { - print("유효한 피치 데이터가 부족합니다.") - return 0.0 - } - - let resampledComparisonData = resamplePitchData(source: validComparisonData, targetLength: validReferenceData.count) - let referenceMean = validReferenceData.reduce(0, +) / CGFloat(validReferenceData.count) - let comparisonMean = resampledComparisonData.reduce(0, +) / CGFloat(resampledComparisonData.count) - let normalizedReferenceData = validReferenceData.map { $0 - referenceMean } - let normalizedComparisonData = resampledComparisonData.map { $0 - comparisonMean } - - var matchingStates = 0 - for i in 1.. 0 ? 1 : (referenceDiff < 0 ? -1 : 0) - let comparisonState = comparisonDiff > 0 ? 1 : (comparisonDiff < 0 ? -1 : 0) - - if referenceState == comparisonState { - matchingStates += 1 - } - } + guard !validReferenceData.isEmpty, !validComparisonData.isEmpty else { + print("유효한 피치 데이터가 부족합니다.") + return 0.0 + } + + let resampledComparisonData = resamplePitchData(source: validComparisonData, targetLength: validReferenceData.count) + let referenceMean = validReferenceData.reduce(0, +) / CGFloat(validReferenceData.count) + let comparisonMean = resampledComparisonData.reduce(0, +) / CGFloat(resampledComparisonData.count) + let normalizedReferenceData = validReferenceData.map { $0 - referenceMean } + let normalizedComparisonData = resampledComparisonData.map { $0 - comparisonMean } + + var matchingStates = 0 + for i in 1.. 0 ? 1 : (referenceDiff < 0 ? -1 : 0) + let comparisonState = comparisonDiff > 0 ? 1 : (comparisonDiff < 0 ? -1 : 0) - let score = (intonationScore * 0.7) + (lengthScore * 0.3) - let finalScore: Double - switch similarity { - case 0.55...1.0: - finalScore = Double(90 + (similarity - 0.55) / 0.15 * 10) - case 0.45..<0.55: - finalScore = Double(75 + (similarity - 0.45) / 0.10 * 15) - case 0.30..<0.45: - finalScore = Double(50 + (similarity - 0.30) / 0.15 * 25) - case 0.0..<0.30: - finalScore = Double(similarity / 0.30 * 50) - default: - finalScore = 0 + if referenceState == comparisonState { + matchingStates += 1 } - - return min(100.0, finalScore) - - } else { - - print("Error: referenceURL is not valid") - return 0 - } + + let similarity = Double(matchingStates) / Double(validReferenceData.count - 1) + let intonationScore = similarity * 100 + let referenceLength = Double(validReferenceData.count) + let comparisonLength = Double(validComparisonData.count) + let lengthScore = max(0.0, 100.0 * (min(comparisonLength, referenceLength) / max(comparisonLength, referenceLength))) + + let score = (intonationScore * 0.7) + (lengthScore * 0.3) + let finalScore: Double + switch similarity { + case 0.55...1.0: + finalScore = Double(90 + (similarity - 0.55) / 0.15 * 10) + case 0.45..<0.55: + finalScore = Double(75 + (similarity - 0.45) / 0.10 * 15) + case 0.30..<0.45: + finalScore = Double(50 + (similarity - 0.30) / 0.15 * 25) + case 0.0..<0.30: + finalScore = Double(similarity / 0.30 * 50) + default: + finalScore = 0 + } + + return min(100.0, finalScore) } - /// 리샘플링 함수 func resamplePitchData(source: [CGFloat], targetLength: Int) -> [CGFloat] { let sourceLength = source.count diff --git a/PepperoniV2/Presentation/Home/HomeView.swift b/PepperoniV2/Presentation/Home/HomeView.swift index f5a0bb5..2317c2f 100644 --- a/PepperoniV2/Presentation/Home/HomeView.swift +++ b/PepperoniV2/Presentation/Home/HomeView.swift @@ -11,7 +11,7 @@ struct HomeView: View { @EnvironmentObject var router: Router @Environment(GameData.self) var gameData @Environment(GameViewModel.self) var gameViewModel - @Environment(FetchDataState.self) var fetchDataState + //@Environment(FetchDataState.self) var fetchDataState @State private var isAnimeSelectPresented = false @State private var isPlayerSettingPresented = false diff --git a/PepperoniV2/Resource/JINGYG_010.m4a b/PepperoniV2/Resource/JINGYG_010.m4a new file mode 100644 index 0000000..9679674 Binary files /dev/null and b/PepperoniV2/Resource/JINGYG_010.m4a differ diff --git a/PepperoniV2/Resource/JINGYG_010.mov b/PepperoniV2/Resource/JINGYG_010.mov new file mode 100644 index 0000000..4beb8d1 Binary files /dev/null and b/PepperoniV2/Resource/JINGYG_010.mov differ diff --git a/PepperoniV2/Resource/JUSULH_010.m4a b/PepperoniV2/Resource/JUSULH_010.m4a new file mode 100644 index 0000000..a16d3f7 Binary files /dev/null and b/PepperoniV2/Resource/JUSULH_010.m4a differ diff --git a/PepperoniV2/Resource/JUSULH_010.mov b/PepperoniV2/Resource/JUSULH_010.mov new file mode 100644 index 0000000..6ba5a98 Binary files /dev/null and b/PepperoniV2/Resource/JUSULH_010.mov differ diff --git a/PepperoniV2/Resource/ONEPIC_010.m4a b/PepperoniV2/Resource/ONEPIC_010.m4a new file mode 100644 index 0000000..934a70a Binary files /dev/null and b/PepperoniV2/Resource/ONEPIC_010.m4a differ diff --git a/PepperoniV2/Resource/ONEPIC_010.mov b/PepperoniV2/Resource/ONEPIC_010.mov new file mode 100644 index 0000000..63f02d7 Binary files /dev/null and b/PepperoniV2/Resource/ONEPIC_010.mov differ diff --git a/PepperoniV2/Resource/SLDUNK_010.m4a b/PepperoniV2/Resource/SLDUNK_010.m4a new file mode 100644 index 0000000..fec4b34 Binary files /dev/null and b/PepperoniV2/Resource/SLDUNK_010.m4a differ diff --git a/PepperoniV2/Resource/SLDUNK_010.mov b/PepperoniV2/Resource/SLDUNK_010.mov new file mode 100644 index 0000000..fb29bac Binary files /dev/null and b/PepperoniV2/Resource/SLDUNK_010.mov differ diff --git a/PepperoniV2/Resource/showcase.json b/PepperoniV2/Resource/showcase.json new file mode 100644 index 0000000..f1a2473 --- /dev/null +++ b/PepperoniV2/Resource/showcase.json @@ -0,0 +1,46 @@ +[ + { + "animeTitle": "원피스", + "animeID": "ONEPIC", + "quoteID": "ONEPIC_010", + "japanese": ["海賊王に", "俺は", "なる"], + "pronunciation": ["카이조쿠오오니", "오레와", "나루"], + "korean": ["나는", "해적왕이", "될거야!"], + "timeMark": [0.0, 2.8, 4.1], + "voicingTime": 4, + "audioFile": "ONEPIC_010.mov" + }, + { + "animeTitle": "주술회전", + "animeID": "JUSULH", + "quoteID": "JUSULH_010", + "japanese": ["領域展開", "無量空処"], + "pronunciation": ["료이키텐카이", "무료오크쇼"], + "korean": ["영역전개", "무량공처"], + "timeMark": [0.1, 5.0], + "voicingTime": 8, + "audioFile": "JUSULH_010.mov" + }, + { + "animeTitle": "진격의 거인", + "animeID": "JINGYG", + "quoteID": "JINGYG_010", + "japanese": ["心臓を", "捧げよ"], + "pronunciation": ["신조오오", "사사게요"], + "korean": ["심장을", "받쳐라"], + "timeMark": [0.5,1.4], + "voicingTime": 3, + "audioFile": "JINGYG_010.mov" + }, + { + "animeTitle": "슬램덩크", + "animeID": "SLDUNK", + "quoteID": "SLDUNK_010", + "japanese": ["左手は","添える","だけ"], + "pronunciation": ["히다리테와", "소에루", "다케"], + "korean": ["왼손은", "거등뿐"], + "timeMark": [0.2,0.8,1.1], + "voicingTime": 3, + "audioFile": "SLDUNK_010.mov" + } + ] \ No newline at end of file diff --git a/PepperoniV2/Service/FirestoreService.swift b/PepperoniV2/Service/FirestoreService.swift index c169ffd..d36c8e3 100644 --- a/PepperoniV2/Service/FirestoreService.swift +++ b/PepperoniV2/Service/FirestoreService.swift @@ -11,176 +11,176 @@ import Firebase import FirebaseFirestore import FirebaseStorage -class FirestoreService { - let db = Firestore.firestore() - let storage = Storage.storage() - let syncKey = "isDataSynced" - - /// Firestore에서 anime의 타이틀만 불러와서 SwiftData 저장 - @MainActor - func fetchAnimeTitles(context: ModelContext) async throws { - // Firestore 컬렉션 경로 설정 - let animeCollectionPath = "Anime" - let animeSnapshot = try await db.collection(animeCollectionPath).getDocuments() - - var newAnimeTitles: [String] = [] - - // Firestore에서 모든 애니 제목 가져오기 - for animeDocument in animeSnapshot.documents { - let animeData = animeDocument.data() - let animeID = animeDocument.documentID - - guard let animeTitle = animeData["animeTitle"] as? String else { - print("Missing animeTitle in document: \(animeDocument.documentID)") - continue - } - - // SwiftData에 이미 저장된 애니인지 확인 - if context.fetch(Anime.self).first(where: { $0.id == animeID }) == nil { - // 새로운 Anime 객체 생성 및 SwiftData에 추가 - let newAnime = Anime(id: animeID, title: animeTitle, quotes: []) - context.insert(newAnime) - newAnimeTitles.append(animeTitle) - } - } - - do { - try context.save() - print("Successfully saved new anime titles to SwiftData.") - } catch { - print("Error saving data to SwiftData: \(error.localizedDescription)") - } - - // TODO: 확인용, 제거 요망 - DispatchQueue.main.async { - if newAnimeTitles.isEmpty { - print("No new anime titles to add.") - } else { - print("New anime titles: \(newAnimeTitles)") - } - } - } - - /// 사용자가 선택한 애니 데이터를 Firestore에서 불러와 SwiftData와 로컬 저장소에 저장 - @MainActor - func fetchAnimeDetailsAndStore(context: ModelContext, animeID: String, progressCallback: @escaping (Double) -> Void) async throws { - let animeCollectionPath = "Anime" - let animeDocumentPath = "\(animeCollectionPath)/\(animeID)" - let quotesPath = "\(animeDocumentPath)/quotes" - - let quotesSnapshot = try await db.collection(quotesPath).getDocuments() - var quotes: [AnimeQuote] = [] - - for (index, quoteDocument) in quotesSnapshot.documents.enumerated() { - let quoteData = quoteDocument.data() - let quoteID = quoteDocument.documentID - - guard let japanese = quoteData["japanese"] as? [String], - let korean = quoteData["korean"] as? [String], - let audioFile = quoteData["audioFile"] as? String else { - print("Missing required fields in quote document: \(quoteDocument.documentID)") - continue - } - - let localFilePath = FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent(audioFile).path - - let storagePath = "Animes/\(animeID)/\(audioFile)" - let shouldUpdate = try await shouldUpdateFile(filePath: localFilePath, storagePath: storagePath) - - if shouldUpdate { - try await downloadAudioFile(storagePath: storagePath, localPath: localFilePath) - } - - let quote = AnimeQuote( - id: quoteID, - japanese: japanese, - pronunciation: quoteData["pronunciation"] as? [String] ?? [], - korean: korean, - timeMark: quoteData["timeMark"] as? [Double] ?? [], - voicingTime: quoteData["voicingTime"] as? Double ?? 0.0, - audioFile: localFilePath, - youtubeID: quoteData["youtubeID"] as? String ?? "", - youtubeStartTime: quoteData["youtubeStartTime"] as? Double ?? 0.0, - youtubeEndTime: quoteData["youtubeEndTime"] as? Double ?? 0.0 - ) - quotes.append(quote) - - let progress = Double(index + 1) / Double(quotesSnapshot.documents.count) - progressCallback(progress) - } - - // swiftdata의 anime와 firebase에서 불러온 anime를 비교, 없으면 swiftdata에 넣어줌 - if let existingAnime = context.fetch(Anime.self).first(where: { $0.id == animeID }) { - quotes.forEach { newQuote in - if !existingAnime.quotes.contains(where: { $0.id == newQuote.id }) { - existingAnime.quotes.append(newQuote) - } - } - } - - do { - try context.save() - print("Successfully updated anime details and quotes to SwiftData.") - } catch { - print("Error saving details to SwiftData: \(error.localizedDescription)") - } - } - - /// Firebase Storage 파일 업데이트 확인 및 다운로드 - func shouldUpdateFile(filePath: String, storagePath: String) async throws -> Bool { - let storageRef = storage.reference().child(storagePath) - - // Firebase Storage 메타데이터 가져오기 - let metadata = try await storageRef.getMetadata() - guard let storageUpdatedTime = metadata.updated else { - print("Failed to retrieve updated time from storage.") - return true // Storage 메타데이터가 없으면 업데이트 강제 - } - - let fileManager = FileManager.default - - // 로컬 파일이 있는지 확인 - guard fileManager.fileExists(atPath: filePath) else { - print("File does not exist locally. Update required.") - return true - } - - // 로컬 파일 메타데이터 가져오기 - let attributes = try fileManager.attributesOfItem(atPath: filePath) - guard let localUpdatedTime = attributes[.modificationDate] as? Date else { - print("Failed to retrieve local modification date. Update required.") - return true - } - - // 로컬 파일과 Firebase 파일 비교 - if localUpdatedTime < storageUpdatedTime { - print("Update needed: Local file is older than Storage file.") - print("Local modified time: \(localUpdatedTime)") - print("Storage updated time: \(storageUpdatedTime)") - return true - } - - print("No update needed: Local file is up-to-date.") - return false - } - - func downloadAudioFile(storagePath: String, localPath: String) async throws { - let storageRef = storage.reference().child(storagePath) - let localURL = URL(fileURLWithPath: localPath) - - print("Downloading file from Firebase Storage: \(storagePath)") - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - storageRef.write(toFile: localURL) { url, error in - if let error = error { - continuation.resume(throwing: error) - } else { - print("File downloaded to: \(localURL.path)") - continuation.resume() - } - } - } - } -} +//class FirestoreService { +// let db = Firestore.firestore() +// let storage = Storage.storage() +// let syncKey = "isDataSynced" +// +// /// Firestore에서 anime의 타이틀만 불러와서 SwiftData 저장 +// @MainActor +// func fetchAnimeTitles(context: ModelContext) async throws { +// // Firestore 컬렉션 경로 설정 +// let animeCollectionPath = "Anime" +// let animeSnapshot = try await db.collection(animeCollectionPath).getDocuments() +// +// var newAnimeTitles: [String] = [] +// +// // Firestore에서 모든 애니 제목 가져오기 +// for animeDocument in animeSnapshot.documents { +// let animeData = animeDocument.data() +// let animeID = animeDocument.documentID +// +// guard let animeTitle = animeData["animeTitle"] as? String else { +// print("Missing animeTitle in document: \(animeDocument.documentID)") +// continue +// } +// +// // SwiftData에 이미 저장된 애니인지 확인 +// if context.fetch(Anime.self).first(where: { $0.id == animeID }) == nil { +// // 새로운 Anime 객체 생성 및 SwiftData에 추가 +// let newAnime = Anime(id: animeID, title: animeTitle, quotes: []) +// context.insert(newAnime) +// newAnimeTitles.append(animeTitle) +// } +// } +// +// do { +// try context.save() +// print("Successfully saved new anime titles to SwiftData.") +// } catch { +// print("Error saving data to SwiftData: \(error.localizedDescription)") +// } +// +// // TODO: 확인용, 제거 요망 +// DispatchQueue.main.async { +// if newAnimeTitles.isEmpty { +// print("No new anime titles to add.") +// } else { +// print("New anime titles: \(newAnimeTitles)") +// } +// } +// } +// +// /// 사용자가 선택한 애니 데이터를 Firestore에서 불러와 SwiftData와 로컬 저장소에 저장 +// @MainActor +// func fetchAnimeDetailsAndStore(context: ModelContext, animeID: String, progressCallback: @escaping (Double) -> Void) async throws { +// let animeCollectionPath = "Anime" +// let animeDocumentPath = "\(animeCollectionPath)/\(animeID)" +// let quotesPath = "\(animeDocumentPath)/quotes" +// +// let quotesSnapshot = try await db.collection(quotesPath).getDocuments() +// var quotes: [AnimeQuote] = [] +// +// for (index, quoteDocument) in quotesSnapshot.documents.enumerated() { +// let quoteData = quoteDocument.data() +// let quoteID = quoteDocument.documentID +// +// guard let japanese = quoteData["japanese"] as? [String], +// let korean = quoteData["korean"] as? [String], +// let audioFile = quoteData["audioFile"] as? String else { +// print("Missing required fields in quote document: \(quoteDocument.documentID)") +// continue +// } +// +// let localFilePath = FileManager.default +// .urls(for: .documentDirectory, in: .userDomainMask)[0] +// .appendingPathComponent(audioFile).path +// +// let storagePath = "Animes/\(animeID)/\(audioFile)" +// let shouldUpdate = try await shouldUpdateFile(filePath: localFilePath, storagePath: storagePath) +// +// if shouldUpdate { +// try await downloadAudioFile(storagePath: storagePath, localPath: localFilePath) +// } +// +// let quote = AnimeQuote( +// id: quoteID, +// japanese: japanese, +// pronunciation: quoteData["pronunciation"] as? [String] ?? [], +// korean: korean, +// timeMark: quoteData["timeMark"] as? [Double] ?? [], +// voicingTime: quoteData["voicingTime"] as? Double ?? 0.0, +// audioFile: localFilePath, +// youtubeID: quoteData["youtubeID"] as? String ?? "", +// youtubeStartTime: quoteData["youtubeStartTime"] as? Double ?? 0.0, +// youtubeEndTime: quoteData["youtubeEndTime"] as? Double ?? 0.0 +// ) +// quotes.append(quote) +// +// let progress = Double(index + 1) / Double(quotesSnapshot.documents.count) +// progressCallback(progress) +// } +// +// // swiftdata의 anime와 firebase에서 불러온 anime를 비교, 없으면 swiftdata에 넣어줌 +// if let existingAnime = context.fetch(Anime.self).first(where: { $0.id == animeID }) { +// quotes.forEach { newQuote in +// if !existingAnime.quotes.contains(where: { $0.id == newQuote.id }) { +// existingAnime.quotes.append(newQuote) +// } +// } +// } +// +// do { +// try context.save() +// print("Successfully updated anime details and quotes to SwiftData.") +// } catch { +// print("Error saving details to SwiftData: \(error.localizedDescription)") +// } +// } +// +// /// Firebase Storage 파일 업데이트 확인 및 다운로드 +// func shouldUpdateFile(filePath: String, storagePath: String) async throws -> Bool { +// let storageRef = storage.reference().child(storagePath) +// +// // Firebase Storage 메타데이터 가져오기 +// let metadata = try await storageRef.getMetadata() +// guard let storageUpdatedTime = metadata.updated else { +// print("Failed to retrieve updated time from storage.") +// return true // Storage 메타데이터가 없으면 업데이트 강제 +// } +// +// let fileManager = FileManager.default +// +// // 로컬 파일이 있는지 확인 +// guard fileManager.fileExists(atPath: filePath) else { +// print("File does not exist locally. Update required.") +// return true +// } +// +// // 로컬 파일 메타데이터 가져오기 +// let attributes = try fileManager.attributesOfItem(atPath: filePath) +// guard let localUpdatedTime = attributes[.modificationDate] as? Date else { +// print("Failed to retrieve local modification date. Update required.") +// return true +// } +// +// // 로컬 파일과 Firebase 파일 비교 +// if localUpdatedTime < storageUpdatedTime { +// print("Update needed: Local file is older than Storage file.") +// print("Local modified time: \(localUpdatedTime)") +// print("Storage updated time: \(storageUpdatedTime)") +// return true +// } +// +// print("No update needed: Local file is up-to-date.") +// return false +// } +// +// func downloadAudioFile(storagePath: String, localPath: String) async throws { +// let storageRef = storage.reference().child(storagePath) +// let localURL = URL(fileURLWithPath: localPath) +// +// print("Downloading file from Firebase Storage: \(storagePath)") +// +// try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in +// storageRef.write(toFile: localURL) { url, error in +// if let error = error { +// continuation.resume(throwing: error) +// } else { +// print("File downloaded to: \(localURL.path)") +// continuation.resume() +// } +// } +// } +// } +//}