combine projects into one

This commit is contained in:
2021-08-11 14:09:03 +08:00
parent 24f39a2e27
commit 521d82829d
34 changed files with 431 additions and 192 deletions

View File

@@ -18,16 +18,14 @@ namespace Gallery
public static Dictionary<string, System.DateTime> RefreshTimes { get; } = new();
public static List<IGallerySource> GallerySources { get; } = new()
{
new Yandere.GallerySource(), // https://yande.re
new Danbooru.GallerySource(), // https://danbooru.donmai.us
new Gelbooru.GallerySource() // https://gelbooru.com
new Sources.Yandere.GallerySource(), // https://yande.re
new Sources.Danbooru.GallerySource(), // https://danbooru.donmai.us
new Sources.Gelbooru.GallerySource() // https://gelbooru.com
};
public App()
{
Preferences.Set(Config.IsProxiedKey, true);
Preferences.Set(Config.ProxyHostKey, "192.168.25.9");
Preferences.Set(Config.ProxyPortKey, 1081);
//Device.SetFlags(new string[0]);
}
private void InitResource()
@@ -44,6 +42,7 @@ namespace Gallery
private void InitPreference()
{
Config.Proxy = null;
Config.DownloadThreads = Preferences.Get(Config.DownloadThreadsKey, 1);
var isProxied = Preferences.Get(Config.IsProxiedKey, false);
if (isProxied)

View File

@@ -4,7 +4,8 @@
xmlns:local="clr-namespace:Gallery"
xmlns:r="clr-namespace:Gallery.Resources"
xmlns:ui="clr-namespace:Gallery.Resources.UI"
xmlns:util="clr-namespace:Gallery.Util;assembly=Gallery.Util"
xmlns:util="clr-namespace:Gallery.Util"
xmlns:v="clr-namespace:Gallery.Views"
x:Class="Gallery.AppShell"
x:Name="appShell"
BackgroundColor="{DynamicResource NavigationColor}"
@@ -15,18 +16,18 @@
<Shell.Resources>
<ResourceDictionary>
<Style x:Key="BaseStyle" TargetType="Element">
<Setter Property="Shell.BackgroundColor" Value="{DynamicResource NavigationColor}" />
<Setter Property="Shell.ForegroundColor" Value="{DynamicResource TintColor}" />
<Setter Property="Shell.TitleColor" Value="{DynamicResource TextColor}" />
<Setter Property="Shell.DisabledColor" Value="#B4FFFFFF" />
<Setter Property="Shell.UnselectedColor" Value="{DynamicResource TintColor}" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{DynamicResource NavigationColor}" />
<Setter Property="Shell.BackgroundColor" Value="{DynamicResource NavigationColor}"/>
<Setter Property="Shell.ForegroundColor" Value="{DynamicResource TintColor}"/>
<Setter Property="Shell.TitleColor" Value="{DynamicResource TextColor}"/>
<Setter Property="Shell.DisabledColor" Value="#B4FFFFFF"/>
<Setter Property="Shell.UnselectedColor" Value="{DynamicResource TintColor}"/>
<Setter Property="Shell.TabBarBackgroundColor" Value="{DynamicResource NavigationColor}"/>
<Setter Property="Shell.TabBarForegroundColor" Value="{DynamicResource TintColor}"/>
<Setter Property="Shell.TabBarUnselectedColor" Value="{DynamicResource TintColor}"/>
<Setter Property="Shell.TabBarTitleColor" Value="{DynamicResource TextColor}"/>
</Style>
<Style TargetType="TabBar" BasedOn="{StaticResource BaseStyle}" />
<Style TargetType="FlyoutItem" BasedOn="{StaticResource BaseStyle}" />
<Style TargetType="TabBar" BasedOn="{StaticResource BaseStyle}"/>
<Style TargetType="FlyoutItem" BasedOn="{StaticResource BaseStyle}"/>
<!--
Custom Style you can apply to any Flyout Item
<Style Class="MenuItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True">
@@ -35,7 +36,7 @@
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal">
<VisualState.Setters>
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{DynamicResource Primary}" />
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{DynamicResource Primary}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
@@ -109,10 +110,17 @@
-->
<FlyoutItem x:Name="flyoutItems"
FlyoutDisplayOptions="AsMultipleItems"
Route="{x:Static util:Routes.Gallery}" />
Route="{x:Static util:Routes.Gallery}"/>
<FlyoutItem FlyoutIcon="{DynamicResource FontIconOption}"
Title="{r:Text Option}"
Route="{x:Static util:Routes.Option}">
<Tab>
<ShellContent ContentTemplate="{DataTemplate v:OptionPage}"/>
</Tab>
</FlyoutItem>
<!-- When the Flyout is visible this will be a menu item you can tie a click behavior to -->
<!--<MenuItem Text="Logout" StyleClass="MenuItemLayoutStyle" Clicked="OnMenuItemClicked" />-->
<!--<MenuItem Text="Logout" StyleClass="MenuItemLayoutStyle" Clicked="OnMenuItemClicked"/>-->
<!--
TabBar lets you define content that won't show up in a flyout menu. When this content is active
@@ -121,7 +129,7 @@
content you can do so by calling
await Shell.Current.GoToAsync("//LoginPage");
<TabBar>
<ShellContent Route="LoginPage" ContentTemplate="{DataTemplate local:LoginPage}" />
<ShellContent Route="LoginPage" ContentTemplate="{DataTemplate local:LoginPage}"/>
</TabBar>
-->

View File

@@ -1,7 +1,6 @@
using System;
using Gallery.Resources;
using Gallery.Resources.UI;
using Gallery.Resources.UI;
using Gallery.Util;
using Gallery.Util.Interface;
using Gallery.Views;
using Xamarin.Forms;
@@ -27,9 +26,14 @@ namespace Gallery
private void InitFlyouts()
{
IGallerySource @default = null;
foreach (var source in App.GallerySources)
{
var s = source;
if (@default == null)
{
@default = s;
}
var tab = new Tab
{
Title = source.Name,
@@ -45,6 +49,9 @@ namespace Gallery
.DynamicResource(BaseShellItem.FlyoutIconProperty, source.FlyoutIconKey);
flyoutItems.Items.Add(tab);
}
var route = @default?.Route ?? Routes.Option;
GoToAsync($"//{route}");
}
public void SetNavigationBarHeight(double height)

View File

@@ -28,6 +28,23 @@
<Compile Include="$(MSBuildThisFileDirectory)Views\GalleryPage.xaml.cs">
<DependentUpon>GalleryPage.xaml</DependentUpon>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)Views\OptionPage.xaml.cs">
<DependentUpon>OptionPage.xaml</DependentUpon>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)Views\GalleryItemPage.xaml.cs">
<DependentUpon>GalleryItemPage.xaml</DependentUpon>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)Util\Interface\IGallerySource.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Util\Model\GalleryItem.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Util\Extensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Util\Log.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Util\NetHelper.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Util\ParallelTask.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Util\Store.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Sources\Yandere\GallerySource.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Sources\Yandere\YandereItem.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Sources\Danbooru\GallerySource.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Sources\Gelbooru\GallerySource.cs" />
</ItemGroup>
<ItemGroup>
<Folder Include="$(MSBuildThisFileDirectory)Services\" />
@@ -36,6 +53,13 @@
<Folder Include="$(MSBuildThisFileDirectory)Resources\Theme\" />
<Folder Include="$(MSBuildThisFileDirectory)Resources\Languages\" />
<Folder Include="$(MSBuildThisFileDirectory)Resources\UI\" />
<Folder Include="$(MSBuildThisFileDirectory)Util\" />
<Folder Include="$(MSBuildThisFileDirectory)Util\Interface\" />
<Folder Include="$(MSBuildThisFileDirectory)Util\Model\" />
<Folder Include="$(MSBuildThisFileDirectory)Sources\" />
<Folder Include="$(MSBuildThisFileDirectory)Sources\Yandere\" />
<Folder Include="$(MSBuildThisFileDirectory)Sources\Danbooru\" />
<Folder Include="$(MSBuildThisFileDirectory)Sources\Gelbooru\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="$(MSBuildThisFileDirectory)AppShell.xaml">
@@ -47,5 +71,13 @@
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
</EmbeddedResource>
<EmbeddedResource Include="$(MSBuildThisFileDirectory)Resources\Languages\zh-CN.xml" />
<EmbeddedResource Include="$(MSBuildThisFileDirectory)Views\OptionPage.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
</EmbeddedResource>
<EmbeddedResource Include="$(MSBuildThisFileDirectory)Views\GalleryItemPage.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -1,4 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<root>
<Title>Gallery</Title>
<Option>选项</Option>
<About>关于</About>
<Version>版本</Version>
<Gallery>图库</Gallery>
<DownloadThreads>多线程下载</DownloadThreads>
<Proxy>代理</Proxy>
<Enabled>启用</Enabled>
<Detail>详细</Detail>
<ProxyHost>代理主机</ProxyHost>
<ProxyPort>代理端口</ProxyPort>
</root>

