Save states are now saved to the Gaseous host, making them available anywhere (#255)

* Added ability to save emulator state

* Save states can now be fully managed during a game

* Save states can also be launched from the game info screen
This commit is contained in:
Michael Green
2024-01-15 11:37:18 +11:00
committed by GitHub
parent 1efc47f9cd
commit 127eab683b
21 changed files with 831 additions and 25 deletions

View File

@@ -58,7 +58,7 @@ namespace gaseous_server.Classes
public static GameRomMediaGroupItem GetMediaGroup(long Id)
{
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT * FROM RomMediaGroup WHERE Id=@id;";
string sql = "SELECT DISTINCT RomMediaGroup.*, GameState.RomId AS GameStateRomId FROM gaseous.RomMediaGroup LEFT JOIN GameState ON RomMediaGroup.Id = GameState.RomId AND GameState.IsMediaGroup = 1 WHERE RomMediaGroup.Id=@id;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", Id);
@@ -78,7 +78,7 @@ namespace gaseous_server.Classes
public static List<GameRomMediaGroupItem> GetMediaGroupsFromGameId(long GameId)
{
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT * FROM RomMediaGroup WHERE GameId=@gameid;";
string sql = "SELECT DISTINCT RomMediaGroup.*, GameState.RomId AS GameStateRomId FROM gaseous.RomMediaGroup LEFT JOIN GameState ON RomMediaGroup.Id = GameState.RomId AND GameState.IsMediaGroup = 1 WHERE RomMediaGroup.GameId=@gameid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("gameid", GameId);
@@ -156,7 +156,7 @@ namespace gaseous_server.Classes
public static void DeleteMediaGroup(long Id)
{
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "DELETE FROM RomMediaGroup WHERE Id=@id;";
string sql = "DELETE FROM RomMediaGroup WHERE Id=@id; DELETE FROM GameState WHERE RomId=@id AND IsMediaGroup=1;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", Id);
db.ExecuteCMD(sql, dbDict);
@@ -170,6 +170,15 @@ namespace gaseous_server.Classes
internal static GameRomMediaGroupItem BuildMediaGroupFromRow(DataRow row)
{
bool hasSaveStates = false;
if (row.Table.Columns.Contains("GameStateRomId"))
{
if (row["GameStateRomId"] != DBNull.Value)
{
hasSaveStates = true;
}
}
GameRomMediaGroupItem mediaGroupItem = new GameRomMediaGroupItem();
mediaGroupItem.Id = (long)row["Id"];
mediaGroupItem.Status = (GameRomMediaGroupItem.GroupBuildStatus)row["Status"];
@@ -177,6 +186,7 @@ namespace gaseous_server.Classes
mediaGroupItem.GameId = (long)row["GameId"];
mediaGroupItem.RomIds = new List<long>();
mediaGroupItem.Roms = new List<Roms.GameRomItem>();
mediaGroupItem.HasSaveStates = hasSaveStates;
// get members
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
@@ -397,6 +407,7 @@ namespace gaseous_server.Classes
public Models.PlatformMapping.PlatformMapItem.WebEmulatorItem? Emulator { get; set; }
public List<long> RomIds { get; set; }
public List<Roms.GameRomItem> Roms { get; set; }
public bool HasSaveStates { get; set; } = false;
private GroupBuildStatus _Status { get; set; }
public GroupBuildStatus Status {
get

View File

@@ -15,7 +15,7 @@ namespace gaseous_server.Classes
{}
}
public static GameRomObject GetRoms(long GameId, long PlatformId = -1, string NameSearch = "", int pageNumber = 0, int pageSize = 0)
public static GameRomObject GetRoms(long GameId, long PlatformId = -1, string NameSearch = "", int pageNumber = 0, int pageSize = 0, string userid = "")
{
GameRomObject GameRoms = new GameRomObject();
@@ -25,6 +25,7 @@ namespace gaseous_server.Classes
string sqlPlatform = "";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", GameId);
dbDict.Add("userid", userid);
string NameSearchWhere = "";
if (NameSearch.Length > 0)
@@ -38,13 +39,13 @@ namespace gaseous_server.Classes
if (PlatformId == -1) {
// data query
sql = "SELECT Games_Roms.*, Platform.`Name` AS platformname FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id WHERE Games_Roms.GameId = @id" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
sql = "SELECT DISTINCT Games_Roms.*, Platform.`Name` AS platformname, GameState.RomId AS SavedStateRomId FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id LEFT JOIN GameState ON (Games_Roms.Id = GameState.RomId AND GameState.UserId = @userid AND GameState.IsMediaGroup = 0) WHERE Games_Roms.GameId = @id" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
// count query
sqlCount = "SELECT COUNT(Games_Roms.Id) AS RomCount FROM Games_Roms WHERE Games_Roms.GameId = @id" + NameSearchWhere + ";";
} else {
// data query
sql = "SELECT Games_Roms.*, Platform.`Name` AS platformname FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id WHERE Games_Roms.GameId = @id AND Games_Roms.PlatformId = @platformid" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
sql = "SELECT DISTINCT Games_Roms.*, Platform.`Name` AS platformname, GameState.RomId AS SavedStateRomId FROM Games_Roms LEFT JOIN Platform ON Games_Roms.PlatformId = Platform.Id LEFT JOIN GameState ON (Games_Roms.Id = GameState.RomId AND GameState.UserId = @userid AND GameState.IsMediaGroup = 0) WHERE Games_Roms.GameId = @id AND Games_Roms.PlatformId = @platformid" + NameSearchWhere + " ORDER BY Platform.`Name`, Games_Roms.`Name` LIMIT 1000;";
// count query
sqlCount = "SELECT COUNT(Games_Roms.Id) AS RomCount FROM Games_Roms WHERE Games_Roms.GameId = @id AND Games_Roms.PlatformId = @platformid" + NameSearchWhere + ";";
@@ -131,7 +132,7 @@ namespace gaseous_server.Classes
}
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "DELETE FROM Games_Roms WHERE Id = @id";
string sql = "DELETE FROM Games_Roms WHERE Id = @id; DELETE FROM GameState WHERE RomId = @id;";
Dictionary<string, object> dbDict = new Dictionary<string, object>();
dbDict.Add("id", RomId);
db.ExecuteCMD(sql, dbDict);
@@ -140,6 +141,15 @@ namespace gaseous_server.Classes
private static GameRomItem BuildRom(DataRow romDR)
{
bool hasSaveStates = false;
if (romDR.Table.Columns.Contains("SavedStateRomId"))
{
if (romDR["SavedStateRomId"] != DBNull.Value)
{
hasSaveStates = true;
}
}
GameRomItem romItem = new GameRomItem
{
Id = (long)romDR["id"],
@@ -159,6 +169,7 @@ namespace gaseous_server.Classes
Path = (string)romDR["path"],
SignatureSource = (gaseous_server.Models.Signatures_Games.RomItem.SignatureSourceType)(Int32)romDR["metadatasource"],
SignatureSourceGameTitle = (string)Common.ReturnValueIfNull(romDR["MetadataGameName"], ""),
HasSaveStates = hasSaveStates,
Library = GameLibrary.GetLibrary((int)romDR["LibraryId"])
};
@@ -191,6 +202,7 @@ namespace gaseous_server.Classes
public long GameId { get; set; }
public string? Path { get; set; }
public string? SignatureSourceGameTitle { get; set;}
public bool HasSaveStates { get; set; } = false;
public GameLibrary.LibraryItem Library { get; set; }
}
}

View File

@@ -6,12 +6,14 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Authentication;
using gaseous_server.Classes;
using gaseous_server.Classes.Metadata;
using gaseous_server.Models;
using IGDB.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.Scripting;
using static gaseous_server.Classes.Metadata.AgeRatings;
@@ -25,6 +27,18 @@ namespace gaseous_server.Controllers
[ApiController]
public class GamesController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public GamesController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager
)
{
_userManager = userManager;
_signInManager = signInManager;
}
[MapToApiVersion("1.0")]
[HttpGet]
[ProducesResponseType(typeof(List<Game>), StatusCodes.Status200OK)]
@@ -830,13 +844,15 @@ namespace gaseous_server.Controllers
[ProducesResponseType(typeof(Classes.Roms.GameRomObject), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
//[ResponseCache(CacheProfileName = "5Minute")]
public ActionResult GameRom(long GameId, int pageNumber = 0, int pageSize = 0, long PlatformId = -1, string NameSearch = "")
public async Task<ActionResult> GameRomAsync(long GameId, int pageNumber = 0, int pageSize = 0, long PlatformId = -1, string NameSearch = "")
{
var user = await _userManager.GetUserAsync(User);
try
{
Game gameObject = Classes.Metadata.Games.GetGame(GameId, false, false, false);
return Ok(Classes.Roms.GetRoms(GameId, PlatformId, NameSearch, pageNumber, pageSize));
return Ok(Classes.Roms.GetRoms(GameId, PlatformId, NameSearch, pageNumber, pageSize, user.Id));
}
catch
{

View File

@@ -444,8 +444,6 @@ namespace gaseous_server.Controllers.v1_1
string orderByClause = "ORDER BY `" + orderByField + "` " + orderByOrder;
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
//string sql = "SELECT DISTINCT view_Games.* FROM view_Games LEFT JOIN Relation_Game_Platforms ON view_Games.Id = Relation_Game_Platforms.GameId AND (Relation_Game_Platforms.PlatformsId IN (SELECT DISTINCT PlatformId FROM Games_Roms WHERE Games_Roms.GameId = view_Games.Id)) LEFT JOIN Relation_Game_Genres ON view_Games.Id = Relation_Game_Genres.GameId LEFT JOIN Relation_Game_GameModes ON view_Games.Id = Relation_Game_GameModes.GameId LEFT JOIN Relation_Game_PlayerPerspectives ON view_Games.Id = Relation_Game_PlayerPerspectives.GameId LEFT JOIN Relation_Game_Themes ON view_Games.Id = Relation_Game_Themes.GameId " + whereClause + " " + havingClause + " " + orderByClause;
string sql = "SELECT DISTINCT Game.Id, Game.`Name`, Game.NameThe, Game.PlatformId, Game.TotalRating, Game.TotalRatingCount, Game.Cover, Game.Artworks, Game.FirstReleaseDate, Game.Category, Game.ParentGame, Game.AgeRatings, Game.AgeGroupId, Game.RomCount FROM (SELECT DISTINCT Game.*, CASE WHEN Game.`Name` LIKE 'The %' THEN CONCAT(TRIM(SUBSTR(Game.`Name` FROM 4)), ', The') ELSE Game.`Name` END AS NameThe, Games_Roms.PlatformId, AgeGroup.AgeGroupId, COUNT(Games_Roms.Id) AS RomCount FROM Game LEFT JOIN AgeGroup ON Game.Id = AgeGroup.GameId LEFT JOIN Games_Roms ON Game.Id = Games_Roms.GameId" + platformWhereClause + " LEFT JOIN AlternativeName ON Game.Id = AlternativeName.Game " + nameWhereClause + " GROUP BY Game.Id HAVING RomCount > 0) Game LEFT JOIN Relation_Game_Genres ON Game.Id = Relation_Game_Genres.GameId LEFT JOIN Relation_Game_GameModes ON Game.Id = Relation_Game_GameModes.GameId LEFT JOIN Relation_Game_PlayerPerspectives ON Game.Id = Relation_Game_PlayerPerspectives.GameId LEFT JOIN Relation_Game_Themes ON Game.Id = Relation_Game_Themes.GameId " + whereClause + " " + havingClause + " " + orderByClause;
List<IGDB.Models.Game> RetVal = new List<IGDB.Models.Game>();

View File

@@ -0,0 +1,279 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using gaseous_server.Models;
using gaseous_server.Classes;
using Authentication;
using Microsoft.AspNetCore.Identity;
using System.Data;
namespace gaseous_server.Controllers.v1_1
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("1.1")]
[ApiController]
public class StateManagerController: ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public StateManagerController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager
)
{
_userManager = userManager;
_signInManager = signInManager;
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpPost]
[Authorize]
[ProducesResponseType(typeof(Models.GameStateItem), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}")]
public async Task<ActionResult> SaveStateAsync(long RomId, UploadStateModel uploadState, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "INSERT INTO GameState (UserId, RomId, IsMediaGroup, StateDateTime, Name, Screenshot, State) VALUES (@userid, @romid, @ismediagroup, @statedatetime, @name, @screenshot, @state); SELECT LAST_INSERT_ID();";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "userid", user.Id },
{ "romid", RomId },
{ "ismediagroup", IsMediaGroup },
{ "statedatetime", DateTime.UtcNow },
{ "name", "" },
{ "screenshot", uploadState.ScreenshotByteArray },
{ "state", uploadState.StateByteArray }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
return Ok(await GetStateAsync(RomId, (long)(ulong)data.Rows[0][0], IsMediaGroup));
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(List<Models.GameStateItem>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}")]
public async Task<ActionResult> GetAllStateAsync(long RomId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT Id, StateDateTime, `Name`, Screenshot FROM GameState WHERE RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid ORDER BY StateDateTime DESC;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
List<Models.GameStateItem> gameStates = new List<GameStateItem>();
foreach (DataRow row in data.Rows)
{
gameStates.Add(BuildGameStateItem(row));
}
return Ok(gameStates);
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(Models.GameStateItem), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}")]
public async Task<ActionResult> GetStateAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT Id, StateDateTime, `Name`, Screenshot FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
if (data.Rows.Count == 0)
{
// invalid match - return not found
return NotFound();
}
else
{
GameStateItem stateItem = BuildGameStateItem(data.Rows[0]);
return Ok(stateItem);
}
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpDelete]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}")]
public async Task<ActionResult> DeleteStateAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "DELETE FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
db.ExecuteNonQuery(sql, dbDict);
return Ok();
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpPut]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}")]
public async Task<ActionResult> EditStateAsync(long RomId, long StateId, GameStateItemUpdateModel model, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "UPDATE GameState SET `Name` = @name WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup },
{ "name", model.Name }
};
db.ExecuteNonQuery(sql, dbDict);
return Ok();
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}/Screenshot/")]
[Route("{RomId}/{StateId}/Screenshot/image.png")]
public async Task<ActionResult> GetStateScreenshotAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT Screenshot FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
if (data.Rows.Count == 0)
{
// invalid match - return not found
return NotFound();
}
else
{
string filename = "image.jpg";
byte[] bytes = (byte[])data.Rows[0][0];
string contentType = "image/png";
var cd = new System.Net.Mime.ContentDisposition
{
FileName = filename,
Inline = true,
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("Cache-Control", "public, max-age=604800");
return File(bytes, contentType);
}
}
[MapToApiVersion("1.0")]
[MapToApiVersion("1.1")]
[HttpGet]
[Authorize]
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{RomId}/{StateId}/State/")]
[Route("{RomId}/{StateId}/State/savestate.state")]
public async Task<ActionResult> GetStateDataAsync(long RomId, long StateId, bool IsMediaGroup = false)
{
var user = await _userManager.GetUserAsync(User);
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT State FROM GameState WHERE Id = @id AND RomId = @romid AND IsMediaGroup = @ismediagroup AND UserId = @userid;";
Dictionary<string, object> dbDict = new Dictionary<string, object>
{
{ "id", StateId },
{ "romid", RomId },
{ "userid", user.Id },
{ "ismediagroup", IsMediaGroup }
};
DataTable data = db.ExecuteCMD(sql, dbDict);
if (data.Rows.Count == 0)
{
// invalid match - return not found
return NotFound();
}
else
{
string filename = "savestate.state";
byte[] bytes = (byte[])data.Rows[0][0];
string contentType = "application/octet-stream";
var cd = new System.Net.Mime.ContentDisposition
{
FileName = filename,
Inline = true,
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("Cache-Control", "public, max-age=604800");
return File(bytes, contentType);
}
}
private Models.GameStateItem BuildGameStateItem(DataRow dr)
{
bool HasScreenshot = true;
if (dr["Screenshot"] == DBNull.Value)
{
HasScreenshot = false;
}
GameStateItem stateItem = new GameStateItem
{
Id = (long)dr["Id"],
Name = (string)dr["Name"],
SaveTime = DateTime.Parse(((DateTime)dr["StateDateTime"]).ToString("yyyy-MM-ddThh:mm:ss") + 'Z'),
HasScreenshot = HasScreenshot
};
return stateItem;
}
}
}

