diff --git a/iOS/GBHub.h b/iOS/GBHub.h new file mode 100644 index 000000000..2e1ee573a --- /dev/null +++ b/iOS/GBHub.h @@ -0,0 +1,41 @@ +#import + +typedef enum { + GBHubStatusNotReady, + GBHubStatusInProgress, + GBHubStatusReady, + GBHubStatusError, +} GBHubStatus; + +static inline NSString *GBSearchCanonicalString(NSString *string) +{ + return [[string.lowercaseString stringByApplyingTransform:NSStringTransformStripDiacritics reverse:false] stringByApplyingTransform:NSStringTransformStripCombiningMarks reverse:false]; +} + +@interface GBHubGame : NSObject +@property (readonly) NSString *title; +@property (readonly) NSString *developer; +@property (readonly) NSString *type; +@property (readonly) NSString *license; +@property (readonly) NSDate *publicationDate; +@property (readonly) NSArray *tags; +@property (readonly) NSURL *repository; +@property (readonly) NSURL *website; +@property (readonly) NSArray *screenshots; +@property (readonly) NSURL *file; +@property (readonly) NSString *slug; +@property (readonly) NSString *entryDescription; +@property (readonly) NSString *keywords; +@end + +extern NSString *const GBHubStatusChangedNotificationName; + +@interface GBHub : NSObject ++ (instancetype)sharedHub; +- (void)refresh; +- (unsigned)countForTag:(NSString *)tag; +@property (readonly) GBHubStatus status; +@property (readonly) NSDictionary *allGames; +@property (readonly) NSArray *sortedTags; +@property (readonly) NSArray *showcaseGames; +@end diff --git a/iOS/GBHub.m b/iOS/GBHub.m new file mode 100644 index 000000000..ed5959771 --- /dev/null +++ b/iOS/GBHub.m @@ -0,0 +1,339 @@ +#import "GBHub.h" +#pragma clang diagnostic ignored "-Warc-retain-cycles" + +NSString *const GBHubStatusChangedNotificationName = @"GBHubStatusChangedNotification"; + +static NSURL *StringToWebURL(NSString *string) +{ + if (![string isKindOfClass:[NSString class]]) return nil; + NSURL *url = [NSURL URLWithString:string]; + if (![url.scheme isEqual:@"http"] && [url.scheme isEqual:@"https"]) { + return nil; + } + + return url; +} + +@implementation GBHubGame + +- (instancetype)initWithJSON:(NSDictionary *)json +{ + self = [super init]; + + // Skip NSFW titles + if ([json[@"nsfw"] boolValue]) return nil; + + if (json[@"tags"] && ![json[@"tags"] isKindOfClass:[NSArray class]]) return nil; + _tags = [NSMutableArray array]; + + for (__strong NSString *tag in json[@"tags"]) { + if (![tag isKindOfClass:[NSString class]]) { + return nil; + } + if ([tag isEqual:@"hw:gbprinter"]) { + continue; + } + + if ([tag hasPrefix:@"event:"]) { + tag = [tag substringFromIndex:strlen("event:")]; + } + if ([tag hasPrefix:@"gb-showdown-"]) { + tag = [NSString stringWithFormat:@"Game Boy Showdown %@", [tag substringFromIndex:strlen("gb-showdown-")]]; + } + if ([tag hasPrefix:@"gbcompo"]) { + tag = [NSString stringWithFormat:@"GBCompo%@", [[tag substringFromIndex:strlen("gbcompo")].capitalizedString stringByReplacingOccurrencesOfString:@"-" withString:@" "]]; + } + if ([tag isEqual:tag.lowercaseString]) { + tag = [tag stringByReplacingOccurrencesOfString:@"-" withString:@" "].capitalizedString; + } + [(NSMutableArray *)_tags addObject:tag]; + } + + NSMutableSet *licenses = [NSMutableSet set]; + + if (json[@"license"]) { + [licenses addObject:json[@"license"]]; + } + if (json[@"gameLicense"]) { + [licenses addObject:json[@"gameLicense"]]; + } + if (json[@"assetsLicense"]) { + [licenses addObject:json[@"assetsLicense"]]; + } + + + if (licenses.count == 1) { + _license = licenses.anyObject; + if (![_license isKindOfClass:[NSString class]]) { + return nil; + } + if (!_license.length) _license = nil; + } + else if (licenses.count > 1) { + if (json[@"license"]) { + return nil; + } + _license = [NSString stringWithFormat:@"%@ (Assets: %@)", json[@"gameLicense"], json[@"assetsLicense"]]; + } + + if (_license && ![_tags containsObject:@"Open Source"]) { + // License is guaranteed to be Open Source by spec + [(NSMutableArray *)_tags addObject:@"Open Source"]; + } + + _title = json[@"title"]; + if (![_title isKindOfClass:[NSString class]]) { + return nil; + } + + _entryDescription = json[@"description"]; + if (_entryDescription && ![_entryDescription isKindOfClass:[NSString class]]) { + return nil; + } + + _developer = json[@"developer"]; + if (![_developer isKindOfClass:[NSString class]]) { + if ([_developer isKindOfClass:[NSArray class]] && ((NSArray *)_developer).count) { + if ([((NSArray *)_developer)[0] isKindOfClass:[NSString class]]) { + _developer = [(NSArray *)_developer componentsJoinedByString:@", "]; + } + else if ([((NSArray *)_developer)[0] isKindOfClass:[NSDictionary class]]) { + NSMutableArray *developers = [NSMutableArray array]; + for (NSDictionary *developer in (NSArray *)_developer) { + if (![developer isKindOfClass:[NSDictionary class]]) return nil; + NSString *name = developer[@"name"]; + if (!name) return nil; + [developers addObject:name]; + } + _developer = [developers componentsJoinedByString:@", "]; + } + else { + return nil; + } + } + else if ([_developer isKindOfClass:[NSDictionary class]]) { + _developer = ((NSDictionary *)_developer)[@"name"]; + } + else { + return nil; + } + } + + + _slug = json[@"slug"]; + if (![_slug isKindOfClass:[NSString class]]) { + return nil; + } + + _type = json[@"typetag"]; + if (![_type isKindOfClass:[NSString class]]) { + return nil; + } + + NSString *dateString = json[@"date"]; + if ([dateString isKindOfClass:[NSString class]]) { + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setDateFormat:@"yyyy-MM-dd"]; + _publicationDate = [dateFormatter dateFromString:dateString]; + } + + _repository = StringToWebURL(json[@"repository"]); + _website = StringToWebURL(json[@"website"]); + + NSURL *base = [NSURL URLWithString:[NSString stringWithFormat:@"https://hh3.gbdev.io/static/database-gb/entries/%@", _slug]]; + + NSMutableArray *screenshots = [NSMutableArray array]; + for (NSString *screenshot in json[@"screenshots"]) { + [screenshots addObject:[base URLByAppendingPathComponent:screenshot]]; + } + + + _screenshots = screenshots; + + for (NSDictionary *file in json[@"files"]) { + NSString *extension = [file[@"filename"] pathExtension].lowercaseString; + if (![extension isEqual:@"gb"] && ![extension isEqual:@"gbc"]) { + // Not a DMG/CGB game + continue; + } + if ([file[@"default"] boolValue] || !_file) { + _file = [base URLByAppendingPathComponent:file[@"filename"]]; + } + } + + if (!_file) { + return nil; + } + + _keywords = [NSString stringWithFormat:@"%@ %@ %@ %@", + GBSearchCanonicalString(_title), + GBSearchCanonicalString(_developer), + GBSearchCanonicalString(_entryDescription) ?: @"", + GBSearchCanonicalString([_tags componentsJoinedByString:@" "]) + ]; + + return self; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@ %p: %@>", self.class, self, self.title]; +} + +@end + +@implementation GBHub +{ + NSMutableDictionary *_allGames; + NSMutableDictionary *_tags; + NSMutableArray *_showcaseGames; + NSSet *_showcaseExtras; +} + ++ (instancetype)sharedHub +{ + static GBHub *hub = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + hub = [[self alloc] init]; + }); + return hub; +} + +- (void)setStatus:(GBHubStatus)status +{ + if (_status != status) { + _status = status; + if ([NSThread isMainThread]) { + [[NSNotificationCenter defaultCenter] postNotificationName:GBHubStatusChangedNotificationName + object:self]; + } + else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:GBHubStatusChangedNotificationName + object:self]; + }); + } + } +} + +- (void)handleAPIData:(NSData *)data forBaseURL:(NSString *)base completion:(void(^)(GBHubStatus))completion +{ + @try { + if (!data) { + completion(GBHubStatusError); + return; + } + NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (!json) { + completion(GBHubStatusError); + return; + } + + if (!json[@"page_current"] || !json[@"page_total"]) { + completion(GBHubStatusError); + return; + } + + for (NSDictionary *entry in json[@"entries"]) { + @autoreleasepool { + GBHubGame *game = [[GBHubGame alloc] initWithJSON:entry]; + if (game && !_allGames[game.slug]) { + _allGames[game.slug] = game; + bool showcase = [_showcaseExtras containsObject:game.slug]; + if (!showcase) { + for (NSString *tag in game.tags) { + _tags[tag] = @(_tags[tag].unsignedIntValue + 1); + if ([tag containsString:@"Shortlist"]) { + showcase = true; + break; + } + } + } + if (showcase) { + [_showcaseGames addObject:game]; + } + } + } + } + + if ([json[@"page_current"] unsignedIntValue] == [json[@"page_total"] unsignedIntValue]) { + completion(GBHubStatusReady); + return; + } + + [[[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"%@&page=%u", + base, + [json[@"page_current"] unsignedIntValue] + 1]] + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + [self handleAPIData:data forBaseURL:base completion:completion]; + }] resume]; + } + @catch (NSException *exception) { + self.status = GBHubStatusError; + } +} + +- (void)addGamesForURL:(NSString *)url completion:(void(^)(GBHubStatus))completion +{ + [[[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:url] + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + completion(GBHubStatusError); + return; + } + [self handleAPIData:data forBaseURL:url completion:completion]; + }] resume]; +} + +- (unsigned int)countForTag:(NSString *)tag +{ + return _tags[tag].unsignedIntValue; +} + +- (void)refresh +{ + if (_status == GBHubStatusInProgress) { + return; + } + self.status = GBHubStatusInProgress; + _allGames = [NSMutableDictionary dictionary]; + _tags = [NSMutableDictionary dictionary]; + _showcaseGames = [NSMutableArray array]; + + [[[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:@"https://sameboy.github.io/ios-showcase"] + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (data) { + _showcaseExtras = [NSSet setWithArray:[[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] componentsSeparatedByString:@"\n"]]; + } + [self addGamesForURL:@"https://hh3.gbdev.io/api/search?tags=Open+Source&results=1000" + completion:^(GBHubStatus ret) { + if (ret != GBHubStatusReady) { + self.status = ret; + return; + } + [self addGamesForURL:@"https://hh3.gbdev.io/api/search?thirdparty=sameboy&results=1000" + completion:^(GBHubStatus ret) { + if (ret == GBHubStatusReady) { + _sortedTags = [_tags.allKeys sortedArrayUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) { + return [obj1 compare:obj2]; + }]; + } + unsigned day = time(NULL) / 60 / 60 / 24; + if (_showcaseGames.count > 5) { + typeof(_showcaseGames) temp = [NSMutableArray array]; + for (unsigned i = 5; i--;) { + unsigned index = day % _showcaseGames.count; + GBHubGame *game = _showcaseGames[index]; + [_showcaseGames removeObjectAtIndex:index]; + [temp addObject:game]; + } + _showcaseGames = temp; + } + self.status = ret; + }]; + }]; + }] resume]; +} + +@end diff --git a/iOS/GBHubCell.h b/iOS/GBHubCell.h new file mode 100644 index 000000000..cf5f90077 --- /dev/null +++ b/iOS/GBHubCell.h @@ -0,0 +1,8 @@ +#import +#import "GBHub.h" + +@interface GBHubCell : UITableViewCell + +@property GBHubGame *game; + +@end diff --git a/iOS/GBHubCell.m b/iOS/GBHubCell.m new file mode 100644 index 000000000..1917d1fad --- /dev/null +++ b/iOS/GBHubCell.m @@ -0,0 +1,4 @@ +#import "GBHubCell.h" + +@implementation GBHubCell +@end diff --git a/iOS/GBHubGameViewController.h b/iOS/GBHubGameViewController.h new file mode 100644 index 000000000..89b3f1e37 --- /dev/null +++ b/iOS/GBHubGameViewController.h @@ -0,0 +1,7 @@ +#import +#import "GBHub.h" + +@interface GBHubGameViewController : UIViewController +- (instancetype)initWithGame:(GBHubGame *)game; +@end + diff --git a/iOS/GBHubGameViewController.m b/iOS/GBHubGameViewController.m new file mode 100644 index 000000000..d417928d5 --- /dev/null +++ b/iOS/GBHubGameViewController.m @@ -0,0 +1,315 @@ +#import "GBHubGameViewController.h" +#import "GBROMManager.h" +#import "UILabel+TapLocation.h" + +@implementation NSMutableAttributedString (append) + +- (void)appendWithAttributes:(NSDictionary *)attributes format:(NSString *)format, ... +{ + va_list args; + va_start(args, format); + NSString *string = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + [self appendAttributedString:[[NSAttributedString alloc] initWithString:string attributes:attributes]]; +} + +@end + +@implementation GBHubGameViewController +{ + GBHubGame *_game; + UIScrollView *_scrollView; + UIScrollView *_screenshotsScrollView; + UILabel *_titleLabel; + UILabel *_descriptionLabel; +} + +- (instancetype)initWithGame:(GBHubGame *)game +{ + self = [super init]; + _game = game; + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + UIColor *labelColor, *linkColor, *secondaryLabelColor; + if (@available(iOS 13.0, *)) { + self.view.backgroundColor = [UIColor systemBackgroundColor]; + labelColor = UIColor.labelColor; + linkColor = UIColor.linkColor; + secondaryLabelColor = UIColor.secondaryLabelColor; + + } + else { + self.view.backgroundColor = [UIColor whiteColor]; + labelColor = UIColor.blackColor; + linkColor = UIColor.blueColor; + secondaryLabelColor = [UIColor colorWithWhite:0.55 alpha:1.0]; + } + _scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds]; + _scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _scrollView.scrollEnabled = true; + _scrollView.pagingEnabled = false; + _scrollView.showsVerticalScrollIndicator = true; + _scrollView.showsHorizontalScrollIndicator = false; + [self.view addSubview:_scrollView]; + + _scrollView.contentSize = CGSizeMake(self.view.bounds.size.width, self.view.bounds.size.height * 2); + + _titleLabel = [[UILabel alloc] initWithFrame:(CGRectMake(0, 8, + self.view.bounds.size.width - 16, + 56))]; + NSMutableParagraphStyle *style = [NSParagraphStyle defaultParagraphStyle].mutableCopy; + style.paragraphSpacing = 4; + + NSMutableAttributedString *titleText = [[NSMutableAttributedString alloc] init]; + [titleText appendWithAttributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:34 weight:UIFontWeightBold], + NSForegroundColorAttributeName: labelColor, + NSParagraphStyleAttributeName: style, + } format:@"%@", _game.title]; + + [titleText appendWithAttributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:20], + NSForegroundColorAttributeName: secondaryLabelColor, + NSParagraphStyleAttributeName: style, + } format:@"\n by %@", _game.developer]; + + _titleLabel.attributedText = titleText; + _titleLabel.lineBreakMode = NSLineBreakByWordWrapping; + _titleLabel.numberOfLines = 0; + [_scrollView addSubview:_titleLabel]; + + + NSMutableAttributedString *text = [[NSMutableAttributedString alloc] init]; + NSDictionary *labelAttributes = @{ + NSFontAttributeName: [UIFont boldSystemFontOfSize:UIFont.labelFontSize], + NSForegroundColorAttributeName: labelColor, + }; + NSDictionary *valueAttributes = @{ + NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize], + NSForegroundColorAttributeName: labelColor, + }; + + if (_game.entryDescription) { + [text appendWithAttributes:valueAttributes format:@"%@\n\n", _game.entryDescription]; + } + if (_game.publicationDate) { + [text appendWithAttributes:labelAttributes format:@"Published: "]; + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + formatter.dateStyle = NSDateFormatterMediumStyle; + formatter.timeStyle = NSDateFormatterNoStyle; + formatter.locale = [NSLocale currentLocale]; + [text appendWithAttributes:valueAttributes format:@"%@\n", [formatter stringFromDate:_game.publicationDate]]; + } + if (_game.website) { + [text appendWithAttributes:labelAttributes format:@"Website: "]; + [text appendWithAttributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize], + @"GBLinkAttribute": _game.website, + NSForegroundColorAttributeName: linkColor, + } format:@"%@\n", _game.website]; + } + if (_game.repository) { + [text appendWithAttributes:labelAttributes format:@"Repository: "]; + [text appendWithAttributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize], + @"GBLinkAttribute": _game.repository, + NSForegroundColorAttributeName: linkColor, + } format:@"%@\n", _game.repository]; + } + if (_game.license) { + [text appendWithAttributes:labelAttributes format:@"License: "]; + [text appendWithAttributes:valueAttributes format:@"%@\n", _game.license]; + } + if (_game.tags.count) { + [text appendWithAttributes:labelAttributes format:@"Categories: "]; + bool first = true; + for (NSString *tag in _game.tags) { + if (!first) { + [text appendWithAttributes:valueAttributes format:@", ", _game.license]; + } + first = false; + [text appendWithAttributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize], + @"GBHubTag": tag, + NSForegroundColorAttributeName: linkColor, + } format:@"%@", tag]; + } + [text appendWithAttributes:valueAttributes format:@"\n"]; + } + + _descriptionLabel = [[UILabel alloc] init]; + _descriptionLabel.numberOfLines = 0; + _descriptionLabel.lineBreakMode = NSLineBreakByWordWrapping; + if (@available(iOS 14.0, *)) { + _descriptionLabel.lineBreakStrategy = NSLineBreakStrategyNone; + } + _descriptionLabel.attributedText = text; + [_scrollView addSubview:_descriptionLabel]; + + unsigned screenshotWidth = (unsigned)(MIN(self.view.bounds.size.width, self.view.bounds.size.height) - 16) / 160 * 160; + unsigned screenshotHeight = screenshotWidth / 160 * 144; + if (_game.screenshots.count) { + _screenshotsScrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, + self.view.bounds.size.width, + screenshotHeight + 8)]; + _screenshotsScrollView.scrollEnabled = true; + _screenshotsScrollView.pagingEnabled = false; + _screenshotsScrollView.showsVerticalScrollIndicator = false; + _screenshotsScrollView.showsHorizontalScrollIndicator = true; + + unsigned x = 0; + for (NSURL *url in _game.screenshots) { + x += 8; + UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(x, 0, + screenshotWidth, + screenshotHeight)]; + [imageView.layer setMinificationFilter:kCAFilterLinear]; + [imageView.layer setMagnificationFilter:kCAFilterNearest]; + imageView.layer.cornerRadius = 4; + imageView.layer.borderWidth = 1; + imageView.layer.masksToBounds = true; + + if (@available(iOS 13.0, *)) { + imageView.layer.borderColor = [UIColor tertiaryLabelColor].CGColor; + } + else { + imageView.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.5].CGColor; + } + + [_screenshotsScrollView addSubview:imageView]; + [[[NSURLSession sharedSession] downloadTaskWithURL:url completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) { + if (location) { + UIImage *image = [UIImage imageWithContentsOfFile:location.path]; + dispatch_async(dispatch_get_main_queue(), ^{ + CGRect frame = imageView.frame; + imageView.image = image; + imageView.frame = frame; + imageView.contentMode = UIViewContentModeScaleAspectFit; + }); + } + }] resume]; + x += screenshotWidth + 8; + } + _screenshotsScrollView.contentSize = CGSizeMake(x, screenshotHeight); + _screenshotsScrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin; + + [_scrollView addSubview:_screenshotsScrollView]; + } + [self viewDidLayoutSubviews]; + _descriptionLabel.userInteractionEnabled = true; + [_descriptionLabel addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(tappedLabel:)]]; + + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Download" + style:UIBarButtonItemStylePlain + target:self + action:@selector(rightButtonPressed)]; + if ([GBROMManager.sharedManager romFileForROM:_game.title]) { + self.navigationItem.rightBarButtonItem.title = @"Open"; + } +} + +- (void)rightButtonPressed +{ + if ([GBROMManager.sharedManager romFileForROM:_game.title]) { + [GBROMManager sharedManager].currentROM = _game.title; + [self.navigationController dismissViewControllerAnimated:true completion:nil]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"GBROMChanged" object:nil]; + } + else { + UIActivityIndicatorViewStyle style = UIActivityIndicatorViewStyleWhite; + if (@available(iOS 13.0, *)) { + style = UIActivityIndicatorViewStyleMedium; + } + UIActivityIndicatorView *view = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:style]; + CGRect frame = view.frame; + frame.size.width += 16; + view.frame = frame; + [view startAnimating]; + self.navigationItem.rightBarButtonItem.customView = view; + [[[NSURLSession sharedSession] downloadTaskWithURL:_game.file completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) { + if (!location) { + dispatch_async(dispatch_get_main_queue(), ^{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Could not download ROM" + message:@"Could not download this ROM from Homebrew Hub. Please try again later." + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Close" + style:UIAlertActionStyleCancel + handler:nil]]; + [self presentViewController:alert animated:true completion:nil]; + self.navigationItem.rightBarButtonItem.customView = nil; + }); + return; + } + NSString *newTempName = [[location.path stringByDeletingLastPathComponent] stringByAppendingPathComponent:_game.file.lastPathComponent]; + [[NSFileManager defaultManager] moveItemAtPath:location.path toPath:newTempName error:nil]; + [[GBROMManager sharedManager] importROM:newTempName withName:_game.title keepOriginal:false]; + dispatch_async(dispatch_get_main_queue(), ^{ + self.navigationItem.rightBarButtonItem.title = @"Open"; + self.navigationItem.rightBarButtonItem.customView = nil; + }); + }] resume]; + } +} + +- (void)tappedLabel:(UITapGestureRecognizer *)tap +{ + unsigned characterIndex = [(UILabel *)tap.view characterAtTap:tap]; + + NSURL *url = [((UILabel *)tap.view).attributedText attribute:@"GBLinkAttribute" atIndex:characterIndex effectiveRange:NULL]; + + if (url) { + [[UIApplication sharedApplication] openURL:url options:nil completionHandler:nil]; + return; + } + + NSString *tag = [((UILabel *)tap.view).attributedText attribute:@"GBHubTag" atIndex:characterIndex effectiveRange:NULL]; + + if (tag) { + UINavigationItem *parent = self.navigationController.navigationBar.items[self.navigationController.navigationBar.items.count - 2]; + [self.navigationController popViewControllerAnimated:true]; + if (@available(iOS 13.0, *)) { + parent.searchController.searchBar.searchTextField.text = @""; + parent.searchController.searchBar.searchTextField.tokens = nil; + UISearchToken *token = [UISearchToken tokenWithIcon:nil text:tag]; + token.representedObject = tag; + [parent.searchController.searchBar.searchTextField insertToken:token atIndex:0]; + } + else { + parent.searchController.searchBar.text = tag; + } + parent.searchController.active = true; + return; + } + +} + +- (void)viewDidLayoutSubviews +{ + unsigned y = 12; + CGSize size = [_titleLabel sizeThatFits:(CGSize){_scrollView.bounds.size.width - 32, INFINITY}];; + _titleLabel.frame = (CGRect){{16, y}, {_scrollView.bounds.size.width - 32, size.height}}; + y += size.height + 24; + + if (_screenshotsScrollView) { + _screenshotsScrollView.frame = CGRectMake(0, y, _scrollView.bounds.size.width, _screenshotsScrollView.frame.size.height); + y += _screenshotsScrollView.frame.size.height + 8; + if (_game.screenshots.count == 1) { + CGRect frame = _screenshotsScrollView.frame; + frame.origin.x = (_scrollView.bounds.size.width - _screenshotsScrollView.contentSize.width) / 2; + _screenshotsScrollView.frame = frame; + } + } + + size = [_descriptionLabel sizeThatFits:(CGSize){_scrollView.bounds.size.width - 32, INFINITY}];; + _descriptionLabel.frame = (CGRect){{16, y}, {_scrollView.bounds.size.width - 32, size.height}}; + y += size.height; + + _scrollView.contentSize = CGSizeMake(_scrollView.bounds.size.width, y); +} + +@end diff --git a/iOS/GBHubViewController.h b/iOS/GBHubViewController.h new file mode 100644 index 000000000..dea83f269 --- /dev/null +++ b/iOS/GBHubViewController.h @@ -0,0 +1,6 @@ +#import + +@interface GBHubViewController : UITableViewController + +@end + diff --git a/iOS/GBHubViewController.m b/iOS/GBHubViewController.m new file mode 100644 index 000000000..7cabe24e6 --- /dev/null +++ b/iOS/GBHubViewController.m @@ -0,0 +1,393 @@ +#import "GBHubViewController.h" +#import "GBHub.h" +#import "GBHubGameViewController.h" +#import "GBHubCell.h" +#import "UILabel+TapLocation.h" + +@interface GBHubViewController() +@end + +@implementation GBHubViewController +{ + UISearchController *_searchController; + NSMutableDictionary *_imageCache; + NSArray *_results; + NSString *_resultsTitle; + bool _showingAllGames; +} + +- (instancetype)init +{ + self = [self initWithStyle:UITableViewStyleGrouped]; + [[NSNotificationCenter defaultCenter] addObserver:self.tableView + selector:@selector(reloadData) + name:GBHubStatusChangedNotificationName + object:nil]; + _imageCache = [NSMutableDictionary dictionary]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + [GBHub.sharedHub refresh]; + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.navigationItem.searchController = + _searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; + _searchController.searchResultsUpdater = self; + self.tableView.scrollsToTop = true; + self.navigationItem.hidesSearchBarWhenScrolling = false; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UIColor *labelColor; + UIColor *secondaryLabelColor; + if (@available(iOS 13.0, *)) { + labelColor = UIColor.labelColor; + secondaryLabelColor = UIColor.secondaryLabelColor; + } + else { + labelColor = UIColor.blackColor; + secondaryLabelColor = [UIColor colorWithWhite:0.55 alpha:1.0]; + } + switch (GBHub.sharedHub.status) { + case GBHubStatusNotReady: return nil; + case GBHubStatusInProgress: { + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:0]; + UIActivityIndicatorViewStyle style = UIActivityIndicatorViewStyleWhite; + if (@available(iOS 13.0, *)) { + style = UIActivityIndicatorViewStyleMedium; + } + + UIActivityIndicatorView *spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:style]; + cell.bounds = spinner.bounds; + [cell addSubview:spinner]; + [spinner startAnimating]; + spinner.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + return cell; + } + case GBHubStatusReady: { + if (indexPath.section == 0) { + GBHubCell *cell = [[GBHubCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:0]; + cell.game = _results? _results[indexPath.item] : GBHub.sharedHub.showcaseGames[indexPath.item]; + NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:cell.game.title + attributes:@{ + NSFontAttributeName: [UIFont boldSystemFontOfSize:UIFont.labelFontSize], + NSForegroundColorAttributeName: labelColor + }]; + [text appendAttributedString:[[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@" by %@\n", + cell.game.developer] + attributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:UIFont.labelFontSize], + NSForegroundColorAttributeName: labelColor + }]]; + [text appendAttributedString:[[NSAttributedString alloc] initWithString:cell.game.entryDescription ?: [cell.game.tags componentsJoinedByString:@", "] + attributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:UIFont.smallSystemFontSize], + NSForegroundColorAttributeName: secondaryLabelColor + }]]; + cell.textLabel.attributedText = text; + cell.textLabel.numberOfLines = 2; + cell.textLabel.lineBreakMode = NSLineBreakByTruncatingTail; + + static UIImage *emptyImage = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + UIGraphicsBeginImageContextWithOptions((CGSize){60, 60}, false, tableView.window.screen.scale); + UIBezierPath *mask = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 3, 60, 54) cornerRadius:4]; + [mask addClip]; + [[UIColor whiteColor] set]; + [mask fill]; + if (@available(iOS 13.0, *)) { + [[UIColor tertiaryLabelColor] set]; + } + else { + [[UIColor colorWithWhite:0 alpha:0.5] set]; + } + [mask stroke]; + emptyImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + }); + cell.imageView.image = emptyImage; + return cell; + } + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:0]; + cell.selectionStyle = UITableViewCellSelectionStyleBlue; + NSString *tag = GBHub.sharedHub.sortedTags[indexPath.item]; + cell.textLabel.text = tag; + unsigned count = [GBHub.sharedHub countForTag:tag]; + if (count == 1) { + cell.detailTextLabel.text = @"1 Game"; + } + else { + cell.detailTextLabel.text = [NSString stringWithFormat:@"%u Games", count]; + } + cell.textLabel.numberOfLines = 2; + cell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; + return cell; + } + case GBHubStatusError: { + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:0]; + cell.textLabel.text = @"Could not connect to Homebrew Hub"; + return cell; + } + } + return nil; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + switch (GBHub.sharedHub.status) { + case GBHubStatusNotReady: return 0; + case GBHubStatusInProgress: return 1; + case GBHubStatusReady: { + if (_results) return _results.count; + if (section == 0) return GBHub.sharedHub.showcaseGames.count; + return GBHub.sharedHub.sortedTags.count; + } + case GBHubStatusError: return 1; + } + return 0; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + switch (GBHub.sharedHub.status) { + case GBHubStatusNotReady: return 0; + case GBHubStatusInProgress: return 1; + case GBHubStatusReady: return _results? 1 : 2; + case GBHubStatusError: return 1; + } + return 0; +} + +- (NSString *)title +{ + return @"Homebrew Hub"; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + if (GBHub.sharedHub.status != GBHubStatusReady) return nil; + if (section == 0) return _results? _resultsTitle : @"Homebrew Showcase"; + return @"Categories"; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (GBHub.sharedHub.status == GBHubStatusReady && indexPath.section == 0) { + return 60; + } + return 45; +} + +- (void)tableView:(UITableView *)tableView willDisplayCell:(GBHubCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (![cell isKindOfClass:[GBHubCell class]]) return; + if (!cell.game.screenshots.count) return; + + NSURL *url = cell.game.screenshots[0]; + UIImage *image = _imageCache[url]; + if ([image isKindOfClass:[UIImage class]]) { + cell.imageView.image = image; + return; + } + if (!image) { + _imageCache[url] = (id)[NSNull null]; + [[[NSURLSession sharedSession] downloadTaskWithURL:url + completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) { + if (!location) return; + dispatch_sync(dispatch_get_main_queue(), ^{ + UIGraphicsBeginImageContextWithOptions((CGSize){60, 60}, false, tableView.window.screen.scale); + UIBezierPath *mask = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 3, 60, 54) cornerRadius:4]; + [mask addClip]; + UIImage *image = [UIImage imageWithContentsOfFile:location.path]; + [image drawInRect:mask.bounds]; + if (@available(iOS 13.0, *)) { + [[UIColor tertiaryLabelColor] set]; + } + else { + [[UIColor colorWithWhite:0 alpha:0.5] set]; + } + [mask stroke]; + _imageCache[url] = cell.imageView.image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + }); + }] resume]; + } +} + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + static unsigned cookie = 0; + NSArray *keywords = [GBSearchCanonicalString([searchController.searchBar.text + stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]) + componentsSeparatedByString:@" "]; + if (keywords.count == 1 && keywords[0].length == 0) { + keywords = @[]; + } + NSArray *tokens = nil; + if (@available(iOS 13.0, *)) { + tokens = searchController.searchBar.searchTextField.tokens; + } + + if (!searchController.isActive && tokens.count == 0 && !keywords.count) { + cookie++; + _results = nil; + _showingAllGames = false; + [self.tableView reloadData]; + [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathWithIndexes:(NSUInteger[]){0,0} length:2] + atScrollPosition:UITableViewScrollPositionTop + animated:false]; + return; + } + if (tokens.count || keywords.count) { + _showingAllGames = false; + cookie++; + unsigned myCookie = cookie; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSMutableArray *results = [NSMutableArray array]; + for (GBHubGame *game in GBHub.sharedHub.allGames.allValues) { + bool matches = true; + if (@available(iOS 13.0, *)) { + for (UISearchToken *token in tokens) { + if (![game.tags containsObject:token.representedObject]) { + matches = false; + break; + } + } + if (!matches) continue; + } + for (NSString *keyword in keywords) { + if (keyword.length == 0) continue; + if (![game.keywords containsString:keyword]) { + matches = false; + break; + } + } + if (matches) { + [results addObject:game]; + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (myCookie != cookie) return; + if (tokens.count) { + if (searchController.searchBar.text.length) { + _resultsTitle = [NSString stringWithFormat:@"Showing %@ games matching “%@”", [tokens[0] representedObject], searchController.searchBar.text]; + } + else { + _resultsTitle = [NSString stringWithFormat:@"Showing %@ games", [tokens[0] representedObject]]; + } + } + else { + _resultsTitle = [NSString stringWithFormat:@"Showing results for “%@”", searchController.searchBar.text]; + } + _results = results; + _results = [results sortedArrayUsingComparator:^NSComparisonResult(GBHubGame *obj1, GBHubGame *obj2) { + return [obj1.title.lowercaseString compare:obj2.title.lowercaseString]; + }]; + [self.tableView reloadData]; + if (_results.count) { + [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathWithIndexes:(NSUInteger[]){0,0} length:2] + atScrollPosition:UITableViewScrollPositionTop + animated:false]; + } + }); + }); + } + else { + if (_showingAllGames) return; + cookie++; + _showingAllGames = true; + _resultsTitle = @"Showing all games"; + _results = [GBHub.sharedHub.allGames.allValues sortedArrayUsingComparator:^NSComparisonResult(GBHubGame *obj1, GBHubGame *obj2) { + return [obj1.title.lowercaseString compare:obj2.title.lowercaseString]; + }]; + [self.tableView reloadData]; + if (_results.count) { + [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathWithIndexes:(NSUInteger[]){0,0} length:2] + atScrollPosition:UITableViewScrollPositionTop + animated:false]; + } + return; + } +} + +- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (GBHub.sharedHub.status == GBHubStatusReady) return indexPath; + return nil; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (GBHub.sharedHub.status != GBHubStatusReady) return; + if (indexPath.section == 1) { + NSString *tag = GBHub.sharedHub.sortedTags[indexPath.item]; + if (@available(iOS 13.0, *)) { + UISearchToken *token = [UISearchToken tokenWithIcon:nil + text:tag]; + token.representedObject = tag; + [_searchController.searchBar.searchTextField insertToken:token + atIndex:0]; + } + else { + _searchController.searchBar.text = tag; + } + [_searchController setActive:true]; + return; + } + + GBHubCell *cell = [tableView cellForRowAtIndexPath:indexPath]; + if ([cell isKindOfClass:[GBHubCell class]]) { + GBHubGameViewController *controller = [[GBHubGameViewController alloc] initWithGame:cell.game]; + [self.navigationController pushViewController:controller animated:true]; + } +} + +- (void)tableView:(UITableView *)tableView willDisplayFooterView:(UIView *)view forSection:(NSInteger)section +{ + UIColor *linkColor; + if (@available(iOS 13.0, *)) { + linkColor = UIColor.linkColor; + + } + else { + linkColor = UIColor.blueColor; + } + + if (section != [self numberOfSectionsInTableView:nil] - 1) return; + UITableViewHeaderFooterView *footer = (UITableViewHeaderFooterView *)view; + NSMutableAttributedString *string = footer.textLabel.attributedText.mutableCopy; + + [string addAttributes:@{ + @"GBLinkAttribute": [NSURL URLWithString:@"https://hh.gbdev.io"], + NSForegroundColorAttributeName: linkColor, + } range:[string.string rangeOfString:@"Homebrew Hub"]]; + + footer.textLabel.attributedText = string; + footer.textLabel.userInteractionEnabled = true; + [footer.textLabel addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(tappedFooterLabel:)]]; +} + +- (void)tappedFooterLabel:(UITapGestureRecognizer *)tap +{ + unsigned characterIndex = [(UILabel *)tap.view characterAtTap:tap]; + + NSURL *url = [((UILabel *)tap.view).attributedText attribute:@"GBLinkAttribute" atIndex:characterIndex effectiveRange:NULL]; + + if (url) { + [[UIApplication sharedApplication] openURL:url options:nil completionHandler:nil]; + } +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + if (section != [self numberOfSectionsInTableView:tableView] - 1) return nil; + return @"Powered by Homebrew Hub"; +} +@end diff --git a/iOS/GBLoadROMTableViewController.m b/iOS/GBLoadROMTableViewController.m index 97f42f5c6..d0a63f01e 100644 --- a/iOS/GBLoadROMTableViewController.m +++ b/iOS/GBLoadROMTableViewController.m @@ -1,6 +1,7 @@ #import "GBLoadROMTableViewController.h" #import "GBROMManager.h" #import "GBViewController.h" +#import "GBHubViewController.h" #import #import @@ -32,7 +33,7 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { - if (section == 1) return 2; + if (section == 1) return 3; return [GBROMManager sharedManager].allROMs.count; } @@ -42,7 +43,8 @@ UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; switch (indexPath.item) { case 0: cell.textLabel.text = @"Import ROM files"; break; - case 1: cell.textLabel.text = @"Show Library in Files"; break; + case 1: cell.textLabel.text = @"Browse Homebrew Hub"; break; + case 2: cell.textLabel.text = @"Show Library in Files"; break; } return cell; } @@ -139,6 +141,11 @@ return; } case 1: { + [self.navigationController pushViewController:[[GBHubViewController alloc] init] + animated:true]; + return; + } + case 2: { [[UIApplication sharedApplication] openURL:[NSURL URLWithString:[NSString stringWithFormat:@"shareddocuments://%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject]] options:nil completionHandler:nil]; @@ -323,4 +330,9 @@ contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath } } +- (void)viewWillAppear:(BOOL)animated +{ + [self.tableView reloadData]; +} + @end diff --git a/iOS/GBROMManager.h b/iOS/GBROMManager.h index 6c76929bc..4c71538ff 100644 --- a/iOS/GBROMManager.h +++ b/iOS/GBROMManager.h @@ -16,6 +16,7 @@ - (NSString *)autosaveStateFileForROM:(NSString *)rom; - (NSString *)stateFile:(unsigned)index forROM:(NSString *)rom; - (NSString *)importROM:(NSString *)romFile keepOriginal:(bool)keep; +- (NSString *)importROM:(NSString *)romFile withName:(NSString *)friendlyName keepOriginal:(bool)keep; - (NSString *)renameROM:(NSString *)rom toName:(NSString *)newName; - (NSString *)duplicateROM:(NSString *)rom; - (void)deleteROM:(NSString *)rom; diff --git a/iOS/UILabel+TapLocation.h b/iOS/UILabel+TapLocation.h new file mode 100644 index 000000000..168bdeb6f --- /dev/null +++ b/iOS/UILabel+TapLocation.h @@ -0,0 +1,5 @@ +#import + +@interface UILabel (TapLocation) +- (unsigned)characterAtTap:(UITapGestureRecognizer *)tap; +@end diff --git a/iOS/UILabel+TapLocation.m b/iOS/UILabel+TapLocation.m new file mode 100644 index 000000000..d797568e7 --- /dev/null +++ b/iOS/UILabel+TapLocation.m @@ -0,0 +1,27 @@ +#import "UILabel+TapLocation.h" + +@implementation UILabel (TapLocation) + +- (unsigned)characterAtTap:(UITapGestureRecognizer *)tap +{ + CGPoint tapLocation = [tap locationInView:self]; + + NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:self.attributedText]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + [textStorage addLayoutManager:layoutManager]; + + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(self.frame.size.width, + self.frame.size.height + 256)]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = 256; + textContainer.lineBreakMode = self.lineBreakMode; + + [layoutManager addTextContainer:textContainer]; + + return [layoutManager characterIndexForPoint:tapLocation + inTextContainer:textContainer + fractionOfDistanceBetweenInsertionPoints:NULL]; + +} + +@end