mirror of
https://github.com/manos555555/PS5-Suite.git
synced 2026-10-06 07:00:30 +02:00
v5.0.0 mobile: full feature parity with desktop (Games/Saves/Screenshots/Hardware/Shell/MountGames tabs) + light flyout menu + 0 warnings
This commit is contained in:
1 parent
0be4325bc5
commit
3526f4874b
20 files changed
+1780
-8
No files matched your search
+88
-5
@@ -4,11 +4,94 @@
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:PS5UploadMobile"
|
||||
Shell.FlyoutBehavior="Disabled">
|
||||
xmlns:pages="clr-namespace:PS5UploadMobile.Pages"
|
||||
Shell.FlyoutBehavior="Flyout"
|
||||
Shell.BackgroundColor="#1E1E1E"
|
||||
Shell.ForegroundColor="White"
|
||||
Shell.TitleColor="White"
|
||||
Shell.FlyoutBackgroundColor="#FFFFFF"
|
||||
Title="PS5 Upload Suite">
|
||||
|
||||
<ShellContent
|
||||
Title="Home"
|
||||
ContentTemplate="{DataTemplate local:MainPage}"
|
||||
Route="MainPage" />
|
||||
<Shell.Resources>
|
||||
<ResourceDictionary>
|
||||
<!-- Flyout item styling: light background, dark text for maximum readability -->
|
||||
<Style x:Key="FlyoutItemLabelStyle" TargetType="Label">
|
||||
<Setter Property="TextColor" Value="#1E1E1E" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="VerticalOptions" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Flyout item template: explicit colors for text + selected background -->
|
||||
<DataTemplate x:Key="FlyoutItemTemplate">
|
||||
<Grid HeightRequest="54" Padding="16,0" ColumnDefinitions="Auto,*">
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup Name="CommonStates">
|
||||
<VisualState Name="Normal">
|
||||
<VisualState.Setters>
|
||||
<Setter TargetName="FlyoutRoot" Property="Grid.BackgroundColor" Value="#FFFFFF" />
|
||||
<Setter TargetName="FlyoutText" Property="Label.TextColor" Value="#1E1E1E" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState Name="Selected">
|
||||
<VisualState.Setters>
|
||||
<Setter TargetName="FlyoutRoot" Property="Grid.BackgroundColor" Value="#E3F2FD" />
|
||||
<Setter TargetName="FlyoutText" Property="Label.TextColor" Value="#0078D4" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
<Grid x:Name="FlyoutRoot" Grid.ColumnSpan="2" BackgroundColor="#FFFFFF" />
|
||||
<Image Grid.Column="0" Source="{Binding FlyoutIcon}" Margin="0,0,12,0"
|
||||
VerticalOptions="Center" HeightRequest="22" WidthRequest="22"/>
|
||||
<Label x:Name="FlyoutText" Grid.Column="1" Text="{Binding Title}"
|
||||
FontSize="16" FontAttributes="Bold"
|
||||
TextColor="#1E1E1E"
|
||||
VerticalOptions="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Apply to all FlyoutItems -->
|
||||
<Style TargetType="FlyoutItem">
|
||||
<Setter Property="Shell.ItemTemplate" Value="{StaticResource FlyoutItemTemplate}" />
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
</Shell.Resources>
|
||||
|
||||
<Shell.FlyoutHeader>
|
||||
<Grid HeightRequest="130" BackgroundColor="#0078D4">
|
||||
<VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Spacing="4">
|
||||
<Label Text="🎮 PS5 Upload Suite" FontSize="22" FontAttributes="Bold" TextColor="White" HorizontalOptions="Center"/>
|
||||
<Label Text="Complete Management" FontSize="13" TextColor="White" Opacity="0.9" HorizontalOptions="Center"/>
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</Shell.FlyoutHeader>
|
||||
|
||||
<FlyoutItem Title="Files" Icon="{OnPlatform Default='folder.png'}">
|
||||
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" Route="files" />
|
||||
</FlyoutItem>
|
||||
|
||||
<FlyoutItem Title="Games">
|
||||
<ShellContent ContentTemplate="{DataTemplate pages:GamesPage}" Route="games" />
|
||||
</FlyoutItem>
|
||||
|
||||
<FlyoutItem Title="Saves">
|
||||
<ShellContent ContentTemplate="{DataTemplate pages:SavesPage}" Route="saves" />
|
||||
</FlyoutItem>
|
||||
|
||||
<FlyoutItem Title="Screenshots">
|
||||
<ShellContent ContentTemplate="{DataTemplate pages:ScreenshotsPage}" Route="screenshots" />
|
||||
</FlyoutItem>
|
||||
|
||||
<FlyoutItem Title="Hardware">
|
||||
<ShellContent ContentTemplate="{DataTemplate pages:HardwarePage}" Route="hardware" />
|
||||
</FlyoutItem>
|
||||
|
||||
<FlyoutItem Title="Shell">
|
||||
<ShellContent ContentTemplate="{DataTemplate pages:ShellPage}" Route="shell" />
|
||||
</FlyoutItem>
|
||||
|
||||
<FlyoutItem Title="Mount Games">
|
||||
<ShellContent ContentTemplate="{DataTemplate pages:MountGamesPage}" Route="mount" />
|
||||
</FlyoutItem>
|
||||
|
||||
</Shell>
|
||||
@@ -310,6 +310,11 @@ public partial class MainPage : ContentPage
|
||||
AddLog($"Connecting to {IpEntry.Text}:{PortEntry.Text}...");
|
||||
bool connected = await _uploadService.ConnectAsync();
|
||||
|
||||
// Also establish shared protocol connection for other tabs (Games, Saves, Screenshots, Hardware, Shell, Mount)
|
||||
int portNum = int.TryParse(PortEntry.Text, out var p) ? p : 9113;
|
||||
bool sharedOk = await ConnectionManager.Instance.ConnectAsync(IpEntry.Text, portNum);
|
||||
AddLog(sharedOk ? "Shared protocol connected (all tabs ready)" : "Shared protocol connection failed — other tabs may not work");
|
||||
|
||||
if (connected)
|
||||
{
|
||||
StatusLabel.Text = "Connected ✓";
|
||||
@@ -332,6 +337,7 @@ public partial class MainPage : ContentPage
|
||||
private async void OnDisconnectClicked(object sender, EventArgs e)
|
||||
{
|
||||
await _uploadService.DisconnectAsync();
|
||||
ConnectionManager.Instance.Disconnect();
|
||||
|
||||
StatusLabel.Text = "Disconnected";
|
||||
StatusLabel.TextColor = Colors.Orange;
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
<!-- Android specific -->
|
||||
<AndroidManifest>Platforms\Android\AndroidManifest.xml</AndroidManifest>
|
||||
|
||||
<!-- Suppress NETSDK1202 (EOL workload warning for .NET 6 Android) -->
|
||||
<CheckEolTargetFramework>false</CheckEolTargetFramework>
|
||||
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
|
||||
<NoWarn>$(NoWarn);NETSDK1202</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="$(TargetFramework.StartsWith('net6.0-android'))">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="PS5UploadMobile.Pages.GamesPage"
|
||||
BackgroundColor="#121212"
|
||||
Title="Games">
|
||||
<Grid RowDefinitions="Auto,Auto,*" Padding="10">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<HorizontalStackLayout Grid.Row="0" Spacing="8">
|
||||
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White" FontSize="14"/>
|
||||
<Button Text="🎮 Mount All" Clicked="OnMountAllClicked" BackgroundColor="#107C10" TextColor="White" FontSize="14"/>
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<!-- Status Label -->
|
||||
<Label Grid.Row="1" x:Name="StatusLabel" Text="Tap Refresh to load games"
|
||||
TextColor="Gray" FontSize="12" Margin="4,6"/>
|
||||
|
||||
<!-- Games List -->
|
||||
<RefreshView Grid.Row="2" x:Name="GamesRefresh" Refreshing="OnRefreshing">
|
||||
<CollectionView x:Name="GamesListView" SelectionMode="Single"
|
||||
SelectionChanged="OnGameSelected">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Padding="10,8" ColumnDefinitions="80,*,Auto" ColumnSpacing="10" BackgroundColor="#1E1E1E" Margin="0,0,0,4">
|
||||
<!-- Icon -->
|
||||
<Frame Grid.Column="0" Padding="0" CornerRadius="6" IsClippedToBounds="True"
|
||||
BackgroundColor="#333" WidthRequest="80" HeightRequest="80" HasShadow="False">
|
||||
<Image Source="{Binding Icon}" Aspect="AspectFill" WidthRequest="80" HeightRequest="80"/>
|
||||
</Frame>
|
||||
<!-- Info -->
|
||||
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center" Spacing="2">
|
||||
<Label Text="{Binding Name}" TextColor="White" FontSize="16" FontAttributes="Bold" LineBreakMode="TailTruncation"/>
|
||||
<Label Text="{Binding TitleId}" TextColor="#80B0FF" FontSize="12"/>
|
||||
<Label Text="{Binding SizeDisplay}" TextColor="Gray" FontSize="12"/>
|
||||
<Label Text="{Binding Region}" TextColor="Gray" FontSize="11"/>
|
||||
</VerticalStackLayout>
|
||||
<!-- Status -->
|
||||
<Label Grid.Column="2" Text="{Binding StatusDisplay}" TextColor="LimeGreen" FontSize="11" VerticalOptions="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
<CollectionView.EmptyView>
|
||||
<Label Text="No games found. Pull to refresh." TextColor="Gray" HorizontalOptions="Center" VerticalOptions="Center"/>
|
||||
</CollectionView.EmptyView>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,164 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5UploadMobile.Pages;
|
||||
|
||||
public partial class GamesPage : ContentPage
|
||||
{
|
||||
private readonly ObservableCollection<PS5MountedGame> _games = new();
|
||||
|
||||
public GamesPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
GamesListView.ItemsSource = _games;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (_games.Count == 0) await LoadGamesAsync();
|
||||
}
|
||||
|
||||
private async void OnRefreshClicked(object sender, EventArgs e) => await LoadGamesAsync();
|
||||
|
||||
private async void OnRefreshing(object sender, EventArgs e)
|
||||
{
|
||||
await LoadGamesAsync();
|
||||
GamesRefresh.IsRefreshing = false;
|
||||
}
|
||||
|
||||
private async Task LoadGamesAsync()
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
|
||||
StatusLabel.Text = "Loading games…";
|
||||
try
|
||||
{
|
||||
var list = await proto.GetGameListAsync();
|
||||
_games.Clear();
|
||||
foreach (var g in list) _games.Add(g);
|
||||
StatusLabel.Text = $"Loaded {_games.Count} games";
|
||||
|
||||
// Load icons in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
foreach (var game in list)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[]? iconBytes = await proto.GetGameIconAsync(game.TitleId);
|
||||
if (iconBytes != null && iconBytes.Length > 0)
|
||||
{
|
||||
var ms = new MemoryStream(iconBytes);
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
game.Icon = ImageSource.FromStream(() => new MemoryStream(iconBytes));
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusLabel.Text = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnGameSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
|
||||
if (e.CurrentSelection[0] is not PS5MountedGame game) return;
|
||||
GamesListView.SelectedItem = null;
|
||||
|
||||
string action = await DisplayActionSheet($"{game.Name}", "Cancel", null,
|
||||
"▶️ Launch Game",
|
||||
"📖 Game Details",
|
||||
"⏏️ Unmount Game",
|
||||
"📋 Copy Title ID");
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "▶️ Launch Game":
|
||||
await LaunchGameAsync(game);
|
||||
break;
|
||||
case "📖 Game Details":
|
||||
await ShowGameDetailsAsync(game);
|
||||
break;
|
||||
case "⏏️ Unmount Game":
|
||||
await UnmountGameAsync(game);
|
||||
break;
|
||||
case "📋 Copy Title ID":
|
||||
await Clipboard.SetTextAsync(game.TitleId);
|
||||
await DisplayAlert("Copied", $"Copied {game.TitleId}", "OK");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LaunchGameAsync(PS5MountedGame game)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
bool confirm = await DisplayAlert("Launch Game", $"Launch '{game.Name}' on your PS5?", "Launch", "Cancel");
|
||||
if (!confirm) return;
|
||||
var (ok, msg) = await proto.LaunchGameAsync(game.TitleId);
|
||||
await DisplayAlert(ok ? "Launching" : "Error", msg, "OK");
|
||||
}
|
||||
|
||||
private async Task UnmountGameAsync(PS5MountedGame game)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
bool confirm = await DisplayAlert("Unmount Game", $"Unmount '{game.Name}' from PS5?", "Unmount", "Cancel");
|
||||
if (!confirm) return;
|
||||
var (ok, msg) = await proto.UnmountGameAsync(game.TitleId);
|
||||
await DisplayAlert(ok ? "Unmounted" : "Error", msg, "OK");
|
||||
if (ok) await LoadGamesAsync();
|
||||
}
|
||||
|
||||
private async Task ShowGameDetailsAsync(PS5MountedGame game)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
StatusLabel.Text = "Loading game details…";
|
||||
var details = await proto.GetGameDetailsAsync(game.TitleId);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"Title: {game.Name}");
|
||||
sb.AppendLine($"Title ID: {game.TitleId}");
|
||||
sb.AppendLine($"Region: {game.Region}");
|
||||
sb.AppendLine($"Size: {game.SizeDisplay}");
|
||||
sb.AppendLine($"Path: {game.Path}");
|
||||
sb.AppendLine($"Status: {game.StatusDisplay}");
|
||||
if (details != null)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("—— param.json ——");
|
||||
foreach (var kv in details) sb.AppendLine($"{kv.Key}: {kv.Value}");
|
||||
}
|
||||
StatusLabel.Text = $"Loaded {_games.Count} games";
|
||||
await DisplayAlert("Game Details", sb.ToString(), "Close");
|
||||
}
|
||||
|
||||
private async void OnMountAllClicked(object sender, EventArgs e)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
bool confirm = await DisplayAlert("Mount Games",
|
||||
"This will scan all known locations (internal, USB, M.2) and mount unmounted games. Continue?",
|
||||
"Mount", "Cancel");
|
||||
if (!confirm) return;
|
||||
|
||||
StatusLabel.Text = "Mounting games (this can take a while)…";
|
||||
var (ok, summary) = await proto.MountGamesAsync(msg => MainThread.BeginInvokeOnMainThread(() => StatusLabel.Text = msg));
|
||||
StatusLabel.Text = ok ? "Mount complete" : "Mount failed";
|
||||
await DisplayAlert(ok ? "Mount Games - Done" : "Mount Games - Error", summary, "OK");
|
||||
if (ok) await LoadGamesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="PS5UploadMobile.Pages.HardwarePage"
|
||||
BackgroundColor="#121212"
|
||||
Title="Hardware">
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="12" Spacing="14">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<HorizontalStackLayout Spacing="10">
|
||||
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White"/>
|
||||
<HorizontalStackLayout Spacing="6" VerticalOptions="Center">
|
||||
<CheckBox x:Name="AutoRefreshCheck" Color="#0078D4" CheckedChanged="OnAutoRefreshChanged"/>
|
||||
<Label Text="Auto-refresh (5s)" TextColor="White" VerticalOptions="Center"/>
|
||||
</HorizontalStackLayout>
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<Label x:Name="StatusLabel" Text="Tap Refresh to load hardware info" TextColor="Gray" FontSize="12"/>
|
||||
|
||||
<!-- System Info -->
|
||||
<Frame BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="🖥️ System Info" TextColor="#80B0FF" FontSize="16" FontAttributes="Bold"/>
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="6">
|
||||
<Label Grid.Row="0" Grid.Column="0" Text="Model" TextColor="Gray"/>
|
||||
<Label Grid.Row="0" Grid.Column="1" x:Name="ModelLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Text="Serial" TextColor="Gray"/>
|
||||
<Label Grid.Row="1" Grid.Column="1" x:Name="SerialLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Text="Machine" TextColor="Gray"/>
|
||||
<Label Grid.Row="2" Grid.Column="1" x:Name="MachineLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Text="OS" TextColor="Gray"/>
|
||||
<Label Grid.Row="3" Grid.Column="1" x:Name="OsLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="4" Grid.Column="0" Text="CPU Cores" TextColor="Gray"/>
|
||||
<Label Grid.Row="4" Grid.Column="1" x:Name="CpuCoresLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="5" Grid.Column="0" Text="Physical RAM" TextColor="Gray"/>
|
||||
<Label Grid.Row="5" Grid.Column="1" x:Name="RamLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="6" Grid.Column="0" Text="Wi-Fi / BT" TextColor="Gray"/>
|
||||
<Label Grid.Row="6" Grid.Column="1" x:Name="WlanLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="7" Grid.Column="0" Text="Optical Out" TextColor="Gray"/>
|
||||
<Label Grid.Row="7" Grid.Column="1" x:Name="OpticalLabel" Text="—" TextColor="White"/>
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
<!-- Live Sensors -->
|
||||
<Frame BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label Text="🔥 Live Sensors" TextColor="#FF9050" FontSize="16" FontAttributes="Bold"/>
|
||||
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="CPU Temperature" TextColor="White"/>
|
||||
<Label Grid.Column="1" x:Name="CpuTempLabel" Text="—" TextColor="Orange" FontAttributes="Bold"/>
|
||||
</Grid>
|
||||
<ProgressBar x:Name="CpuTempBar" Progress="0" ProgressColor="#FF9050"/>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="SoC Temperature" TextColor="White"/>
|
||||
<Label Grid.Column="1" x:Name="SocTempLabel" Text="—" TextColor="Orange" FontAttributes="Bold"/>
|
||||
</Grid>
|
||||
<ProgressBar x:Name="SocTempBar" Progress="0" ProgressColor="#FF5050"/>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="CPU Frequency" TextColor="White"/>
|
||||
<Label Grid.Column="1" x:Name="CpuFreqLabel" Text="—" TextColor="LimeGreen" FontAttributes="Bold"/>
|
||||
</Grid>
|
||||
<ProgressBar x:Name="CpuFreqBar" Progress="0" ProgressColor="LimeGreen"/>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="SoC Power" TextColor="White"/>
|
||||
<Label Grid.Column="1" x:Name="SocPowerLabel" Text="—" TextColor="Yellow" FontAttributes="Bold"/>
|
||||
</Grid>
|
||||
<ProgressBar x:Name="SocPowerBar" Progress="0" ProgressColor="Yellow"/>
|
||||
</VerticalStackLayout>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
<!-- Power Info -->
|
||||
<Frame BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="⚡ Power Stats" TextColor="#FFD050" FontSize="16" FontAttributes="Bold"/>
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="6">
|
||||
<Label Grid.Row="0" Grid.Column="0" Text="Operating Time" TextColor="Gray"/>
|
||||
<Label Grid.Row="0" Grid.Column="1" x:Name="OpTimeLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Text="Boot Count" TextColor="Gray"/>
|
||||
<Label Grid.Row="1" Grid.Column="1" x:Name="BootCountLabel" Text="—" TextColor="White"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Text="Total Power" TextColor="Gray"/>
|
||||
<Label Grid.Row="2" Grid.Column="1" x:Name="PowerConsLabel" Text="—" TextColor="White"/>
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,129 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Dispatching;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5UploadMobile.Pages;
|
||||
|
||||
public partial class HardwarePage : ContentPage
|
||||
{
|
||||
private IDispatcherTimer? _autoTimer;
|
||||
private int _busy; // Interlocked guard
|
||||
|
||||
public HardwarePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await RefreshAllAsync();
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
StopAutoTimer();
|
||||
}
|
||||
|
||||
private async void OnRefreshClicked(object sender, EventArgs e) => await RefreshAllAsync();
|
||||
|
||||
private void OnAutoRefreshChanged(object sender, CheckedChangedEventArgs e)
|
||||
{
|
||||
if (e.Value) StartAutoTimer();
|
||||
else StopAutoTimer();
|
||||
}
|
||||
|
||||
private void StartAutoTimer()
|
||||
{
|
||||
StopAutoTimer();
|
||||
_autoTimer = Dispatcher.CreateTimer();
|
||||
_autoTimer.Interval = TimeSpan.FromSeconds(5);
|
||||
_autoTimer.Tick += async (_, __) => await RefreshSensorsAsync();
|
||||
_autoTimer.Start();
|
||||
}
|
||||
|
||||
private void StopAutoTimer()
|
||||
{
|
||||
if (_autoTimer != null) { _autoTimer.Stop(); _autoTimer = null; }
|
||||
}
|
||||
|
||||
private async Task RefreshAllAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _busy, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) { StatusLabel.Text = "Disconnected"; return; }
|
||||
StatusLabel.Text = "Loading hardware info…";
|
||||
|
||||
var hw = await proto.GetHardwareInfoAsync();
|
||||
if (hw != null)
|
||||
{
|
||||
ModelLabel.Text = hw.Model;
|
||||
SerialLabel.Text = hw.Serial;
|
||||
MachineLabel.Text = hw.HwMachine;
|
||||
OsLabel.Text = hw.OsVersion;
|
||||
CpuCoresLabel.Text = hw.NumCpu > 0 ? hw.NumCpu.ToString() : "—";
|
||||
RamLabel.Text = hw.PhysMemDisplay;
|
||||
WlanLabel.Text = hw.WlanBtDisplay;
|
||||
OpticalLabel.Text = hw.OpticalDisplay;
|
||||
}
|
||||
|
||||
await UpdateSensorsAsync(proto);
|
||||
await UpdatePowerAsync(proto);
|
||||
|
||||
StatusLabel.Text = $"Updated {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex) { StatusLabel.Text = $"Error: {ex.Message}"; }
|
||||
finally { Interlocked.Exchange(ref _busy, 0); }
|
||||
}
|
||||
|
||||
private async Task RefreshSensorsAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _busy, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
var proto = PageHelper.Conn.Protocol;
|
||||
if (proto == null || !proto.IsConnected) { StopAutoTimer(); return; }
|
||||
await UpdateSensorsAsync(proto);
|
||||
StatusLabel.Text = $"Updated {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch { }
|
||||
finally { Interlocked.Exchange(ref _busy, 0); }
|
||||
}
|
||||
|
||||
private async Task UpdateSensorsAsync(PS5Protocol proto)
|
||||
{
|
||||
var temps = await proto.GetTemperatureInfoAsync();
|
||||
if (temps == null) return;
|
||||
|
||||
CpuTempLabel.Text = temps.CpuTempDisplay;
|
||||
SocTempLabel.Text = temps.SocTempDisplay;
|
||||
CpuFreqLabel.Text = temps.CpuFreqDisplay;
|
||||
SocPowerLabel.Text = temps.SocPowerDisplay;
|
||||
|
||||
// Progress bars - map sensible ranges
|
||||
CpuTempBar.Progress = Math.Clamp(temps.CpuTemp / 100.0, 0, 1);
|
||||
SocTempBar.Progress = Math.Clamp(temps.SocTemp / 100.0, 0, 1);
|
||||
// PS5 CPU typical 3.5 GHz max
|
||||
CpuFreqBar.Progress = Math.Clamp(temps.CpuFreqMhz / 3500.0, 0, 1);
|
||||
// SoC power 0–200W range
|
||||
SocPowerBar.Progress = Math.Clamp(temps.SocPowerMw / 200000.0, 0, 1);
|
||||
}
|
||||
|
||||
private async Task UpdatePowerAsync(PS5Protocol proto)
|
||||
{
|
||||
var power = await proto.GetPowerInfoAsync();
|
||||
if (power == null) return;
|
||||
OpTimeLabel.Text = $"{power.OperatingTimeHours}h {power.OperatingTimeMinutes}m";
|
||||
BootCountLabel.Text = power.BootCount.ToString();
|
||||
PowerConsLabel.Text = power.PowerConsumptionMw > 0
|
||||
? $"{power.PowerConsumptionMw / 1000.0:F2} kW·s (lifetime)"
|
||||
: "—";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="PS5UploadMobile.Pages.MountGamesPage"
|
||||
BackgroundColor="#121212"
|
||||
Title="Mount Games">
|
||||
<Grid RowDefinitions="Auto,Auto,*" Padding="14">
|
||||
|
||||
<Frame Grid.Row="0" BackgroundColor="#1E1E1E" BorderColor="#2D2D2D" Padding="14" CornerRadius="8" HasShadow="False">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="🎮 Mount Games" TextColor="#80B0FF" FontSize="18" FontAttributes="Bold"/>
|
||||
<Label Text="Scan all known locations (internal, USB 0–3, M.2 SSD) and automatically mount any unmounted games onto the PS5 home screen."
|
||||
TextColor="LightGray" FontSize="12"/>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
<Button Grid.Row="1" x:Name="StartBtn" Text="🚀 Start Mount Scan"
|
||||
Clicked="OnStartClicked" BackgroundColor="#107C10" TextColor="White"
|
||||
FontAttributes="Bold" HeightRequest="55" Margin="0,12,0,12"/>
|
||||
|
||||
<Border Grid.Row="2" Stroke="#333" StrokeThickness="1" Background="#000" Padding="8">
|
||||
<ScrollView x:Name="LogScroll">
|
||||
<Label x:Name="LogLabel" Text="Ready. Tap 'Start Mount Scan' to begin."
|
||||
TextColor="#00FF40" FontFamily="Monospace" FontSize="12"/>
|
||||
</ScrollView>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Text;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace PS5UploadMobile.Pages;
|
||||
|
||||
public partial class MountGamesPage : ContentPage
|
||||
{
|
||||
private readonly StringBuilder _buffer = new();
|
||||
|
||||
public MountGamesPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void AppendLine(string s)
|
||||
{
|
||||
_buffer.AppendLine(s);
|
||||
LogLabel.Text = _buffer.ToString();
|
||||
MainThread.BeginInvokeOnMainThread(async () =>
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(50);
|
||||
await LogScroll.ScrollToAsync(0, double.MaxValue, false);
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnStartClicked(object sender, EventArgs e)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
|
||||
StartBtn.IsEnabled = false;
|
||||
_buffer.Clear();
|
||||
AppendLine("🚀 Starting mount scan…");
|
||||
AppendLine("Scanning: /data/etaHEN/games, /mnt/usb0-3, /mnt/ext0/games …");
|
||||
|
||||
var (ok, summary) = await proto.MountGamesAsync(msg =>
|
||||
MainThread.BeginInvokeOnMainThread(() => AppendLine(msg)));
|
||||
|
||||
AppendLine("");
|
||||
AppendLine(ok ? "✅ Mount scan complete" : "❌ Mount scan failed");
|
||||
AppendLine("");
|
||||
AppendLine("—— Summary ——");
|
||||
AppendLine(summary);
|
||||
StartBtn.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
using PS5Upload;
|
||||
using PS5UploadMobile.Services;
|
||||
|
||||
namespace PS5UploadMobile.Pages
|
||||
{
|
||||
internal static class PageHelper
|
||||
{
|
||||
public static ConnectionManager Conn => ConnectionManager.Instance;
|
||||
|
||||
public static async Task<PS5Protocol?> EnsureConnectedAsync(Page page)
|
||||
{
|
||||
var proto = await Conn.EnsureConnectedAsync();
|
||||
if (proto == null)
|
||||
{
|
||||
await page.DisplayAlert("Not Connected",
|
||||
$"Could not connect to {Conn.IpAddress}:{Conn.Port}. Please connect from the Files tab first.",
|
||||
"OK");
|
||||
}
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static string DownloadsDir
|
||||
{
|
||||
get
|
||||
{
|
||||
string? root = Microsoft.Maui.Storage.FileSystem.AppDataDirectory;
|
||||
string path = Path.Combine(root, "PS5Downloads");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ThumbnailCacheDir
|
||||
{
|
||||
get
|
||||
{
|
||||
string path = Path.Combine(Microsoft.Maui.Storage.FileSystem.CacheDirectory, "thumbnails");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="PS5UploadMobile.Pages.SavesPage"
|
||||
BackgroundColor="#121212"
|
||||
Title="Saves">
|
||||
<Grid RowDefinitions="Auto,Auto,*" Padding="10">
|
||||
<HorizontalStackLayout Grid.Row="0" Spacing="8">
|
||||
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White" FontSize="14"/>
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<Label Grid.Row="1" x:Name="StatusLabel" Text="Tap Refresh to load save files"
|
||||
TextColor="Gray" FontSize="12" Margin="4,6"/>
|
||||
|
||||
<RefreshView Grid.Row="2" x:Name="SavesRefresh" Refreshing="OnRefreshing">
|
||||
<CollectionView x:Name="SavesListView" SelectionMode="Single"
|
||||
SelectionChanged="OnSaveSelected">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Padding="10,8" ColumnDefinitions="60,*,Auto" ColumnSpacing="10"
|
||||
BackgroundColor="#1E1E1E" Margin="0,0,0,4">
|
||||
<Frame Grid.Column="0" Padding="0" CornerRadius="6" IsClippedToBounds="True"
|
||||
BackgroundColor="#333" WidthRequest="60" HeightRequest="60" HasShadow="False">
|
||||
<Image Source="{Binding Icon}" Aspect="AspectFill"/>
|
||||
</Frame>
|
||||
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center" Spacing="2">
|
||||
<Label Text="{Binding GameName}" TextColor="White" FontSize="15" FontAttributes="Bold"
|
||||
LineBreakMode="TailTruncation"/>
|
||||
<Label Text="{Binding TitleId}" TextColor="#80B0FF" FontSize="11"/>
|
||||
<Label TextColor="Gray" FontSize="10">
|
||||
<Label.FormattedText>
|
||||
<FormattedString>
|
||||
<Span Text="User: "/>
|
||||
<Span Text="{Binding ShortUserId}"/>
|
||||
<Span Text=" • "/>
|
||||
<Span Text="{Binding ModifiedDisplay}"/>
|
||||
</FormattedString>
|
||||
</Label.FormattedText>
|
||||
</Label>
|
||||
</VerticalStackLayout>
|
||||
<Label Grid.Column="2" Text="{Binding SizeDisplay}" TextColor="Orange" FontSize="12"
|
||||
VerticalOptions="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
<CollectionView.EmptyView>
|
||||
<Label Text="No saves found." TextColor="Gray" HorizontalOptions="Center" VerticalOptions="Center"/>
|
||||
</CollectionView.EmptyView>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,115 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5UploadMobile.Pages;
|
||||
|
||||
public partial class SavesPage : ContentPage
|
||||
{
|
||||
private readonly ObservableCollection<PS5SaveGame> _saves = new();
|
||||
private readonly System.Collections.Generic.Dictionary<string, string> _gameNameCache = new();
|
||||
|
||||
public SavesPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
SavesListView.ItemsSource = _saves;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (_saves.Count == 0) await LoadSavesAsync();
|
||||
}
|
||||
|
||||
private async void OnRefreshClicked(object sender, EventArgs e) => await LoadSavesAsync();
|
||||
|
||||
private async void OnRefreshing(object sender, EventArgs e)
|
||||
{
|
||||
await LoadSavesAsync();
|
||||
SavesRefresh.IsRefreshing = false;
|
||||
}
|
||||
|
||||
private async Task LoadSavesAsync()
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
|
||||
StatusLabel.Text = "Loading saves…";
|
||||
try
|
||||
{
|
||||
var list = await proto.ListSavesAsync();
|
||||
_saves.Clear();
|
||||
foreach (var s in list)
|
||||
{
|
||||
s.GameName = _gameNameCache.TryGetValue(s.TitleId, out var nm) ? nm : s.TitleId;
|
||||
_saves.Add(s);
|
||||
}
|
||||
StatusLabel.Text = $"Loaded {_saves.Count} saves";
|
||||
|
||||
// Background: fetch game names from GetGameList + icons
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var games = await proto.GetGameListAsync();
|
||||
var gameDict = new System.Collections.Generic.Dictionary<string, string>();
|
||||
foreach (var g in games) gameDict[g.TitleId] = g.Name;
|
||||
|
||||
foreach (var save in list)
|
||||
{
|
||||
if (gameDict.TryGetValue(save.TitleId, out var nm))
|
||||
{
|
||||
_gameNameCache[save.TitleId] = nm;
|
||||
await MainThread.InvokeOnMainThreadAsync(() => save.GameName = nm);
|
||||
}
|
||||
byte[]? iconBytes = await proto.GetGameIconAsync(save.TitleId);
|
||||
if (iconBytes != null && iconBytes.Length > 0)
|
||||
{
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
save.Icon = ImageSource.FromStream(() => new MemoryStream(iconBytes));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusLabel.Text = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnSaveSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
|
||||
if (e.CurrentSelection[0] is not PS5SaveGame save) return;
|
||||
SavesListView.SelectedItem = null;
|
||||
|
||||
string action = await DisplayActionSheet($"{save.GameName} ({save.TitleId})", "Cancel", null,
|
||||
"📋 Copy Save Path",
|
||||
"ℹ️ Save Info");
|
||||
|
||||
if (action == "📋 Copy Save Path")
|
||||
{
|
||||
await Clipboard.SetTextAsync(save.SavePath);
|
||||
await DisplayAlert("Copied", $"Copied: {save.SavePath}", "OK");
|
||||
}
|
||||
else if (action == "ℹ️ Save Info")
|
||||
{
|
||||
string info =
|
||||
$"Game: {save.GameName}\n" +
|
||||
$"Title ID: {save.TitleId}\n" +
|
||||
$"User ID: {save.UserId}\n" +
|
||||
$"Size: {save.SizeDisplay}\n" +
|
||||
$"Modified: {save.ModifiedDisplay}\n" +
|
||||
$"Path: {save.SavePath}";
|
||||
await DisplayAlert("Save Details", info, "Close");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="PS5UploadMobile.Pages.ScreenshotsPage"
|
||||
BackgroundColor="#121212"
|
||||
Title="Screenshots">
|
||||
<Grid RowDefinitions="Auto,Auto,*" Padding="10">
|
||||
<HorizontalStackLayout Grid.Row="0" Spacing="8">
|
||||
<Button Text="🔄 Refresh" Clicked="OnRefreshClicked" BackgroundColor="#0078D4" TextColor="White" FontSize="14"/>
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<Label Grid.Row="1" x:Name="StatusLabel" Text="Tap Refresh to load screenshots"
|
||||
TextColor="Gray" FontSize="12" Margin="4,6"/>
|
||||
|
||||
<RefreshView Grid.Row="2" x:Name="ShotsRefresh" Refreshing="OnRefreshing">
|
||||
<CollectionView x:Name="ShotsListView" SelectionMode="Single"
|
||||
SelectionChanged="OnShotSelected">
|
||||
<CollectionView.ItemsLayout>
|
||||
<GridItemsLayout Orientation="Vertical" Span="2" VerticalItemSpacing="6" HorizontalItemSpacing="6"/>
|
||||
</CollectionView.ItemsLayout>
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Frame Padding="0" CornerRadius="8" IsClippedToBounds="True"
|
||||
BackgroundColor="#1E1E1E" HasShadow="False" BorderColor="#333">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<Image Grid.Row="0" Source="{Binding Thumbnail}" Aspect="AspectFill" HeightRequest="130"
|
||||
BackgroundColor="#2A2A2A"/>
|
||||
<VerticalStackLayout Grid.Row="1" Padding="6,4" Spacing="0">
|
||||
<Label Text="{Binding FileName}" TextColor="White" FontSize="11"
|
||||
LineBreakMode="TailTruncation"/>
|
||||
<Label TextColor="Gray" FontSize="10">
|
||||
<Label.FormattedText>
|
||||
<FormattedString>
|
||||
<Span Text="{Binding SizeDisplay}"/>
|
||||
<Span Text=" • "/>
|
||||
<Span Text="{Binding DateDisplay}"/>
|
||||
</FormattedString>
|
||||
</Label.FormattedText>
|
||||
</Label>
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</Frame>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
<CollectionView.EmptyView>
|
||||
<Label Text="No screenshots found. Pull to refresh." TextColor="Gray"
|
||||
HorizontalOptions="Center" VerticalOptions="Center"/>
|
||||
</CollectionView.EmptyView>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,173 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5UploadMobile.Pages;
|
||||
|
||||
public partial class ScreenshotsPage : ContentPage
|
||||
{
|
||||
private readonly ObservableCollection<PS5Screenshot> _shots = new();
|
||||
|
||||
public ScreenshotsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
ShotsListView.ItemsSource = _shots;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (_shots.Count == 0) await LoadShotsAsync();
|
||||
}
|
||||
|
||||
private async void OnRefreshClicked(object sender, EventArgs e) => await LoadShotsAsync();
|
||||
|
||||
private async void OnRefreshing(object sender, EventArgs e)
|
||||
{
|
||||
await LoadShotsAsync();
|
||||
ShotsRefresh.IsRefreshing = false;
|
||||
}
|
||||
|
||||
private async Task LoadShotsAsync()
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
|
||||
StatusLabel.Text = "Loading screenshots…";
|
||||
try
|
||||
{
|
||||
var list = await proto.ListScreenshotsAsync();
|
||||
_shots.Clear();
|
||||
foreach (var s in list) _shots.Add(s);
|
||||
StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
|
||||
|
||||
// Load thumbnails in background
|
||||
_ = Task.Run(() => LoadThumbnailsAsync(list));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusLabel.Text = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadThumbnailsAsync(System.Collections.Generic.List<PS5Screenshot> shots)
|
||||
{
|
||||
string cacheDir = PageHelper.ThumbnailCacheDir;
|
||||
foreach (var shot in shots)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Cache key
|
||||
string hash;
|
||||
using (var md5 = MD5.Create())
|
||||
{
|
||||
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(shot.FullPath));
|
||||
hash = BitConverter.ToString(bytes).Replace("-", "").ToLower();
|
||||
}
|
||||
string cacheFile = Path.Combine(cacheDir, hash + ".jpg");
|
||||
|
||||
if (!File.Exists(cacheFile))
|
||||
{
|
||||
// Need its own protocol - use shared connection
|
||||
var proto = PageHelper.Conn.Protocol;
|
||||
if (proto == null || !proto.IsConnected) return;
|
||||
bool ok = await proto.DownloadFileAsync(shot.FullPath, cacheFile);
|
||||
if (!ok) continue;
|
||||
}
|
||||
|
||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||
{
|
||||
shot.Thumbnail = ImageSource.FromFile(cacheFile);
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnShotSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.CurrentSelection == null || e.CurrentSelection.Count == 0) return;
|
||||
if (e.CurrentSelection[0] is not PS5Screenshot shot) return;
|
||||
ShotsListView.SelectedItem = null;
|
||||
|
||||
string action = await DisplayActionSheet(shot.FileName, "Cancel", null,
|
||||
"👁️ View",
|
||||
"⬇️ Download",
|
||||
"🗑️ Delete",
|
||||
"📋 Copy Path");
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "👁️ View":
|
||||
await ViewShotAsync(shot);
|
||||
break;
|
||||
case "⬇️ Download":
|
||||
await DownloadShotAsync(shot);
|
||||
break;
|
||||
case "🗑️ Delete":
|
||||
await DeleteShotAsync(shot);
|
||||
break;
|
||||
case "📋 Copy Path":
|
||||
await Clipboard.SetTextAsync(shot.FullPath);
|
||||
await DisplayAlert("Copied", shot.FullPath, "OK");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ViewShotAsync(PS5Screenshot shot)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
StatusLabel.Text = "Downloading for preview…";
|
||||
string tmp = Path.Combine(PageHelper.ThumbnailCacheDir, "preview_" + Path.GetFileName(shot.FileName));
|
||||
bool ok = await proto.DownloadFileAsync(shot.FullPath, tmp);
|
||||
StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
|
||||
if (!ok) { await DisplayAlert("Error", "Download failed", "OK"); return; }
|
||||
try
|
||||
{
|
||||
await Microsoft.Maui.ApplicationModel.Launcher.OpenAsync(
|
||||
new Microsoft.Maui.ApplicationModel.OpenFileRequest { File = new ReadOnlyFile(tmp) });
|
||||
}
|
||||
catch (Exception ex) { await DisplayAlert("Open failed", ex.Message, "OK"); }
|
||||
}
|
||||
|
||||
private async Task DownloadShotAsync(PS5Screenshot shot)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
string dir = PageHelper.DownloadsDir;
|
||||
string local = Path.Combine(dir, shot.FileName);
|
||||
StatusLabel.Text = $"Downloading {shot.FileName}…";
|
||||
bool ok = await proto.DownloadFileAsync(shot.FullPath, local);
|
||||
StatusLabel.Text = $"Loaded {_shots.Count} screenshots";
|
||||
await DisplayAlert(ok ? "Downloaded" : "Error",
|
||||
ok ? $"Saved to:\n{local}" : "Download failed", "OK");
|
||||
}
|
||||
|
||||
private async Task DeleteShotAsync(PS5Screenshot shot)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
bool confirm = await DisplayAlert("Delete Screenshot",
|
||||
$"Delete {shot.FileName}? This also removes the thumbnail from PS5 Media Gallery.",
|
||||
"Delete", "Cancel");
|
||||
if (!confirm) return;
|
||||
|
||||
var (ok, msg) = await proto.DeleteScreenshotAsync(shot.FullPath);
|
||||
if (ok)
|
||||
{
|
||||
_shots.Remove(shot);
|
||||
StatusLabel.Text = $"Deleted. {_shots.Count} screenshots remaining.";
|
||||
}
|
||||
else
|
||||
{
|
||||
await DisplayAlert("Delete failed", msg, "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="PS5UploadMobile.Pages.ShellPage"
|
||||
BackgroundColor="#121212"
|
||||
Title="Shell">
|
||||
<Grid RowDefinitions="Auto,*,Auto" Padding="10">
|
||||
|
||||
<HorizontalStackLayout Grid.Row="0" Spacing="8" Margin="0,0,0,6">
|
||||
<Button Text="🔗 Open Shell" Clicked="OnOpenClicked" BackgroundColor="#107C10" TextColor="White" FontSize="13"/>
|
||||
<Button Text="❌ Close" Clicked="OnCloseClicked" BackgroundColor="#D13438" TextColor="White" FontSize="13"/>
|
||||
<Button Text="🧹 Clear" Clicked="OnClearClicked" BackgroundColor="#3E3E3E" TextColor="White" FontSize="13"/>
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<Border Grid.Row="1" Stroke="#333" StrokeThickness="1" Background="#000" Padding="8">
|
||||
<ScrollView x:Name="OutputScroll">
|
||||
<Label x:Name="OutputLabel" Text="Shell output will appear here…"
|
||||
TextColor="#00FF40" FontFamily="Monospace" FontSize="12"/>
|
||||
</ScrollView>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,6,0,0">
|
||||
<Entry x:Name="CmdEntry" Placeholder="Enter shell command…"
|
||||
Text="ls /" TextColor="White" PlaceholderColor="Gray"
|
||||
Completed="OnCmdCompleted"/>
|
||||
<Button Grid.Column="1" Text="▶ Run" Clicked="OnRunClicked"
|
||||
BackgroundColor="#0078D4" TextColor="White"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,76 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace PS5UploadMobile.Pages;
|
||||
|
||||
public partial class ShellPage : ContentPage
|
||||
{
|
||||
private readonly StringBuilder _buffer = new();
|
||||
private bool _shellOpen;
|
||||
|
||||
public ShellPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void AppendLine(string s)
|
||||
{
|
||||
_buffer.AppendLine(s);
|
||||
OutputLabel.Text = _buffer.ToString();
|
||||
MainThread.BeginInvokeOnMainThread(async () =>
|
||||
{
|
||||
await Task.Delay(50);
|
||||
await OutputScroll.ScrollToAsync(0, double.MaxValue, false);
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnOpenClicked(object sender, EventArgs e)
|
||||
{
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
bool ok = await proto.OpenShellAsync();
|
||||
_shellOpen = ok;
|
||||
AppendLine(ok ? "🟢 Shell opened" : "🔴 Failed to open shell");
|
||||
}
|
||||
|
||||
private async void OnCloseClicked(object sender, EventArgs e)
|
||||
{
|
||||
var proto = PageHelper.Conn.Protocol;
|
||||
if (proto == null) { AppendLine("(no connection)"); return; }
|
||||
bool ok = await proto.CloseShellAsync();
|
||||
_shellOpen = false;
|
||||
AppendLine(ok ? "🟡 Shell closed" : "⚠️ Close failed");
|
||||
}
|
||||
|
||||
private void OnClearClicked(object sender, EventArgs e)
|
||||
{
|
||||
_buffer.Clear();
|
||||
OutputLabel.Text = "";
|
||||
}
|
||||
|
||||
private async void OnCmdCompleted(object sender, EventArgs e) => await RunCmdAsync();
|
||||
private async void OnRunClicked(object sender, EventArgs e) => await RunCmdAsync();
|
||||
|
||||
private async Task RunCmdAsync()
|
||||
{
|
||||
string cmd = CmdEntry.Text?.Trim() ?? "";
|
||||
if (string.IsNullOrEmpty(cmd)) return;
|
||||
|
||||
var proto = await PageHelper.EnsureConnectedAsync(this);
|
||||
if (proto == null) return;
|
||||
|
||||
if (!_shellOpen)
|
||||
{
|
||||
bool opened = await proto.OpenShellAsync();
|
||||
_shellOpen = opened;
|
||||
if (!opened) { AppendLine("🔴 Shell not open"); return; }
|
||||
}
|
||||
|
||||
AppendLine($"$ {cmd}");
|
||||
string result = await proto.ExecuteShellCommandAsync(cmd);
|
||||
if (!string.IsNullOrEmpty(result)) AppendLine(result.TrimEnd('\n', '\r'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using PS5Upload;
|
||||
|
||||
namespace PS5UploadMobile.Services
|
||||
{
|
||||
public class ConnectionManager
|
||||
{
|
||||
private static ConnectionManager? _instance;
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
public string IpAddress { get; set; } = "192.168.0.160";
|
||||
public int Port { get; set; } = 9113;
|
||||
public MainPage? MainPageReference { get; set; }
|
||||
|
||||
// Shared protocol instance used by all pages
|
||||
private PS5Protocol? _protocol;
|
||||
public PS5Protocol? Protocol => _protocol;
|
||||
public bool IsConnected => _protocol?.IsConnected ?? false;
|
||||
|
||||
public event Action? ConnectionStateChanged;
|
||||
|
||||
private ConnectionManager() { }
|
||||
|
||||
public static ConnectionManager Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
lock (_lock) { if (_instance == null) _instance = new ConnectionManager(); }
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetConnection(string ipAddress, int port)
|
||||
{
|
||||
IpAddress = ipAddress;
|
||||
Port = port;
|
||||
}
|
||||
|
||||
public void SetMainPage(MainPage mainPage) => MainPageReference = mainPage;
|
||||
|
||||
public async Task<bool> ConnectAsync(string? ip = null, int? port = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ip)) IpAddress = ip!;
|
||||
if (port.HasValue) Port = port.Value;
|
||||
|
||||
try { _protocol?.Dispose(); } catch { }
|
||||
_protocol = new PS5Protocol();
|
||||
bool ok = await _protocol.ConnectAsync(IpAddress, Port);
|
||||
if (!ok)
|
||||
{
|
||||
try { _protocol.Dispose(); } catch { }
|
||||
_protocol = null;
|
||||
}
|
||||
ConnectionStateChanged?.Invoke();
|
||||
return ok;
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
try { _protocol?.Dispose(); } catch { }
|
||||
_protocol = null;
|
||||
ConnectionStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure the shared protocol is connected, attempt reconnect if not.
|
||||
/// </summary>
|
||||
public async Task<PS5Protocol?> EnsureConnectedAsync()
|
||||
{
|
||||
if (_protocol != null && _protocol.IsConnected) return _protocol;
|
||||
return await ConnectAsync() ? _protocol : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
@@ -28,10 +29,29 @@ namespace PS5Upload
|
||||
ShellExec = 0x21,
|
||||
ShellInterrupt = 0x22,
|
||||
ShellClose = 0x23,
|
||||
MountGames = 0x30,
|
||||
GetFileInfo = 0x31,
|
||||
GetSystemInfo = 0x32,
|
||||
VerifyFile = 0x33,
|
||||
GetHwInfo = 0x34,
|
||||
GetTemps = 0x35,
|
||||
GetRunningApps = 0x36,
|
||||
KillApp = 0x37,
|
||||
LaunchBrowser = 0x38,
|
||||
GetPowerInfo = 0x39,
|
||||
GetGameList = 0x3A,
|
||||
UnmountGame = 0x3B,
|
||||
GetGameIcon = 0x3C,
|
||||
GetGameDetails = 0x3D,
|
||||
GetGamePic = 0x3E,
|
||||
ListSaves = 0x3F,
|
||||
IndexStart = 0x40,
|
||||
IndexStatus = 0x41,
|
||||
SearchIndex = 0x42,
|
||||
IndexCancel = 0x43,
|
||||
LaunchGame = 0x44,
|
||||
ListScreenshots = 0x45,
|
||||
DeleteScreenshot = 0x46,
|
||||
Shutdown = 0xFF
|
||||
}
|
||||
|
||||
@@ -45,12 +65,27 @@ namespace PS5Upload
|
||||
Progress = 0x05
|
||||
}
|
||||
|
||||
public class PS5Protocol : IDisposable
|
||||
public partial class PS5Protocol : IDisposable
|
||||
{
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private readonly SemaphoreSlim _commandLock = new SemaphoreSlim(1, 1);
|
||||
private const int BufferSize = 4 * 1024 * 1024; // 4MB buffer - maximum throughput for chunk uploads
|
||||
|
||||
// Expose helpers for partial methods in ProtocolExtras.cs
|
||||
internal Task SendCommandInternalAsync(Command cmd, byte[]? data = null) => SendCommandAsync(cmd, data);
|
||||
internal Task<(Response response, byte[] data)> ReceiveResponseInternalAsync() => ReceiveResponseAsync();
|
||||
internal async Task<(Response response, byte[] data)> RunCommandAsync(Command cmd, byte[]? data = null)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandAsync(cmd, data);
|
||||
return await ReceiveResponseAsync();
|
||||
}
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
public bool IsConnected => _client?.Connected ?? false;
|
||||
|
||||
public async Task<bool> ConnectAsync(string ipAddress, int port = 9113)
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace PS5Upload
|
||||
{
|
||||
// ========== Additional commands: Hardware, Games, Saves, Screenshots, Mount, Shell, etc. ==========
|
||||
public partial class PS5Protocol
|
||||
{
|
||||
// ---------------- Hardware info ----------------
|
||||
public async Task<PS5HardwareInfo?> GetHardwareInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (response, data) = await RunCommandAsync(Command.GetHwInfo);
|
||||
if (response != Response.Data) return null;
|
||||
string text = Encoding.UTF8.GetString(data);
|
||||
var info = new PS5HardwareInfo();
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var parts = line.Split('=', 2);
|
||||
if (parts.Length != 2) continue;
|
||||
var k = parts[0].Trim(); var v = parts[1].Trim();
|
||||
switch (k)
|
||||
{
|
||||
case "model": info.Model = v; break;
|
||||
case "serial": info.Serial = v; break;
|
||||
case "has_wlan_bt": info.HasWlanBt = v == "1"; break;
|
||||
case "has_optical_out": info.HasOpticalOut = v == "1"; break;
|
||||
case "hw_machine": info.HwMachine = v; break;
|
||||
case "os": info.OsVersion = v; break;
|
||||
case "ncpu": if (int.TryParse(v, out var n)) info.NumCpu = n; break;
|
||||
case "physmem": if (ulong.TryParse(v, out var pm)) info.PhysMem = pm; break;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------- Live sensors ----------------
|
||||
public async Task<PS5TemperatureInfo?> GetTemperatureInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (response, data) = await RunCommandAsync(Command.GetTemps);
|
||||
if (response != Response.Data) return null;
|
||||
string text = Encoding.UTF8.GetString(data);
|
||||
var info = new PS5TemperatureInfo();
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var parts = line.Split('=', 2);
|
||||
if (parts.Length != 2) continue;
|
||||
var k = parts[0].Trim(); var v = parts[1].Trim();
|
||||
switch (k)
|
||||
{
|
||||
case "cpu_temp": if (int.TryParse(v, out var ct)) info.CpuTemp = ct; break;
|
||||
case "soc_temp": if (int.TryParse(v, out var st)) info.SocTemp = st; break;
|
||||
case "cpu_freq_mhz": if (long.TryParse(v, out var cf)) info.CpuFreqMhz = cf; break;
|
||||
case "soc_power_mw": if (uint.TryParse(v, out var pw)) info.SocPowerMw = pw; break;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------- Power ----------------
|
||||
public async Task<PS5PowerInfo?> GetPowerInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (response, data) = await RunCommandAsync(Command.GetPowerInfo);
|
||||
if (response != Response.Data) return null;
|
||||
string text = Encoding.UTF8.GetString(data);
|
||||
var info = new PS5PowerInfo();
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var parts = line.Split('=', 2);
|
||||
if (parts.Length != 2) continue;
|
||||
var k = parts[0].Trim(); var v = parts[1].Trim();
|
||||
switch (k)
|
||||
{
|
||||
case "operating_time_sec": if (ulong.TryParse(v, out var a)) info.OperatingTimeSec = a; break;
|
||||
case "operating_time_hours": if (ulong.TryParse(v, out var b)) info.OperatingTimeHours = b; break;
|
||||
case "operating_time_minutes": if (ulong.TryParse(v, out var c)) info.OperatingTimeMinutes = c; break;
|
||||
case "boot_count": if (uint.TryParse(v, out var d)) info.BootCount = d; break;
|
||||
case "power_consumption_mw": if (uint.TryParse(v, out var e)) info.PowerConsumptionMw = e; break;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------- Mounted games ----------------
|
||||
public async Task<List<PS5MountedGame>> GetGameListAsync()
|
||||
{
|
||||
var result = new List<PS5MountedGame>();
|
||||
try
|
||||
{
|
||||
var (response, data) = await RunCommandAsync(Command.GetGameList);
|
||||
if (response != Response.Data) return result;
|
||||
string text = Encoding.UTF8.GetString(data);
|
||||
if (text.Trim() == "NO_GAMES") return result;
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length < 6) continue;
|
||||
var g = new PS5MountedGame
|
||||
{
|
||||
TitleId = parts[0],
|
||||
Name = parts[1],
|
||||
Path = parts[2],
|
||||
Region = parts[4],
|
||||
IsActive = parts[5] == "1"
|
||||
};
|
||||
if (ulong.TryParse(parts[3], out var sz)) g.Size = sz;
|
||||
result.Add(g);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<(bool success, string message)> UnmountGameAsync(string titleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(titleId + "\0");
|
||||
var (response, respData) = await RunCommandAsync(Command.UnmountGame, data);
|
||||
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
public async Task<(bool success, string message)> LaunchGameAsync(string titleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(titleId + "\0");
|
||||
var (response, respData) = await RunCommandAsync(Command.LaunchGame, data);
|
||||
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetGameIconAsync(string titleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(titleId + "\0");
|
||||
var (response, respData) = await RunCommandAsync(Command.GetGameIcon, data);
|
||||
if (response != Response.Data || respData == null || respData.Length == 0) return null;
|
||||
return respData;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, string>?> GetGameDetailsAsync(string titleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(titleId + "\0");
|
||||
var (response, respData) = await RunCommandAsync(Command.GetGameDetails, data);
|
||||
if (response != Response.Data) return null;
|
||||
var dict = new Dictionary<string, string>();
|
||||
foreach (var line in Encoding.UTF8.GetString(respData).Split('\n'))
|
||||
{
|
||||
var p = line.Split('=', 2);
|
||||
if (p.Length == 2) dict[p[0].Trim()] = p[1].Trim();
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------- Saves ----------------
|
||||
public async Task<List<PS5SaveGame>> ListSavesAsync()
|
||||
{
|
||||
var saves = new List<PS5SaveGame>();
|
||||
try
|
||||
{
|
||||
var (response, data) = await RunCommandAsync(Command.ListSaves);
|
||||
if (response != Response.Data) return saves;
|
||||
string text = Encoding.UTF8.GetString(data);
|
||||
if (text.Trim() == "NO_SAVES") return saves;
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length < 5) continue;
|
||||
var s = new PS5SaveGame
|
||||
{
|
||||
TitleId = parts[0],
|
||||
UserId = parts[1],
|
||||
SavePath = parts[2]
|
||||
};
|
||||
if (ulong.TryParse(parts[3], out var sz)) s.Size = sz;
|
||||
if (long.TryParse(parts[4], out var mt)) s.ModifiedUnixTime = mt;
|
||||
saves.Add(s);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return saves;
|
||||
}
|
||||
|
||||
// ---------------- Screenshots ----------------
|
||||
public async Task<List<PS5Screenshot>> ListScreenshotsAsync()
|
||||
{
|
||||
var shots = new List<PS5Screenshot>();
|
||||
try
|
||||
{
|
||||
var (response, data) = await RunCommandAsync(Command.ListScreenshots);
|
||||
if (response != Response.Data) return shots;
|
||||
string body = Encoding.UTF8.GetString(data);
|
||||
if (body.StartsWith("NO_SCREENSHOTS")) return shots;
|
||||
foreach (var line in body.Split('\n'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length < 4) continue;
|
||||
long.TryParse(parts[2], out var size);
|
||||
long.TryParse(parts[3], out var mtime);
|
||||
shots.Add(new PS5Screenshot
|
||||
{
|
||||
FullPath = parts[0],
|
||||
FileName = parts[1],
|
||||
Size = size,
|
||||
ModifiedTime = DateTimeOffset.FromUnixTimeSeconds(mtime).LocalDateTime
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return shots.OrderByDescending(s => s.ModifiedTime).ToList();
|
||||
}
|
||||
|
||||
public async Task<(bool success, string message)> DeleteScreenshotAsync(string fullPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(fullPath + "\0");
|
||||
var (response, respData) = await RunCommandAsync(Command.DeleteScreenshot, data);
|
||||
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
// ---------------- Mount all games (long-running, progress) ----------------
|
||||
public async Task<(bool success, string summary)> MountGamesAsync(Action<string>? onProgress = null)
|
||||
{
|
||||
await _commandLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await SendCommandInternalAsync(Command.MountGames);
|
||||
// Server streams Progress responses, then final OK or Error
|
||||
while (true)
|
||||
{
|
||||
var (resp, data) = await ReceiveResponseInternalAsync();
|
||||
if (resp == Response.Progress)
|
||||
{
|
||||
onProgress?.Invoke(Encoding.UTF8.GetString(data).TrimEnd('\0'));
|
||||
}
|
||||
else if (resp == Response.Ok)
|
||||
{
|
||||
return (true, Encoding.UTF8.GetString(data));
|
||||
}
|
||||
else if (resp == Response.Error)
|
||||
{
|
||||
return (false, Encoding.UTF8.GetString(data));
|
||||
}
|
||||
else { return (false, $"Unexpected response {resp}"); }
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
finally { _commandLock.Release(); }
|
||||
}
|
||||
|
||||
// ---------------- Launch Browser ----------------
|
||||
public async Task<(bool success, string message)> LaunchBrowserAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes(url + "\0");
|
||||
var (response, respData) = await RunCommandAsync(Command.LaunchBrowser, data);
|
||||
return (response == Response.Ok, Encoding.UTF8.GetString(respData));
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
// Shell methods (OpenShellAsync/ExecuteShellCommandAsync/CloseShellAsync)
|
||||
// already exist in Protocol.cs — not duplicated here.
|
||||
}
|
||||
|
||||
// ========== Data models ==========
|
||||
|
||||
public class PS5HardwareInfo
|
||||
{
|
||||
public string Model { get; set; } = "";
|
||||
public string Serial { get; set; } = "";
|
||||
public bool HasWlanBt { get; set; }
|
||||
public bool HasOpticalOut { get; set; }
|
||||
public string HwMachine { get; set; } = "";
|
||||
public string OsVersion { get; set; } = "";
|
||||
public int NumCpu { get; set; }
|
||||
public ulong PhysMem { get; set; }
|
||||
|
||||
public string PhysMemDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PhysMem == 0) return "—";
|
||||
double gb = PhysMem / (1024.0 * 1024.0 * 1024.0);
|
||||
return $"{gb:F1} GB";
|
||||
}
|
||||
}
|
||||
|
||||
public string WlanBtDisplay => HasWlanBt ? "✓ Yes" : "✗ None";
|
||||
public string OpticalDisplay => HasOpticalOut ? "✓ Yes" : "✗ None";
|
||||
}
|
||||
|
||||
public class PS5TemperatureInfo
|
||||
{
|
||||
public int CpuTemp { get; set; }
|
||||
public int SocTemp { get; set; }
|
||||
public long CpuFreqMhz { get; set; }
|
||||
public uint SocPowerMw { get; set; }
|
||||
|
||||
public string CpuTempDisplay => CpuTemp > 0 ? $"{CpuTemp}°C" : "—";
|
||||
public string SocTempDisplay => SocTemp > 0 ? $"{SocTemp}°C" : "—";
|
||||
public string CpuFreqDisplay => CpuFreqMhz > 0 ? $"{CpuFreqMhz / 1000.0:F2} GHz" : "—";
|
||||
public string SocPowerDisplay => SocPowerMw > 0 ? $"{SocPowerMw / 1000.0:F1} W" : "—";
|
||||
}
|
||||
|
||||
public class PS5PowerInfo
|
||||
{
|
||||
public ulong OperatingTimeSec { get; set; }
|
||||
public ulong OperatingTimeHours { get; set; }
|
||||
public ulong OperatingTimeMinutes { get; set; }
|
||||
public uint BootCount { get; set; }
|
||||
public uint PowerConsumptionMw { get; set; }
|
||||
}
|
||||
|
||||
public class PS5MountedGame : INotifyPropertyChanged
|
||||
{
|
||||
public string TitleId { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public ulong Size { get; set; }
|
||||
public string Region { get; set; } = "";
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
private ImageSource? _icon;
|
||||
public ImageSource? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
|
||||
}
|
||||
|
||||
public string SizeDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
double mb = Size / (1024.0 * 1024.0);
|
||||
if (mb < 1024) return $"{mb:F1} MB";
|
||||
return $"{mb / 1024.0:F2} GB";
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusDisplay => IsActive ? "✓ Mounted" : "✗ Not Mounted";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
public class PS5SaveGame : INotifyPropertyChanged
|
||||
{
|
||||
public string TitleId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public string SavePath { get; set; } = "";
|
||||
public ulong Size { get; set; }
|
||||
public long ModifiedUnixTime { get; set; }
|
||||
|
||||
public string SizeDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
double mb = Size / (1024.0 * 1024.0);
|
||||
if (mb < 1) return $"{Size / 1024.0:F1} KB";
|
||||
if (mb < 1024) return $"{mb:F1} MB";
|
||||
return $"{mb / 1024.0:F2} GB";
|
||||
}
|
||||
}
|
||||
|
||||
public string ModifiedDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var dt = DateTimeOffset.FromUnixTimeSeconds(ModifiedUnixTime).LocalDateTime;
|
||||
return dt.ToString("yyyy-MM-dd HH:mm");
|
||||
}
|
||||
catch { return "—"; }
|
||||
}
|
||||
}
|
||||
|
||||
public string ShortUserId => UserId.Length > 8 ? UserId.Substring(0, 8) + "…" : UserId;
|
||||
|
||||
private string _gameName = "";
|
||||
public string GameName
|
||||
{
|
||||
get => _gameName;
|
||||
set { _gameName = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(GameName))); }
|
||||
}
|
||||
|
||||
private ImageSource? _icon;
|
||||
public ImageSource? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set { _icon = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Icon))); }
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
public class PS5Screenshot : INotifyPropertyChanged
|
||||
{
|
||||
public string FullPath { get; set; } = "";
|
||||
public string FileName { get; set; } = "";
|
||||
public long Size { get; set; }
|
||||
public DateTime ModifiedTime { get; set; }
|
||||
|
||||
private ImageSource? _thumbnail;
|
||||
public ImageSource? Thumbnail
|
||||
{
|
||||
get => _thumbnail;
|
||||
set { _thumbnail = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Thumbnail))); }
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public string SizeDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Size < 1024) return $"{Size} B";
|
||||
if (Size < 1024 * 1024) return $"{Size / 1024.0:0.0} KB";
|
||||
return $"{Size / (1024.0 * 1024.0):0.0} MB";
|
||||
}
|
||||
}
|
||||
|
||||
public string DateDisplay => ModifiedTime.ToString("yyyy-MM-dd HH:mm");
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,14 @@ Complete all-in-one PS5 management platform — **104+ MB/s** file transfers, li
|
||||
|
||||
| File | Size | Purpose |
|
||||
|------|------|---------|
|
||||
| `PS5Upload.exe` | 146 MB | Windows GUI client (self-contained, no .NET install required) |
|
||||
| `PS5Upload.exe` | 147 MB | Windows GUI client (self-contained, no .NET install required) |
|
||||
| `PS5UploadMobile.apk` | 30 MB | **NEW:** Android client with full feature parity (8+ tabs) |
|
||||
| `ps5_upload_server.elf` | 187 KB | PS5 payload — load via elfldr, listens on port 9113 |
|
||||
|
||||
**Load the payload on your PS5:** copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/` and run it. You will see a notification `PS5 Upload Server: 192.168.0.XXX:9113 - By Manos` confirming it's ready.
|
||||
|
||||
**Install the Android app:** download `PS5UploadMobile.apk` and install it on your Android device (enable "Install from unknown sources").
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v5.0.0
|
||||
@@ -113,7 +116,41 @@ Complete all-in-one PS5 management platform — **104+ MB/s** file transfers, li
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
---
|
||||
|
||||
## 📱 NEW: Android Mobile Client — Full Feature Parity
|
||||
|
||||
The Android app (`PS5UploadMobile.apk`) now has **every feature** of the desktop client, organized via a **hamburger flyout menu (☰)** with a clean light theme for maximum readability:
|
||||
|
||||
### 7 Fully-Functional Tabs
|
||||
- 📂 **Files** — Browse, upload, download, delete, rename, copy/move with drag-friendly UI
|
||||
- 🎮 **Games** — Mounted games list with cover icons, **Launch Game**, Unmount, Game Details
|
||||
- 💾 **Saves** — Per-game save browsing with icons, metadata, user IDs
|
||||
- 📷 **Screenshots** — 2-column gallery with live thumbnails, batch download, clean delete
|
||||
- 🖥️ **Hardware** — System Info + Live Sensors (CPU/SoC temps, freq, power) + Power Stats + auto-refresh
|
||||
- 💻 **Shell** — Terminal-style remote command execution with auto-scroll
|
||||
- 🎮 **Mount Games** — Dedicated page for multi-path scan with streaming progress log
|
||||
|
||||
### Mobile-Specific Features
|
||||
- ✅ **Shared connection** — Connect once in Files tab, all other tabs use it automatically
|
||||
- ✅ **Thumbnail caching** — Local cache in app cache directory, zero re-downloads
|
||||
- ✅ **Pull-to-refresh** on games, saves, screenshots
|
||||
- ✅ **Light flyout menu** — white background, dark text, blue accent on selected item
|
||||
- ✅ **Dark content pages** — modern readable UI throughout
|
||||
- ✅ **Interlocked guards** — prevents overlapping refreshes on Hardware tab
|
||||
- ✅ **Auto-stop timers** — auto-refresh stops on disconnect
|
||||
- ✅ **0 warnings, 0 errors** build
|
||||
|
||||
### Mobile Quick Start
|
||||
1. Enable **"Install from unknown sources"** in Android settings
|
||||
2. Download and install `PS5UploadMobile.apk`
|
||||
3. Load the PS5 payload (`ps5_upload_server.elf`)
|
||||
4. Open the app, enter your PS5 IP, tap **Connect**
|
||||
5. Open the **☰ menu** to access all 7 tabs
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start (Desktop)
|
||||
|
||||
1. **Load payload on PS5:** Copy `ps5_upload_server.elf` to `/data/etaHEN/payloads/` and execute it
|
||||
2. **Run client on PC:** Launch `PS5Upload.exe` on Windows
|
||||
|
||||
Reference in new issue
Block a user