View File

@@ -0,0 +1,35 @@
namespace gaseous_server.Models
{
public class UploadStateModel
{
public string ScreenshotByteArrayBase64 { get; set; }
public string StateByteArrayBase64 { get; set; }
public byte[] ScreenshotByteArray
{
get
{
return Convert.FromBase64String(ScreenshotByteArrayBase64);
}
}
public byte[] StateByteArray
{
get
{
return Convert.FromBase64String(StateByteArrayBase64);
}
}
}
public class GameStateItem
{
public long Id { get; set; }
public string Name { get; set; } = "";
public DateTime SaveTime { get; set; }
public bool HasScreenshot { get; set; }
}
public class GameStateItemUpdateModel
{
public string Name { get; set; } = "";
}
}

View File

@@ -0,0 +1,11 @@
CREATE TABLE `GameState` (
`Id` BIGINT NOT NULL AUTO_INCREMENT,
`UserId` VARCHAR(45) NULL,
`RomId` BIGINT NULL,
`IsMediaGroup` INT NULL,
`StateDateTime` DATETIME NULL,
`Name` VARCHAR(100) NULL,
`Screenshot` LONGBLOB NULL,
`State` LONGBLOB NULL,
PRIMARY KEY (`Id`),
INDEX `idx_UserId` (`UserId` ASC) VISIBLE);