View File

@@ -22,6 +22,7 @@ namespace Gallery.Resources.Theme
public const string ScreenBottomPadding = nameof(ScreenBottomPadding);
public const string IconClose = nameof(IconClose);
public const string FontIconOption = nameof(FontIconOption);
public const string FontIconRefresh = nameof(FontIconRefresh);
protected void InitResources()
@@ -31,6 +32,7 @@ namespace Gallery.Resources.Theme
Add(IconSolidFamily, Definition.IconSolidFamily);
Add(ScreenBottomPadding, Definition.ScreenBottomPadding);
Add(FontIconOption, GetFontIcon(Definition.IconOption, Definition.IconSolidFamily));
Add(FontIconRefresh, GetFontIcon(Definition.IconRefresh, Definition.IconSolidFamily));
Add(IconClose, Definition.IconClose);

View File

@@ -31,6 +31,7 @@ namespace Gallery.Resources.UI
public const string IconLeft = "\uf053";
#endif
public const string IconOption = "\uf013";
public const string IconRefresh = "\uf2f9";
public const string IconLove = "\uf004";
public const string IconCircleLove = "\uf4c7";

View File

@@ -0,0 +1,75 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Gallery.Util;
using Gallery.Util.Interface;
using Gallery.Util.Model;
using Xamarin.Forms;
namespace Gallery.Sources.Danbooru
{
public class GallerySource : IGallerySource
{
public string Name => "Danbooru";
public string Route => "danbooru";
public string FlyoutIconKey => "Danbooru";
public string HomePage => "https://danbooru.donmai.us";
public async Task<GalleryItem[]> GetRecentItemsAsync(int page)
{
var url = $"https://danbooru.donmai.us/posts?page={page}";
var (result, error) = await NetHelper.RequestObject(url, @return: content => ResolveGalleryItems(content));
if (result == null || !string.IsNullOrEmpty(error))
{
Log.Error("danbooru.content.load", $"failed to load content array, error: {error}");
return null;
}
return result;
}
private GalleryItem[] ResolveGalleryItems(string content)
{
var regex = new Regex(
@"<article id=""post_\d+"".+?data-id=""(\d+)"".+?data-tags=""([^""]+?)"".+?" +
@"data-width=""(\d+?)"" data-height=""(\d+?)"".+?data-source=""([^""]+?)"".+?" +
@"data-uploader-id=""(\d+?)"".+?data-normalized-source=""([^""]+?)"".+?" +
@"data-file-url=""([^""]+?)"".+?data-large-file-url=""([^""]+?)""", RegexOptions.Multiline);
var matches = regex.Matches(content);
var items = new GalleryItem[matches.Count];
for (var i = 0; i < items.Length; i++)
{
var g = matches[i].Groups;
items[i] = new GalleryItem(int.Parse(g[1].Value))
{
Tags = g[2].Value.Split(' '),
Width = int.Parse(g[3].Value),
Height = int.Parse(g[4].Value),
UserId = g[6].Value,
Source = g[7].Value,
RawUrl = g[8].Value,
PreviewUrl = g[9].Value
};
}
return items;
}
public void SetCookie()
{
throw new NotImplementedException();
}
public void InitDynamicResources(string family, ResourceDictionary light, ResourceDictionary dark)
{
var icon = new FontImageSource
{
FontFamily = family,
Glyph = "\uf5d2",
Size = 18.0
};
light.Add(FlyoutIconKey, icon);
dark.Add(FlyoutIconKey, icon);
}
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Gallery.Util;
using Gallery.Util.Interface;
using Gallery.Util.Model;
using Xamarin.Forms;
namespace Gallery.Sources.Gelbooru
{
public class GallerySource : IGallerySource
{
public string Name => "Gelbooru";
public string Route => "gelbooru";
public string FlyoutIconKey => "Gelbooru";
public string HomePage => "https://gelbooru.com";
public async Task<GalleryItem[]> GetRecentItemsAsync(int page)
{
var offset = (page - 1) * 42;
var url = $"https://gelbooru.com/index.php?page=post&s=list&tags=all&pid={offset}";
var (result, error) = await NetHelper.RequestObject(url, @return: content => ResolveGalleryItems(content));
if (result == null || !string.IsNullOrEmpty(error))
{
Log.Error("gelbooru.content.load", $"failed to load content array, error: {error}");
return null;
}
return result;
}
private GalleryItem[] ResolveGalleryItems(string content)
{
var regex = new Regex(
@"<article(.|\n)+?<a id=""p(\d+?)"" href=""([^""]+?)"">(.|\n)+?<img src=""([^""]+?)"" title=""([^""]+?)""",
RegexOptions.Multiline);
var matches = regex.Matches(content);
var items = new GalleryItem[matches.Count];
for (var i = 0; i < items.Length; i++)
{
var g = matches[i].Groups;
items[i] = new GalleryItem(int.Parse(g[2].Value))
{
RawUrl = g[3].Value,
PreviewUrl = g[5].Value,
Tags = g[6].Value.Split(' '),
IsRawPage = true
};
}
return items;
}
public void SetCookie()
{
throw new NotImplementedException();
}
public void InitDynamicResources(string family, ResourceDictionary light, ResourceDictionary dark)
{
var icon = new FontImageSource
{
FontFamily = family,
Glyph = "\uf5d2",
Size = 18.0
};
light.Add(FlyoutIconKey, icon);
dark.Add(FlyoutIconKey, icon);
}
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Gallery.Util;
using Gallery.Util.Interface;
using Gallery.Util.Model;
using Xamarin.Forms;
namespace Gallery.Sources.Yandere
{
public class GallerySource : IGallerySource
{
public string Name => "Yande.re";
public string Route => "yandere";
public string FlyoutIconKey => "Yandere";
public string HomePage => "https://yande.re";
public async Task<GalleryItem[]> GetRecentItemsAsync(int page)
{
var url = $"https://yande.re/post?page={page}";
var (result, error) = await NetHelper.RequestObject<YandereItem[]>(url, contentHandler: ContentHandler);
if (result == null || !string.IsNullOrEmpty(error))
{
Log.Error("yandere.content.load", $"failed to load content array, error: {error}");
return null;
}
var items = new GalleryItem[result.Length];
for (var i = 0; i < items.Length; i++)
{
var y = result[i];
var item = new GalleryItem(y.id)
{
Tags = y.tags?.Split(' '),
CreatedTime = y.created_at.ToLocalTime(),
UpdatedTime = y.updated_at.ToLocalTime(),
UserId = y.creator_id.ToString(),
UserName = y.author,
Source = y.source,
PreviewUrl = y.preview_url,
RawUrl = y.file_url,
Width = y.width,
Height = y.height
};
items[i] = item;
}
return items;
}
private string ContentHandler(string content)
{
var regex = new Regex(@"Post\.register\((\{.+\})\)\s*$", RegexOptions.Multiline);
var matches = regex.Matches(content);
var array = new string[matches.Count];
for (var i = 0; i < array.Length; i++)
{
array[i] = matches[i].Groups[1].Value;
}
return $"[{string.Join(',', array)}]";
}
public void SetCookie()
{
throw new NotImplementedException();
}
public void InitDynamicResources(string family, ResourceDictionary light, ResourceDictionary dark)
{
var icon = new FontImageSource
{
FontFamily = family,
Glyph = "\uf302",
Size = 18.0
};
light.Add(FlyoutIconKey, icon);
dark.Add(FlyoutIconKey, icon);
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
namespace Gallery.Sources.Yandere
{
public class YandereItem
{
#pragma warning disable IDE1006 // Naming Styles
public long id { get; set; }
public string tags { get; set; }
public long created_at { get; set; }
public long updated_at { get; set; }
public long creator_id { get; set; }
public string author { get; set; }
public string source { get; set; }
public int score { get; set; }
public int file_size { get; set; }
public string file_ext { get; set; }
public string file_url { get; set; }
public string preview_url { get; set; }
public int actual_preview_width { get; set; }
public int actual_preview_height { get; set; }
public string rating { get; set; }
public int width { get; set; }
public int height { get; set; }
#pragma warning restore IDE1006 // Naming Styles
}
}

View File

@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Gallery.Util.Model;
namespace Gallery.Util
{
public class GalleryItemConverter : JsonConverter<GalleryItem>
{
public override GalleryItem Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
return null;
}
var item = new GalleryItem();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
return item;
}
if (reader.TokenType == JsonTokenType.PropertyName)
{
var name = reader.GetString();
reader.Read();
switch (name)
{
case nameof(GalleryItem.Id): item.Id = reader.GetInt64(); break;
case nameof(GalleryItem.Tags):
if (reader.TokenType == JsonTokenType.StartArray)
{
var tags = new List<string>();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
break;
}
if (reader.TokenType == JsonTokenType.String)
{
tags.Add(reader.GetString());
}
}
item.Tags = tags.ToArray();
}
break;
case nameof(GalleryItem.CreatedTime): item.CreatedTime = reader.GetDateTime(); break;
case nameof(GalleryItem.UpdatedTime): item.UpdatedTime = reader.GetDateTime(); break;
case nameof(GalleryItem.UserId): item.UserId = reader.GetString(); break;
case nameof(GalleryItem.UserName): item.UserName = reader.GetString(); break;
case nameof(GalleryItem.Source): item.Source = reader.GetString(); break;
case nameof(GalleryItem.PreviewUrl): item.PreviewUrl = reader.GetString(); break;
case nameof(GalleryItem.RawUrl): item.RawUrl = reader.GetString(); break;
case nameof(GalleryItem.Width): item.Width = reader.GetInt32(); break;
case nameof(GalleryItem.Height): item.Height = reader.GetInt32(); break;
case nameof(GalleryItem.BookmarkId): item.BookmarkId = reader.GetString(); break;
case nameof(GalleryItem.IsRawPage): item.IsRawPage = reader.GetBoolean(); break;
}
}
}
throw new ArgumentOutOfRangeException();
}
public override void Write(Utf8JsonWriter writer, GalleryItem value, JsonSerializerOptions options)
{
if (value == null)
{
writer.WriteNullValue();
return;
}
writer.WriteStartObject();
writer.WriteNumber(nameof(GalleryItem.Id), value.Id);
if (value.Tags != null)
{
writer.WritePropertyName(nameof(GalleryItem.Tags));
writer.WriteStartArray();
for (var i = 0; i < value.Tags.Length; i++)
{
writer.WriteStringValue(value.Tags[i]);
}
writer.WriteEndArray();
}
writer.WriteString(nameof(GalleryItem.CreatedTime), value.CreatedTime);
writer.WriteString(nameof(GalleryItem.UpdatedTime), value.UpdatedTime);
writer.WriteString(nameof(GalleryItem.UserId), value.UserId);
writer.WriteString(nameof(GalleryItem.UserName), value.UserName);
writer.WriteString(nameof(GalleryItem.Source), value.Source);
writer.WriteString(nameof(GalleryItem.PreviewUrl), value.PreviewUrl);
writer.WriteString(nameof(GalleryItem.RawUrl), value.RawUrl);
writer.WriteNumber(nameof(GalleryItem.Width), value.Width);
writer.WriteNumber(nameof(GalleryItem.Height), value.Height);
writer.WriteString(nameof(GalleryItem.BookmarkId), value.BookmarkId);
writer.WriteBoolean(nameof(GalleryItem.IsRawPage), value.IsRawPage);
writer.WriteEndObject();
}
}
}

View File

@@ -0,0 +1,67 @@
using System;
namespace Gallery.Util
{
public static class Extensions
{
public static int IndexOf<T>(this T[] array, Predicate<T> predicate)
{
for (var i = 0; i < array.Length; i++)
{
if (predicate(array[i]))
{
return i;
}
}
return -1;
}
public static int LastIndexOf<T>(this T[] array, Predicate<T> predicate)
{
for (var i = array.Length - 1; i >= 0; i--)
{
if (predicate(array[i]))
{
return i;
}
}
return -1;
}
public static bool All<T>(this T[] array, Predicate<T> predicate)
{
for (var i = 0; i < array.Length; i++)
{
if (!predicate(array[i]))
{
return false;
}
}
return true;
}
public static bool AnyFor<T>(this T[] array, int from, int to, Predicate<T> predicate)
{
for (var i = from; i <= to; i++)
{
if (predicate(array[i]))
{
return true;
}
}
return false;
}
public static DateTime ToLocalTime(this long time)
{
//return new DateTime(1970, 1, 1, 0, 0, 0).AddMilliseconds(time).ToLocalTime();
return new DateTime(621355968000000000L + time * 10000000).ToLocalTime();
}
public static long ToTimestamp(this DateTime datetime)
{
var ticks = datetime.Ticks;
return (ticks - 621355968000000000L) / 10000000;
}
}
}

View File

@@ -0,0 +1,23 @@
using System.Threading.Tasks;
using Gallery.Util.Model;
using Xamarin.Forms;
namespace Gallery.Util.Interface
{
public interface IGallerySource
{
string Name { get; }
string Route { get; }
string FlyoutIconKey { get; }
string HomePage { get; }
void SetCookie();
void InitDynamicResources(string family, ResourceDictionary light, ResourceDictionary dark);
Task<GalleryItem[]> GetRecentItemsAsync(int page);
}
}

41
Gallery.Share/Util/Log.cs Normal file
View File

@@ -0,0 +1,41 @@
using System;
using System.Diagnostics;
namespace Gallery.Util
{
public static class Log
{
public static ILog Logger { get; set; } = new DefaultLogger();
public static void Print(string message)
{
Logger?.Print(message);
}
public static void Error(string category, string message)
{
Logger?.Error(category, message);
}
}
public class DefaultLogger : ILog
{
public void Print(string message)
{
#if DEBUG
Debug.WriteLine("[{0:HH:mm:ss.fff}] - {1}", DateTime.Now, message);
#endif
}
public void Error(string category, string message)
{
Debug.Fail(string.Format("[{0:HH:mm:ss.fff}] - {1} - {2}", DateTime.Now, category, message));
}
}
public interface ILog
{
void Print(string message);
void Error(string category, string message);
}
}

View File

@@ -0,0 +1,131 @@
using System;
//using System.Text.Json.Serialization;
using Newtonsoft.Json;
using Xamarin.Forms;
namespace Gallery.Util.Model
{
//[JsonConverter(typeof(GalleryItemConverter))]
[JsonObject(MemberSerialization.OptIn)]
public class GalleryItem : BindableObject
{
const double PREVIEW_WIDTH = 200.0;
public static readonly BindableProperty TagDescriptionProperty = BindableProperty.Create(nameof(TagDescription), typeof(string), typeof(GalleryItem));
public static readonly BindableProperty PreviewImageProperty = BindableProperty.Create(nameof(PreviewImage), typeof(ImageSource), typeof(GalleryItem));
public static readonly BindableProperty UserNameProperty = BindableProperty.Create(nameof(UserName), typeof(string), typeof(GalleryItem));
public static readonly BindableProperty CreatedTimeProperty = BindableProperty.Create(nameof(CreatedTime), typeof(DateTime), typeof(GalleryItem));
public static readonly BindableProperty UpdatedTimeProperty = BindableProperty.Create(nameof(UpdatedTime), typeof(DateTime), typeof(GalleryItem));
public static readonly BindableProperty ImageHeightProperty = BindableProperty.Create(nameof(ImageHeight), typeof(GridLength), typeof(GalleryItem),
defaultValue: GridLength.Auto);
public static readonly BindableProperty IsFavoriteProperty = BindableProperty.Create(nameof(IsFavorite), typeof(bool), typeof(GalleryItem));
public static readonly BindableProperty BookmarkIdProperty = BindableProperty.Create(nameof(BookmarkId), typeof(string), typeof(GalleryItem));
public string TagDescription
{
get => (string)GetValue(TagDescriptionProperty);
set => SetValue(TagDescriptionProperty, value);
}
public ImageSource PreviewImage
{
get => (ImageSource)GetValue(PreviewImageProperty);
set => SetValue(PreviewImageProperty, value);
}
public GridLength ImageHeight
{
get => (GridLength)GetValue(ImageHeightProperty);
set => SetValue(ImageHeightProperty, value);
}
public bool IsFavorite
{
get => (bool)GetValue(IsFavoriteProperty);
set => SetValue(IsFavoriteProperty, value);
}
[JsonProperty]
public string BookmarkId
{
get => (string)GetValue(BookmarkIdProperty);
set => SetValue(BookmarkIdProperty, value);
}
[JsonProperty]
public long Id { get; internal set; }
private string[] tags;
[JsonProperty]
public string[] Tags
{
get => tags;
set
{
tags = value;
if (value != null)
{
TagDescription = string.Join(' ', tags);
}
else
{
TagDescription = null;
}
}
}
[JsonProperty]
public DateTime CreatedTime { get; set; }
[JsonProperty]
public DateTime UpdatedTime { get; set; }
[JsonProperty]
public string UserId { get; set; }
[JsonProperty]
public string UserName { get; set; }
[JsonProperty]
public string Source { get; set; }
[JsonProperty]
public string PreviewUrl { get; set; }
[JsonProperty]
public string RawUrl { get; set; }
[JsonProperty]
public bool IsRawPage { get; set; }
private int width;
private int height;
[JsonProperty]
public int Width
{
get => width;
set
{
width = value;
if (width > 0 && height > 0)
{
ImageHeight = new GridLength(PREVIEW_WIDTH * height / width);
}
}
}
[JsonProperty]
public int Height
{
get => height;
set
{
height = value;
if (width > 0 && height > 0)
{
ImageHeight = new GridLength(PREVIEW_WIDTH * height / width);
}
}
}
internal GalleryItem() { }
public GalleryItem(long id)
{
Id = id;
}
public override string ToString()
{
var source = string.IsNullOrEmpty(Source) ? RawUrl : Source;
return $"{Id}, {source}";
}
}
}

View File

@@ -0,0 +1,283 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Xamarin.Essentials;
namespace Gallery.Util
{
public static class NetHelper
{
public static bool NetworkAvailable
{
get
{
try
{
return Connectivity.NetworkAccess == NetworkAccess.Internet
|| Connectivity.NetworkAccess == NetworkAccess.ConstrainedInternet;
}
catch
{
return false;
}
}
}
public static async Task<(T result, string error)> RequestObject<T>(string url,
string referer = null,
HttpContent post = null,
Action<HttpRequestHeaders> headerHandler = null,
Func<string, string> contentHandler = null,
Func<string, T> @return = null)
{
var response = await Request(url, headers =>
{
if (referer != null)
{
headers.Referrer = new Uri(referer);
}
headers.Add("User-Agent", Config.UserAgent);
headerHandler?.Invoke(headers);
}, post);
if (response == null)
{
return (default, "response is null");
}
if (!response.IsSuccessStatusCode)
{
Log.Print($"http failed with code: {(int)response.StatusCode} - {response.StatusCode}");
return (default, response.StatusCode.ToString());
}
string content;
using (response)
{
try
{
content = await response.Content.ReadAsStringAsync();
if (contentHandler != null)
{
content = contentHandler(content);
}
if (@return != null)
{
return (@return(content), null);
}
}
catch (Exception ex)
{
Log.Error("stream.load", $"failed to read stream, error: {ex.Message}");
return (default, ex.Message);
}
}
if (content == null)
{
content = string.Empty;
}
try
{
var result = JsonConvert.DeserializeObject<T>(content);
return (result, null);
}
catch (Exception ex)
{
var memo = content.Length < 20 ? content : content[0..20] + "...";
Log.Error("content.deserialize", $"failed to parse JSON object, content: {memo}, error: {ex.Message}");
return (default, content);
}
}
public static async Task<string> DownloadImage(string url, string working, string folder)
{
try
{
var directory = Path.Combine(working, folder);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var file = Path.Combine(directory, Path.GetFileName(url));
var response = await Request(url, headers =>
{
headers.Add("User-Agent", Config.UserAgent);
headers.Add("Accept", Config.AcceptImage);
});
if (response == null)
{
return null;
}
using (response)
using (var fs = File.OpenWrite(file))
{
await response.Content.CopyToAsync(fs);
}
return file;
}
catch (Exception ex)
{
Log.Error("image.download", ex.Message);
return null;
}
}
public static async Task<string> DownloadImageAsync(string url, string id, string working, string folder)
{
try
{
var directory = Path.Combine(working, folder);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var file = Path.Combine(directory, Path.GetFileName(url));
var proxy = Config.Proxy;
var handler = new HttpClientHandler
{
UseCookies = false
};
if (proxy != null)
{
handler.Proxy = proxy;
handler.UseProxy = true;
}
var client = new HttpClient(handler, true)
{
Timeout = Config.Timeout
};
long size;
DateTimeOffset lastModified;
using (var request = new HttpRequestMessage(HttpMethod.Head, url))
{
var headers = request.Headers;
headers.Add("Accept", Config.AcceptImage);
headers.Add("Accept-Language", Config.AcceptLanguage);
headers.Add("User-Agent", Config.UserAgent);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
size = response.Content.Headers.ContentLength.Value;
lastModified = response.Content.Headers.LastModified.Value;
#if DEBUG
Log.Print($"content length: {size:n0} bytes, last modified: {lastModified}");
#endif
}
// segments
const int SIZE = 150000;
var list = new List<(long from, long to)>();
for (long i = 0; i < size; i += SIZE)
{
long to;
if (i + SIZE >= size)
{
to = size - 1;
}
else
{
to = i + SIZE - 1;
}
list.Add((i, to));
}
var data = new byte[size];
var task = new TaskCompletionSource<string>();
ParallelTask.Start($"download.async.{id}", 0, list.Count, 2, i =>
{
var (from, to) = list[i];
using (var request = new HttpRequestMessage(HttpMethod.Get, url))
{
var headers = request.Headers;
headers.Add("Accept", Config.AcceptImage);
headers.Add("Accept-Language", Config.AcceptLanguage);
headers.Add("Accept-Encoding", "identity");
headers.IfRange = new RangeConditionHeaderValue(lastModified);
headers.Range = new RangeHeaderValue(from, to);
headers.Add("User-Agent", Config.UserAgent);
using var response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).Result;
using var ms = new MemoryStream(data, (int)from, (int)(to - from + 1));
response.Content.CopyToAsync(ms).Wait();
#if DEBUG
Log.Print($"downloaded range: from({from:n0}) to ({to:n0})");
#endif
}
return true;
},
complete: o =>
{
using (var fs = File.OpenWrite(file))
{
fs.Write(data, 0, data.Length);
}
task.SetResult(file);
});
return await task.Task;
}
catch (Exception ex)
{
Log.Error("image.download.async", $"failed to download image, error: {ex.Message}");
return null;
}
}
private static async Task<HttpResponseMessage> Request(string url, Action<HttpRequestHeaders> headerHandler, HttpContent post = null)
{
#if DEBUG
var method = post == null ? "GET" : "POST";
Log.Print($"{method}: {url}");
#endif
var uri = new Uri(url);
var proxy = Config.Proxy;
var handler = new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
UseCookies = false
};
if (proxy != null)
{
handler.Proxy = proxy;
handler.UseProxy = true;
}
var client = new HttpClient(handler, true)
{
BaseAddress = new Uri($"{uri.Scheme}://{uri.Host}:{uri.Port}"),
Timeout = Config.Timeout
};
return await TryCount(() =>
{
using var request = new HttpRequestMessage(post == null ? HttpMethod.Get : HttpMethod.Post, uri.PathAndQuery);
var headers = request.Headers;
headerHandler?.Invoke(headers);
headers.Add("Accept-Language", Config.AcceptLanguage);
if (post != null)
{
request.Content = post;
}
return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
});
}
private static T TryCount<T>(Func<T> func, int tryCount = 2)
{
int tries = 0;
while (tries < tryCount)
{
try
{
return func();
}
catch (Exception ex)
{
tries++;
Thread.Sleep(1000);
Log.Error("try.do", $"tries: {tries}, error: {ex.Message}");
}
}
return default;
}
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Threading;
namespace Gallery.Util
{
public class ParallelTask : IDisposable
{
public static ParallelTask Start(string tag, int from, int toExclusive, int maxCount, Predicate<int> action, int tagIndex = -1, WaitCallback complete = null)
{
if (toExclusive <= from)
{
if (complete != null)
{
ThreadPool.QueueUserWorkItem(complete);
}
return null;
}
var task = new ParallelTask(tag, from, toExclusive, maxCount, action, tagIndex, complete);
task.Start();
return task;
}
private readonly object sync = new();
private int count;
private bool disposed;
public int TagIndex { get; private set; }
private readonly string tag;
private readonly int max;
private readonly int from;
private readonly int to;
private readonly Predicate<int> action;
private readonly WaitCallback complete;
private ParallelTask(string tag, int from, int to, int maxCount, Predicate<int> action, int tagIndex, WaitCallback complete)
{
if (maxCount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maxCount));
}
max = maxCount;
if (from >= to)
{
throw new ArgumentOutOfRangeException(nameof(from));
}
TagIndex = tagIndex;
this.tag = tag;
this.from = from;
this.to = to;
this.action = action;
this.complete = complete;
}
public void Start()
{
ThreadPool.QueueUserWorkItem(DoStart);
}
public void Dispose()
{
disposed = true;
}
private void DoStart(object state)
{
#if DEBUG
const long TIMEOUT = 60000L;
var sw = new System.Diagnostics.Stopwatch();
long lastElapsed = 0;
sw.Start();
#endif
for (int i = from; i < to; i++)
{
var index = i;
while (count >= max)
{
#if DEBUG
var elapsed = sw.ElapsedMilliseconds;
if (elapsed - lastElapsed > TIMEOUT)
{
lastElapsed = elapsed;
Log.Print($"WARNING: parallel task ({tag}), {count} tasks in queue, cost too much time ({elapsed:n0}ms)");
}
#endif
if (disposed)
{
#if DEBUG
sw.Stop();
Log.Print($"parallel task determinate, disposed ({tag}), cost time ({elapsed:n0}ms)");
#endif
return;
}
Thread.Sleep(16);
}
lock (sync)
{
count++;
}
ThreadPool.QueueUserWorkItem(o =>
{
try
{
if (!action(index))
{
disposed = true;
}
}
catch (Exception ex)
{
Log.Error($"parallel.start ({tag})", $"failed to run action, index: {index}, error: {ex}");
}
finally
{
lock (sync)
{
count--;
}
}
});
}
while (count > 0)
{
#if DEBUG
var elapsed = sw.ElapsedMilliseconds;
if (elapsed - lastElapsed > TIMEOUT)
{
lastElapsed = elapsed;
Log.Print($"WARNING: parallel task ({tag}), {count} ending tasks in queue, cost too much time ({elapsed:n0}ms)");
}
#endif
if (disposed)
{
#if DEBUG
sw.Stop();
Log.Print($"parallel task determinate, disposed ({tag}), ending cost time ({elapsed:n0}ms)");
#endif
return;
}
Thread.Sleep(16);
}
#if DEBUG
sw.Stop();
Log.Print($"parallel task done ({tag}), cost time ({sw.ElapsedMilliseconds:n0}ms)");
#endif
complete?.Invoke(null);
}
}
}

