diff --git a/Podfile.lock b/Podfile.lock index 44f124d..c8aa384 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -110,4 +110,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: bd3025cc71699ad0300e7ce21de6683a7ce4a10a -COCOAPODS: 1.10.1 +COCOAPODS: 1.11.2 diff --git a/ios.xcodeproj/project.pbxproj b/ios.xcodeproj/project.pbxproj index 272cd64..42e2bed 100644 --- a/ios.xcodeproj/project.pbxproj +++ b/ios.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 365045EF26D5B03900AD6214 /* MchadTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 365045EE26D5B03900AD6214 /* MchadTypes.swift */; }; 36C224DB26D197CD00982BB1 /* FilterViewSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36C224DA26D197CD00982BB1 /* FilterViewSwiftUI.swift */; }; 36CB58DE26C57F5F0002B8AF /* Superchat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36CB58DD26C57F5F0002B8AF /* Superchat.swift */; }; + 36DA10A02755C2600093E393 /* LtlAPIServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36DA109F2755C2600093E393 /* LtlAPIServices.swift */; }; + 36DA10A12755C2600093E393 /* LtlAPIServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36DA109F2755C2600093E393 /* LtlAPIServices.swift */; }; 36E6C1FA26A71DF600C2B08C /* HoloDexResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EF35F026A64C370005E6FC /* HoloDexResponse.swift */; }; 36E6C1FB26A71DF600C2B08C /* TranslatedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EF35F226A64C370005E6FC /* TranslatedMessage.swift */; }; 36E6C1FC26A71DF600C2B08C /* YouTubeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EF35FE26A64C370005E6FC /* YouTubeService.swift */; }; @@ -125,6 +127,7 @@ 3652A47E26C20E7C003DA76B /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizeable.strings; sourceTree = ""; }; 36C224DA26D197CD00982BB1 /* FilterViewSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterViewSwiftUI.swift; sourceTree = ""; }; 36CB58DD26C57F5F0002B8AF /* Superchat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Superchat.swift; sourceTree = ""; }; + 36DA109F2755C2600093E393 /* LtlAPIServices.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LtlAPIServices.swift; sourceTree = ""; }; 36EF35EC26A64C370005E6FC /* Language.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; 36EF35ED26A64C370005E6FC /* Organizations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Organizations.swift; sourceTree = ""; }; 36EF35EE26A64C370005E6FC /* InjectedMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InjectedMessage.swift; sourceTree = ""; }; @@ -282,6 +285,7 @@ 36EF35FC26A64C370005E6FC /* Services */ = { isa = PBXGroup; children = ( + 36DA109F2755C2600093E393 /* LtlAPIServices.swift */, 36EF35FD26A64C370005E6FC /* HoloDexServices.swift */, 36EF35FE26A64C370005E6FC /* YouTubeService.swift */, 36EF35FF26A64C370005E6FC /* AppServices.swift */, @@ -693,6 +697,7 @@ 36E6C1FB26A71DF600C2B08C /* TranslatedMessage.swift in Sources */, 3600776326A73653004CB44B /* StreamView.swift in Sources */, 36E6C1FF26A71DF600C2B08C /* AppStep.swift in Sources */, + 36DA10A12755C2600093E393 /* LtlAPIServices.swift in Sources */, 36E6C1FE26A71DF600C2B08C /* DisplayableMessage.swift in Sources */, 36E6C1FC26A71DF600C2B08C /* YouTubeService.swift in Sources */, 36FD3ED326978CC200EAAF51 /* ChatCell.swift in Sources */, @@ -739,6 +744,7 @@ 36EF361026A64C370005E6FC /* AppServices.swift in Sources */, 6636808B261983B500125A76 /* ChatCell.swift in Sources */, 36EF360526A64C370005E6FC /* HoloDexResponse.swift in Sources */, + 36DA10A02755C2600093E393 /* LtlAPIServices.swift in Sources */, 665774AE260C6ECE00072F30 /* HomeView.swift in Sources */, 36EF360926A64C370005E6FC /* BaseView.swift in Sources */, 36EF360F26A64C370005E6FC /* YouTubeService.swift in Sources */, @@ -842,7 +848,7 @@ CODE_SIGN_ENTITLEMENTS = "yt-extension/yt-extension.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 6S4L29QT59; + DEVELOPMENT_TEAM = RJNC97Y8QD; INFOPLIST_FILE = "yt-extension/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 14.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -851,7 +857,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "app.livetl.ios.yt-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "app.livetl-dev.ios.yt-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -866,7 +872,7 @@ CODE_SIGN_ENTITLEMENTS = "yt-extension/yt-extension.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 6S4L29QT59; + DEVELOPMENT_TEAM = RJNC97Y8QD; INFOPLIST_FILE = "yt-extension/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 14.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -875,7 +881,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "app.livetl.ios.yt-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "app.livetl-dev.ios.yt-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1012,7 +1018,7 @@ CODE_SIGN_ENTITLEMENTS = ios/ios.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 6S4L29QT59; + DEVELOPMENT_TEAM = RJNC97Y8QD; INFOPLIST_FILE = ios/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -1020,7 +1026,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.1.0; - PRODUCT_BUNDLE_IDENTIFIER = app.livetl.ios; + PRODUCT_BUNDLE_IDENTIFIER = "app.livetl-dev.ios"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1040,7 +1046,7 @@ CODE_SIGN_ENTITLEMENTS = ios/ios.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 6S4L29QT59; + DEVELOPMENT_TEAM = RJNC97Y8QD; INFOPLIST_FILE = ios/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -1048,7 +1054,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.1.0; - PRODUCT_BUNDLE_IDENTIFIER = app.livetl.ios; + PRODUCT_BUNDLE_IDENTIFIER = "app.livetl-dev.ios"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; diff --git a/ios/Core/Models/HomeModel.swift b/ios/Core/Models/HomeModel.swift index 3f46fb5..39c8448 100644 --- a/ios/Core/Models/HomeModel.swift +++ b/ios/Core/Models/HomeModel.swift @@ -38,14 +38,17 @@ protocol HomeModelOutput { class HomeModel: BaseModel { let refresh = BehaviorRelay(value: ()) - private let streamers = BehaviorRelay(value: nil) + private var streamers = BehaviorRelay(value: nil) + private let liveStreamers = BehaviorRelay(value: nil) + private let upStreamers = BehaviorRelay(value: nil) + private let pastStreamers = BehaviorRelay(value: nil) private let refreshState = BehaviorRelay(value: false) override init(_ services: AppServices) { super.init(services) refresh.subscribe(onNext: { _ in self.loadStreamers(services.settings.orgFilter) }).disposed(by: bag) - streamers.compactMap { $0 }.distinctUntilChanged() + liveStreamers.compactMap { $0 }//.distinctUntilChanged() .map { _ in false } .bind(to: refreshState) .disposed(by: bag) @@ -53,8 +56,28 @@ class HomeModel: BaseModel { func loadStreamers(_ org: Organization) { refreshState.accept(true) - services.holodex.streamers(org.description) + services.holodex.streamers(org.description, status: "live") .asObservable() + .bind(to: liveStreamers) + .disposed(by: bag) + services.holodex.streamers(org.description, status: "upcoming") + .asObservable() + .bind(to: upStreamers) + .disposed(by: bag) + services.holodex.streamers(org.description, status: "past") + .asObservable() + .bind(to: pastStreamers) + .disposed(by: bag) + + Observable.combineLatest(liveStreamers, upStreamers, pastStreamers) + .asObservable() + .map({ live, upcoming, past -> HoloDexResponse in + var combined: [HoloDexResponse.Streamer] = [] + combined.append(contentsOf: live?.items ?? []) + combined.append(contentsOf: upcoming?.items ?? []) + combined.append(contentsOf: past?.items ?? []) + return HoloDexResponse(items: combined) + }) .bind(to: streamers) .disposed(by: bag) } @@ -88,7 +111,7 @@ extension HomeModel: HomeModelOutput { func video(for section: Int, and index: Int) -> String { let r = streamers.value!.sections() - //return "x1EZYh8aGwA" + //return "sVCp2PhQBaE" return r[section].items[index].id } func thumbnail(for section: Int, and index: Int) -> URL? { @@ -123,14 +146,14 @@ extension HoloDexResponse { let l = items.filter { $0.status == .live }.sorted { $0.start_scheduled > $1.start_scheduled } let u = items.filter { $0.status == .upcoming }.sorted { $0.start_scheduled < $1.start_scheduled } - let e = items.filter { $0.status == .past }.sorted { $0.start_scheduled > $1.start_scheduled } + let e = items.filter { $0.status == .past && $0.start_scheduled <= Date() }.sorted { $0.start_scheduled > $1.start_scheduled } var rtr: [StreamerItemModel] = [] if !l.isEmpty { rtr.append(StreamerItemModel(title: Bundle.main.localizedString(forKey: "Live", value: "Live", table: "Localizeable"), items: l)) } if !u.isEmpty { rtr.append(StreamerItemModel(title: Bundle.main.localizedString(forKey: "Upcoming", value: "Upcoming", table: "Localizeable"), items: u)) } if !e.isEmpty { rtr.append(StreamerItemModel(title: Bundle.main.localizedString(forKey: "Ended", value: "Ended", table: "Localizeable"), items: e))} - rtr.append(StreamerItemModel(title: Bundle.main.localizedString(forKey: "Stream data provided by Holodex. Results capped at 50.", value: "Stream data provided by Holodex. Results capped at 50.", table: "Localizeable"), items: [])) + rtr.append(StreamerItemModel(title: Bundle.main.localizedString(forKey: "Stream data provided by Holodex.", value: "Stream data provided by Holodex.", table: "Localizeable"), items: [])) return rtr } diff --git a/ios/Core/Models/StreamModel.swift b/ios/Core/Models/StreamModel.swift index 1804443..74dd8b1 100644 --- a/ios/Core/Models/StreamModel.swift +++ b/ios/Core/Models/StreamModel.swift @@ -57,7 +57,7 @@ class StreamModel: BaseModel { private let chatURLRelay = BehaviorRelay(value: nil) private let mchadRoomRelay = BehaviorRelay(value: nil) - private let mchadScriptRelay = BehaviorRelay<[MchadScript?]>(value: []) + private let mchadScriptRelay = BehaviorRelay<([TranslatedMessage], MchadRoom?)?>(value: ([], nil)) private let metadataRelay = BehaviorRelay(value: nil) private let replayRelay = BehaviorRelay(value: false) @@ -98,12 +98,17 @@ class StreamModel: BaseModel { .map { $0.sorted { $0.sortTimestamp > $1.sortTimestamp } } .bind(to: chatRelay) .disposed(by: bag) - + playerRelay.compactMap { $0 } .map { (id: $0.identifier, duration: $0.duration) } .subscribe(onNext: loadChat) .disposed(by: bag) + playerRelay.compactMap { $0 } + .map { (id: $0.identifier, duration: $0.duration) } + .subscribe(onNext: loadMchadRooms) + .disposed(by: bag) + chatView.navigationDelegate = self chatURLRelay.compactMap { $0 } .map { URLRequest(url: $0) } @@ -113,6 +118,15 @@ class StreamModel: BaseModel { .map { $0.absoluteString.contains("live_chat_replay") } .bind(to: replayRelay) .disposed(by: bag) + mchadRoomRelay.compactMap { $0 } + .subscribe(onNext: { room in self.loadMchadArchiveScript(room) }) + .disposed(by: bag) + mchadScriptRelay.compactMap { $0 } + .subscribe(onNext: { script, room in + self.appendMchadScriptTls(script, room: room) + }) + .disposed(by: bag) + do { let path = Bundle.main.path(forResource: "WindowInjector", ofType: "js") ?? "" let js = try String(contentsOfFile: path, encoding: .utf8) @@ -156,37 +170,76 @@ class StreamModel: BaseModel { .bind(to: metadataRelay) .disposed(by: bag) } - - private func loadChat(_ id: String, duration: Double) { - let request = services.youtube.getYTChatURL(id, videoDuration: duration) + + private func loadMchadRooms(_ id: String, duration: Double) { + let request = services.mchad.getMchadRoom(id: id, duration: duration) .asObservable() .materialize() - let mchad = services.mchad.getMchadRoom(id: id, duration: duration) + request.map { $0.element } + .bind(to: mchadRoomRelay) + .disposed(by: bag) + request.map { $0.error } + .bind(to: errorRelay) + .disposed(by: bag) + } + + private func loadMchadArchiveScript(_ room: MchadRoom) { + let request = services.mchad.getMchadArchiveTls(room) .asObservable() .materialize() - mchadRoomRelay.compactMap { $0 } - .subscribe(onNext: { room in - self.services.mchad.getMchadArchiveTls(id, room: room) - .asObservable() - .materialize() - .subscribe(onNext: { messages in - if messages.element != nil { - self.translatedRelay.accept(messages.element as! [DisplayableMessage]) - } - }) - .disposed(by: self.bag) - }) + request.map { $0.element } + .bind(to: mchadScriptRelay) .disposed(by: bag) + request.map { $0.error } + .subscribe(onError: { error in print(error) }) + .disposed(by: bag) + } + + private func appendMchadScriptTls(_ script: [TranslatedMessage], room: MchadRoom?) { + var mutableScript = script + var mchadOffset: Double = 0.0 -// mchad.map { $0.element?.first } -// .bind(to: mchadRoomRelay) -// .disposed(by: bag) -// mchad.map { $0.error } -// .bind(to: errorRelay) -// .disposed(by: bag) + for displayableMessage in script { + if displayableMessage.message.first == Message.text("--- Stream Starts ---") { + mchadOffset = displayableMessage.showTimestamp + break + } + } + replayEventRelay.compactMap { $0.current } + .sample(Observable.interval(.milliseconds(500), scheduler: MainScheduler.instance), defaultValue: 0.0) + .subscribe(onNext: { time in + var currentRelay = self.translatedRelay.value + let tmpScript = mutableScript.filter { $0.showTimestamp - mchadOffset <= time * 1000 } + mutableScript.removeFirst(tmpScript.endIndex) + for message in tmpScript { + for lang in message.languages { + if self.services.settings.languages.map({ $0.tag }).contains(lang) || + self.services.settings.languages.map({ $0.description.lowercased().hasPrefix(lang) }).contains(Bool(true)) || + self.services.settings.languages.map({ $0.tag.lowercased().hasPrefix(lang) }).contains(Bool(true)), + !self.services.settings.neverUsers.contains(message.displayAuthor) + { + var mutableMessage = message + mutableMessage.timestamp = Date() + currentRelay.append(mutableMessage) + } + } + } + + if !tmpScript.isEmpty { + self.translatedRelay.accept(currentRelay) + } + }) + .disposed(by: bag) + } + + private func loadChat(_ id: String, duration: Double) { + let request = services.youtube.getYTChatURL(id, videoDuration: duration) + .asObservable() + .materialize() + request.map { $0.element } .bind(to: chatURLRelay) .disposed(by: bag) @@ -348,9 +401,11 @@ extension StreamModel: StreamModelInput { func load(_ id: String) { loadVideoPlayer(id) } + func loadPreviewChat(_ id: String, duration: Double) { loadChat(id, duration: duration) } + func getMetadata(_ id: String) { getVideoMeta(id) } diff --git a/ios/Core/Routing/AppFlow.swift b/ios/Core/Routing/AppFlow.swift index e460528..54e9aa1 100644 --- a/ios/Core/Routing/AppFlow.swift +++ b/ios/Core/Routing/AppFlow.swift @@ -8,6 +8,7 @@ import UIKit import RxCocoa import RxFlow +import Kingfisher class AppFlow: Flow { var root: Presentable { @@ -24,6 +25,7 @@ class AppFlow: Flow { switch step { case .home : return toHome() case .view(let id) : return toStreamView(id) + case .streamDone : return streamViewDone() case .settings : return toSettings() case .settingsDone : return settingsDone() case .toConsent(let showAlert): return toConsent(showAlert) @@ -35,14 +37,21 @@ class AppFlow: Flow { private func toHome() -> FlowContributors { let controller = HomeView(stepper, services) - rootViewController.setViewControllers([controller], animated: true) - + rootViewController.pushViewController(controller, animated: true) + //rootViewController.setViewControllers([controller], animated: true) + return .none } private func toStreamView(_ id: String) -> FlowContributors { let controller = StreamView(stepper, services) controller.load(id) - rootViewController.setViewControllers([controller], animated: true) + KingfisherManager.shared.cache.clearMemoryCache() + rootViewController.pushViewController(controller, animated: true) + + return .none + } + private func streamViewDone() -> FlowContributors { + rootViewController.popViewController(animated: true) return .none } diff --git a/ios/Core/Routing/AppStep.swift b/ios/Core/Routing/AppStep.swift index 4cd89b8..f5db283 100644 --- a/ios/Core/Routing/AppStep.swift +++ b/ios/Core/Routing/AppStep.swift @@ -11,6 +11,7 @@ import RxFlow enum AppStep: Step { case home case view(_ id: String) + case streamDone case settings, settingsDone case filter, filterDone case toConsent(_ showAlert: Bool), consentDone diff --git a/ios/Core/Services/AppServices.swift b/ios/Core/Services/AppServices.swift index 9a49ee0..dbaddd2 100644 --- a/ios/Core/Services/AppServices.swift +++ b/ios/Core/Services/AppServices.swift @@ -11,6 +11,7 @@ class AppServices { let holodex = HoloDexServices() let youtube = YouTubeService() let mchad = MchadServices() + //let ltl = LtlAPIServices() let settings = SettingsService() init() {} diff --git a/ios/Core/Services/HoloDexServices.swift b/ios/Core/Services/HoloDexServices.swift index 6a8d823..76c7b5e 100644 --- a/ios/Core/Services/HoloDexServices.swift +++ b/ios/Core/Services/HoloDexServices.swift @@ -12,10 +12,13 @@ import RxSwift struct HoloDexServices { init() {} - func streamers(_ org: String) -> Single { + func streamers(_ org: String, status: String) -> Single { return Single.create { observer in - let url = URL(string: "https://holodex.net/api/v2/videos?status=live%2Cupcoming%2Cpast&lang=all&type=stream&include=description%2Clive_info&org=\(org.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "Hololive")&sort=start_scheduled&order=desc&limit=50&offset=0&paginated=%3Cempty%3E&max_upcoming_hours=48")! - let task = URLSession.shared.dataTask(with: url) { response, _, error in + let url = URL(string: "https://holodex.net/api/v2/videos?status=\(status)&lang=all&type=stream&include=description%2Clive_info&org=\(org.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "Hololive")&sort=start_scheduled&order=desc&limit=50&offset=0&paginated=%3Cempty%3E&max_upcoming_hours=48")! + + var request = URLRequest(url: url) + request.setValue(ProcessInfo.processInfo.environment["HOLODEX_API_KEY"]!, forHTTPHeaderField: "X-APIKEY") + let task = URLSession.shared.dataTask(with: request) { response, _, error in if let response = response { do { let decoder = JSONDecoder() @@ -89,3 +92,5 @@ extension JSONDecoder.DateDecodingStrategy { } } } + + diff --git a/ios/Core/Services/MchadServices.swift b/ios/Core/Services/MchadServices.swift index a94669c..93aba10 100644 --- a/ios/Core/Services/MchadServices.swift +++ b/ios/Core/Services/MchadServices.swift @@ -12,8 +12,8 @@ import RxSwift struct MchadServices { init() {} - func getMchadRoom(id: String, duration: Double) -> Single<[MchadRoom]> { - return Single.create { observer in + func getMchadRoom(id: String, duration: Double) -> Observable { + return Observable.create { observer in let request: URLRequest if duration > 0 { // is replay @@ -22,76 +22,116 @@ struct MchadServices { request = URLRequest(url: URL(string: "https://repo.mchatx.org/Room?link=YT_\(id)")!) } let task = URLSession.shared.dataTask(with: request) { data, _, error in - if let data = data, let room = String(data: data, encoding: .utf8) { + if let data = data { do { - //print(room) + let decoder = JSONDecoder() let json = try decoder.decode([MchadRoom].self, from: data) - observer(.success(json)) + if !json.isEmpty { + for room in json { + observer.onNext(room) + } + } else { + print("No mchad room") + } + } catch { + observer.onError(error) + } + } else if let error = error { + observer.onError(error) + } + } + task.resume() + + return Disposables.create { + task.cancel() + } + } + } + + func getMchadArchiveOffset(_ room: MchadRoom) -> Single { + Single.create { observer in + var request = URLRequest(url: URL(string: "https://repo.mchatx.org/Archive")!) + request.httpMethod = "POST" + request.addValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let jsonObject = NSMutableDictionary() + jsonObject.setValue(room.Link, forKey: "link") + let jsonData: NSData + do { + jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: JSONSerialization.WritingOptions()) as NSData + request.httpBody = jsonData as Data? + } catch { + print(error) + } + + let task = URLSession.shared.dataTask(with: request) { data, _, error in + if let data = data { + do { + let decoder = JSONDecoder() + + let json = try decoder.decode([MchadScript].self, from: data) + if (json.first(where: { $0.Stext == "--- Stream Starts ---" })?.Stime) != nil { + let offset = json.first(where: { $0.Stext == "--- Stream Starts ---" })!.Stime + + observer(.success(offset)) + } } catch { print(error) - //observer(.failure(error)) + observer(.failure(error)) } } else if let error = error { + print(error) observer(.failure(error)) } } task.resume() - + return Disposables.create { task.cancel() } } } - func getMchadArchiveTls(_ id: String, room: MchadRoom) -> Single<[TranslatedMessage?]> { + func getMchadArchiveTls(_ room: MchadRoom) -> Single<([TranslatedMessage], MchadRoom)> { Single.create { observer in - let bag = DisposeBag() - let meta = BehaviorRelay(value: nil) - let start = BehaviorRelay(value: nil) - var request = URLRequest(url: URL(string: "https://repo.mchatx.org/Archive")!) request.httpMethod = "POST" request.addValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") request.addValue("application/json", forHTTPHeaderField: "Content-Type") - + let jsonObject = NSMutableDictionary() jsonObject.setValue(room.Link, forKey: "link") let jsonData: NSData do { jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: JSONSerialization.WritingOptions()) as NSData request.httpBody = jsonData as Data? - } catch { + } catch { print(error) } - + let task = URLSession.shared.dataTask(with: request) { data, _, error in if let data = data { do { let decoder = JSONDecoder() let json = try decoder.decode([MchadScript].self, from: data) - - if let startTime = json.first(where: { $0.Stext == "--- Stream Starts ---"})?.Stime { + if json.first != nil { let messages = json.map { TranslatedMessage(from: $0, room: room) } - - observer(.success(messages)) - } else if let startTime = json.first?.Stime { - - } else { - let startTime = Date.distantPast + + observer(.success((messages, room))) } - //observer(.success(json)) } catch { - //observer(.failure(error)) print(error) + observer(.failure(error)) } } else if let error = error { print(error) - //observer(.failure(error)) + observer(.failure(error)) } } task.resume() - + return Disposables.create { task.cancel() } diff --git a/ios/Core/Types/DisplayableMessage.swift b/ios/Core/Types/DisplayableMessage.swift index 4b45181..b7fd11b 100644 --- a/ios/Core/Types/DisplayableMessage.swift +++ b/ios/Core/Types/DisplayableMessage.swift @@ -14,9 +14,10 @@ protocol DisplayableMessage { var isMod : Bool { get } var isMember : Bool { get } var superchatData : Superchat? { get } + var isMchad : Bool { get } var sortTimestamp: Date { get } - var showTimestamp: Double { get } + } extension DisplayableMessage { @@ -55,6 +56,4 @@ enum Message: Decodable { } } -extension Message: Equatable { - -} +extension Message: Equatable {} diff --git a/ios/Core/Types/InjectedMessage.swift b/ios/Core/Types/InjectedMessage.swift index 2141a06..6ad9180 100644 --- a/ios/Core/Types/InjectedMessage.swift +++ b/ios/Core/Types/InjectedMessage.swift @@ -17,7 +17,6 @@ struct MessageChunk: Decodable { struct InjectedMessage: Decodable { let author: Author let messages: [Message] - let showtime: Double let timestamp: Date let superchat: Superchat? @@ -29,6 +28,7 @@ struct InjectedMessage: Decodable { } extension InjectedMessage: DisplayableMessage { + var isMchad: Bool { false } var displayAuthor: String { author.name } var displayTimestamp: String { timestamp.toRelative(style: RelativeFormatter.twitterStyle()) } var displayMessage: [Message] { messages } @@ -45,8 +45,9 @@ extension InjectedMessage: DisplayableMessage { } return false } + var superchatData: Superchat? { superchat } var sortTimestamp: Date { timestamp } - var showTimestamp: Double { showtime } + } diff --git a/ios/Core/Types/MchadTypes.swift b/ios/Core/Types/MchadTypes.swift index ea2eb8f..8ccdede 100644 --- a/ios/Core/Types/MchadTypes.swift +++ b/ios/Core/Types/MchadTypes.swift @@ -20,8 +20,15 @@ struct MchadRoom: Decodable { let Downloadable: Bool? } +struct MchadIncoming: Decodable { + let flag: String? + let content: MchadScript +} + struct MchadScript: Decodable { - let Stime: Date + let _id: String? + let Stime: Double + var Stimestamp: Date { Date() } //Doesn't matter, never shown to the user, just for sorting let Stext: String let CC: String? let OC: String? diff --git a/ios/Core/Types/TranslatedMessage.swift b/ios/Core/Types/TranslatedMessage.swift index fe1df8d..1e3dd5a 100644 --- a/ios/Core/Types/TranslatedMessage.swift +++ b/ios/Core/Types/TranslatedMessage.swift @@ -13,24 +13,25 @@ struct TranslatedMessage { let message: [Message] let languages: [String] - let timestamp: Date - let show : Double + var timestamp: Date + let show: Double let superchat: Superchat? + let isMchad: Bool init?(from message: InjectedMessage) { self.author = Author(from: message.author) self.timestamp = message.timestamp - self.show = message.showtime + self.show = 0 self.superchat = message.superchat var m: [Message?] = [] - var l: [String]? = nil + var l: [String]? - if case let .text(s) = message.messages.first { + if case .text(let s) = message.messages.first { for token in tokens { guard let begin = s.firstIndex(of: token.start), - let end = s.firstIndex(of: token.end) + let end = s.firstIndex(of: token.end) else { continue } guard begin < end else { continue } @@ -43,15 +44,13 @@ struct TranslatedMessage { do { for splitLang in try lang.split(usingRegex: "\\W+") { - guard TranslatedLanguageTag.allCases.map({ $0.tag }).contains(splitLang) || TranslatedLanguageTag.allCases.map({ $0.description.lowercased().hasPrefix(splitLang) }).contains(Bool.init(true)) || TranslatedLanguageTag.allCases.map({ $0.tag.lowercased().hasPrefix(splitLang) }).contains(Bool.init(true)) else { continue } + guard TranslatedLanguageTag.allCases.map({ $0.tag }).contains(splitLang) || TranslatedLanguageTag.allCases.map({ $0.description.lowercased().hasPrefix(splitLang) }).contains(Bool(true)) || TranslatedLanguageTag.allCases.map({ $0.tag.lowercased().hasPrefix(splitLang) }).contains(Bool(true)) else { continue } finalLang.append(splitLang) } } catch { print("Whoops") } - - let mStart = s.index(after: end) m.append(Message.text(String(s[mStart.. [String] { - //### Crashes when you pass invalid `pattern` + // ### Crashes when you pass invalid `pattern` let regex = try NSRegularExpression(pattern: pattern) let matches = regex.matches(in: self, range: NSRange(0.. UIContextMenuConfiguration? { let index = indexPath.row @@ -190,7 +205,7 @@ extension HomeView: UITableViewDelegate { let viewController = UIViewController() let popoutView = UIView() let imageView = UIImageView() - popoutView.frame = CGRect(x: 0, y: 0, width: 333, height: 999) + popoutView.frame = CGRect(x: 0, y: 0, width: popoutWidth, height: 999) // popoutView.clipsToBounds = true imageView.kf.indicatorType = .activity @@ -204,7 +219,7 @@ extension HomeView: UITableViewDelegate { } } popoutView.addSubview(imageView) - imageView.anchorToEdge(.top, padding: 0, width: 333, height: 187) + imageView.anchorToEdge(.top, padding: 0, width: popoutWidth, height: popoutImageHeight) let titleText = model.output.title(for: indexPath.section, and: indexPath.row) let nsText = titleText as NSString? @@ -218,13 +233,13 @@ extension HomeView: UITableViewDelegate { popoutView.addSubview(title) title.sizeToFit() - title.align(.underCentered, relativeTo: imageView, padding: 10, width: 300, height: textSize?.height ?? 0) + title.align(.underCentered, relativeTo: imageView, padding: 10, width: popoutWidth - 30, height: textSize?.height ?? 0) title.leadingAnchor.constraint(equalTo: popoutView.safeAreaLayoutGuide.leadingAnchor, constant: 100).isActive = true title.trailingAnchor.constraint(equalTo: popoutView.safeAreaLayoutGuide.trailingAnchor, constant: -100).isActive = true title.layoutIfNeeded() let popoutHeight = title.height + imageView.height + 20 - popoutView.frame = CGRect(x: 0, y: 0, width: 333, height: popoutHeight) + popoutView.frame = CGRect(x: 0, y: 0, width: popoutWidth, height: popoutHeight) viewController.view = popoutView viewController.preferredContentSize = popoutView.frame.size @@ -258,4 +273,30 @@ extension HomeView: UITableViewDelegate { return UIMenu(title: "", image: nil, children: [shareAction, youtubeAction]) } } + + func iPhoneLayoutPortrait() { + popoutWidth = 333 + popoutImageHeight = 187 + } + + func iPhoneLayoutLandscape() { + popoutWidth = 262 + popoutImageHeight = 147 + } + + func iPadLayoutPortrait() { + popoutWidth = 340 + popoutImageHeight = 191 + } + + func iPadLayoutLandscape() { + popoutWidth = 343 + popoutImageHeight = 192 + } +} + +extension HomeView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } } diff --git a/ios/Views/Home/StreamerCell/StreamerCell.swift b/ios/Views/Home/StreamerCell/StreamerCell.swift index b307aef..a3f963a 100644 --- a/ios/Views/Home/StreamerCell/StreamerCell.swift +++ b/ios/Views/Home/StreamerCell/StreamerCell.swift @@ -85,6 +85,8 @@ class StreamerCell: UITableViewCell { break } } + + self.setNeedsLayout() } override func layoutSubviews() { diff --git a/ios/Views/StreamView/ChatCell/ChatCell.swift b/ios/Views/StreamView/ChatCell/ChatCell.swift index 1a7f854..c0653b9 100644 --- a/ios/Views/StreamView/ChatCell/ChatCell.swift +++ b/ios/Views/StreamView/ChatCell/ChatCell.swift @@ -22,15 +22,25 @@ class ChatCell: UITableViewCell { func configure(_ item: DisplayableMessage, useTimestamps: Bool) { author.text = item.displayAuthor timestamp.text = useTimestamps ? item.displayTimestamp : "" + if item.isMchad { + timestamp.text = "Mchad" + } + + // This should reset the cell, so we aviod duplicate superchats and members + timestamp.font = .systemFont(ofSize: 17) + timestamp.textColor = .secondaryLabel + contentView.layer.cornerRadius = 0 + contentView.backgroundColor = .clear + author.textColor = .secondaryLabel if item.superchatData != nil { - // print(item.superchatData) timestamp.text = item.superchatData?.amount timestamp.font = .boldSystemFont(ofSize: 17) timestamp.textColor = .label contentView.layer.cornerRadius = 10 contentView.backgroundColor = item.superchatData?.UIcolor } + if item.isMember { author.textColor = UIColor(red: 44/255, green: 166/255, blue: 63/255, alpha: 1) @@ -47,13 +57,17 @@ class ChatCell: UITableViewCell { case .text(let s): let am = NSAttributedString(string: s) fullMessage.append(am) - case .emote(let u, let id): + case .emote(var u, let id): if id != nil { if id!.isSingleEmoji { let am = NSAttributedString(string: id!) fullMessage.append(am) } else { - let html = " " + if u.pathExtension == "svg" { + u.deletePathExtension() + u.appendPathExtension("png") + } + let html = " " let data = Data(html.utf8) do { @@ -69,6 +83,7 @@ class ChatCell: UITableViewCell { } } + // print("\(item.displayAuthor): \(item.displayMessage)") message.attributedText = fullMessage } } diff --git a/ios/Views/StreamView/StreamView.swift b/ios/Views/StreamView/StreamView.swift index 5c7e25f..944a77e 100644 --- a/ios/Views/StreamView/StreamView.swift +++ b/ios/Views/StreamView/StreamView.swift @@ -26,6 +26,7 @@ class StreamView: BaseController { var waitRoom = false let waitTimeText = UILabel() let waitTimeScheduled = UILabel() + var videoLoaded: Bool = false let chatTable = ChatTable(frame: .zero, style: .plain) let chatControl: UISegmentedControl @@ -92,6 +93,7 @@ class StreamView: BaseController { } } let playerItem = AVPlayerItem(url: streamURL!) + videoLoaded = true player?.replaceCurrentItem(with: playerItem) videoPlayer.player = player @@ -189,7 +191,7 @@ class StreamView: BaseController { videoPlayer.player = nil player = nil settingsService.spotlightUser = nil - stepper.steps.accept(AppStep.home) + stepper.steps.accept(AppStep.streamDone) } @objc func settings() { @@ -199,57 +201,72 @@ class StreamView: BaseController { override func handle(_ error: Error) { let nserror = error as NSError -// if nserror.code == -2, waitRoom == false { -// waitRoom = true -// waitRoomView.kf.setImage(with: URL(string: "https://i.ytimg.com/vi/\(videoID)/maxresdefault.jpg"), options: [.cacheOriginalImage]) { result in -// switch result { -// case .failure: do { -// self.waitRoomView.kf.setImage(with: URL(string: "https://i.ytimg.com/vi/\(self.videoID)/mqdefault.jpg"), options: [.cacheOriginalImage]) -// } -// case .success: -// break -// } -// } -// // Get Timestamp -// let startStringTimestamp = nserror.userInfo["startTimestamp"] as! String -// let startTimestamp = Date(timeIntervalSince1970: Double(startStringTimestamp)!) -// let interval = DateInterval(start: Date(), end: startTimestamp) -// -// // Create Countdown Timer -// let countDown = Int(interval.duration) -// Observable.timer(.seconds(0), period: .seconds(1), scheduler: MainScheduler.instance) -// .take(countDown + 1) -// .subscribe(onNext: { timePassed in -// let count = countDown - timePassed -// let h = String(format: "%02d", count / 3600) -// let m = String(format: "%02d", (count % 3600) / 60) -// let s = String(format: "%02d", (count % 3600) % 60) -// var timer = "\(m):\(s)" -// if h != "00" { -// timer = "\(h):\(timer)" -// } -// self.waitTimeText.text = "Live in " + timer -// -// }, onCompleted: { -// self.waitTimeText.text = "Waiting on stream to start" -// }) -// .disposed(by: bag) -// waitTimeText.font = .systemFont(ofSize: 19) -// waitTimeText.textColor = .white -// -// let dateFormatter = DateFormatter() -// dateFormatter.dateStyle = .long -// dateFormatter.timeStyle = .medium -// dateFormatter.timeZone = .current -// dateFormatter.locale = .current -// waitTimeScheduled.text = dateFormatter.string(from: startTimestamp) -// waitTimeScheduled.font = .systemFont(ofSize: 15) -// waitTimeScheduled.textColor = .white -// -// waitRoomTextView.addSubview(waitTimeText) -// waitRoomTextView.addSubview(waitTimeScheduled) -// model.input.loadPreviewChat(videoID, duration: 0) -// } + if nserror.code == -2, let startStringTimestamp = nserror.userInfo["startTimestamp"] as? String, waitRoom == false { + waitRoom = true + waitRoomView.kf.setImage(with: URL(string: "https://i.ytimg.com/vi/\(videoID)/maxresdefault.jpg"), options: [.cacheOriginalImage]) { result in + switch result { + case .failure: do { + self.waitRoomView.kf.setImage(with: URL(string: "https://i.ytimg.com/vi/\(self.videoID)/mqdefault.jpg"), options: [.cacheOriginalImage]) + } + case .success: + break + } + } + // Get Timestamp + if let startStreamTimeDouble = Double(startStringTimestamp) { + let startTimestamp = Date(timeIntervalSince1970: startStreamTimeDouble) + let interval = DateInterval(start: Date(), end: startTimestamp) + // try to load the video until it loads + Observable.timer(.seconds(0), period: .seconds(10), scheduler: MainScheduler.instance) + .take(until: { _ in self.videoLoaded }) + .subscribe(onNext: {_ in self.load(self.videoID)}, onCompleted: { + print("Video loaded") + + }) + .disposed(by: bag) + + // Create Countdown Timer + let countDown = Int(interval.duration) + Observable.timer(.seconds(0), period: .seconds(1), scheduler: MainScheduler.instance) + .take(countDown + 1) + .subscribe(onNext: { timePassed in + let count = countDown - timePassed + let h = String(format: "%02d", count / 3600) + let m = String(format: "%02d", (count % 3600) / 60) + let s = String(format: "%02d", (count % 3600) % 60) + var timer = "\(m):\(s)" + if h != "00" { + timer = "\(h):\(timer)" + } + self.waitTimeText.text = "Live in " + timer + }, onCompleted: { + self.waitTimeText.text = "Waiting on stream to start..." + }) + .disposed(by: bag) + waitTimeText.font = .systemFont(ofSize: 19) + waitTimeText.textColor = .white + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .medium + dateFormatter.timeZone = .current + dateFormatter.locale = .current + waitTimeScheduled.text = dateFormatter.string(from: startTimestamp) + waitTimeScheduled.font = .systemFont(ofSize: 15) + waitTimeScheduled.textColor = .white + + waitRoomTextView.addSubview(waitTimeText) + waitRoomTextView.addSubview(waitTimeScheduled) + model.input.loadPreviewChat(videoID, duration: 0) + } else { + let alert = SCLAlertView() + alert.addButton(Bundle.main.localizedString(forKey: "Go Back", value: "Go Back", table: "Localizeable")) { + self.closeStream() + } + + alert.showError(Bundle.main.localizedString(forKey: "An Error Occurred", value: "An Error Occurred", table: "Localizeable"), subTitle: "An error occured, and we cannot open the waitroom.") + } + } if nserror.code == -6, nserror.userInfo["consentHtmlData"] as? String != nil { closeStream() @@ -272,7 +289,7 @@ class StreamView: BaseController { } alert.showError(Bundle.main.localizedString(forKey: "An Error Occurred", value: "An Error Occurred", table: "Localizeable"), subTitle: error.localizedDescription + " You'll need to join the channel from youtube.com or the YouTube app. If this is in error, try logging out and logging in again.") } - } else { //if !nserror.localizedDescription.starts(with: "This live event will begin in") { + } else if !nserror.localizedDescription.starts(with: "This live event will begin in") { let alert = SCLAlertView() alert.addButton(Bundle.main.localizedString(forKey: "Go Back", value: "Go Back", table: "Localizeable")) { self.closeStream() @@ -353,3 +370,5 @@ class StreamView: BaseController { chatControl.align(.aboveCentered, relativeTo: chatTable, padding: 2, width: view.width * 0.3, height: 35) } } + + diff --git a/ios/WindowInjector.js b/ios/WindowInjector.js index c6e6e09..93ac13f 100644 --- a/ios/WindowInjector.js +++ b/ios/WindowInjector.js @@ -57,11 +57,13 @@ const messageReceiveCallback = async(response) => { console.debug('Response was invalid', response); return; } + ( response.continuationContents.liveChatContinuation.actions || [] ).forEach((action, i) => { try { let currentElement = action.addChatItemAction; + if (action.replayChatItemAction != null) { const thisAction = action.replayChatItemAction.actions[0]; currentElement = thisAction.addChatItemAction; @@ -120,8 +122,7 @@ const messageReceiveCallback = async(response) => { index: i, messages: runs, timestamp: Math.round(parseInt(timestampUsec) / 1000), - showtime: isReplay ? getMillis(timestampText, timestampUsec) - : date.getTime() - (timestampUsec / 1000) + showtime: isReplay ? getMillis(timestampText, timestampUsec) : date.getTime() - (timestampUsec / 1000) }; if (currentElement.liveChatPaidMessageRenderer) { item.superchat = { diff --git a/ios/en.lproj/Localizeable.strings b/ios/en.lproj/Localizeable.strings index 4642e43..785158b 100644 --- a/ios/en.lproj/Localizeable.strings +++ b/ios/en.lproj/Localizeable.strings @@ -200,3 +200,6 @@ /* a setting to enable english channel names */ "Use English Channel Names" = "Use English Channel Names"; + +/* a button that takes you to the About LiveTL screen */ +"About LiveTL" = "About LiveTL"; diff --git a/ios/ios.entitlements b/ios/ios.entitlements index 2eb7e33..4da9e8f 100644 --- a/ios/ios.entitlements +++ b/ios/ios.entitlements @@ -2,7 +2,11 @@ + com.apple.security.app-sandbox + com.apple.security.application-groups + com.apple.security.network.client +