View File

@@ -59,6 +59,7 @@
<None Remove="Support\Database\MySQL\gaseous-1012.sql" />
<None Remove="Support\Database\MySQL\gaseous-1013.sql" />
<None Remove="Support\Database\MySQL\gaseous-1014.sql" />
<None Remove="Support\Database\MySQL\gaseous-1015.sql" />
<None Remove="Classes\Metadata\" />
</ItemGroup>
<ItemGroup>
@@ -95,5 +96,6 @@
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1012.sql" />
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1013.sql" />
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1014.sql" />
<EmbeddedResource Include="Support\Database\MySQL\gaseous-1015.sql" />
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<script src="/api/v1.1/System/VersionFile"></script>
<link type="text/css" rel="stylesheet" href="/styles/style.css" dat-href="/styles/style.css" />
<link type="text/css" rel="stylesheet" href="/styles/notifications.css" dat-href="/styles/notifications.css" />
<script src="/scripts/jquery-3.6.0.min.js"></script>
<script src="/scripts/moment-with-locales.min.js"></script>
<link href="/styles/select2.min.css" rel="stylesheet" />
@@ -14,6 +15,7 @@
<script src="/scripts/dropzone.min.js"></script>
<script src="/scripts/simpleUpload.min.js"></script>
<script src="/scripts/main.js" type="text/javascript"></script>
<script src="/scripts/notifications.js" type="text/javascript"></script>
<script src="/scripts/filterformating.js" type="text/javascript"></script>
<script src="/scripts/gamesformating.js" type="text/javascript"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
@@ -43,6 +45,9 @@
</script>
</head>
<body>
<!-- Notifications -->
<div id="notifications_target"></div>
<div id="banner_icon" onclick="window.location.href = '/index.html';">
<img src="/images/logo.png" alt="Gaseous" id="banner_icon_image" />
</div>