View File

@@ -0,0 +1,98 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Xamarin.Essentials;
using Xamarin.Forms;
namespace Gallery.Util
{
public static class Store
{
public static readonly string PersonalFolder = Environment.GetFolderPath(Environment.SpecialFolder.Personal);
public static readonly string CacheFolder = FileSystem.CacheDirectory;
private const string imageFolder = "img-original";
private const string previewFolder = "img-preview";
public static async Task<ImageSource> LoadRawImage(string url)
{
return await LoadImageAsync(url, null, PersonalFolder, imageFolder, force: true);
}
public static async Task<ImageSource> LoadPreviewImage(string url, bool downloading, bool force = false)
{
return await LoadImage(url, CacheFolder, previewFolder, downloading, force: force);
}
private static async Task<ImageSource> LoadImage(string url, string working, string folder, bool downloading, bool force = false)
{
var file = Path.Combine(working, folder, Path.GetFileName(url));
ImageSource image;
if (!force && File.Exists(file))
{
image = ImageSource.FromFile(file);
}
else
{
image = null;
}
if (downloading && image == null)
{
file = await NetHelper.DownloadImage(url, working, folder);
if (file != null)
{
return ImageSource.FromFile(file);
}
}
return image;
}
private static async Task<ImageSource> LoadImageAsync(string url, string id, string working, string folder, bool force = false)
{
var file = Path.Combine(working, folder, Path.GetFileName(url));
ImageSource image;
if (!force && File.Exists(file))
{
image = ImageSource.FromFile(file);
}
else
{
image = null;
}
if (image == null)
{
file = await NetHelper.DownloadImageAsync(url, id, working, folder);
if (file != null)
{
image = ImageSource.FromFile(file);
}
}
return image;
}
}
public static class Config
{
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
public const string DownloadThreadsKey = "download_threads";
public const string IsProxiedKey = "is_proxied";
public const string ProxyHostKey = "proxy_host";
public const string ProxyPortKey = "proxy_port";
public const int MaxThreads = 8;
public const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36";
public const string AcceptLanguage = "zh-cn";
public const string AcceptImage = "image/png,image/*,*/*;q=0.8";
public static int DownloadThreads;
public static WebProxy Proxy;
}
public static class Routes
{
public const string Gallery = "gallery";
public const string Option = "option";
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ui:AdaptedPage
xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:ui="clr-namespace:Gallery.Resources.UI"
x:Class="Gallery.Views.GalleryItemPage"
x:Name="galleryItemPage"
BindingContext="{x:Reference galleryItemPage}">
<ContentPage.Content>
</ContentPage.Content>
</ui:AdaptedPage>

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using Gallery.Resources.UI;
using Xamarin.Forms;
namespace Gallery.Views
{
public partial class GalleryItemPage : AdaptedPage
{
public GalleryItemPage()
{
InitializeComponent();
}
}
}

View File

@@ -6,7 +6,8 @@
x:Class="Gallery.Views.GalleryPage"
x:Name="yanderePage"
BackgroundColor="{DynamicResource WindowColor}"
BindingContext="{x:Reference yanderePage}">
BindingContext="{x:Reference yanderePage}"
Title="{Binding Source.Name}">
<ContentPage.Content>
<Grid>
<ScrollView x:Name="scrollView" Scrolled="ScrollView_Scrolled"

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ui:AdaptedPage
xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:r="clr-namespace:Gallery.Resources"
xmlns:ui="clr-namespace:Gallery.Resources.UI"
x:Class="Gallery.Views.OptionPage"
x:Name="optionPage"
BindingContext="{x:Reference optionPage}"
Title="{r:Text Option}">
<ContentPage.Content>
<TableView Intent="Settings" VerticalOptions="Start"
BackgroundColor="{DynamicResource OptionBackColor}">
<TableRoot>
<TableSection Title="{r:Text About}">
<ui:OptionTextCell Title="{r:Text Version}" Detail="{Binding Version}"/>
</TableSection>
<TableSection Title="{r:Text Gallery}">
<ui:OptionEntryCell Title="{r:Text DownloadThreads}"
Text="{Binding DownloadThreads, Mode=TwoWay}"
Keyboard="Numeric" Placeholder="1~10"/>
</TableSection>
<TableSection Title="{r:Text Proxy}">
<ui:OptionSwitchCell Title="{r:Text Enabled}"
IsToggled="{Binding IsProxied, Mode=TwoWay}"/>
</TableSection>
<TableSection Title="{r:Text Detail}">
<ui:OptionEntryCell Title="{r:Text ProxyHost}"
Text="{Binding ProxyHost, Mode=TwoWay}"
Keyboard="Url" Placeholder="www.example.com"/>
<ui:OptionEntryCell Title="{r:Text ProxyPort}"
Text="{Binding ProxyPort, Mode=TwoWay}"
Keyboard="Numeric" Placeholder="8080"/>
</TableSection>
</TableRoot>
</TableView>
</ContentPage.Content>
</ui:AdaptedPage>

View File

@@ -0,0 +1,152 @@
using System;
using Gallery.Resources.UI;
using Gallery.Util;
using Xamarin.Essentials;
using Xamarin.Forms;
namespace Gallery.Views
{
public partial class OptionPage : AdaptedPage
{
public static readonly BindableProperty VersionProperty = BindableProperty.Create(nameof(Version), typeof(string), typeof(OptionPage));
public string Version
{
get => (string)GetValue(VersionProperty);
set => SetValue(VersionProperty, value);
}
public static readonly BindableProperty DownloadThreadsProperty = BindableProperty.Create(nameof(DownloadThreads), typeof(string), typeof(OptionPage));
public static readonly BindableProperty IsProxiedProperty = BindableProperty.Create(nameof(IsProxied), typeof(bool), typeof(OptionPage));
public static readonly BindableProperty ProxyHostProperty = BindableProperty.Create(nameof(ProxyHost), typeof(string), typeof(OptionPage));
public static readonly BindableProperty ProxyPortProperty = BindableProperty.Create(nameof(ProxyPort), typeof(string), typeof(OptionPage));
public string DownloadThreads
{
get => (string)GetValue(DownloadThreadsProperty);
set => SetValue(DownloadThreadsProperty, value);
}
public bool IsProxied
{
get => (bool)GetValue(IsProxiedProperty);
set => SetValue(IsProxiedProperty, value);
}
public string ProxyHost
{
get => (string)GetValue(ProxyHostProperty);
set => SetValue(ProxyHostProperty, value);
}
public string ProxyPort
{
get => (string)GetValue(ProxyPortProperty);
set => SetValue(ProxyPortProperty, value);
}
public OptionPage()
{
InitializeComponent();
#if OBSOLETE
#if __IOS__
string version = Foundation.NSBundle.MainBundle.ObjectForInfoDictionary("CFBundleShortVersionString").ToString();
int build = int.Parse(Foundation.NSBundle.MainBundle.ObjectForInfoDictionary("CFBundleVersion").ToString());
#elif __ANDROID__
var context = Android.App.Application.Context;
var manager = context.PackageManager;
var info = manager.GetPackageInfo(context.PackageName, 0);
string version = info.VersionName;
long build = info.LongVersionCode;
#endif
Version = $"{version} ({build})";
#endif
}
protected override void OnAppearing()
{
base.OnAppearing();
Version = $"{AppInfo.VersionString} ({AppInfo.BuildString})";
DownloadThreads = Config.DownloadThreads.ToString();
var proxy = Config.Proxy;
if (proxy != null)
{
IsProxied = true;
ProxyHost = proxy.Address.Host;
ProxyPort = proxy.Address.Port.ToString();
}
else
{
IsProxied = false;
ProxyHost = Preferences.Get(Config.ProxyHostKey, string.Empty);
ProxyPort = Preferences.Get(Config.ProxyPortKey, string.Empty);
}
}
protected override void OnDisappearing()
{
base.OnDisappearing();
var proxied = IsProxied;
if (int.TryParse(DownloadThreads, out int threads) && threads > 0 && threads <= 10 && threads != Config.DownloadThreads)
{
Preferences.Set(Config.DownloadThreadsKey, threads);
Config.DownloadThreads = threads;
#if DEBUG
Log.Print($"will use {threads} threads to download image");
#endif
}
var proxy = Config.Proxy;
var h = ProxyHost?.Trim();
int.TryParse(ProxyPort, out int pt);
if (proxied &&
!string.IsNullOrEmpty(h) &&
pt > 0 && pt < 65535)
{
if (proxy == null ||
proxy.Address.Host != h ||
proxy.Address.Port != pt)
{
Preferences.Set(Config.IsProxiedKey, true);
Preferences.Set(Config.ProxyHostKey, h);
Preferences.Set(Config.ProxyPortKey, pt);
try
{
if (h.IndexOf(':') >= 0)
{
h = $"[{h}]";
}
var uri = new Uri($"http://{h}:{pt}");
Config.Proxy = new System.Net.WebProxy(uri);
#if DEBUG
Log.Print($"set proxy to: {uri}");
#endif
}
catch (Exception ex)
{
Log.Error("on.disappearing", $"failed to parse proxy: {h}:{pt}, error: {ex.Message}");
}
}
}
else
{
Preferences.Set(Config.IsProxiedKey, false);
Preferences.Set(Config.ProxyHostKey, h);
if (pt > 0)
{
Preferences.Set(Config.ProxyPortKey, pt);
}
if (proxy != null)
{
Config.Proxy = null;
#if DEBUG
Log.Print("clear proxy");
#endif
}
}
}
}
}