diff --git a/WHATSNEW.md b/WHATSNEW.md index 63b74616..5fefba34 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -1,3 +1,9 @@ +# Version 2022.3 +* The Recently Played section now scrolls horizontally and supports up to 100 entries. +* Updated the expanding/collapsing UI so that it can be expanded/collapsed using a +/- button on the right side, instead of a chevron in the header. You can still tap the header to expand/collapse as well. +* Added prefetching support: images are now cached using prefetching for improved performance. +* Changed font of header to be Bold instead of Heavy + # Version 2022.2 * Updated to [MAME 244](https://www.mamedev.org/releases/whatsnew_0244.txt). * hi-res font for `MAME` Config Menu. diff --git a/xcode/MAME4iOS/ChooseGameController+Prefetching.swift b/xcode/MAME4iOS/ChooseGameController+Prefetching.swift new file mode 100644 index 00000000..76623283 --- /dev/null +++ b/xcode/MAME4iOS/ChooseGameController+Prefetching.swift @@ -0,0 +1,26 @@ +// +// ChooseGameController+Prefetching.swift +// MAME4iOS +// +// Created by Yoshi Sugawara on 5/31/22. +// Copyright © 2022 MAME4iOS Team. All rights reserved. +// + +extension ChooseGameController: UICollectionViewDataSourcePrefetching { + public func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + print("ChooseGameController: Start prefetch of images for indexPaths: \(indexPaths)") + for indexPath in indexPaths { + guard let game = getGameInfo(indexPath), + let imageUrl = game.gameImageURLs.first else { + continue + } + guard ImageCache.sharedInstance().getImage(imageUrl) == nil else { + print("image already in cache, not fetching...") + continue + } + ImageCache.sharedInstance().getImage(imageUrl, localURL: game.gameLocalImageURL) { _ in + print("Prefetch: fetched image and put in cache") + } + } + } +} diff --git a/xcode/MAME4iOS/ChooseGameController.h b/xcode/MAME4iOS/ChooseGameController.h index a1532c63..8e7eb525 100644 --- a/xcode/MAME4iOS/ChooseGameController.h +++ b/xcode/MAME4iOS/ChooseGameController.h @@ -30,6 +30,7 @@ NS_ASSUME_NONNULL_BEGIN +(NSAttributedString*)getGameText:(GameInfo*)game; +(UIImage*)getGameIcon:(GameInfo*)game; +-( GameInfo* _Nullable)getGameInfo:(NSIndexPath*)indexPath; @end diff --git a/xcode/MAME4iOS/ChooseGameController.m b/xcode/MAME4iOS/ChooseGameController.m index 084acaab..18381999 100644 --- a/xcode/MAME4iOS/ChooseGameController.m +++ b/xcode/MAME4iOS/ChooseGameController.m @@ -102,7 +102,7 @@ #define SCOPE_MODE_KEY @"ScopeMode" #define SCOPE_MODE_DEFAULT @"System" #define ALL_SCOPES @[@"System", @"Software", @"Clones", @"Manufacturer", @"Year", @"Genre", @"Driver"] -#define RECENT_GAMES_MAX 8 +#define RECENT_GAMES_MAX 100 #define SELECTED_GAME_KEY @"SelectedGame" #define SELECTED_GAME_SECTION_KEY @"SelectedGameSection" @@ -330,11 +330,13 @@ - (void)viewDidLoad // collection view [self.collectionView registerClass:[GameInfoCell class] forCellWithReuseIdentifier:CELL_IDENTIFIER]; [self.collectionView registerClass:[GameInfoHeader class] forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:HEADER_IDENTIFIER]; + [self.collectionView registerClass:[RecentlyPlayedCell class] forCellWithReuseIdentifier:[RecentlyPlayedCell identifier]]; self.collectionView.backgroundColor = BACKGROUND_COLOR; self.collectionView.allowsMultipleSelection = NO; self.collectionView.allowsSelection = YES; self.collectionView.alwaysBounceVertical = YES; + self.collectionView.prefetchDataSource = self; #if TARGET_OS_IOS // we do our own navigation via game controllers @@ -811,6 +813,10 @@ - (void)filterGameList } } +-(BOOL)hasRecentlyPlayed { + return [_gameData objectForKey:RECENT_GAMES_TITLE] != nil; +} + #pragma mark - UISearchResultsUpdating - (void)updateSearchResultsForSearchController:(UISearchController *)searchController @@ -1131,12 +1137,15 @@ - (void)setCollapsed:(NSString*)title isCollapsed:(BOOL)flag [NSUserDefaults.standardUserDefaults setObject:state forKey:COLLAPSED_STATE_KEY]; } --(void)headerTap:(UITapGestureRecognizer*)sender +-(void)headerTap:(UITapGestureRecognizer*)sender { + NSInteger section = sender.view.tag; + [self headerTapForSection:section]; +} + +-(void)headerTapForSection:(NSInteger)section { - NSLog(@"HEADER TAP: %d", (int)sender.view.tag); if (_isSearchResults) return; - NSInteger section = sender.view.tag; if (section >= 0 && section < _gameSectionTitles.count) { NSString* title = _gameSectionTitles[section]; @@ -1160,13 +1169,13 @@ - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSe NSString* title = _gameSectionTitles[section]; if ([self isCollapsed:title]) return 0; + if ([self hasRecentlyPlayed] && section == 0) + return 1; NSInteger num = [_gameData[title] count]; - // restrict the Recent items to a single row, always - if ([title isEqualToString:RECENT_GAMES_TITLE]) - num = MIN(num, _layoutCollums); return num; } --(GameInfo*)getGameInfo:(NSIndexPath*)indexPath + +-( GameInfo* _Nullable)getGameInfo:(NSIndexPath*)indexPath { if (indexPath.section >= _gameSectionTitles.count) return nil; @@ -1293,15 +1302,16 @@ -(NSAttributedString*)getGameText:(GameInfo*)game clone:game.gameParent.length != 0]; } -// compute the size(s) of a single item. returns: (x = image_height, y = text_height) -- (CGPoint)heightForItemAtIndexPath:(NSIndexPath *)indexPath -{ - UICollectionViewFlowLayout* layout = (UICollectionViewFlowLayout*)self.collectionView.collectionViewLayout; - GameInfo* game = [self getGameInfo:indexPath]; +// compute the size(s) of a single game. returns: (x = image_height, y = text_height) +-(CGPoint)heightForGameInfo:(GameInfo*)game usingLayout:(nullable UICollectionViewLayout*)layout { + UICollectionViewFlowLayout *flowLayout = (UICollectionViewFlowLayout*)self.collectionView.collectionViewLayout; + if (layout) { + flowLayout = (UICollectionViewFlowLayout*)layout; + } NSAttributedString* text = [self getGameText:game]; // start with the (itemSize.width,0.0) - CGFloat item_width = layout.itemSize.width; + CGFloat item_width = flowLayout.itemSize.width; CGFloat image_height, text_height; // get the screen, assume the game is 4:3 if we dont know. @@ -1331,6 +1341,14 @@ - (CGPoint)heightForItemAtIndexPath:(NSIndexPath *)indexPath return CGPointMake(image_height, text_height); } +// compute the size(s) of a single item. returns: (x = image_height, y = text_height) +- (CGPoint)heightForItemAtIndexPath:(NSIndexPath *)indexPath +{ + UICollectionViewFlowLayout* layout = (UICollectionViewFlowLayout*)self.collectionView.collectionViewLayout; + GameInfo* game = [self getGameInfo:indexPath]; + return [self heightForGameInfo:game usingLayout:layout]; +} + // compute (or return from cache) the height(s) of a single row. // returns: (x = image_height, y = text_height) - (CGPoint)heightForRowAtIndexPath:(NSIndexPath *)indexPath @@ -1422,9 +1440,7 @@ +(UIImage*)getGameIcon:(GameInfo*)game return image ?: [self makeIcon:game]; } -// get size of an item -- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewFlowLayout *)layout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { - +-(CGSize)getGameInfoItemSizeFor:(UICollectionViewFlowLayout*)layout at:(NSIndexPath*)indexPath { if (_layoutMode == LayoutList || _layoutCollums == 0) return layout.itemSize; @@ -1432,18 +1448,50 @@ - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollection return CGSizeMake(layout.itemSize.width, row_height.x + row_height.y); } +-(UICollectionViewFlowLayout*)layoutForRecentlyPlayedCell { + if (_layoutMode != LayoutList) { + return (UICollectionViewFlowLayout*) self.collectionView.collectionViewLayout; + } + UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; + layout.scrollDirection = UICollectionViewScrollDirectionVertical; + layout.sectionInset = UIEdgeInsetsMake(SECTION_INSET_Y, SECTION_INSET_X, SECTION_INSET_Y, SECTION_INSET_X); + layout.minimumLineSpacing = SECTION_LINE_SPACING; + layout.minimumInteritemSpacing = SECTION_ITEM_SPACING; + layout.sectionHeadersPinToVisibleBounds = YES; +#if TARGET_OS_MACCATALYST + CGFloat height = [UIFont preferredFontForTextStyle:UIFontTextStyleLargeTitle].pointSize * 1.5; +#elif TARGET_OS_IOS + CGFloat height = [UIFont preferredFontForTextStyle:UIFontTextStyleLargeTitle].pointSize; +#else + CGFloat height = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline].pointSize * 1.5; +#endif + layout.headerReferenceSize = CGSizeMake(height, height); + layout.sectionInsetReference = UICollectionViewFlowLayoutSectionInsetFromSafeArea; + layout.itemSize = CGSizeMake(CELL_SMALL_WIDTH, height); + return layout; +} + +// get size of an item +- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewFlowLayout *)layout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { + if ([self hasRecentlyPlayed] && indexPath.section == 0) { + NSArray *items = _gameData[_gameSectionTitles[0]]; + CGFloat maxHeight = 0; + for (GameInfo *game in items) { + CGPoint heightInfo = [self heightForGameInfo:game usingLayout:[self layoutForRecentlyPlayedCell]]; + maxHeight = MAX(maxHeight, heightInfo.x + heightInfo.y); + } + return CGSizeMake(collectionView.frame.size.width, maxHeight); + } + return [self getGameInfoItemSizeFor:layout at:indexPath]; +} + // convert an IndexPath to a non-zero NSInteger, and back #define INDEXPATH_TO_INT(indexPath) ((indexPath.section << 24) | (indexPath.item+1)) #define INT_TO_INDEXPATH(i) [NSIndexPath indexPathForItem:((i) & 0xFFFFFF)-1 inSection:(i) >> 24] -// create a cell for an item. -- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath -{ - NSLog(@"cellForItemAtIndexPath: %d.%d %@", (int)indexPath.section, (int)indexPath.item, [self getGameInfo:indexPath].gameName); - +-(void)setupGameInfoCell:(GameInfoCell*)cell forIndexPath:(NSIndexPath*)indexPath { + BOOL isRecentlyPlayedSection = [self hasRecentlyPlayed] && indexPath.section == 0; GameInfo* game = [self getGameInfo:indexPath]; - - GameInfoCell* cell = [collectionView dequeueReusableCellWithReuseIdentifier:CELL_IDENTIFIER forIndexPath:indexPath]; [cell setBackgroundColor:CELL_BACKGROUND_COLOR]; cell.text.attributedText = [self getGameText:game]; @@ -1457,7 +1505,7 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cell cell.text.numberOfLines = CELL_MAX_LINES; cell.text.lineBreakMode = NSLineBreakByTruncatingTail; } - if (_layoutMode == LayoutTiny || _layoutMode == LayoutList) { + if ((_layoutMode == LayoutTiny || _layoutMode == LayoutList) && !isRecentlyPlayedSection) { [cell setCornerRadius:CELL_CORNER_RADIUS / 2]; } else { @@ -1469,11 +1517,15 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cell CGFloat scale = 1.0 + (space * 1.5 / cell.bounds.size.width); [cell setSelectScale:scale]; - [cell setHorizontal:_layoutMode == LayoutList]; + [cell setHorizontal:isRecentlyPlayedSection ? NO : _layoutMode == LayoutList]; [cell setTextInsets:UIEdgeInsetsMake(CELL_INSET_Y, CELL_INSET_X, CELL_INSET_Y, CELL_INSET_X)]; CGFloat image_height = 0.0; - if (_layoutMode != LayoutList && _layoutCollums > 1) { + if (isRecentlyPlayedSection) { + // For the recently played section, don't use the layout-based cached image heights + CGPoint heightInfo = [self heightForGameInfo:game usingLayout:[self layoutForRecentlyPlayedCell]]; + image_height = heightInfo.x; + } else if (_layoutMode != LayoutList && _layoutCollums > 1) { image_height = [self heightForRowAtIndexPath:indexPath].x; } @@ -1520,7 +1572,7 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cell // the title image is force a aspect of 3:4 or 4:3 BOOL is_vert = [game.gameScreen containsString:kGameInfoScreenVertical]; - if (self->_layoutMode == LayoutList) { + if (self->_layoutMode == LayoutList && !isRecentlyPlayedSection) { CGFloat aspect = 4.0 / 3.0; [cell setImageAspect:aspect]; cell.image.contentMode = is_vert ? UIViewContentModeScaleAspectFill : UIViewContentModeScaleToFill; @@ -1549,13 +1601,45 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cell [cell setImageAspect:(cell.bounds.size.width / image_height)]; [cell startWait]; } - #if TARGET_OS_IOS if ([self getSelection] == indexPath) { cell.selected = YES; } #endif +} +// create a cell for an item. +- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath +{ + NSLog(@"cellForItemAtIndexPath: %d.%d %@", (int)indexPath.section, (int)indexPath.item, [self getGameInfo:indexPath].gameName); + + if ([self hasRecentlyPlayed] && indexPath.section == 0) { + RecentlyPlayedCell *recentCell = [collectionView dequeueReusableCellWithReuseIdentifier:[RecentlyPlayedCell identifier] forIndexPath:indexPath]; + NSArray *items = _gameData[_gameSectionTitles[0]]; + recentCell.setupCellClosure = ^(GameInfoCell* gameInfoCell, NSIndexPath* indexPath) { + [self setupGameInfoCell:gameInfoCell forIndexPath:indexPath]; + }; + recentCell.selectItemClosure = ^(NSIndexPath* indexPath) { + [self didSelectItemAtIndexPath:indexPath]; + }; + recentCell.contextMenuClosure = ^UIContextMenuConfiguration * _Nullable(NSIndexPath * _Nonnull indexPath) { + return [self setupGameInfoContextMenuAtIndexPath:indexPath]; + }; + recentCell.heightForGameInfoClosure = ^CGPoint(GameInfo * _Nonnull gameInfo, UICollectionViewLayout * _Nonnull layout) { + return [self heightForGameInfo:gameInfo usingLayout:layout]; + }; + UICollectionViewFlowLayout *layout = (UICollectionViewFlowLayout*) collectionView.collectionViewLayout; + if (_layoutMode == LayoutList) { + recentCell.itemSize = CGSizeMake(CELL_SMALL_WIDTH, layout.itemSize.height); + } else { + recentCell.itemSize = layout.itemSize; + } + recentCell.items = items; + return recentCell; + } + + GameInfoCell* cell = [collectionView dequeueReusableCellWithReuseIdentifier:CELL_IDENTIFIER forIndexPath:indexPath]; + [self setupGameInfoCell:cell forIndexPath:indexPath]; return cell; } @@ -1606,34 +1690,34 @@ - (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView [cell setHorizontal:TRUE]; NSString* title = _gameSectionTitles[indexPath.section]; cell.text.text = title; - cell.text.font = [UIFont systemFontOfSize:cell.bounds.size.height * 0.8 weight:UIFontWeightHeavy]; + cell.text.font = [UIFont systemFontOfSize:cell.bounds.size.height * 0.8 weight:UIFontWeightBold]; cell.text.textColor = HEADER_TEXT_COLOR; cell.backgroundColor = HEADER_BACKGROUND_COLOR; [cell setTextInsets:UIEdgeInsetsMake(2.0, self.view.safeAreaInsets.left + 2.0, 2.0, self.view.safeAreaInsets.right + 2.0)]; // make the section title tappable to toggle collapse/expand section - if (@available(iOS 13.0, tvOS 13.0, *)) { - BOOL is_collapsed = [self isCollapsed:title]; - - // dont allow collapse if we only have a single (+MAME) section - if (!_isSearchResults && (_gameSectionTitles.count >= 2 || is_collapsed)) - { - // only show a chevron if collapsed? - if (is_collapsed) - { - NSString* str = [NSString stringWithFormat:@"%@ :%@:", cell.text.text, is_collapsed ? @"chevron.right" : @"chevron.down"]; - cell.text.attributedText = attributedString(str, cell.text.font, cell.text.textColor); - } - // install tap handler to toggle collapsed - cell.tag = indexPath.section; - [cell addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(headerTap:)]]; - } + BOOL is_collapsed = [self isCollapsed:title]; + + [cell.expandCollapseButton setHidden:YES]; + + // dont allow collapse if we only have a single (+MAME) section + if (!_isSearchResults && (_gameSectionTitles.count >= 2 || is_collapsed)) + { + // only show a chevron if collapsed? + [cell.expandCollapseButton setHidden:NO]; + [cell.expandCollapseButton setSelected:is_collapsed]; + // install tap handler to toggle collapsed + cell.tag = indexPath.section; + [cell addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(headerTap:)]]; + cell.didToggleClosure = ^{ + [self headerTapForSection:indexPath.section]; + }; } return cell; } -- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath -{ + +-(void)didSelectItemAtIndexPath:(NSIndexPath*)indexPath { GameInfo* game = [self getGameInfo:indexPath]; NSLog(@"DID SELECT ITEM[%d.%d] %@", (int)indexPath.section, (int)indexPath.item, game.gameName); @@ -1643,6 +1727,11 @@ - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPa [self play:game]; } +- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath +{ + [self didSelectItemAtIndexPath:indexPath]; +} + #pragma mark - play game -(void)play:(GameInfo*)game @@ -2108,8 +2197,7 @@ - (NSString*)menuTitleForItemAtIndexPath:(NSIndexPath *)indexPath { #if TARGET_OS_IOS -- (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionView contextMenuConfigurationForItemAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) { - +-(UIContextMenuConfiguration *)setupGameInfoContextMenuAtIndexPath:(NSIndexPath*)indexPath { [self.collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; UIViewController* menu = [self menuForItemAtIndexPath:indexPath]; @@ -2123,6 +2211,10 @@ - (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionVie } ]; } + +- (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionView contextMenuConfigurationForItemAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) { + return [self setupGameInfoContextMenuAtIndexPath:indexPath]; +} #endif #pragma mark - LongPress menu (pre iOS 13 and tvOS only) diff --git a/xcode/MAME4iOS/Collection+Safe.swift b/xcode/MAME4iOS/Collection+Safe.swift new file mode 100644 index 00000000..b90f7ce5 --- /dev/null +++ b/xcode/MAME4iOS/Collection+Safe.swift @@ -0,0 +1,16 @@ +// +// Collection+Safe.swift +// MAME4iOS +// +// Created by Yoshi Sugawara on 5/31/22. +// Copyright © 2022 MAME4iOS Team. All rights reserved. +// + +import Foundation + +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/xcode/MAME4iOS/GameInfoCell.swift b/xcode/MAME4iOS/GameInfoCell.swift index 404cc4c7..c6877a70 100644 --- a/xcode/MAME4iOS/GameInfoCell.swift +++ b/xcode/MAME4iOS/GameInfoCell.swift @@ -137,7 +137,7 @@ class GameInfoCell : UICollectionViewCell { override func layoutSubviews() { super.layoutSubviews() - var rect = bounds + var rect = bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 32)) if let size = image.image?.size, size != .zero { // use imageAspect unless it is zero @@ -243,11 +243,45 @@ class GameInfoCell : UICollectionViewCell { // MARK: GameInfoHeader - same as GameInfoCell class GameInfoHeader : GameInfoCell { + let expandCollapseButton: UIButton = { + let button = UIButton(type: .custom) + button.translatesAutoresizingMaskIntoConstraints = false + button.contentMode = .scaleAspectFill + button.contentHorizontalAlignment = .fill + button.contentVerticalAlignment = .fill + button.setImage(UIImage(systemName: "minus.square"), for: .normal) + button.setImage(UIImage(systemName: "plus.square"), for: .selected) + button.heightAnchor.constraint(equalToConstant: 28).isActive = true + button.widthAnchor.constraint(equalTo: button.heightAnchor).isActive = true + return button + }() + #if os(tvOS) override var canBecomeFocused: Bool { return true } #endif + + var didToggleClosure: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(expandCollapseButton) + NSLayoutConstraint.activate([ + expandCollapseButton.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -8), + expandCollapseButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + expandCollapseButton.addTarget(self, action: #selector(expandCollapseButtonPressed(_:)), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func expandCollapseButtonPressed(_ sender: UIButton) { + sender.isSelected.toggle() + didToggleClosure?() + } } // MARK: GameInfoCellLayout - a subclass that will invalidate the layout (for real) on a size change diff --git a/xcode/MAME4iOS/MAME4iOS-Bridging-Header.h b/xcode/MAME4iOS/MAME4iOS-Bridging-Header.h index a6b04477..6f0471f3 100644 --- a/xcode/MAME4iOS/MAME4iOS-Bridging-Header.h +++ b/xcode/MAME4iOS/MAME4iOS-Bridging-Header.h @@ -7,6 +7,7 @@ #import "InfoDatabase.h" #import "ChooseGameController.h" #import "EmulatorController.h" +#import "ImageCache.h" // Globals.h stuff needed from Swift NS_ASSUME_NONNULL_BEGIN diff --git a/xcode/MAME4iOS/MAME4iOS.xcconfig b/xcode/MAME4iOS/MAME4iOS.xcconfig index 02670293..c4e7c4e6 100644 --- a/xcode/MAME4iOS/MAME4iOS.xcconfig +++ b/xcode/MAME4iOS/MAME4iOS.xcconfig @@ -17,8 +17,8 @@ ORG_IDENTIFIER = com.example // CHANGE this to your Organization Identifier. DEVELOPMENT_TEAM = ABC8675309 // CHANGE this to your Team ID. (or select in Xcode project editor) -CURRENT_PROJECT_VERSION = 2022.2 -MARKETING_VERSION = 2022.2 +CURRENT_PROJECT_VERSION = 2022.3 +MARKETING_VERSION = 2022.3 // 2. enable or disable entitlements // tvOS TopShelf and iCloud import/export require special app entitlements diff --git a/xcode/MAME4iOS/MAME4iOS.xcodeproj/project.pbxproj b/xcode/MAME4iOS/MAME4iOS.xcodeproj/project.pbxproj index eaaad3e3..7ef7fced 100644 --- a/xcode/MAME4iOS/MAME4iOS.xcodeproj/project.pbxproj +++ b/xcode/MAME4iOS/MAME4iOS.xcodeproj/project.pbxproj @@ -32,6 +32,10 @@ 926C771D21F5034100103EDE /* TVInputOptionsController.m in Sources */ = {isa = PBXBuildFile; fileRef = 926C771C21F5034100103EDE /* TVInputOptionsController.m */; }; 928F7B2D27D5F18E00377C40 /* CommandLineArgsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928F7B2C27D5F18E00377C40 /* CommandLineArgsHelper.swift */; }; 928F7B2E27F0223100377C40 /* CommandLineArgsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928F7B2C27D5F18E00377C40 /* CommandLineArgsHelper.swift */; }; + 9293C61D284681E3002729B4 /* ChooseGameController+Prefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9293C61C284681E3002729B4 /* ChooseGameController+Prefetching.swift */; }; + 9293C61E284681E3002729B4 /* ChooseGameController+Prefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9293C61C284681E3002729B4 /* ChooseGameController+Prefetching.swift */; }; + 9293C62028475AB4002729B4 /* Collection+Safe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9293C61F28475AB4002729B4 /* Collection+Safe.swift */; }; + 9293C62128475AB4002729B4 /* Collection+Safe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9293C61F28475AB4002729B4 /* Collection+Safe.swift */; }; 9296524C27FC1FF30064D1F5 /* EmulatorTouchMouse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9296524B27FC1FF30064D1F5 /* EmulatorTouchMouse.swift */; }; 9296524E27FC20330064D1F5 /* EmulatorController+TouchMouse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9296524D27FC20330064D1F5 /* EmulatorController+TouchMouse.swift */; }; 92A5332321EB57F00089FBB9 /* Options.m in Sources */ = {isa = PBXBuildFile; fileRef = 92A5332121EB57F00089FBB9 /* Options.m */; }; @@ -73,6 +77,8 @@ 92A533DB21EEFDE20089FBB9 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92A533DA21EEFDE20089FBB9 /* CFNetwork.framework */; }; 92A533DF21EEFE520089FBB9 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92A533DE21EEFE520089FBB9 /* CFNetwork.framework */; }; 92B99F0F2022706600CC44E3 /* UIView+Toast.m in Sources */ = {isa = PBXBuildFile; fileRef = 92B99F0D2022706600CC44E3 /* UIView+Toast.m */; }; + 92C4164D284495AC00085A4B /* RecentlyPlayedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C4164C284495AC00085A4B /* RecentlyPlayedCell.swift */; }; + 92C4164E284495AC00085A4B /* RecentlyPlayedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C4164C284495AC00085A4B /* RecentlyPlayedCell.swift */; }; 92D96574215B03E100EFE3AE /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 92D96573215B03E100EFE3AE /* libc++.tbd */; }; 92ECB8F721EA985000D1E3D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 92ECB8F621EA985000D1E3D0 /* Assets.xcassets */; }; 92ECB8FF21EA992100D1E3D0 /* libmame-tvos.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ECB8FE21EA992100D1E3D0 /* libmame-tvos.a */; }; @@ -299,6 +305,8 @@ 926C771B21F5034100103EDE /* TVInputOptionsController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TVInputOptionsController.h; sourceTree = ""; }; 926C771C21F5034100103EDE /* TVInputOptionsController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TVInputOptionsController.m; sourceTree = ""; }; 928F7B2C27D5F18E00377C40 /* CommandLineArgsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandLineArgsHelper.swift; sourceTree = ""; }; + 9293C61C284681E3002729B4 /* ChooseGameController+Prefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChooseGameController+Prefetching.swift"; sourceTree = ""; }; + 9293C61F28475AB4002729B4 /* Collection+Safe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Safe.swift"; sourceTree = ""; }; 9296524B27FC1FF30064D1F5 /* EmulatorTouchMouse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmulatorTouchMouse.swift; sourceTree = ""; }; 9296524D27FC20330064D1F5 /* EmulatorController+TouchMouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmulatorController+TouchMouse.swift"; sourceTree = ""; }; 92A5332021EB57F00089FBB9 /* Options.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Options.h; sourceTree = ""; }; @@ -342,6 +350,7 @@ 92A533E021EEFE570089FBB9 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.1.sdk/System/Library/Frameworks/MobileCoreServices.framework; sourceTree = DEVELOPER_DIR; }; 92B99F0C2022706600CC44E3 /* UIView+Toast.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIView+Toast.h"; sourceTree = ""; }; 92B99F0D2022706600CC44E3 /* UIView+Toast.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIView+Toast.m"; sourceTree = ""; }; + 92C4164C284495AC00085A4B /* RecentlyPlayedCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyPlayedCell.swift; sourceTree = ""; }; 92D96573215B03E100EFE3AE /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; 92D96575215B041300EFE3AE /* crt1.o */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.objfile"; name = crt1.o; path = usr/lib/crt1.o; sourceTree = SDKROOT; }; 92D96577215B048900EFE3AE /* crt1.3.1.o */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.objfile"; name = crt1.3.1.o; path = usr/lib/crt1.3.1.o; sourceTree = SDKROOT; }; @@ -689,6 +698,7 @@ EF514446282815E4001457B1 /* GameInfoController.swift */, EF19F22E235D1C0300C8EE7F /* ChooseGameController.h */, EF19F229235D1BDF00C8EE7F /* ChooseGameController.m */, + 9293C61C284681E3002729B4 /* ChooseGameController+Prefetching.swift */, CEE680B41635BB4900051BC2 /* EmulatorController.h */, CEE680B51635BB4900051BC2 /* EmulatorController.m */, EF31447B27BB1E81002C3C6A /* ControllerButtonPress.swift */, @@ -709,6 +719,8 @@ EF286D32253CA930007DA6D3 /* CloudSync.h */, EF286D33253CA930007DA6D3 /* CloudSync.m */, CEBFBC0E163C5BD300A05CD0 /* resources */, + 92C4164C284495AC00085A4B /* RecentlyPlayedCell.swift */, + 9293C61F28475AB4002729B4 /* Collection+Safe.swift */, ); name = RELOADED; sourceTree = ""; @@ -1228,11 +1240,13 @@ 925ABCA91E46DCC500997182 /* OptionsController.m in Sources */, EFE003382638ACA000E42246 /* XmlFile.m in Sources */, EF8EAA8A244F7F5D00DA02BB /* SteamControllerExtendedGamepad.m in Sources */, + 92C4164D284495AC00085A4B /* RecentlyPlayedCell.swift in Sources */, EF8EAA8C244F7F5D00DA02BB /* SteamControllerInput.m in Sources */, 92A533C021EBDBAD0089FBB9 /* GCDWebServerURLEncodedFormRequest.m in Sources */, 92A533D821EE601D0089FBB9 /* WebServer.m in Sources */, EFB3C6D624890F6200F44780 /* simpleCRT.metal in Sources */, EF31447C27BB1E81002C3C6A /* ControllerButtonPress.swift in Sources */, + 9293C61D284681E3002729B4 /* ChooseGameController+Prefetching.swift in Sources */, EF474B8B2791E4CD00578663 /* EmulatorKeyboard.swift in Sources */, 92A533BA21EBDBAD0089FBB9 /* GCDWebServerDataResponse.m in Sources */, EF24933A236264DA00DD0A6C /* ImageCache.m in Sources */, @@ -1260,6 +1274,7 @@ EF7B232C23FD0469001FF51D /* (null) in Sources */, EF8E427D249348220049C84C /* megaTron.metal in Sources */, 92A533B421EBDBAD0089FBB9 /* GCDWebServerErrorResponse.m in Sources */, + 9293C62028475AB4002729B4 /* Collection+Safe.swift in Sources */, 928F7B2D27D5F18E00377C40 /* CommandLineArgsHelper.swift in Sources */, EF23D331243CB9CF006B3EE2 /* PopupSegmentedControl.m in Sources */, EFF9F82A2470755800DDA88C /* MetalScreenView.m in Sources */, @@ -1297,18 +1312,21 @@ 92A533B821EBDBAD0089FBB9 /* GCDWebServerFileResponse.m in Sources */, 92A533C121EBDBAD0089FBB9 /* GCDWebServerURLEncodedFormRequest.m in Sources */, 92ECB91921EAD57900D1E3D0 /* UIView+Toast.m in Sources */, + 9293C61E284681E3002729B4 /* ChooseGameController+Prefetching.swift in Sources */, EF19F22D235D1BDF00C8EE7F /* ChooseGameController.m in Sources */, 928F7B2E27F0223100377C40 /* CommandLineArgsHelper.swift in Sources */, EF8EAA89244F7F5D00DA02BB /* SteamController.m in Sources */, 92A533A621EBDBAD0089FBB9 /* GCDWebServerResponse.m in Sources */, 92A533D921EE601D0089FBB9 /* WebServer.m in Sources */, EFE0033E2638D97E00E42246 /* SoftwareList.m in Sources */, + 92C4164E284495AC00085A4B /* RecentlyPlayedCell.swift in Sources */, EFB7B21D247ACFE300AD96A4 /* MameShaders.metal in Sources */, 92A533D421EBDDF20089FBB9 /* GCDWebUploader.m in Sources */, 92ECB91221EAA1B700D1E3D0 /* EmulatorController.m in Sources */, EF8EAA87244F7F5D00DA02BB /* SteamControllerManager.m in Sources */, EF514448282815E4001457B1 /* GameInfoController.swift in Sources */, EF24BAFD24C786A900D9C55A /* SkinManager.m in Sources */, + 9293C62128475AB4002729B4 /* Collection+Safe.swift in Sources */, 92A533AC21EBDBAD0089FBB9 /* GCDWebServerFunctions.m in Sources */, EF51444B282C5C68001457B1 /* GameInfo.swift in Sources */, EF7B232D23FD0469001FF51D /* (null) in Sources */, diff --git a/xcode/MAME4iOS/RecentlyPlayedCell.swift b/xcode/MAME4iOS/RecentlyPlayedCell.swift new file mode 100644 index 00000000..6a2a5339 --- /dev/null +++ b/xcode/MAME4iOS/RecentlyPlayedCell.swift @@ -0,0 +1,104 @@ +// +// RecentlyPlayedCell.swift +// MAME4iOS +// +// Created by Yoshi Sugawara on 5/29/22. +// Copyright © 2022 MAME4iOS Team. All rights reserved. +// + +import UIKit + +@objcMembers class RecentlyPlayedCell: UICollectionViewCell { + static let identifier = "RecentlyPlayedCell" + + var items = [GameInfo]() { + didSet { + collectionView.reloadData() + } + } + + let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + return collectionView + }() + + var itemSize = CGSize.zero { + didSet { + guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { + return + } + layout.itemSize = itemSize + } + } + var setupCellClosure: ((GameInfoCell, IndexPath) -> Void)? + var selectItemClosure: ((IndexPath) -> Void)? + var contextMenuClosure: ((IndexPath) -> UIContextMenuConfiguration?)? + var heightForGameInfoClosure: ((GameInfo, UICollectionViewLayout) -> CGPoint)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + backgroundColor = .clear + contentView.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register(GameInfoCell.self, forCellWithReuseIdentifier: Self.identifier) + } +} + +extension RecentlyPlayedCell: UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.identifier, for: indexPath) as? GameInfoCell else { + return UICollectionViewCell() + } + setupCellClosure?(cell, indexPath) + return cell + } +} + +extension RecentlyPlayedCell: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + selectItemClosure?(indexPath) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return contextMenuClosure?(indexPath) + } +} + +extension RecentlyPlayedCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + guard let layout = collectionViewLayout as? UICollectionViewFlowLayout, + let heightForGameInfoClosure = heightForGameInfoClosure, + let game = items[safe: indexPath.row] else { + return .zero + } + let heightInfo = heightForGameInfoClosure(game, layout) + let height = heightInfo.x + heightInfo.y + return CGSize(width: layout.itemSize.width, height: height) + } +}