View File

@@ -18,18 +18,56 @@
// URL to Game rom
EJS_gameUrl = decodeURIComponent(getQueryString('rompath', 'string'));
// load state if defined
if (StateUrl) {
console.log('Loading saved state from: ' + StateUrl);
EJS_loadStateURL = StateUrl;
EJS_startOnLoaded = true;
}
// Path to the data directory
EJS_pathtodata = '/emulators/EmulatorJS/data/';
EJS_DEBUG_XX = false;
EJS_startOnLoaded = false;
EJS_backgroundImage = emuBackground;
EJS_backgroundBlur = true;
EJS_fullscreenOnLoaded = false;
EJS_gameName = emuGameTitle;
EJS_threads = false;
EJS_onSaveState = function(e) {
var returnValue = {
"ScreenshotByteArrayBase64": btoa(Uint8ToString(e.screenshot)),
"StateByteArrayBase64": btoa(Uint8ToString(e.state))
};
var url = '/api/v1.1/StateManager/' + romId + '?IsMediaGroup=' + IsMediaGroup;
ajaxCall(
url,
'POST',
function (result) {
console.log("Upload complete");
console.log(result);
displayNotification('State Saved', 'Game state has been saved.', '/api/v1.1/StateManager/' + romId + '/' + result.value.id + '/Screenshot/image.png?IsMediaGroup=' + IsMediaGroup);
},
function (error) {
console.log("An error occurred");
console.log(error);
},
JSON.stringify(returnValue)
);
returnValue = undefined;
}
EJS_onLoadState = function(e) {
showDialog('emulatorloadstate', { "romId": romId, "IsMediaGroup": IsMediaGroup });
}
</script>
<script src='/emulators/EmulatorJS/data/loader.js'></script>

