diff --git a/Anikin/App.xaml.cs b/Anikin/App.xaml.cs index e2b6ae0..b5c6102 100644 --- a/Anikin/App.xaml.cs +++ b/Anikin/App.xaml.cs @@ -31,9 +31,9 @@ public App(IServiceProvider provider) Services = provider; - AlertService = Services.GetService()!; + AlertService = Services.GetRequiredService(); - var settingsService = Services.GetService()!; + var settingsService = Services.GetRequiredService(); settingsService.Load(); IsInDeveloperMode = settingsService.EnableDeveloperMode; @@ -112,7 +112,7 @@ public static void ApplyTheme(bool force = false) if (Current is null) return; - var settingsService = Services.GetService()!; + var settingsService = Services.GetRequiredService(); settingsService.Load(); if (force) @@ -121,6 +121,15 @@ public static void ApplyTheme(bool force = false) } Current.UserAppTheme = settingsService.AppTheme; + + //if (Shell.Current.CurrentPage is not null) + //{ + // Berry.Maui.Controls.Insets.SetEdgeToEdge(Shell.Current.CurrentPage, true); + // Berry.Maui.Controls.Insets.SetStatusBarStyle( + // Shell.Current.CurrentPage, + // Berry.Maui.Controls.StatusBarStyle.DarkContent + // ); + //} } protected override void OnAppLinkRequestReceived(Uri uri) diff --git a/Anikin/AppShell.xaml.cs b/Anikin/AppShell.xaml.cs index 3756ac9..e841b08 100644 --- a/Anikin/AppShell.xaml.cs +++ b/Anikin/AppShell.xaml.cs @@ -5,6 +5,7 @@ using Anikin.Services; using Anikin.ViewModels.Framework; using Anikin.Views; +using Anikin.Views.Manga; using Anikin.Views.Settings; using CommunityToolkit.Maui.Alerts; using CommunityToolkit.Maui.Core; @@ -23,7 +24,10 @@ public AppShell() InitializeComponent(); Routing.RegisterRoute(nameof(SearchView), typeof(SearchView)); + Routing.RegisterRoute(nameof(MangaSearchView), typeof(MangaSearchView)); Routing.RegisterRoute(nameof(EpisodePage), typeof(EpisodePage)); + Routing.RegisterRoute(nameof(MangaPage), typeof(MangaPage)); + Routing.RegisterRoute(nameof(MangaReaderPage), typeof(MangaReaderPage)); Routing.RegisterRoute(nameof(AnilistLoginView), typeof(AnilistLoginView)); Routing.RegisterRoute(nameof(ExtensionsView), typeof(ExtensionsView)); diff --git a/Anikin/Controls/PinchZoom.cs b/Anikin/Controls/PinchZoom.cs new file mode 100644 index 0000000..c5f2efd --- /dev/null +++ b/Anikin/Controls/PinchZoom.cs @@ -0,0 +1,219 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Maui; +using Microsoft.Maui.Controls; + +namespace Anikin.Controls; + +// Taken from https://github.com/TBertuzzi/Bertuzzi.MAUI.PinchZoomImage +public partial class PinchZoom : ContentView +{ + private double _currentScale = 1; + private double _startScale = 1; + private double _xOffset = 0; + private double _yOffset = 0; + private bool _secondDoubleTap = false; + + public bool IsPinching { get; set; } + + public PinchZoom() + { + var pinchGesture = new PinchGestureRecognizer(); + pinchGesture.PinchUpdated += PinchUpdated; + GestureRecognizers.Add(pinchGesture); + + var panGesture = new PanGestureRecognizer(); + panGesture.PanUpdated += OnPanUpdated; + GestureRecognizers.Add(panGesture); + + var tapGesture = new TapGestureRecognizer { NumberOfTapsRequired = 2 }; + tapGesture.Tapped += DoubleTapped; + GestureRecognizers.Add(tapGesture); + } + + private void PinchUpdated(object? sender, PinchGestureUpdatedEventArgs e) + { + switch (e.Status) + { + case GestureStatus.Started: + IsPinching = true; + _startScale = Content.Scale; + Content.AnchorX = 0; + Content.AnchorY = 0; + break; + + case GestureStatus.Running: + { + _currentScale += (e.Scale - 1) * _startScale; + _currentScale = Math.Max(1, _currentScale); + + var renderedX = Content.X + _xOffset; + var deltaX = renderedX / Width; + var deltaWidth = Width / (Content.Width * _startScale); + var originX = (e.ScaleOrigin.X - deltaX) * deltaWidth; + + var renderedY = Content.Y + _yOffset; + var deltaY = renderedY / Height; + var deltaHeight = Height / (Content.Height * _startScale); + var originY = (e.ScaleOrigin.Y - deltaY) * deltaHeight; + + var targetX = _xOffset - (originX * Content.Width) * (_currentScale - _startScale); + var targetY = _yOffset - (originY * Content.Height) * (_currentScale - _startScale); + + Content.TranslationX = Math.Min( + 0, + Math.Max(targetX, -Content.Width * (_currentScale - 1)) + ); + Content.TranslationY = Math.Min( + 0, + Math.Max(targetY, -Content.Height * (_currentScale - 1)) + ); + + Content.Scale = _currentScale; + break; + } + + case GestureStatus.Completed: + _xOffset = Content.TranslationX; + _yOffset = Content.TranslationY; + IsPinching = false; + break; + } + } + + public void OnPanUpdated(object? sender, PanUpdatedEventArgs e) + { + if (Content.Scale == 1) + { + return; + } + + switch (e.StatusType) + { + case GestureStatus.Running: + + var newX = (e.TotalX * Scale) + _xOffset; + var newY = (e.TotalY * Scale) + _yOffset; + + var width = (Content.Width * Content.Scale); + var height = (Content.Height * Content.Scale); + + var canMoveX = width > Application.Current.MainPage.Width; + var canMoveY = height > Application.Current.MainPage.Height; + + if (canMoveX) + { + var minX = (width - (Application.Current.MainPage.Width / 2)) * -1; + var maxX = Math.Min(Application.Current.MainPage.Width / 2, width / 2); + + if (newX < minX) + { + newX = minX; + } + + if (newX > maxX) + { + newX = maxX; + } + } + else + { + newX = 0; + } + + if (canMoveY) + { + var minY = (height - (Application.Current.MainPage.Height / 2)) * -1; + var maxY = Math.Min(Application.Current.MainPage.Width / 2, height / 2); + + if (newY < minY) + { + newY = minY; + } + + if (newY > maxY) + { + newY = maxY; + } + } + else + { + newY = 0; + } + + Content.TranslationX = newX; + Content.TranslationY = newY; + break; + case GestureStatus.Completed: + _xOffset = Content.TranslationX; + _yOffset = Content.TranslationY; + break; + case GestureStatus.Started: + break; + case GestureStatus.Canceled: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public async void DoubleTapped(object? sender, TappedEventArgs e) + { + var multiplicator = Math.Pow(2, 1.0 / 10.0); + _startScale = Content.Scale; + Content.AnchorX = 0; + Content.AnchorY = 0; + + for (var i = 0; i < 10; i++) + { + if (!_secondDoubleTap) //if it's not the second double tap we enlarge the scale + { + _currentScale *= multiplicator; + } + else //if it's the second double tap we make the scale smaller again + { + _currentScale /= multiplicator; + } + + var renderedX = Content.X + _xOffset; + var deltaX = renderedX / Width; + var deltaWidth = Width / (Content.Width * _startScale); + var originX = (0.5 - deltaX) * deltaWidth; + + var renderedY = Content.Y + _yOffset; + var deltaY = renderedY / Height; + var deltaHeight = Height / (Content.Height * _startScale); + var originY = (0.5 - deltaY) * deltaHeight; + + var targetX = _xOffset - (originX * Content.Width) * (_currentScale - _startScale); + var targetY = _yOffset - (originY * Content.Height) * (_currentScale - _startScale); + + Content.TranslationX = Math.Min( + 0, + Math.Max(targetX, -Content.Width * (_currentScale - 1)) + ); + Content.TranslationY = Math.Min( + 0, + Math.Max(targetY, -Content.Height * (_currentScale - 1)) + ); + + Content.Scale = _currentScale; + await Task.Delay(10); + } + + //if (_secondDoubleTap) + // Content.Margin = new( + // -(Content.Margin.HorizontalThickness * Content.Scale), + // -(Content.Margin.VerticalThickness * Content.Scale) + // ); + //else + // Content.Margin = new( + // +(Content.Margin.HorizontalThickness / Content.Scale), + // +(Content.Margin.VerticalThickness / Content.Scale) + // ); + + _secondDoubleTap = !_secondDoubleTap; + _xOffset = Content.TranslationX; + _yOffset = Content.TranslationY; + } +} diff --git a/Anikin/MauiProgram.cs b/Anikin/MauiProgram.cs index e03e2f6..9810bce 100644 --- a/Anikin/MauiProgram.cs +++ b/Anikin/MauiProgram.cs @@ -3,6 +3,7 @@ using Anikin.Services; using Anikin.Services.AlertDialog; using Anikin.ViewModels; +using Anikin.ViewModels.Home; using Anikin.Views; using Anikin.Views.Settings; using Berry.Maui; @@ -15,6 +16,12 @@ using Microsoft.Maui.Hosting; using Microsoft.Maui.LifecycleEvents; using Woka; +using Anikin.Views.Home; +using Anikin.ViewModels.Manga; +using Anikin.Views.Manga; + + + #if ANDROID using Microsoft.Maui.Controls.Compatibility.Platform.Android; #endif @@ -108,19 +115,27 @@ public static MauiApp CreateMauiApp() // Views builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); builder.Services.AddTransient(); // ViewModels builder.Services.AddTransient(); - builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); // Services builder.Services.AddTransient(x => AniClientFactory()); diff --git a/Anikin/Models/HomeTabs.cs b/Anikin/Models/HomeTabs.cs new file mode 100644 index 0000000..3a868d5 --- /dev/null +++ b/Anikin/Models/HomeTabs.cs @@ -0,0 +1,8 @@ +namespace Anikin.Models; + +public enum HomeTabs +{ + Anime, + Profile, + Manga +} diff --git a/Anikin/Models/Manga/MangaChapterRange.cs b/Anikin/Models/Manga/MangaChapterRange.cs new file mode 100644 index 0000000..dadb731 --- /dev/null +++ b/Anikin/Models/Manga/MangaChapterRange.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; +using Juro.Core.Models.Manga; + +namespace Anikin.Models.Manga; + +public partial class MangaChapterRange : ObservableObject +{ + public string Name { get; set; } = default!; + + public List Chapters { get; set; } = []; + + [ObservableProperty] + private bool _isSelected; + + public MangaChapterRange(IEnumerable episodes, int startIndex, int endIndex) + { + Chapters.AddRange(episodes); + Name = $"{startIndex} - {endIndex}"; + } + + public override string ToString() => Name; +} diff --git a/Anikin/Models/Manga/MangaHomeRange.cs b/Anikin/Models/Manga/MangaHomeRange.cs new file mode 100644 index 0000000..5fbf281 --- /dev/null +++ b/Anikin/Models/Manga/MangaHomeRange.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using Anikin.Utils; +using Anikin.Utils.Extensions; +using CommunityToolkit.Mvvm.ComponentModel; +using Jita.AniList.Models; + +namespace Anikin.Models.Manga; + +public partial class MangaHomeRange : ObservableObject +{ + public string Name { get; set; } + + [ObservableProperty] + bool _isLoading; + + public MangaHomeTypes Type { get; set; } + + public ObservableRangeCollection Medias { get; set; } = []; + + [ObservableProperty] + private bool _isSelected; + + public MangaHomeRange(MangaHomeTypes type) + { + Type = type; + Name = Type.GetBestDisplayName(); + } + + public MangaHomeRange(MangaHomeTypes type, IEnumerable medias) + : this(type) + { + Medias.AddRange(medias); + } + + public override string ToString() => Name; +} diff --git a/Anikin/Models/Manga/MangaHomeTypes.cs b/Anikin/Models/Manga/MangaHomeTypes.cs new file mode 100644 index 0000000..852e9ce --- /dev/null +++ b/Anikin/Models/Manga/MangaHomeTypes.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; + +namespace Anikin.Models.Manga; + +public enum MangaHomeTypes +{ + [Description("Popular")] + Popular, + + [Description("Recently Updated")] + LastUpdated, + + [Description("Trending")] + Trending, + + [Description("New Season")] + NewSeason, + + [Description("Feminine Audience")] + FeminineMedia, + + [Description("Male Audience")] + MaleMedia, + + [Description("Trash Anime")] + TrashMedia +} diff --git a/Anikin/Services/SettingsService.cs b/Anikin/Services/SettingsService.cs index 9af6665..ac90c32 100644 --- a/Anikin/Services/SettingsService.cs +++ b/Anikin/Services/SettingsService.cs @@ -21,10 +21,16 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged public GridLayoutMode EpisodesGridLayoutMode { get; set; } = GridLayoutMode.Semi; + public GridLayoutMode MangaItemsGridLayoutMode { get; set; } = GridLayoutMode.Full; + public bool EpisodesDescending { get; set; } = true; + public bool MangaChaptersDescending { get; set; } = true; + public bool ShowNonJapaneseAnime { get; set; } + public bool ShowNonJapaneseManga { get; set; } + public bool EnableDeveloperMode { get; set; } public SettingsService() diff --git a/Anikin/Utils/Extensions/MangaProviderExtensions.cs b/Anikin/Utils/Extensions/MangaProviderExtensions.cs new file mode 100644 index 0000000..847894a --- /dev/null +++ b/Anikin/Utils/Extensions/MangaProviderExtensions.cs @@ -0,0 +1,20 @@ +using System.Globalization; +using Juro.Core.Providers; + +namespace Anikin.Utils.Extensions; + +internal static class MangaProviderExtensions +{ + public static string GetLanguageDisplayName(this IMangaProvider provider) + { + try + { + var culture = new CultureInfo(provider.Language); + return culture.NativeName; + } + catch + { + return string.Empty; + } + } +} diff --git a/Anikin/Utils/ProviderResolver.cs b/Anikin/Utils/ProviderResolver.cs index 025d435..1d6947c 100644 --- a/Anikin/Utils/ProviderResolver.cs +++ b/Anikin/Utils/ProviderResolver.cs @@ -6,6 +6,7 @@ using Juro.Core.Providers; using Juro.Providers.Anime; using Juro.Providers.Anime.Indonesian; +using Juro.Providers.Manga; namespace Anikin.Utils; @@ -17,7 +18,6 @@ internal static class ProviderResolver settingsService.Load(); var providers = GetAnimeProviders(); - if (providers.Count == 0) return null; @@ -60,4 +60,42 @@ public static List GetAnimeProviders() return []; } } + + public static IMangaProvider? GetMangaProvider() + { + var settingsService = new SettingsService(); + settingsService.Load(); + + var providers = GetMangaProviders(); + if (providers.Count == 0) + return null; + + if (string.IsNullOrWhiteSpace(settingsService.LastProviderKey)) + return providers.FirstOrDefault(); + + return providers.Find(x => x.Key == settingsService.LastProviderKey) + ?? providers.FirstOrDefault(); + } + + public static List GetMangaProviders() + { + return [new MangaPill(), new MangaKakalot(), new MangaKatana(), new Mangadex()]; + + try + { + var client = new MangaClient(); + var providers = client.GetAllProviders(); + + return [.. providers]; + } + catch (Exception ex) + { + if (App.IsInDeveloperMode) + { + App.AlertService.ShowAlert("Error", $"{ex}"); + } + + return []; + } + } } diff --git a/Anikin/ViewModels/Home/AnimeHomeViewModel.cs b/Anikin/ViewModels/Home/AnimeHomeViewModel.cs new file mode 100644 index 0000000..dd9fc23 --- /dev/null +++ b/Anikin/ViewModels/Home/AnimeHomeViewModel.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Anikin.Models; +using Anikin.Services; +using Anikin.Utils; +using Anikin.Utils.Extensions; +using Anikin.ViewModels.Framework; +using Anikin.Views; +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Jita.AniList; +using Jita.AniList.Models; +using Jita.AniList.Parameters; +using Microsoft.Maui.Controls; + +namespace Anikin.ViewModels.Home; + +public partial class AnimeHomeViewModel : BaseViewModel +{ + private readonly AniClient _anilistClient; + private readonly SettingsService _settingsService; + + public ObservableRangeCollection CurrentSeasonMedias { get; set; } = []; + public ObservableRangeCollection PopularMedias { get; set; } = []; + public ObservableRangeCollection LastUpdatedMedias { get; set; } = []; + + public ObservableRangeCollection Ranges { get; set; } = []; + + [ObservableProperty] + AnimeHomeRange _selectedRange; + + public AnimeHomeViewModel(AniClient anilistClient, SettingsService settingsService) + { + _anilistClient = anilistClient; + _settingsService = settingsService; + + _settingsService.Load(); + + //Load(); + + var homeTypes = Enum.GetValues(typeof(AnimeHomeTypes)).Cast(); + Ranges.AddRange(homeTypes.Select(x => new AnimeHomeRange(x))); + Ranges[0].IsSelected = true; + + SelectedRange = Ranges[0]; + } + + protected override async Task LoadCore() + { + if (!await IsOnline()) + return; + + if (!IsRefreshing) + IsBusy = true; + + RangeSelected(SelectedRange); + + try + { + LoadPopular(); + LoadLastUpdated(); + LoadCurrentSeason(); + //LoadTrending(); + //LoadNewSeason(); + //LoadFeminineMedia(); + //LoadMaleMedia(); + //LoadTrashMedia(); + + //var pages2 = await _anilistClient.GetTrendingMediaAsync(); + //var schedulesResult = await _anilistClient.GetMediaSchedulesAsync( + // new MediaSchedulesFilter + // { + // StartedAfterDate = DateTime.Now, + // EndedBeforeDate = DateTime.Now.AddDays(7) + // } + //); + // + //var result2 = schedulesResult.Data + // .Where(x => x.Media is not null) + // .Select(x => x.Media!) + // .ToList(); + // + //LastUpdatedMedias.Clear(); + //LastUpdatedMedias.Push(result2); + } + catch (Exception ex) + { + await App.AlertService.ShowAlertAsync("Error", ex.ToString()); + } + finally + { + IsBusy = false; + IsRefreshing = false; + //IsLoading = false; + } + } + + [RelayCommand] + async Task Refresh() + { + if (IsBusy) + return; + + if (!await IsOnline()) + { + IsRefreshing = false; + return; + } + + if (!IsRefreshing) + IsBusy = true; + + await LoadCore(); + } + + [RelayCommand] + //async Task ItemSelected(IAnimeInfo item) + async Task ItemSelected(Media item) + { + var navigationParameter = new Dictionary { { "SourceItem", item } }; + + await Shell.Current.GoToAsync(nameof(EpisodePage), navigationParameter); + } + + [RelayCommand] + async Task GoToSearch() + { + await Shell.Current.GoToAsync(nameof(SearchView)); + } + + [RelayCommand] + void RangeSelected(AnimeHomeRange range) + { + for (var i = 0; i < Ranges.Count; i++) + { + Ranges[i].IsSelected = false; + } + + range.IsSelected = true; + + SelectedRange = range; + + switch (range.Type) + { + case AnimeHomeTypes.Popular: + break; + case AnimeHomeTypes.LastUpdated: + break; + case AnimeHomeTypes.CurrentSeason: + break; + case AnimeHomeTypes.Trending: + LoadTrending(); + break; + case AnimeHomeTypes.NewSeason: + LoadNewSeason(); + break; + case AnimeHomeTypes.FeminineMedia: + LoadFeminineMedia(); + break; + case AnimeHomeTypes.MaleMedia: + LoadMaleMedia(); + break; + case AnimeHomeTypes.TrashMedia: + LoadTrashMedia(); + break; + } + } + + private async void LoadMaleMedia() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.MaleMedia); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Tags = new Dictionary() { ["Shounen"] = true }, + Type = MediaType.Anime, + IsAdult = false, + Sort = MediaSort.Popularity + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadFeminineMedia() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.FeminineMedia); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Tags = new Dictionary() { ["Shoujo"] = true }, + Type = MediaType.Anime, + IsAdult = false, + Sort = MediaSort.Popularity + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadTrashMedia() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.TrashMedia); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Type = MediaType.Anime, + IsAdult = false, + Sort = MediaSort.Favorites, + SortDescending = false + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadNewSeason() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.NewSeason); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter { Season = MediaSeason.Winter } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadLastUpdated() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.LastUpdated); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var recentlyUpdateResult = await _anilistClient.GetMediaSchedulesAsync( + new MediaSchedulesFilter + { + StartedAfterDate = DateTime.Now.AddDays(-7), + EndedBeforeDate = DateTime.Now, + NotYetAired = false, + Sort = MediaScheduleSort.Time, + SortDescending = true + }, + new AniPaginationOptions(1, 50) + ); + + var data = recentlyUpdateResult + .Data.Where(x => + x.Media is not null + && !x.Media.IsAdult + && (_settingsService.ShowNonJapaneseAnime || x.Media.CountryOfOrigin == "JP") + ) + .Select(x => x.Media!) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + + LastUpdatedMedias.Clear(); + LastUpdatedMedias.Push(data); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadTrending() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.Trending); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.GetTrendingMediaAsync( + new MediaTrendFilter() { Sort = MediaTrendSort.Popularity }, + new AniPaginationOptions() + ); + + var data = result + .Data.Where(x => + x.Media is not null + && (_settingsService.ShowNonJapaneseAnime || x.Media.CountryOfOrigin == "JP") + ) + .Select(x => x.Media!) + .ToList(); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadCurrentSeason() + { + var currentMediaSeason = DateTime.Now.Month switch + { + 1 or 2 or 3 => MediaSeason.Winter, + 4 or 5 or 6 => MediaSeason.Spring, + 7 or 8 or 9 => MediaSeason.Summer, + 10 or 11 or 12 => MediaSeason.Fall, + _ => MediaSeason.Winter, + }; + + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.CurrentSeason); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Type = MediaType.Anime, + Sort = MediaSort.Popularity, + Season = currentMediaSeason, + IsAdult = false, + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + CurrentSeasonMedias.Clear(); + CurrentSeasonMedias.Push(data); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadPopular() + { + var rangeItem = Ranges.First(x => x.Type == AnimeHomeTypes.Popular); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + //var result = await _anilistClient.SearchMediaAsync(new SearchMediaFilter() + //{ + // Query = "demon slayer", + // Type = MediaType.Anime, + //}); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Type = MediaType.Anime, + Sort = MediaSort.Popularity, + IsAdult = false, + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + PopularMedias.Clear(); + PopularMedias.Push(data); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } +} diff --git a/Anikin/ViewModels/Home/HomeViewModel.cs b/Anikin/ViewModels/Home/HomeViewModel.cs new file mode 100644 index 0000000..1995ad3 --- /dev/null +++ b/Anikin/ViewModels/Home/HomeViewModel.cs @@ -0,0 +1,101 @@ +using System.Threading.Tasks; +using Anikin.Models; +using Anikin.Services; +using Anikin.ViewModels.Framework; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Anikin.ViewModels.Home; + +public partial class HomeViewModel : BaseViewModel +{ + private readonly SettingsService _settingsService; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SelectedTab))] + int _selectedTabIndex; + + public HomeTabs SelectedTab => (HomeTabs)SelectedTabIndex; + + [ObservableProperty] + ProfileViewModel _profileViewModel; + + [ObservableProperty] + AnimeHomeViewModel _animeHomeViewModel; + + [ObservableProperty] + MangaHomeViewModel _mangaHomeViewModel; + + public HomeViewModel( + AnimeHomeViewModel animeHomeViewModel, + MangaHomeViewModel mangaHomeViewModel, + ProfileViewModel profileViewModel, + SettingsService settingsService + ) + { + AnimeHomeViewModel = animeHomeViewModel; + MangaHomeViewModel = mangaHomeViewModel; + ProfileViewModel = profileViewModel; + _settingsService = settingsService; + _settingsService.Load(); + } + + async partial void OnSelectedTabIndexChanged(int value) + { + switch (SelectedTab) + { + case HomeTabs.Anime: + if (!AnimeHomeViewModel.IsInitialized) + await AnimeHomeViewModel.Load(); + break; + + case HomeTabs.Profile: + break; + + case HomeTabs.Manga: + if (!MangaHomeViewModel.IsInitialized) + await MangaHomeViewModel.Load(); + break; + } + } + + protected override async Task LoadCore() + { + if (!await IsOnline()) + return; + + switch (SelectedTab) + { + case HomeTabs.Anime: + if (!AnimeHomeViewModel.IsInitialized) + await AnimeHomeViewModel.Load(); + break; + + case HomeTabs.Profile: + break; + + case HomeTabs.Manga: + if (!MangaHomeViewModel.IsInitialized) + await MangaHomeViewModel.Load(); + break; + } + } + + [RelayCommand] + async Task Refresh() + { + if (IsBusy) + return; + + if (!await IsOnline()) + { + IsRefreshing = false; + return; + } + + if (!IsRefreshing) + IsBusy = true; + + await LoadCore(); + } +} diff --git a/Anikin/ViewModels/Home/MangaHomeViewModel.cs b/Anikin/ViewModels/Home/MangaHomeViewModel.cs new file mode 100644 index 0000000..e5e15d2 --- /dev/null +++ b/Anikin/ViewModels/Home/MangaHomeViewModel.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Anikin.Models.Manga; +using Anikin.Services; +using Anikin.Utils; +using Anikin.Utils.Extensions; +using Anikin.ViewModels.Framework; +using Anikin.Views; +using Anikin.Views.Manga; +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Jita.AniList; +using Jita.AniList.Models; +using Jita.AniList.Parameters; +using Microsoft.Maui.Controls; + +namespace Anikin.ViewModels.Home; + +public partial class MangaHomeViewModel : BaseViewModel +{ + private readonly AniClient _anilistClient; + private readonly SettingsService _settingsService; + + public ObservableRangeCollection PopularMedias { get; set; } = []; + public ObservableRangeCollection LastUpdatedMedias { get; set; } = []; + + public ObservableRangeCollection Ranges { get; set; } = []; + + [ObservableProperty] + MangaHomeRange _selectedRange; + + public MangaHomeViewModel(AniClient anilistClient, SettingsService settingsService) + { + _anilistClient = anilistClient; + _settingsService = settingsService; + + _settingsService.Load(); + + //Load(); + + var homeTypes = Enum.GetValues(typeof(MangaHomeTypes)).Cast(); + Ranges.AddRange(homeTypes.Select(x => new MangaHomeRange(x))); + Ranges[0].IsSelected = true; + + SelectedRange = Ranges[0]; + } + + protected override async Task LoadCore() + { + if (!await IsOnline()) + return; + + if (!IsRefreshing) + IsBusy = true; + + RangeSelected(SelectedRange); + + try + { + LoadPopular(); + LoadLastUpdated(); + } + catch (Exception ex) + { + await App.AlertService.ShowAlertAsync("Error", ex.ToString()); + } + finally + { + IsBusy = false; + IsRefreshing = false; + //IsLoading = false; + } + } + + [RelayCommand] + async Task Refresh() + { + if (IsBusy) + return; + + if (!await IsOnline()) + { + IsRefreshing = false; + return; + } + + if (!IsRefreshing) + IsBusy = true; + + await LoadCore(); + } + + [RelayCommand] + async Task ItemSelected(Media item) + { + var navigationParameter = new Dictionary { { "Media", item } }; + + await Shell.Current.GoToAsync(nameof(MangaPage), navigationParameter); + } + + [RelayCommand] + async Task GoToSearch() + { + await Shell.Current.GoToAsync(nameof(MangaSearchView)); + } + + [RelayCommand] + void RangeSelected(MangaHomeRange range) + { + for (var i = 0; i < Ranges.Count; i++) + { + Ranges[i].IsSelected = false; + } + + range.IsSelected = true; + + SelectedRange = range; + + switch (range.Type) + { + case MangaHomeTypes.Popular: + break; + case MangaHomeTypes.LastUpdated: + break; + case MangaHomeTypes.Trending: + LoadTrending(); + break; + case MangaHomeTypes.NewSeason: + LoadNewSeason(); + break; + case MangaHomeTypes.FeminineMedia: + LoadFeminineMedia(); + break; + case MangaHomeTypes.MaleMedia: + LoadMaleMedia(); + break; + case MangaHomeTypes.TrashMedia: + LoadTrashMedia(); + break; + } + } + + private async void LoadMaleMedia() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.MaleMedia); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Tags = new Dictionary() { ["Shounen"] = true }, + Type = MediaType.Manga, + IsAdult = false, + Sort = MediaSort.Popularity + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseManga || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadFeminineMedia() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.FeminineMedia); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Tags = new Dictionary() { ["Shoujo"] = true }, + Type = MediaType.Manga, + IsAdult = false, + Sort = MediaSort.Popularity + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseManga || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadTrashMedia() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.TrashMedia); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Type = MediaType.Manga, + IsAdult = false, + Sort = MediaSort.Favorites, + SortDescending = false + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseManga || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadNewSeason() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.NewSeason); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter { Season = MediaSeason.Winter } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseManga || x.CountryOfOrigin == "JP") + .ToList(); + + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadLastUpdated() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.LastUpdated); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var recentlyUpdateResult = await _anilistClient.GetMediaSchedulesAsync( + new MediaSchedulesFilter + { + StartedAfterDate = DateTime.Now.AddDays(-7), + EndedBeforeDate = DateTime.Now, + NotYetAired = false, + Sort = MediaScheduleSort.Time, + SortDescending = true + }, + new AniPaginationOptions(1, 50) + ); + + var data = recentlyUpdateResult + .Data.Where(x => + x.Media is not null + && !x.Media.IsAdult + && (_settingsService.ShowNonJapaneseManga || x.Media.CountryOfOrigin == "JP") + ) + .Select(x => x.Media!) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + + LastUpdatedMedias.Clear(); + LastUpdatedMedias.Push(data); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadTrending() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.Trending); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.GetTrendingMediaAsync( + new MediaTrendFilter() { Sort = MediaTrendSort.Popularity }, + new AniPaginationOptions() + ); + + var data = result + .Data.Where(x => + x.Media is not null + && (_settingsService.ShowNonJapaneseManga || x.Media.CountryOfOrigin == "JP") + ) + .Select(x => x.Media!) + .ToList(); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } + + private async void LoadPopular() + { + var rangeItem = Ranges.First(x => x.Type == MangaHomeTypes.Popular); + + try + { + rangeItem.IsLoading = true; + rangeItem.Medias.Clear(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Type = MediaType.Manga, + Sort = MediaSort.Popularity, + IsAdult = false, + } + ); + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseManga || x.CountryOfOrigin == "JP") + .ToList(); + + PopularMedias.Clear(); + PopularMedias.Push(data); + + rangeItem.Medias.Clear(); + rangeItem.Medias.Push(data); + } + catch (Exception ex) + { + await Toast.Make(ex.ToString()).Show(); + } + finally + { + rangeItem.IsLoading = false; + } + } +} diff --git a/Anikin/ViewModels/Manga/MangaItemViewModel.cs b/Anikin/ViewModels/Manga/MangaItemViewModel.cs new file mode 100644 index 0000000..a72f988 --- /dev/null +++ b/Anikin/ViewModels/Manga/MangaItemViewModel.cs @@ -0,0 +1,574 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Anikin.Models; +using Anikin.Models.Manga; +using Anikin.Services; +using Anikin.Utils; +using Anikin.Utils.Extensions; +using Anikin.ViewModels.Components; +using Anikin.ViewModels.Framework; +using Anikin.Views.BottomSheets; +using Anikin.Views.Manga; +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Jita.AniList; +using Jita.AniList.Models; +using Juro.Core.Models.Manga; +using Juro.Core.Providers; +using Microsoft.Maui.ApplicationModel.DataTransfer; +using Microsoft.Maui.Controls; + +namespace Anikin.ViewModels.Manga; + +public partial class MangaItemViewModel : CollectionViewModel, IQueryAttributable +{ + private readonly AniClient _anilistClient; + private readonly PlayerSettings _playerSettings = new(); + private readonly SettingsService _settingsService = new(); + + private IMangaProvider? _provider = ProviderResolver.GetMangaProvider(); + private readonly List _providers = ProviderResolver.GetMangaProviders(); + + public static List Chapters { get; private set; } = []; + + public ObservableRangeCollection ProviderNames { get; set; } = []; + + public ObservableRangeCollection> ProviderGroups { get; set; } = []; + + [ObservableProperty] + private Media? _media; + + private IMangaResult? Manga { get; set; } + + public ObservableRangeCollection Ranges { get; set; } = []; + + public List MangaChapterChunks { get; set; } = []; + + [ObservableProperty] + private string? _searchingText; + + [ObservableProperty] + private int _selectedViewModelIndex; + + [ObservableProperty] + private bool _isFavorite; + + [ObservableProperty] + private GridLayoutMode _gridLayoutMode; + + [ObservableProperty] + private bool _isDubSelected; + + private bool IsSavingFavorite { get; set; } + + private bool IsProviderSearchSheetShowing { get; set; } + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + private ChangeSourceSheet? ChangeSourceSheet { get; set; } + + public MangaItemViewModel(AniClient aniClient) + { + _anilistClient = aniClient; + + SelectedViewModelIndex = 1; + + ProviderNames.AddRange(_providers.ConvertAll(x => x.Name)); + + var providers = _providers.Select(x => new ProviderModel() + { + Key = x.Key, + Language = x.Language, + Name = x.Name, + LanguageDisplayName = x.GetLanguageDisplayName() + }); + + var selectedProvider = _providers.Find(x => x.Key == _provider?.Key); + + var groups = providers.GroupBy(x => x.LanguageDisplayName); + foreach (var group in groups) + { + ProviderGroups.Add(new(group.Key, group.ToList())); + } + + var list = ProviderGroups.SelectMany(x => x).ToList(); + list.ForEach(x => x.IsSelected = false); + + var defaultProvider = list.Find(x => x.Key == selectedProvider?.Key); + if (defaultProvider is not null) + { + defaultProvider.IsSelected = true; + } + + //Load(); + + _playerSettings.Load(); + _settingsService.Load(); + + GridLayoutMode = _settingsService.MangaItemsGridLayoutMode; + + Shell.Current.Navigating += Current_Navigating; + } + + private void Current_Navigating(object? sender, ShellNavigatingEventArgs e) + { + Shell.Current.Navigating -= Current_Navigating; + + if (e.Source is ShellNavigationSource.PopToRoot or ShellNavigationSource.Pop) + Cancel(); + } + + [RelayCommand] + private async Task ShowProviderSourcesSheet() + { + ChangeSourceSheet = new ChangeSourceSheet() { BindingContext = this }; + + await ChangeSourceSheet.ShowAsync(); + } + + [RelayCommand] + private async Task SelectedProviderKeyChanged(string? key) + { + if (string.IsNullOrWhiteSpace(key) || _provider?.Key == key) + return; + + if (ChangeSourceSheet is not null) + { + await ChangeSourceSheet.DismissAsync(); + ChangeSourceSheet = null; + } + + var provider = _providers.Find(x => x.Key == key); + if (provider is null) + return; + + var list = ProviderGroups.SelectMany(x => x).ToList(); + list.ForEach(x => x.IsSelected = false); + + var defaultProvider = list.Find(x => x.Key == provider.Key); + if (defaultProvider is not null) + { + defaultProvider.IsSelected = true; + } + + await Snackbar.Make($"Source provider changed to {provider.Name}").Show(); + + _settingsService.LastProviderKey = provider.Key; + _settingsService.Save(); + + Entities.Clear(); + + _provider = provider; + + await LoadCore(); + } + + protected override async Task LoadCore() + { + if (Media is null) + { + IsBusy = false; + IsRefreshing = false; + return; + } + + if (_provider is null) + { + IsBusy = false; + IsRefreshing = false; + await Toast.Make("No providers installed").Show(); + return; + } + + IsBusy = true; + IsRefreshing = true; + + try + { + // Find best match + Manga = await TryFindBestManga(); + + if (CancellationToken.IsCancellationRequested) + return; + + if (Manga is null) + { + await Toast.Make("Nothing found").Show(); + await ShowProviderSearch(); + return; + } + + await LoadChapters(Manga); + } + catch + { + if (!CancellationToken.IsCancellationRequested) + { + SearchingText = "Nothing Found"; + } + } + finally + { + IsBusy = false; + IsRefreshing = false; + } + } + + public async Task LoadChapters(IMangaResult manga) + { + Manga = manga; + + SearchingText = $"Found : {manga.Title}"; + + IsBusy = true; + IsRefreshing = true; + + Ranges.Clear(); + Entities.Clear(); + + if (_provider is null) + { + await Toast.Make("No providers installed").Show(); + return; + } + + try + { + var result = await _provider.GetMangaInfoAsync(manga.Id, CancellationToken); + if (result is null || result.Chapters.Count == 0) + return; + + //result = result.OrderBy(x => x.Number).ToList(); + + Chapters.Clear(); + Chapters.AddRange(result.Chapters); + + MangaChapterChunks = result.Chapters.Chunk(50).ToList(); + + var ranges = new List(); + + if (MangaChapterChunks.Count > 1) + { + var startIndex = 1; + var endIndex = 0; + + for (var i = 0; i < MangaChapterChunks.Count; i++) + { + if (_settingsService.MangaChaptersDescending) + { + MangaChapterChunks[i] = MangaChapterChunks[i].Reverse().ToArray(); + } + + endIndex = startIndex + MangaChapterChunks[i].Length - 1; + ranges.Add(new(MangaChapterChunks[i], startIndex, endIndex)); + startIndex += MangaChapterChunks[i].Length; + } + + ranges[0].IsSelected = true; + } + else + { + if (_settingsService.MangaChaptersDescending) + { + MangaChapterChunks[0] = MangaChapterChunks[0].Reverse().ToArray(); + } + } + + RefreshProgress(); + + Ranges.Push(ranges); + OnPropertyChanged(nameof(Ranges)); + + Entities.Push(MangaChapterChunks[0]); + OnPropertyChanged(nameof(Entities)); + } + catch (Exception ex) + { + if (App.IsInDeveloperMode) + { + await App.AlertService.ShowAlertAsync("Error", $"{ex}"); + } + + SearchingText = "Nothing Found"; + } + finally + { + IsBusy = false; + IsRefreshing = false; + } + } + + [RelayCommand] + private void RangeSelected(MangaChapterRange range) + { + for (var i = 0; i < Ranges.Count; i++) + { + Ranges[i].IsSelected = false; + } + + range.IsSelected = true; + + Entities.ReplaceRange(range.Chapters); + } + + private void RefreshProgress() + { + if (MangaChapterChunks.Count == 0) + return; + + _playerSettings.Load(); + + //foreach (var list in MangaChapterChunks) + //{ + // foreach (var chapter in list) + // { + // var episodeKey = $"{Media.Id}-{chapter.Page}"; + // _playerSettings.WatchedEpisodes.TryGetValue(episodeKey, out var watchedEpisode); + // if (watchedEpisode is not null) + // { + // chapter.Progress = watchedEpisode.WatchedPercentage; + // } + // } + //} + + Entities.Clear(); + Entities.Push(MangaChapterChunks[0]); + } + + private async Task TryFindBestManga() + { + if (_provider is null) + return null; + + try + { + var dubText = IsDubSelected ? " (dub)" : ""; + + SearchingText = $"Searching : {Media.Title?.PreferredTitle}" + dubText; + + var result = await _provider.SearchAsync( + Media.Title.RomajiTitle + dubText, + CancellationToken + ); + + if (result.Count == 0) + { + result = await _provider.SearchAsync( + Media.Title.NativeTitle + dubText, + CancellationToken + ); + } + + if (result.Count == 0) + { + result = await _provider.SearchAsync( + Media.Title.EnglishTitle + dubText, + CancellationToken + ); + } + + return result.FirstOrDefault(); + } + catch (Exception ex) + { + if (App.IsInDeveloperMode) + { + await App.AlertService.ShowAlertAsync("Error", $"{ex}"); + } + + SearchingText = "Nothing found"; + + return null; + } + } + + public override void OnAppearing() + { + base.OnAppearing(); + + Shell.Current.Navigating -= Current_Navigating; + Shell.Current.Navigating += Current_Navigating; + + RefreshProgress(); + } + + [RelayCommand] + private async Task ItemClick(IMangaChapter chapter) + { + if (Media is null) + return; + + var navigationParameter = new Dictionary() + { + ["Media"] = Media, + ["MangaChapter"] = chapter + }; + + await Shell.Current.GoToAsync(nameof(MangaReaderPage), navigationParameter); + } + + [RelayCommand] + private async Task ShowCoverImage() + { + var sheet = new CoverImageSheet() { BindingContext = this }; + + await sheet.ShowAsync(); + } + + [RelayCommand] + private async Task CopyTitle() + { + if (!string.IsNullOrWhiteSpace(Media?.Title?.PreferredTitle)) + { + await Clipboard.Default.SetTextAsync(Media.Title.PreferredTitle); + + await Toast + .Make( + $"Copied to clipboard:{Environment.NewLine}{Media.Title.PreferredTitle}", + ToastDuration.Short, + 18 + ) + .Show(); + } + } + + [RelayCommand] + async Task ShowSheet(IMangaChapter chapter) + { + //if (Manga is null) + // return; + // + //var sheet = new VideoSourceSheet(); + //sheet.BindingContext = new VideoSourceViewModel(sheet, Manga, chapter, Media); + // + //await sheet.ShowAsync(); + } + + [RelayCommand] + private async Task FavouriteToggle() + { + if (string.IsNullOrWhiteSpace(_settingsService.AnilistAccessToken)) + { + await App.AlertService.ShowAlertAsync("Notice", "Login to Anilist"); + return; + } + + IsFavorite = !IsFavorite; + + if (IsSavingFavorite) + return; + + IsSavingFavorite = true; + + await ToggleFavoriteAsync(); + } + + private async Task ToggleFavoriteAsync() + { + try + { + var isFavorite = await _anilistClient.ToggleMediaFavoriteAsync( + Media.Id, + MediaType.Anime + ); + if (isFavorite != IsFavorite) + { + await ToggleFavoriteAsync(); + return; + } + } + catch (Exception ex) + { + await Toast.Make(ex.ToString(), ToastDuration.Long).Show(); + } + finally + { + IsSavingFavorite = false; + } + + await RefreshIsFavorite(); + } + + private async Task RefreshIsFavorite() + { + if (string.IsNullOrWhiteSpace(_settingsService.AnilistAccessToken)) + { + return; + } + + try + { + var media = await _anilistClient.GetMediaAsync(Media.Id); + Media.IsFavorite = media.IsFavorite; + //IsFavorite = media.IsFavorite; + } + catch (Exception ex) + { + await Toast.Make(ex.ToString(), ToastDuration.Long).Show(); + } + } + + [RelayCommand] + private async Task ShareUri() + { + if (Media?.Url is null) + return; + + await Share.Default.RequestAsync( + new ShareTextRequest + { + //Uri = $"https://anilist.cs/manga/{Media.Id}", + Uri = Media.Url.OriginalString, + Title = "Share Anilist Link" + } + ); + } + + [RelayCommand] + private void ChangeGridMode(GridLayoutMode gridLayoutMode) + { + GridLayoutMode = gridLayoutMode; + _settingsService.MangaItemsGridLayoutMode = gridLayoutMode; + _settingsService.Save(); + } + + [RelayCommand] + private async Task ShowProviderSearch() + { + if (IsProviderSearchSheetShowing) + return; + + IsProviderSearchSheetShowing = true; + + var sheet = new ProviderSearchSheet(); + sheet.BindingContext = new MangaProviderSearchViewModel( + this, + sheet, + Media.Title.PreferredTitle + ); + + sheet.Dismissed += (_, _) => IsProviderSearchSheetShowing = false; + + await sheet.ShowAsync(); + } + + public void ApplyQueryAttributes(IDictionary query) + { + Media = (Media)query["Media"]; + Media.Description = Html.ConvertToPlainText(Media.Description); + IsFavorite = Media.IsFavorite; + + OnPropertyChanged(nameof(Media)); + + RefreshIsFavorite(); + } + + public void Cancel() => _cancellationTokenSource.Cancel(); +} diff --git a/Anikin/ViewModels/Manga/MangaProviderSearchViewModel.cs b/Anikin/ViewModels/Manga/MangaProviderSearchViewModel.cs new file mode 100644 index 0000000..617b385 --- /dev/null +++ b/Anikin/ViewModels/Manga/MangaProviderSearchViewModel.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Anikin.Utils; +using Anikin.ViewModels.Framework; +using Anikin.ViewModels.Manga; +using Berry.Maui.Controls; +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Mvvm.Input; +using Juro.Core.Models.Manga; +using Juro.Core.Providers; + +namespace Anikin.ViewModels.Manga; + +public partial class MangaProviderSearchViewModel : CollectionViewModel +{ + private readonly IMangaProvider? _provider = ProviderResolver.GetMangaProvider(); + private readonly MangaItemViewModel _mangaItemViewModel; + private readonly BottomSheet _bottomSheet; + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + public MangaProviderSearchViewModel( + MangaItemViewModel mangaItemViewModel, + BottomSheet bottomSheet, + string query + ) + { + _mangaItemViewModel = mangaItemViewModel; + _bottomSheet = bottomSheet; + Query = query; + + Load(); + } + + protected override async Task LoadCore() + { + if (string.IsNullOrWhiteSpace(Query)) + { + IsRefreshing = false; + IsBusy = false; + Entities.Clear(); + return; + } + + if (_provider is null) + { + await Toast.Make("No providers installed").Show(); + return; + } + + if (!await IsOnline()) + return; + + if (!IsRefreshing) + IsBusy = true; + + try + { + var result = await _provider.SearchAsync(Query, CancellationToken); + Push(result); + Offset += result.Count; + } + catch (Exception ex) + { + if (!CancellationToken.IsCancellationRequested) + { + await App.AlertService.ShowAlertAsync("Error", ex.ToString()); + } + } + finally + { + IsBusy = false; + IsRefreshing = false; + } + } + + [RelayCommand] + async Task ItemSelected(IMangaResult item) + { + await _bottomSheet.DismissAsync(); + await _mangaItemViewModel.LoadChapters(item); + } + + public void Cancel() => _cancellationTokenSource.Cancel(); +} diff --git a/Anikin/ViewModels/Manga/MangaReaderViewModel.cs b/Anikin/ViewModels/Manga/MangaReaderViewModel.cs new file mode 100644 index 0000000..8ab5934 --- /dev/null +++ b/Anikin/ViewModels/Manga/MangaReaderViewModel.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Anikin.Services; +using Anikin.Utils; +using Anikin.Utils.Extensions; +using Anikin.ViewModels.Framework; +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Httpz; +using Jita.AniList; +using Jita.AniList.Models; +using Juro.Core.Models.Manga; +using Juro.Core.Providers; +using Microsoft.Maui.ApplicationModel.DataTransfer; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Storage; +using TaskExecutor; + +namespace Anikin.ViewModels.Manga; + +public partial class MangaReaderViewModel + : CollectionViewModel, + IQueryAttributable +{ + private readonly AniClient _anilistClient; + private readonly PlayerSettings _playerSettings = new(); + private readonly SettingsService _settingsService = new(); + + private readonly Downloader _downloader = new(); + + private readonly IMangaProvider? _provider = ProviderResolver.GetMangaProvider(); + + public static List Chapters { get; private set; } = []; + + [ObservableProperty] + private Media? _media; + + //private IMangaInfo Manga { get; set; } = default!; + + private IMangaChapter MangaChapter { get; set; } = default!; + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + public MangaReaderViewModel(AniClient aniClient) + { + _anilistClient = aniClient; + + _playerSettings.Load(); + _settingsService.Load(); + + Shell.Current.Navigating += Current_Navigating; + } + + public void ApplyQueryAttributes(IDictionary query) + { + Media = (Media)query["Media"]; + //Manga = (IMangaInfo)query["MangaInfo"]; + MangaChapter = (IMangaChapter)query["MangaChapter"]; + + Media.Description = Html.ConvertToPlainText(Media.Description); + + OnPropertyChanged(nameof(Media)); + } + + private void Current_Navigating(object? sender, ShellNavigatingEventArgs e) + { + Shell.Current.Navigating -= Current_Navigating; + + if (e.Source is ShellNavigationSource.PopToRoot or ShellNavigationSource.Pop) + { + Cancel(); + + foreach (var page in Entities) + { + try + { + if (File.Exists(page.Image)) + File.Delete(page.Image); + } + catch { } + } + } + } + + protected override async Task LoadCore() + { + if (_provider is null) + { + IsBusy = false; + IsRefreshing = false; + await Toast.Make("No providers installed").Show(); + return; + } + + IsBusy = true; + IsRefreshing = true; + + try + { + var pages = await _provider.GetChapterPagesAsync(MangaChapter.Id, CancellationToken); + if (pages.Count == 0) + { + await Toast.Make("Nothing found").Show(); + return; + } + + await DownloadPagesAsync(pages); + + await App.AlertService.ShowAlertAsync("test", $"{pages.FirstOrDefault()?.Image}"); + + Entities.Push(pages); + OnPropertyChanged(nameof(Entities)); + } + catch (Exception ex) + { + if (App.IsInDeveloperMode) + { + await App.AlertService.ShowAlertAsync("Error", $"{ex}"); + } + } + finally + { + IsBusy = false; + IsRefreshing = false; + } + } + + private async Task DownloadPagesAsync(List pages) + { + Exception? exception = null; + + var functions = pages.Select(page => + (Func)( + async () => + { + try + { + var path = Path.Combine( + FileSystem.AppDataDirectory, + $"img-{Guid.NewGuid()}.png" + ); + if (File.Exists(path)) + File.Delete(path); + + await _downloader.DownloadAsync( + page.Image, + path, + page.Headers, + cancellationToken: CancellationToken + ); + + page.Image = path; + } + catch (Exception ex) + { + exception = ex; + } + } + ) + ); + + await TaskEx.Run(functions, 15); + + if (App.IsInDeveloperMode && exception is not null) + { + await App.AlertService.ShowAlertAsync("Error", $"{exception}"); + } + } + + public override void OnAppearing() + { + base.OnAppearing(); + + Shell.Current.Navigating -= Current_Navigating; + Shell.Current.Navigating += Current_Navigating; + } + + [RelayCommand] + private async Task CopyTitle() + { + if (!string.IsNullOrWhiteSpace(Media?.Title?.PreferredTitle)) + { + await Clipboard.Default.SetTextAsync(Media.Title.PreferredTitle); + + await Toast + .Make( + $"Copied to clipboard:{Environment.NewLine}{Media.Title.PreferredTitle}", + ToastDuration.Short, + 18 + ) + .Show(); + } + } + + [RelayCommand] + async Task ShowSheet(IMangaChapter chapter) + { + //if (Manga is null) + // return; + // + //var sheet = new VideoSourceSheet(); + //sheet.BindingContext = new VideoSourceViewModel(sheet, Manga, chapter, Media); + // + //await sheet.ShowAsync(); + } + + [RelayCommand] + private async Task ShareUri() + { + if (Media?.Url is null) + return; + + await Share.Default.RequestAsync( + new ShareTextRequest + { + //Uri = $"https://anilist.cs/manga/{Media.Id}", + Uri = Media.Url.OriginalString, + Title = "Share Anilist Link" + } + ); + } + + public void Cancel() => _cancellationTokenSource.Cancel(); +} diff --git a/Anikin/ViewModels/Manga/MangaSearchViewModel.cs b/Anikin/ViewModels/Manga/MangaSearchViewModel.cs new file mode 100644 index 0000000..e1f31b8 --- /dev/null +++ b/Anikin/ViewModels/Manga/MangaSearchViewModel.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Anikin.Services; +using Anikin.ViewModels.Framework; +using Anikin.Views; +using CommunityToolkit.Mvvm.Input; +using Jita.AniList; +using Jita.AniList.Parameters; +using Microsoft.Maui.Controls; + +namespace Anikin.ViewModels.Manga; + +public partial class MangaSearchViewModel : CollectionViewModel +{ + private readonly AniClient _anilistClient; + private readonly SettingsService _settingsService; + + private CancellationTokenSource CancellationTokenSource = new(); + + public CancellationToken CancellationToken => CancellationTokenSource.Token; + + private int PageIndex { get; set; } = 1; + private int PageSize { get; set; } = 50; + + public MangaSearchViewModel(AniClient aniClient, SettingsService settingsService) + { + _anilistClient = aniClient; + _settingsService = settingsService; + + _settingsService.Load(); + + Load(); + } + + protected override async Task LoadCore() + { + if (string.IsNullOrWhiteSpace(Query)) + { + IsRefreshing = false; + IsBusy = false; + Entities.Clear(); + return; + } + + if (!await IsOnline()) + return; + + if (!IsLoading) + { + PageIndex = 1; + } + else + { + if (IsRefreshing) + IsBusy = true; + } + + try + { + Offset = 0; + + CancellationTokenSource.Cancel(); + CancellationTokenSource = new(); + + var result = await _anilistClient.SearchMediaAsync( + new SearchMediaFilter() + { + Query = Query, + IsAdult = false, + Sort = Jita.AniList.Models.MediaSort.Popularity, + Type = Jita.AniList.Models.MediaType.Anime + }, + new AniPaginationOptions(PageIndex, PageSize), + CancellationTokenSource.Token + ); + + if (result.Data.Length == 0) + { + Offset = -1; + return; + } + + PageIndex++; + + var data = result + .Data.Where(x => _settingsService.ShowNonJapaneseAnime || x.CountryOfOrigin == "JP") + .ToList(); + + Push(data); + } + catch (Exception ex) + { + if (ex is OperationCanceledException) { } + else + { + await App.AlertService.ShowAlertAsync("Error", ex.ToString()); + } + } + finally + { + IsBusy = false; + IsLoading = false; + IsRefreshing = false; + } + } + + public override bool CanLoadMore() => !IsLoading && Offset >= 0; + + [RelayCommand] + async Task ItemSelected(Jita.AniList.Models.Media item) + { + var navigationParameter = new Dictionary { { "SourceItem", item } }; + + await Shell.Current.GoToAsync(nameof(EpisodePage), navigationParameter); + } +} diff --git a/Anikin/Views/BottomSheets/MangaProviderSearchSheet.xaml b/Anikin/Views/BottomSheets/MangaProviderSearchSheet.xaml new file mode 100644 index 0000000..3b2e7c9 --- /dev/null +++ b/Anikin/Views/BottomSheets/MangaProviderSearchSheet.xaml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/BottomSheets/MangaProviderSearchSheet.xaml.cs b/Anikin/Views/BottomSheets/MangaProviderSearchSheet.xaml.cs new file mode 100644 index 0000000..efae2be --- /dev/null +++ b/Anikin/Views/BottomSheets/MangaProviderSearchSheet.xaml.cs @@ -0,0 +1,100 @@ +using Anikin.ViewModels; +using Berry.Maui; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Devices; + +namespace Anikin.Views.BottomSheets; + +public partial class MangaProviderSearchSheet +{ + private const int ItemWidth = 180; + + private DisplayOrientation LastDisplayOrientation { get; set; } + + public MangaProviderSearchSheet() + { + InitializeComponent(); + + Shown += (_, _) => + { + SearchEntry.Focused += SearchEntry_Focused; + SearchEntry.Completed += SearchEntry_Completed; + }; + + Dismissed += (_, _) => + { + SearchEntry.Focused -= SearchEntry_Focused; + SearchEntry.Completed -= SearchEntry_Completed; + + if (BindingContext is ProviderSearchViewModel viewModel) + { + viewModel.Cancel(); + } + }; + + var statusBarHeight = + ApplicationEx.GetStatusBarHeight() / DeviceDisplay.MainDisplayInfo.Density; + MainGrid.Margin = new Thickness(5, statusBarHeight + 10, 5, 0); + + SizeChanged += (_, _) => + { + //var columns = 1 + (int)(Width / ItemWidth); + var columns = + 1 + + (int)( + (MainGrid.Width - (MainGrid.Margin.Left + MainGrid.Margin.Right)) / ItemWidth + ); + + // Fix Maui bug where margins are reducing view when rotating device from + // Portrait to Landscape then back to Portrait + if (LastDisplayOrientation != DeviceDisplay.Current.MainDisplayInfo.Orientation) + { + LastDisplayOrientation = DeviceDisplay.Current.MainDisplayInfo.Orientation; + SearchCollectionView.ItemsLayout = new GridItemsLayout( + columns, + ItemsLayoutOrientation.Vertical + ); + } + else + { + (SearchCollectionView.ItemsLayout as GridItemsLayout)!.Span = columns; + } + }; + } + + private bool IsEntryCompleted = true; + + private void SearchEntry_Completed(object? sender, System.EventArgs e) + { + IsEntryCompleted = true; + } + + private void SearchEntry_Focused(object? sender, FocusEventArgs e) + { + SelectedDetent = Detents[2]; + + if (IsEntryCompleted) + { + Dispatcher.Dispatch(() => + { + SearchEntry.CursorPosition = 0; + SearchEntry.SelectionLength = (SearchEntry.Text?.Length) ?? 0; + }); + } + + //Dispatcher.Dispatch(() => + //{ + // // Highlight all text when a user clicks the entry while the keyboard is not showing + // if (!SearchEntry.IsSoftKeyboardShowing()) + // { + // SearchEntry.CursorPosition = 0; + // SearchEntry.SelectionLength = (SearchEntry.Text?.Length) ?? 0; + // + // SelectedDetent = Detents[2]; + // } + //}); + + IsEntryCompleted = false; + } +} diff --git a/Anikin/Views/Episodes/EpisodePage.xaml b/Anikin/Views/Episodes/EpisodePage.xaml index 2b966fd..c328f74 100644 --- a/Anikin/Views/Episodes/EpisodePage.xaml +++ b/Anikin/Views/Episodes/EpisodePage.xaml @@ -7,7 +7,6 @@ xmlns:converters="clr-namespace:Anikin.Converters" xmlns:local="clr-namespace:Anikin" xmlns:materialDesign="clr-namespace:MaterialDesign" - xmlns:models="clr-namespace:Juro.Core.Models.Anime;assembly=Juro.Core" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" xmlns:viewModels="clr-namespace:Anikin.ViewModels" xmlns:viewTemplates="clr-namespace:Anikin.Views.Templates" diff --git a/Anikin/Views/Episodes/OverviewTabView.xaml b/Anikin/Views/Episodes/OverviewTabView.xaml index 0e63778..d15e0e2 100644 --- a/Anikin/Views/Episodes/OverviewTabView.xaml +++ b/Anikin/Views/Episodes/OverviewTabView.xaml @@ -7,7 +7,6 @@ xmlns:converters="clr-namespace:Anikin.Converters" xmlns:local="clr-namespace:Anikin" xmlns:materialDesign="clr-namespace:MaterialDesign" - xmlns:models="clr-namespace:Juro.Core.Models.Anime;assembly=Juro.Core" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" xmlns:viewModels="clr-namespace:Anikin.ViewModels" xmlns:viewTemplates="clr-namespace:Anikin.Views.Templates" diff --git a/Anikin/Views/Home/MangaTabView.xaml b/Anikin/Views/Home/MangaTabView.xaml new file mode 100644 index 0000000..15c4386 --- /dev/null +++ b/Anikin/Views/Home/MangaTabView.xaml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Home/MangaTabView.xaml.cs b/Anikin/Views/Home/MangaTabView.xaml.cs new file mode 100644 index 0000000..a8cf4c3 --- /dev/null +++ b/Anikin/Views/Home/MangaTabView.xaml.cs @@ -0,0 +1,102 @@ +using System; +using Anikin.ViewModels.Home; +using Anikin.Views.Templates; +using Berry.Maui; +using CommunityToolkit.Maui.Markup; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Devices; +using Microsoft.Maui.Dispatching; + +namespace Anikin.Views.Home; + +public partial class MangaTabView +{ + public MangaTabView() + { + InitializeComponent(); + + SizeChanged += (_, _) => SetMargins(); + + SetupView(); + + DeviceDisplay.Current.MainDisplayInfoChanged += (s, e) => SetupView(); + } + + private void SetMargins() + { + var statusBarHeight = + ApplicationEx.GetStatusBarHeight() / DeviceDisplay.MainDisplayInfo.Density; + var navigationBarHeight = + ApplicationEx.GetNavigationBarHeight() / DeviceDisplay.MainDisplayInfo.Density; + + var leftMargin = 15.0; + var rightMargin = 15.0; + + if (DeviceDisplay.MainDisplayInfo.Orientation == DisplayOrientation.Landscape) + { + leftMargin += navigationBarHeight - 5.0; + rightMargin += navigationBarHeight - 5.0; + } + + NavGrid.Margin = new Thickness(leftMargin, statusBarHeight + 10.0, rightMargin, 0); + + if (navigationBarHeight > 0) + MainGrid.Margin = new Thickness(0, 0, 0, navigationBarHeight + 90); + } + + public void SetupView() + { + var view = new CarouselView() + { + ItemTemplate = new MainDataTemplateSelector() + { + DataTemplate = new DataTemplate(() => new MangaCarouselTemplateView()) + }, + IsBounceEnabled = false, + IsScrollAnimated = false, + IsSwipeEnabled = true, + ItemsUpdatingScrollMode = ItemsUpdatingScrollMode.KeepItemsInView, + PeekAreaInsets = 0, + ItemsLayout = new LinearItemsLayout(ItemsLayoutOrientation.Horizontal) + { + ItemSpacing = 0, + SnapPointsAlignment = SnapPointsAlignment.Start, + SnapPointsType = SnapPointsType.MandatorySingle + } + }.Bind(ItemsView.ItemsSourceProperty, (MangaHomeViewModel vm) => vm.PopularMedias); + + IDispatcherTimer? timer = null; + + view.Scrolled += (_, _) => SetTimer(); + view.Loaded += (_, _) => SetTimer(); + + void SetTimer() + { + timer?.Stop(); + + timer = Dispatcher.CreateTimer(); + timer.Interval = TimeSpan.FromMilliseconds(4200); + timer.Tick += (s, e) => + { + if (BindingContext is MangaHomeViewModel vm) + { + if (!App.IsOnline(false)) + return; + + try + { + view?.ScrollTo((view.Position + 1) % vm.PopularMedias.Count); + } + catch + { + // Ignore + } + } + }; + timer.Start(); + } + + CarouselContent.Content = view; + } +} diff --git a/Anikin/Views/HomeView.xaml b/Anikin/Views/HomeView.xaml index 08e0089..78bbf21 100644 --- a/Anikin/Views/HomeView.xaml +++ b/Anikin/Views/HomeView.xaml @@ -8,8 +8,8 @@ xmlns:skl="clr-namespace:SkiaSharp.Extended.UI.Controls;assembly=SkiaSharp.Extended.UI" xmlns:templates="clr-namespace:Anikin.Views.Templates" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" - xmlns:viewModels="clr-namespace:Anikin.ViewModels" - xmlns:views="clr-namespace:Anikin.Views" + xmlns:viewModels="clr-namespace:Anikin.ViewModels.Home" + xmlns:views="clr-namespace:Anikin.Views.Home" x:Name="this" x:DataType="viewModels:HomeViewModel"> @@ -17,7 +17,7 @@ x:Name="Switcher" Margin="0" Animate="True" - SelectedIndex="{Binding SelectedViewModelIndex, Mode=TwoWay}"> + SelectedIndex="{Binding SelectedTabIndex, Mode=TwoWay}"> + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Manga/MangaPage.xaml.cs b/Anikin/Views/Manga/MangaPage.xaml.cs new file mode 100644 index 0000000..5ae586a --- /dev/null +++ b/Anikin/Views/Manga/MangaPage.xaml.cs @@ -0,0 +1,32 @@ +using System; +using Anikin.ViewModels; +using Anikin.ViewModels.Manga; +using Microsoft.Maui.Controls; + +namespace Anikin.Views.Manga; + +public partial class MangaPage +{ + public MangaPage(MangaItemViewModel viewModel) + { + InitializeComponent(); + + BindingContext = viewModel; + } + + private void CoverImage_OnDoubleTap(object sender, TappedEventArgs e) => ToggleFavourite(); + + private void FavouriteButton_OnClick(object sender, EventArgs e) => ToggleFavourite(); + + private async void ToggleFavourite() + { + if (BindingContext is MangaItemViewModel viewModel) + { + viewModel.FavouriteToggleCommand.Execute(null); + + await favouriteBtn.ScaleTo(0.2, 100); + await favouriteBtn.ScaleTo(2, 100); + await favouriteBtn.ScaleTo(1, 100); + } + } +} diff --git a/Anikin/Views/Manga/MangaReaderPage.xaml b/Anikin/Views/Manga/MangaReaderPage.xaml new file mode 100644 index 0000000..a6d0396 --- /dev/null +++ b/Anikin/Views/Manga/MangaReaderPage.xaml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Manga/MangaReaderPage.xaml.cs b/Anikin/Views/Manga/MangaReaderPage.xaml.cs new file mode 100644 index 0000000..2f82332 --- /dev/null +++ b/Anikin/Views/Manga/MangaReaderPage.xaml.cs @@ -0,0 +1,13 @@ +using Anikin.ViewModels.Manga; + +namespace Anikin.Views.Manga; + +public partial class MangaReaderPage +{ + public MangaReaderPage(MangaReaderViewModel viewModel) + { + InitializeComponent(); + + BindingContext = viewModel; + } +} diff --git a/Anikin/Views/Manga/MangaSearchView.xaml b/Anikin/Views/Manga/MangaSearchView.xaml new file mode 100644 index 0000000..35bc87a --- /dev/null +++ b/Anikin/Views/Manga/MangaSearchView.xaml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Manga/MangaTabView.xaml.cs b/Anikin/Views/Manga/MangaTabView.xaml.cs new file mode 100644 index 0000000..257e62d --- /dev/null +++ b/Anikin/Views/Manga/MangaTabView.xaml.cs @@ -0,0 +1,9 @@ +namespace Anikin.Views.Manga; + +public partial class MangaTabView +{ + public MangaTabView() + { + InitializeComponent(); + } +} diff --git a/Anikin/Views/Manga/OverviewTabView.xaml b/Anikin/Views/Manga/OverviewTabView.xaml new file mode 100644 index 0000000..22ecaee --- /dev/null +++ b/Anikin/Views/Manga/OverviewTabView.xaml @@ -0,0 +1,32 @@ + + + + + \ No newline at end of file diff --git a/Anikin/Views/Manga/OverviewTabView.xaml.cs b/Anikin/Views/Manga/OverviewTabView.xaml.cs new file mode 100644 index 0000000..639d5d2 --- /dev/null +++ b/Anikin/Views/Manga/OverviewTabView.xaml.cs @@ -0,0 +1,9 @@ +namespace Anikin.Views.Manga; + +public partial class OverviewTabView +{ + public OverviewTabView() + { + InitializeComponent(); + } +} diff --git a/Anikin/Views/MangaItemView.xaml b/Anikin/Views/MangaItemView.xaml new file mode 100644 index 0000000..e9dded7 --- /dev/null +++ b/Anikin/Views/MangaItemView.xaml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/MangaItemView.xaml.cs b/Anikin/Views/MangaItemView.xaml.cs new file mode 100644 index 0000000..74396a9 --- /dev/null +++ b/Anikin/Views/MangaItemView.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.Maui.Controls; + +namespace Anikin.Views; + +public partial class MangaItemView +{ + public MangaItemView() + { + InitializeComponent(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + Scale = 0.4; + this.ScaleTo(1, 150); + } +} diff --git a/Anikin/Views/Templates/EpisodeRangeTemplateView.xaml b/Anikin/Views/Templates/EpisodeRangeTemplateView.xaml index c943a57..022c37d 100644 --- a/Anikin/Views/Templates/EpisodeRangeTemplateView.xaml +++ b/Anikin/Views/Templates/EpisodeRangeTemplateView.xaml @@ -14,7 +14,8 @@ diff --git a/Anikin/Views/Templates/Manga/FullItemTemplateView.xaml b/Anikin/Views/Templates/Manga/FullItemTemplateView.xaml new file mode 100644 index 0000000..d997e2e --- /dev/null +++ b/Anikin/Views/Templates/Manga/FullItemTemplateView.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Templates/Manga/FullItemTemplateView.xaml.cs b/Anikin/Views/Templates/Manga/FullItemTemplateView.xaml.cs new file mode 100644 index 0000000..c8112b1 --- /dev/null +++ b/Anikin/Views/Templates/Manga/FullItemTemplateView.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.Maui.Controls; + +namespace Anikin.Views.Templates.Manga; + +public partial class FullItemTemplateView +{ + public FullItemTemplateView() + { + InitializeComponent(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + Scale = 0.4; + this.ScaleTo(1, 150); + } +} diff --git a/Anikin/Views/Templates/Manga/ItemRangeTemplateView.xaml b/Anikin/Views/Templates/Manga/ItemRangeTemplateView.xaml new file mode 100644 index 0000000..49f2bea --- /dev/null +++ b/Anikin/Views/Templates/Manga/ItemRangeTemplateView.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Templates/Manga/ItemRangeTemplateView.xaml.cs b/Anikin/Views/Templates/Manga/ItemRangeTemplateView.xaml.cs new file mode 100644 index 0000000..d6432e8 --- /dev/null +++ b/Anikin/Views/Templates/Manga/ItemRangeTemplateView.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.Maui.Controls; + +namespace Anikin.Views.Templates.Manga; + +public partial class ItemRangeTemplateView +{ + public ItemRangeTemplateView() + { + InitializeComponent(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + Scale = 0.4; + this.ScaleTo(1, 150); + } +} diff --git a/Anikin/Views/Templates/Manga/ItemTemplateView.xaml b/Anikin/Views/Templates/Manga/ItemTemplateView.xaml new file mode 100644 index 0000000..97eaa2f --- /dev/null +++ b/Anikin/Views/Templates/Manga/ItemTemplateView.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Templates/Manga/ItemTemplateView.xaml.cs b/Anikin/Views/Templates/Manga/ItemTemplateView.xaml.cs new file mode 100644 index 0000000..211e468 --- /dev/null +++ b/Anikin/Views/Templates/Manga/ItemTemplateView.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.Maui.Controls; + +namespace Anikin.Views.Templates.Manga; + +public partial class ItemTemplateView +{ + public ItemTemplateView() + { + InitializeComponent(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + Scale = 0.4; + this.ScaleTo(1, 150); + } +} diff --git a/Anikin/Views/Templates/MangaCarouselTemplateView.xaml b/Anikin/Views/Templates/MangaCarouselTemplateView.xaml new file mode 100644 index 0000000..18a33ac --- /dev/null +++ b/Anikin/Views/Templates/MangaCarouselTemplateView.xaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Templates/MangaCarouselTemplateView.xaml.cs b/Anikin/Views/Templates/MangaCarouselTemplateView.xaml.cs new file mode 100644 index 0000000..74b9d23 --- /dev/null +++ b/Anikin/Views/Templates/MangaCarouselTemplateView.xaml.cs @@ -0,0 +1,9 @@ +namespace Anikin.Views.Templates; + +public partial class MangaCarouselTemplateView +{ + public MangaCarouselTemplateView() + { + InitializeComponent(); + } +} diff --git a/Anikin/Views/Templates/MangaTypeRangeTemplateView.xaml b/Anikin/Views/Templates/MangaTypeRangeTemplateView.xaml new file mode 100644 index 0000000..b2b4465 --- /dev/null +++ b/Anikin/Views/Templates/MangaTypeRangeTemplateView.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Templates/MangaTypeRangeTemplateView.xaml.cs b/Anikin/Views/Templates/MangaTypeRangeTemplateView.xaml.cs new file mode 100644 index 0000000..a9fd2a7 --- /dev/null +++ b/Anikin/Views/Templates/MangaTypeRangeTemplateView.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.Maui.Controls; + +namespace Anikin.Views.Templates; + +public partial class MangaTypeRangeTemplateView +{ + public MangaTypeRangeTemplateView() + { + InitializeComponent(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + Scale = 0.4; + this.ScaleTo(1, 150); + } +} diff --git a/Anikin/Views/Templates/ProviderMangaItemView.xaml b/Anikin/Views/Templates/ProviderMangaItemView.xaml new file mode 100644 index 0000000..5b08745 --- /dev/null +++ b/Anikin/Views/Templates/ProviderMangaItemView.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Anikin/Views/Templates/ProviderMangaItemView.xaml.cs b/Anikin/Views/Templates/ProviderMangaItemView.xaml.cs new file mode 100644 index 0000000..c837f3d --- /dev/null +++ b/Anikin/Views/Templates/ProviderMangaItemView.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.Maui.Controls; + +namespace Anikin.Views; + +public partial class ProviderMangaItemView +{ + public ProviderMangaItemView() + { + InitializeComponent(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + Scale = 0.4; + this.ScaleTo(1, 150); + } +}