mirror of
https://github.com/bsnes-emu/bsnes.git
synced 2025-09-02 14:42:47 +02:00
Homebrew Hub integration in iOS
This commit is contained in:
41
iOS/GBHub.h
Normal file
41
iOS/GBHub.h
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
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 <NSString *> *tags;
|
||||||
|
@property (readonly) NSURL *repository;
|
||||||
|
@property (readonly) NSURL *website;
|
||||||
|
@property (readonly) NSArray <NSURL *> *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<NSString *, GBHubGame *> *allGames;
|
||||||
|
@property (readonly) NSArray<NSString *> *sortedTags;
|
||||||
|
@property (readonly) NSArray<GBHubGame *> *showcaseGames;
|
||||||
|
@end
|
339
iOS/GBHub.m
Normal file
339
iOS/GBHub.m
Normal file
@@ -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<NSString *, GBHubGame *> *_allGames;
|
||||||
|
NSMutableDictionary<NSString *, NSNumber *> *_tags;
|
||||||
|
NSMutableArray<GBHubGame *> *_showcaseGames;
|
||||||
|
NSSet<NSString *> *_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
|
8
iOS/GBHubCell.h
Normal file
8
iOS/GBHubCell.h
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#import "GBHub.h"
|
||||||
|
|
||||||
|
@interface GBHubCell : UITableViewCell
|
||||||
|
|
||||||
|
@property GBHubGame *game;
|
||||||
|
|
||||||
|
@end
|
4
iOS/GBHubCell.m
Normal file
4
iOS/GBHubCell.m
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#import "GBHubCell.h"
|
||||||
|
|
||||||
|
@implementation GBHubCell
|
||||||
|
@end
|
7
iOS/GBHubGameViewController.h
Normal file
7
iOS/GBHubGameViewController.h
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#import "GBHub.h"
|
||||||
|
|
||||||
|
@interface GBHubGameViewController : UIViewController
|
||||||
|
- (instancetype)initWithGame:(GBHubGame *)game;
|
||||||
|
@end
|
||||||
|
|
315
iOS/GBHubGameViewController.m
Normal file
315
iOS/GBHubGameViewController.m
Normal file
@@ -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
|
6
iOS/GBHubViewController.h
Normal file
6
iOS/GBHubViewController.h
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
@interface GBHubViewController : UITableViewController
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
393
iOS/GBHubViewController.m
Normal file
393
iOS/GBHubViewController.m
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
#import "GBHubViewController.h"
|
||||||
|
#import "GBHub.h"
|
||||||
|
#import "GBHubGameViewController.h"
|
||||||
|
#import "GBHubCell.h"
|
||||||
|
#import "UILabel+TapLocation.h"
|
||||||
|
|
||||||
|
@interface GBHubViewController() <UISearchResultsUpdating>
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation GBHubViewController
|
||||||
|
{
|
||||||
|
UISearchController *_searchController;
|
||||||
|
NSMutableDictionary<NSURL *, UIImage *> *_imageCache;
|
||||||
|
NSArray<GBHubGame *> *_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<NSString *> *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<GBHubGame *> *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
|
@@ -1,6 +1,7 @@
|
|||||||
#import "GBLoadROMTableViewController.h"
|
#import "GBLoadROMTableViewController.h"
|
||||||
#import "GBROMManager.h"
|
#import "GBROMManager.h"
|
||||||
#import "GBViewController.h"
|
#import "GBViewController.h"
|
||||||
|
#import "GBHubViewController.h"
|
||||||
#import <CoreServices/CoreServices.h>
|
#import <CoreServices/CoreServices.h>
|
||||||
#import <objc/runtime.h>
|
#import <objc/runtime.h>
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
|
|
||||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||||
{
|
{
|
||||||
if (section == 1) return 2;
|
if (section == 1) return 3;
|
||||||
return [GBROMManager sharedManager].allROMs.count;
|
return [GBROMManager sharedManager].allROMs.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@
|
|||||||
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
|
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
|
||||||
switch (indexPath.item) {
|
switch (indexPath.item) {
|
||||||
case 0: cell.textLabel.text = @"Import ROM files"; break;
|
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;
|
return cell;
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 1: {
|
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]]
|
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:[NSString stringWithFormat:@"shareddocuments://%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject]]
|
||||||
options:nil
|
options:nil
|
||||||
completionHandler:nil];
|
completionHandler:nil];
|
||||||
@@ -323,4 +330,9 @@ contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)viewWillAppear:(BOOL)animated
|
||||||
|
{
|
||||||
|
[self.tableView reloadData];
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
@@ -16,6 +16,7 @@
|
|||||||
- (NSString *)autosaveStateFileForROM:(NSString *)rom;
|
- (NSString *)autosaveStateFileForROM:(NSString *)rom;
|
||||||
- (NSString *)stateFile:(unsigned)index forROM:(NSString *)rom;
|
- (NSString *)stateFile:(unsigned)index forROM:(NSString *)rom;
|
||||||
- (NSString *)importROM:(NSString *)romFile keepOriginal:(bool)keep;
|
- (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 *)renameROM:(NSString *)rom toName:(NSString *)newName;
|
||||||
- (NSString *)duplicateROM:(NSString *)rom;
|
- (NSString *)duplicateROM:(NSString *)rom;
|
||||||
- (void)deleteROM:(NSString *)rom;
|
- (void)deleteROM:(NSString *)rom;
|
||||||
|
5
iOS/UILabel+TapLocation.h
Normal file
5
iOS/UILabel+TapLocation.h
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
@interface UILabel (TapLocation)
|
||||||
|
- (unsigned)characterAtTap:(UITapGestureRecognizer *)tap;
|
||||||
|
@end
|
27
iOS/UILabel+TapLocation.m
Normal file
27
iOS/UILabel+TapLocation.m
Normal file
@@ -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
|
Reference in New Issue
Block a user