View File

@@ -0,0 +1,159 @@
<div id="saved_states">
</div>
<script text="text/javascript">
document.getElementById('modal-heading').innerHTML = "Load saved state";
console.log(modalVariables);
var statesUrl = '/api/v1.1/StateManager/' + modalVariables.romId + '?IsMediaGroup=' + modalVariables.IsMediaGroup;
console.log(statesUrl);
function LoadStates() {
ajaxCall(
statesUrl,
'GET',
function(result) {
var statesBox = document.getElementById('saved_states');
statesBox.innerHTML = '';
for (var i = 0; i < result.length; i++) {
var stateBox = document.createElement('div');
stateBox.id = 'stateBox_' + result[i].id;
stateBox.className = 'saved_state_box';
// screenshot panel
var stateImageBox = document.createElement('div');
stateImageBox.id = 'stateImageBox_' + result[i].id;
stateImageBox.className = 'saved_state_image_box';
if (result[i].hasScreenshot == true) {
var stateImage = document.createElement('img');
stateImage.className = 'saved_state_image_image';
stateImage.src = '/api/v1.1/StateManager/' + modalVariables.romId + '/' + result[i].id + '/Screenshot/image.png?IsMediaGroup=' + modalVariables.IsMediaGroup;
stateImageBox.appendChild(stateImage);
}
stateBox.appendChild(stateImageBox);
// main panel
var stateMainPanel = document.createElement('div');
stateMainPanel.id = 'stateMainPanel_' + result[i].id;
stateMainPanel.className = 'saved_state_main_box';
var stateName = document.createElement('input');
stateName.id = 'stateName_' + result[i].id;
stateName.type = 'text';
stateName.className = 'saved_state_name';
stateName.setAttribute('onblur', 'UpdateStateSave(' + result[i].id + ', ' + modalVariables.IsMediaGroup + ');');
if (result[i].name) {
stateName.value = result[i].name;
} else {
stateName.setAttribute('placeholder', "Untitled");
}
stateMainPanel.appendChild(stateName);
var stateTime = document.createElement('div');
stateTime.id = 'stateTime_' + result[i].id;
stateTime.className = 'saved_state_date';
stateTime.innerHTML = moment(result[i].saveTime).format("YYYY-MM-DD h:mm:ss a");
stateMainPanel.appendChild(stateTime);
var stateControls = document.createElement('div');
stateControls.id = 'stateControls_' + result[i].id;
stateControls.className = 'saved_state_controls';
var stateControlsLaunch= document.createElement('span');
stateControlsLaunch.id = 'stateControlsLaunch_' + result[i].id;
stateControlsLaunch.className = 'romstart';
var emulatorTarget = '/index.html?page=emulator&engine=@engine&core=@core&platformid=@platformid&gameid=@gameid&romid=@romid&mediagroup=@mediagroup&rompath=@rompath&stateid=' + result[i].id;
switch (getQueryString('page', 'string')) {
case 'emulator':
var mediagroupint = 0;
if (modalVariables.IsMediaGroup == true) {
mediagroupint = 1;
}
emulatorTarget = emulatorTarget.replaceAll('@engine', getQueryString('engine', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@core', getQueryString('core', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@platformid', getQueryString('platformid', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@gameid', getQueryString('gameid', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@romid', getQueryString('romid', 'string'));
emulatorTarget = emulatorTarget.replaceAll('@mediagroup', mediagroupint);
emulatorTarget = emulatorTarget.replaceAll('@rompath', getQueryString('rompath', 'string'));
stateControlsLaunch.setAttribute("onclick", 'window.location.replace("' + emulatorTarget + '")');
break;
case 'game':
console.log(modalVariables);
emulatorTarget = emulatorTarget.replaceAll('@engine', modalVariables.engine);
emulatorTarget = emulatorTarget.replaceAll('@core', modalVariables.core);
emulatorTarget = emulatorTarget.replaceAll('@platformid', modalVariables.platformid);
emulatorTarget = emulatorTarget.replaceAll('@gameid', modalVariables.gameid);
emulatorTarget = emulatorTarget.replaceAll('@romid', modalVariables.romId);
emulatorTarget = emulatorTarget.replaceAll('@mediagroup', modalVariables.mediagroup);
emulatorTarget = emulatorTarget.replaceAll('@rompath', modalVariables.rompath);
stateControlsLaunch.setAttribute("onclick", 'window.location.href = "' + emulatorTarget + '"');
break;
}
stateControlsLaunch.innerHTML = 'Launch';
stateControlsLaunch.style.float = 'right';
stateControls.appendChild(stateControlsLaunch);
var stateControlsDownload = document.createElement('a');
stateControlsDownload.id = 'stateControlsDownload_' + result[i].id;
stateControlsDownload.className = 'saved_state_buttonlink';
stateControlsDownload.href = '/api/v1.1/StateManager/' + modalVariables.romId + '/' + result[i].id + '/State/savestate.state?IsMediaGroup=' + modalVariables.IsMediaGroup;
stateControlsDownload.innerHTML = '<img src="/images/Download.svg" class="banner_button_image" alt="Download" title="Download" />';
stateControls.appendChild(stateControlsDownload);
var stateControlsDelete = document.createElement('span');
stateControlsDelete.id = 'stateControlsDelete_' + result[i].id;
stateControlsDelete.className = 'saved_state_buttonlink';
stateControlsDelete.setAttribute('onclick', 'DeleteStateSave(' + result[i].id + ', ' + modalVariables.IsMediaGroup + ');');
stateControlsDelete.innerHTML = '<img src="/images/delete.svg" class="banner_button_image" alt="Delete" title="Delete" />';
stateControls.appendChild(stateControlsDelete);
stateMainPanel.appendChild(stateControls);
stateBox.appendChild(stateMainPanel);
statesBox.appendChild(stateBox);
}
}
);
}
LoadStates();
function DeleteStateSave(StateId, IsMediaGroup) {
ajaxCall(
'/api/v1.1/StateManager/' + modalVariables.romId + '/' + StateId + '?IsMediaGroup=' + IsMediaGroup,
'DELETE',
function(success) {
LoadStates();
},
function (error) {
LoadStates();
}
);
}
function UpdateStateSave(StateId, IsMediaGroup) {
var stateName = document.getElementById('stateName_' + StateId);
var model = {
"name": stateName.value
};
ajaxCall(
'/api/v1.1/StateManager/' + modalVariables.romId + '/' + StateId + '?IsMediaGroup=' + IsMediaGroup,
'PUT',
function(success) {
LoadStates();
},
function (error) {
LoadStates();
},
JSON.stringify(model)
);
}
</script>

View File

@@ -1,4 +1,4 @@
<p>Are you sure you want to delete this media group?</p>
<p>Are you sure you want to delete this media group and all associated saved states?</p>
<p><strong>Warning:</strong> This cannot be undone!</p>
<div style="width: 100%; text-align: center;">
<div style="display: inline-block; margin-right: 20px;">

View File

@@ -1,4 +1,4 @@
<p>Are you sure you want to delete this ROM?</p>
<p>Are you sure you want to delete this ROM and all associated saved states?</p>
<p><strong>Warning:</strong> This cannot be undone!</p>
<div style="width: 100%; text-align: center;">
<div style="display: inline-block; margin-right: 20px;">

View File

@@ -1,4 +1,4 @@
<p>Are you sure you want to delete the selected ROMs?</p>
<p>Are you sure you want to delete the selected ROMs and any associated saved states?</p>
<p><strong>Warning:</strong> This cannot be undone!</p>
<div style="width: 100%; text-align: center;">
<div style="display: inline-block; margin-right: 20px;">

View File

@@ -6,7 +6,15 @@
<script type="text/javascript">
var gameId = getQueryString('gameid', 'int');
var romId = getQueryString('romid', 'int');
var platformId = getQueryString('platformid', 'int');
var IsMediaGroupInt = getQueryString('mediagroup', 'int');
var IsMediaGroup = false;
if (IsMediaGroupInt == 1) { IsMediaGroup = true; }
var StateUrl = undefined;
if (getQueryString('stateid', 'int')) {
StateUrl = '/api/v1.1/StateManager/' + romId + '/' + getQueryString('stateid', 'int') + '/State/savestate.state?IsMediaGroup=' + IsMediaGroup;
}
var gameData;
var artworks = null;
var artworksPosition = 0;

View File

@@ -97,7 +97,7 @@
<button value="Search" onclick="loadRoms();">Search</button>
</div>
<div class="games_library_controlblock">
<span class="games_library_label">0 ROMs</span>
<span id="games_roms_count" class="games_library_label">0 ROMs</span>
</div>
</div>
<div id="gamesummaryromscontent"></div>
@@ -476,7 +476,7 @@
mgTable.id = 'mediagrouptable';
mgTable.className = 'romtable';
mgTable.setAttribute('cellspacing', 0);
mgTable.appendChild(createTableRow(true, ['Platform', 'Images', 'Size', '', '', '']));
mgTable.appendChild(createTableRow(true, ['Platform', 'Images', 'Size', '', '', '', '']));
lastPlatform = '';
for (var i = 0; i < result.length; i++) {
@@ -484,9 +484,29 @@
// get rom details including emulator and friendly platform name
var launchButton = '';
var saveStatesButton = '';
if (mediaGroup.emulator) {
if (mediaGroup.emulator.type.length > 0) {
launchButton = '<a href="/index.html?page=emulator&engine=' + mediaGroup.emulator.type + '&core=' + mediaGroup.emulator.core + '&platformid=' + mediaGroup.platformId + '&gameid=' + gameId + '&rompath=' + encodeURIComponent('/api/v1.1/Games/' + gameId + '/romgroup/' + mediaGroup.id + '/' + gameData.name + '(' + mediaGroup.id + ')' + '.zip') + '" class="romstart">Launch</a>';
var romPath = encodeURIComponent('/api/v1.1/Games/' + gameId + '/romgroup/' + mediaGroup.id + '/' + gameData.name + '(' + mediaGroup.id + ')' + '.zip');
if (mediaGroup.hasSaveStates == true) {
var modalVariables = {
"romId": mediaGroup.id,
"IsMediaGroup": true,
"engine": mediaGroup.emulator.type,
"core": mediaGroup.emulator.core,
"platformid": mediaGroup.platformId,
"gameid": gameId,
"mediagroup": 1,
"rompath": romPath
};
saveStatesButton = document.createElement('a');
saveStatesButton.href = '#';
saveStatesButton.setAttribute('onclick', 'showDialog("emulatorloadstate", ' + JSON.stringify(modalVariables) + ');');
saveStatesButton.innerHTML = '<img src="/images/SaveStates.png" class="savedstateicon" />';
}
launchButton = '<a href="/index.html?page=emulator&engine=' + mediaGroup.emulator.type + '&core=' + mediaGroup.emulator.core + '&platformid=' + mediaGroup.platformId + '&gameid=' + gameId + '&romid=' + mediaGroup.id + '&mediagroup=1&rompath=' + romPath + '" class="romstart">Launch</a>';
}
}
@@ -532,15 +552,19 @@
mediaGroup.romIds.length,
packageSize,
statusText,
saveStatesButton,
launchButtonContent,
'<div style="text-align: right;">' + downloadLink + deleteButton + '</div>'
]
mgTable.appendChild(createTableRow(false, newRow, 'romrow', 'romcell'));
var mgRowBody = document.createElement('tbody');
mgRowBody.className = 'romrow';
mgRowBody.appendChild(createTableRow(false, newRow, '', 'romcell'));
var mgRomRow = document.createElement('tr');
var mgRomCell = document.createElement('td');
mgRomCell.setAttribute('colspan', 6);
mgRomCell.setAttribute('colspan', 7);
mgRomCell.className = 'romGroupTitles';
@@ -557,7 +581,9 @@
}
mgRomCell.innerHTML = groupMemberNames.join("<br />");
mgRomRow.appendChild(mgRomCell);
mgTable.appendChild(mgRomRow);
mgRowBody.appendChild(mgRomRow);
mgTable.appendChild(mgRowBody);
}
mediaGroupDiv.appendChild(mgTable);
@@ -600,6 +626,13 @@
var gameRoms = document.getElementById('gamesummaryromscontent');
var pageSize = 20;
ajaxCall('/api/v1.1/Games/' + gameId + '/roms?pageNumber=' + pageNumber + '&pageSize=' + pageSize + '&platformId=' + selectedPlatform + nameSearchQuery, 'GET', function (result) {
var romCount = document.getElementById('games_roms_count');
if (result.count != 1) {
romCount.innerHTML = result.count + ' ROMs';
} else {
romCount.innerHTML = result.count + ' ROM';
}
if (result.gameRomItems) {
var gameRomItems = result.gameRomItems;
@@ -608,7 +641,7 @@
newTable.id = 'romtable';
newTable.className = 'romtable';
newTable.setAttribute('cellspacing', 0);
newTable.appendChild(createTableRow(true, [['<input id="rom_mastercheck" type="checkbox" onclick="selectAllChecks(); handleChecks();"/>', 'rom_checkbox_box_hidden', 'rom_edit_checkbox'], 'Name', 'Size', 'Media', '', '', '']));
newTable.appendChild(createTableRow(true, [['<input id="rom_mastercheck" type="checkbox" onclick="selectAllChecks(); handleChecks();"/>', 'rom_checkbox_box_hidden', 'rom_edit_checkbox'], 'Name', 'Size', 'Media', '', '', '', '']));
var lastPlatform = '';
for (var i = 0; i < gameRomItems.length; i++) {
@@ -622,11 +655,29 @@
newTable.appendChild(platformRow);
}
var saveStatesButton = '';
var launchButton = '';
if (result.gameRomItems[i].emulator) {
if (gameRomItems[i].emulator.type) {
if (gameRomItems[i].emulator.type.length > 0) {
launchButton = '<a href="/index.html?page=emulator&engine=' + gameRomItems[i].emulator.type + '&core=' + gameRomItems[i].emulator.core + '&platformid=' + gameRomItems[i].platformId + '&gameid=' + gameId + '&rompath=' + encodeURIComponent('/api/v1.1/Games/' + gameId + '/roms/' + gameRomItems[i].id + '/' + encodeURIComponent(gameRomItems[i].name)) + '" class="romstart">Launch</a>';
var romPath = encodeURIComponent('/api/v1.1/Games/' + gameId + '/roms/' + gameRomItems[i].id + '/' + gameRomItems[i].name);
if (gameRomItems[i].hasSaveStates == true) {
var modalVariables = {
"romId": gameRomItems[i].id,
"IsMediaGroup": false,
"engine": gameRomItems[i].emulator.type,
"core": gameRomItems[i].emulator.core,
"platformid": gameRomItems[i].platformId,
"gameid": gameId,
"mediagroup": 0,
"rompath": romPath
};
saveStatesButton = document.createElement('a');
saveStatesButton.href = '#';
saveStatesButton.setAttribute('onclick', 'showDialog("emulatorloadstate", ' + JSON.stringify(modalVariables) + ');');
saveStatesButton.innerHTML = '<img src="/images/SaveStates.png" class="savedstateicon" />';
}
launchButton = '<a href="/index.html?page=emulator&engine=' + gameRomItems[i].emulator.type + '&core=' + gameRomItems[i].emulator.core + '&platformid=' + gameRomItems[i].platformId + '&gameid=' + gameId + '&romid=' + gameRomItems[i].id + '&mediagroup=0&rompath=' + romPath + '" class="romstart">Launch</a>';
}
}
}
@@ -637,10 +688,11 @@
formatBytes(gameRomItems[i].size, 2),
gameRomItems[i].romTypeMedia,
gameRomItems[i].mediaLabel,
saveStatesButton,
launchButton,
'<div class="properties_button" onclick="showDialog(\'rominfo\', ' + gameRomItems[i].id + ');">i</div>'
];
newTable.appendChild(createTableRow(false, newRow, 'romrow', 'romcell'));
newTable.appendChild(createTableRow(false, newRow, 'romrow romrowgamepage', 'romcell'));
}
gameRoms.appendChild(newTable);

View File

@@ -495,4 +495,13 @@ function SetPreference_Local(Setting, Value) {
userProfile.userPreferences.push(model);
}
}
}
function Uint8ToString(u8a){
var CHUNK_SZ = 0x8000;
var c = [];
for (var i=0; i < u8a.length; i+=CHUNK_SZ) {
c.push(String.fromCharCode.apply(null, u8a.subarray(i, i+CHUNK_SZ)));
}
return c.join("");
}

View File

@@ -0,0 +1,58 @@
function displayNotification(heading, message, image, link) {
var noteId = Math.random().toString(36).substr(2, 9);
var noteBox = document.createElement('div');
noteBox.id = noteId;
noteBox.className = 'notification';
noteBox.style.display = 'none;'
if (link) {
noteBox.setAttribute('onclick', 'window.location.href = "' + link + '"');
} else {
noteBox.setAttribute('onclick', 'closeNotification("' + noteId + '");');
}
if (image) {
var noteImageBox = document.createElement('div');
noteImageBox.className = 'notification_imagebox';
var noteImage = document.createElement('img');
noteImage.className = 'notification_image';
noteImage.src = image;
noteImageBox.appendChild(noteImage);
noteBox.appendChild(noteImageBox);
}
var noteMessageBox = document.createElement('div');
noteMessageBox.className = 'notification_messagebox';
if (heading) {
var noteMessageHeading = document.createElement('div');
noteMessageHeading.className = 'notification_title';
noteMessageHeading.innerHTML = heading;
noteMessageBox.appendChild(noteMessageHeading);
}
var noteMessageBody = document.createElement('div');
noteMessageBody.className = 'notification_message';
noteMessageBody.innerHTML = message;
noteMessageBox.appendChild(noteMessageBody);
noteBox.appendChild(noteMessageBox);
document.getElementById('notifications_target').appendChild(noteBox);
$(noteBox).hide().fadeIn(500);
setTimeout(function() {
closeNotification(noteId);
}, 5000);
}
function closeNotification(id) {
var notificationObj = document.getElementById(id);
$(notificationObj).fadeOut(1000, function() {
notificationObj.parentElement.removeChild(notificationObj);
});
}

View File

@@ -0,0 +1,58 @@
#notification_target {
position: fixed;
top: 40px;
width: 400px;
right: 0px;
bottom: 0px;
}
.notification {
position: absolute;
display: flex;
flex-direction: column nowrap;
top: 55px;
right: 15px;
width: 350px;
border-style: solid;
border-width: 1px;
border-radius: 7px;
border-color: rgba(0, 22, 56, 0.7);
padding-left: 15px;
padding-right: 20px;
padding-top: 15px;
padding-bottom: 10px;
background-color: rgba(0, 22, 56, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 5px 5px 19px 0px rgba(0,0,0,0.44);
-webkit-box-shadow: 5px 5px 19px 0px rgba(0,0,0,0.44);
-moz-box-shadow: 5px 5px 19px 0px rgba(0,0,0,0.44);
z-index: 10;
}
.notification_imagebox {
flex-basis: 70px;
margin-bottom: 0px;
padding-bottom: 0px;
}
.notification_image {
width: 80px;
margin-right: 10px;
}
.notification_messagebox {
flex-grow: 1;
}
.notification_title {
font-weight: bold;
text-transform: uppercase;
margin-bottom: 5px;
font-size: 14px;
color: rgb(160, 160, 186);
}
.notification_message {
}

View File

@@ -792,10 +792,20 @@ table .romrow:nth-child(odd) {
background: rgba(56, 56, 56, 0.3);
}
.romrow.romrowgamepage {
height: 42px;
}
.romcell {
padding: 5px;
}
.savedstateicon {
height: 24px;
width: 24px;
margin-top: 4px;
}
th {
text-align: left;
padding: 5px;
@@ -1416,4 +1426,49 @@ button:not(.select2-selection__choice__remove):not(.ejs_menu_button):disabled {
margin-bottom: 5px;
margin-left: 5px;
margin-right: 5px;
}
#saved_states {
overflow-x: scroll;
height: 350px;
}
.saved_state_box {
display: flex;
flex-direction: column nowrap;
padding: 10px;
}
.saved_state_image_box {
flex-basis: 128px;
}
.saved_state_image_image {
width: 128px;
margin: 0px;
}
.saved_state_main_box {
flex-grow: 1;
padding-left: 20px;
padding-right: 20px;
}
.saved_state_name {
font-size: 15px;
width: 100%;
margin-bottom: 10px;
}
.saved_state_date {
color: #888;
}
.saved_state_controls {
margin-top: 10px;
text-align: left;
}
.saved_state_buttonlink {
cursor: pointer;
}