Compare commits

...

106 Commits

Author SHA1 Message Date
rmcrackan
17612dacd2 incr ver 2025-03-22 06:35:30 -04:00
rmcrackan
e61ad41d5a Merge pull request #1202 from Mbucari/master
Add multiselect feature and a bugfix
2025-03-22 06:31:33 -04:00
Michael Bucari-Tovo
c77f2e2162 Add multi-select context menu support (rmcrackan/Libation#1195) 2025-03-21 16:49:21 -06:00
Michael Bucari-Tovo
bfcd226795 Fix libation hanging on first inport of large libraries 2025-03-21 11:08:36 -06:00
rmcrackan
0af7c4d90a fix ver 2025-03-21 09:19:03 -04:00
rmcrackan
e4826388be incr ver 2025-03-21 09:18:48 -04:00
rmcrackan
98a1fa4dda Merge pull request #1193 from Mbucari/master
Add support for custom themes in chardonnay
2025-03-21 09:12:35 -04:00
Michael Bucari-Tovo
81e9ab7fb2 Fix theme not resetting properly
Change button foreground color
2025-03-20 16:30:08 -06:00
Mbucari
9c82d34ba4 Merge branch 'rmcrackan:master' into master 2025-03-20 15:30:18 -06:00
Mbucari
a384bceab0 Update Readme for Chardonnay Themes 2025-03-20 15:29:46 -06:00
Michael Bucari-Tovo
545540d9a4 Improve Libation glass icons for use with dark mode. 2025-03-20 15:04:22 -06:00
MBucari
f402912a92 Mark resource as dynamic and delete unused resource 2025-03-19 22:43:50 -06:00
Michael Bucari-Tovo
aab4f1d9d6 Add theme import and export function 2025-03-19 21:47:24 -06:00
Michael Bucari-Tovo
f183b587b8 Revert all changes if window is closed by user. 2025-03-19 16:38:58 -06:00
Michael Bucari-Tovo
733a091ebd Add theme preview dialog 2025-03-19 16:26:14 -06:00
Mbucari
9043ea6334 Merge branch 'rmcrackan:master' into master 2025-03-19 14:21:51 -06:00
Michael Bucari-Tovo
40890f242a Fix spelling errors 2025-03-19 14:16:32 -06:00
rmcrackan
6c03f525bf Update InstallOnMac.md -- minimum OS supported 2025-03-19 16:01:40 -04:00
Michael Bucari-Tovo
dcda1a0cc2 Add contributors to about page 2025-03-18 21:18:25 -06:00
Michael Bucari-Tovo
e509f842e4 Remove unused windows forms buttons and streamline dialogs 2025-03-18 21:18:25 -06:00
Mbucari
faa2e04b9f Merge branch 'rmcrackan:master' into master 2025-03-18 21:17:30 -06:00
Robert McRackan
71afb5c9f4 incr ver 2025-03-18 21:09:27 -04:00
Michael Bucari-Tovo
d90ef3f4d4 Mark IconFill as a dynamic resource 2025-03-18 12:33:01 -06:00
Michael Bucari-Tovo
f84bb753e9 Revert custom window border on Windows 2025-03-13 16:44:16 -06:00
Michael Bucari-Tovo
b34970bd47 Add support for custom themes in chardonnay 2025-03-13 16:05:32 -06:00
Robert McRackan
a37eb383cd Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-03-10 19:11:22 -04:00
Robert McRackan
614965e1ab incr ver 2025-03-10 19:09:44 -04:00
rmcrackan
52d611a74c Merge pull request #1189 from Mbucari/master
Minor bug fixes.
2025-03-10 17:30:24 -04:00
Michael Bucari-Tovo
653381b1df Fix Auto download not working sometimes (#1183) 2025-03-10 12:57:52 -06:00
Michael Bucari-Tovo
4e067f5b5b Remove inadvertently committed debugging code 2025-03-10 10:46:36 -06:00
Michael Bucari-Tovo
ee05ca4eb2 Handle corrupted LibraryScans.zip file (#1185) 2025-03-10 09:49:22 -06:00
Michael Bucari-Tovo
65e12d9a8f Add default window dimensions 2025-03-10 09:07:35 -06:00
rmcrackan
5dc1bafb94 Update Docker.md 2025-03-07 10:12:05 -05:00
rmcrackan
3010f80834 Merge pull request #1181 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.7
Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
2025-03-06 20:49:34 -05:00
dependabot[bot]
6c20b85200 Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-06 22:29:43 +00:00
rmcrackan
bf87180fe9 Merge pull request #1179 from Mbucari/master
UI tweak and Linux command updates
2025-03-05 17:41:47 -05:00
Michael Bucari-Tovo
ae9aac789f Use polkit (#1176) and dnf5/dnf (#1177) 2025-03-05 14:22:51 -07:00
Mbucari
e6cd182872 Merge branch 'rmcrackan:master' into master 2025-03-05 11:25:50 -07:00
Michael Bucari-Tovo
7eeb2dcd7f Move encoding options above to the mp3 settings column (#764)
Additionally, add more tool tips for cryptic options.
2025-03-05 11:17:47 -07:00
rmcrackan
71bb0571d1 Merge pull request #1178 from RokeJulianLockhart/patch-1
Add instructions for Fedora to `InstallOnLinux.md`.
2025-03-05 11:32:10 -05:00
Mr. Beedell, Roke Julian Lockhart
7bb5b2968e Add instructions for Fedora to InstallOnLinux.md
Partially improves upon https://github.com/rmcrackan/Libation/issues/1177#issue-2897597653.
2025-03-05 15:17:49 +00:00
rmcrackan
b051283fca Update bug_report.md 2025-03-05 09:30:43 -05:00
rmcrackan
53af2ee39e Update feature_request.md 2025-03-05 09:30:18 -05:00
rmcrackan
fab32a1744 Update bug_report.md 2025-03-05 09:29:04 -05:00
rmcrackan
e2dabc8a53 Update feature_request.md 2025-03-05 09:26:47 -05:00
rmcrackan
fd82f7ae5a Update feature_request.md 2025-03-05 09:26:19 -05:00
rmcrackan
df9535a83d Update FrequentlyAskedQuestions.md 2025-03-05 08:20:42 -05:00
rmcrackan
85b6792468 Merge pull request #1175 from Mbucari/master
Additional null safety
2025-03-04 21:32:35 -05:00
Michael Bucari-Tovo
e37abbf276 Fix dark theme text color in DataGridTextColumn 2025-03-04 16:18:06 -07:00
Michael Bucari-Tovo
c3938c49a9 Additional null safety 2025-03-04 15:41:26 -07:00
Michael Bucari-Tovo
7658f21d7c Fix tags font color in dark mode 2025-03-04 15:07:37 -07:00
Michael Bucari-Tovo
c4827fc761 Add error logging 2025-03-04 12:54:05 -07:00
rmcrackan
649b52af1d Merge pull request #1174 from Mbucari/master
Null safety & UI tweak
2025-03-04 13:10:51 -05:00
Michael Bucari-Tovo
da06511951 Only show buttons on mouse over 2025-03-04 10:34:09 -07:00
Michael Bucari-Tovo
88d3e5ff0c Null safety checks. 2025-03-04 10:33:29 -07:00
rmcrackan
5f99e594d8 Merge pull request #1172 from Mbucari/master
Thread safety and AccountSettings.json error handling
2025-03-03 19:27:45 -05:00
Michael Bucari-Tovo
981a183992 Add a border around dialogs with CanResize=true 2025-03-03 15:20:58 -07:00
Michael Bucari-Tovo
ac036f65f1 Handle and notify users of invalid account settings file 2025-03-03 14:41:56 -07:00
Michael Bucari-Tovo
b36e110b49 Add thread safety and comments re threading 2025-03-03 10:11:17 -07:00
Robert McRackan
ef3c71a939 Incr ver 2025-03-02 20:34:45 -05:00
rmcrackan
b2af93bed9 Merge pull request #1169 from Mbucari/master
12.0.1 Bug Fixes
2025-03-02 20:33:59 -05:00
Mbucari
1f427919e6 Try to solve threadding issues (#1168) 2025-03-02 11:59:35 -07:00
Mbucari
c9c5bbb687 Fix errorre rmoving entries from the cache (#1167) 2025-03-02 10:55:26 -07:00
Robert McRackan
efbefa2784 Increm ver 2025-03-01 10:44:49 -05:00
rmcrackan
51aabe5dd4 Merge pull request #1161 from Mbucari/master
Performance and UI tweaks
2025-02-28 22:48:49 -05:00
Michael Bucari-Tovo
1c668adff8 Deliminated category names in library exports with semicolon (#1107) 2025-02-28 17:35:58 -07:00
Michael Bucari-Tovo
4170dcc1d5 Chardonnay UI bug fixes and improvements
- Theme changes do not require restart
- Fix some text appearing black in dark mode
- Fix dialog boxes not appearing correctly on Windows
- Fix queue vertical scroll bar overlapping items
2025-02-28 17:34:55 -07:00
Michael Bucari-Tovo
68cfae1d58 Fix ever-widening Form1 when form size is restored. 2025-02-28 12:04:49 -07:00
Michael Bucari-Tovo
a790c7535c Changes to default directories for file storage (#1112)
- Add My Music and Local Application Data to known directories
- Make %localappdata%\Libation the default settings folder on *nix machines
- Make %MyMusic%\Libation\Books the default books folder on *nix machines
2025-02-28 12:01:58 -07:00
Michael Bucari-Tovo
3b7d5a354f Re-add books to queue that failed or were cancelled. 2025-02-28 10:47:26 -07:00
MBucari
a9375f1520 Improve file cache performance and add migration
LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size.

The new cache uses a dictionary with the ID as its key, and a CacheEntry list as its value.
2025-02-28 10:07:45 -07:00
Michael Bucari-Tovo
47c9fcb883 Improve LibrarySizeChanged performance 2025-02-27 22:56:30 -07:00
rmcrackan
5f5c9f65ed Merge pull request #1160 from Mbucari/master
Fixed stack overflow crash when movifying large databases
2025-02-27 21:41:33 -05:00
MBucari
1417a4b992 Improve library load performance 2025-02-27 19:16:36 -07:00
Michael Bucari-Tovo
2d6120f0c4 Get full library in LibrarySizeChanged event and pass as EventArgs
There are multiple subscribers to LibraryCommands.LibrarySizeChanged, and each one calls GetLibrary_Flat_NoTracking(). Passing the full library as an event argument speeds up all operations which happen after the library size changes.

Fix initial backup counts
2025-02-27 13:11:28 -07:00
Michael Bucari-Tovo
2a25b7e0ad Load MainWindow before library finishes loading like Classic 2025-02-27 11:17:18 -07:00
Michael Bucari-Tovo
4766ea7372 Improve NRE safety for quick filters 2025-02-27 10:10:26 -07:00
Michael Bucari-Tovo
d195dd07dc Remove upload-release-assets package (out of support) 2025-02-27 09:30:08 -07:00
Michael Bucari-Tovo
bcbb7610ad Refactor GetAllEntries methods for clarity and maintainability
d
2025-02-27 09:00:26 -07:00
Michael Bucari-Tovo
6c5773df24 Fix stack overflow exception when updating large databases (#1158) 2025-02-26 14:41:59 -07:00
rmcrackan
211f15af25 Update InstallOnMac.md 2025-02-25 10:03:11 -05:00
Robert McRackan
e3b0f80016 incr ver: 12.0 2025-02-24 12:05:24 -05:00
Robert McRackan
7b2c7e49e5 fix unit tests 2025-02-23 11:11:25 -05:00
Robert McRackan
6a40d19393 Fix unit tests 2025-02-23 10:28:09 -05:00
Robert McRackan
3167744111 #1151 : default character replacement for colon is _ 2025-02-23 10:13:15 -05:00
Robert McRackan
b6a3ba335a Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-22 22:54:12 -05:00
Robert McRackan
fa1ddc726a #1151 - unicode colon is causing issues with Windows Chardonnay 2025-02-22 22:54:00 -05:00
rmcrackan
b3b0662dec Update InstallOnMac.md
Apple can’t check app for malicious software
2025-02-19 17:09:26 -05:00
Robert McRackan
5cb22cfd24 Update AAXClean.Codecs 2025-02-17 20:07:50 -05:00
Michael Bucari-Tovo
e911344850 Workaround for DataGridView filtering internal NullReferenceException 2025-02-10 10:20:18 -07:00
Michael Bucari-Tovo
8ec7e5a9d2 Revert "DataGridView filtering internal NullReferenceException **HACK**"
This reverts commit e1f749c3da.
2025-02-10 10:19:24 -07:00
Robert McRackan
e1f749c3da DataGridView filtering internal NullReferenceException **HACK** 2025-02-10 11:23:27 -05:00
Robert McRackan
ba060d15aa Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-10 09:12:32 -05:00
Robert McRackan
93fde236c8 format for clarity 2025-02-10 09:12:29 -05:00
Michael Bucari-Tovo
13aad1a7cb Restrict audio sample rate settings to allowed values (#1116) 2025-01-16 10:35:58 -07:00
rmcrackan
65c64c4504 Update Docker.md 2025-01-13 09:28:55 -05:00
rmcrackan
14ba04c28b Update Docker.md 2025-01-13 09:26:52 -05:00
rmcrackan
96e886d207 Update FrequentlyAskedQuestions.md
Brazil login
2025-01-08 10:04:55 -05:00
Robert McRackan
c7279574a9 Upgrade avalonia 2025-01-07 08:00:35 -05:00
rmcrackan
a522e1ff7e Merge pull request #1072 from shuvashish76/patch-1
Update InstallOnLinux.md with repology badge
2024-12-08 08:56:32 -05:00
shuvashish76
9ebc4444bd Update InstallOnLinux.md 2024-12-07 06:24:34 +00:00
shuvashish76
525afdf050 Update InstallOnLinux.md 2024-12-07 06:03:46 +00:00
shuvashish76
983cdf6ad5 Update InstallOnLinux.md with repology badge 2024-12-07 05:38:35 +00:00
Robert McRackan
09bb32a435 incr ver 2024-12-06 13:10:37 -05:00
rmcrackan
817ef33fbd Merge pull request #1071 from pixil98/master
update docker image to dotnet 9
2024-12-06 13:07:39 -05:00
Aaron Reisman
be52b496a6 Update docker image to .net 9 2024-12-06 10:29:47 -06:00
192 changed files with 3579 additions and 1780 deletions

View File

@@ -6,10 +6,10 @@ labels: bug
assignees: ''
---
**Describe the bug**
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
@@ -17,15 +17,14 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Platform**
**Platform**
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here.
**Log Files**
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'

View File

@@ -6,14 +6,26 @@ labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
**No-go ideas**
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
* comprehensive api/cli
* aax/audiobook import
* bulk rename of existing files
* general metadata/tag editor
* playback features
* web gui
* supporting non-audible vendors
* official docker support
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -51,13 +51,8 @@ jobs:
with:
name: Libation ${{ needs.prerelease.outputs.version }}
body: <Put a body here>
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: false
- name: Upload release assets
uses: dwenegar/upload-release-assets@v2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
release_id: "${{ steps.release.outputs.id }}"
assets_path: ./artifacts
files: |
artifacts/*/*

View File

@@ -1,5 +1,5 @@
# Dockerfile
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
COPY Source /Source
@@ -10,7 +10,7 @@ RUN dotnet publish \
--output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
FROM mcr.microsoft.com/dotnet/runtime:8.0
FROM mcr.microsoft.com/dotnet/runtime:9.0
ARG USER_UID=1001
ARG USER_GID=1001

View File

@@ -11,6 +11,7 @@
- [Settings](#settings)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
@@ -86,3 +87,25 @@ CLI: Full library. No prompt
libationcli set-status -n
libationcli set-status -d -n
```
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
#### Theme Editor Window
The theme editor has a list of style names and their currently assigned colors. To change a style color, click on the color swatch in the left-hand column to open the color editor for that style. Observe the color changes in real-time on the built-in preview panel on the right-hand side of the theme editor.
You may import or export themes using the buttons at the bottom-left of the theme editor.
"Cancel" or closing the window will revert any changes you've made in the theme editor.
"Reset" will reset any changes you've made in the theme editor.
"Defaults" will restore the application default colors for the active theme ("Light" or "Dark")
"Save" will save the theme colors to the ChardonnayTheme.json file and close the editor.
Note: you may only edit the currently applied theme ("Light" or "Dark").
#### Video Walkthrough
The below video demonstrates using the theme editor to make changes to the Dark theme color pallet.
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)

View File

@@ -5,7 +5,7 @@
> [!WARNING]
> ## Breaking Changes
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them.
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
@@ -49,7 +49,9 @@ sudo docker run -d \
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
### User
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
@@ -63,5 +65,12 @@ sudo docker run -d \
rmcrackan/libation:latest
```
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
### Advanced Database Options
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
### Getting help
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.

View File

@@ -31,6 +31,10 @@ Self-hosting online:
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
## Q: I'm having trouble loggin into my Brazil account.
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
## Q: How do I use Libation with a South Africa account?
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.

View File

@@ -4,25 +4,61 @@
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Packaging status
# Install and Run Libation on Ubuntu
New Libation releases are automatically packed into .deb and .rpm package and are available from the Libation repository's releases page.
[![Packaging status](https://repology.org/badge/vertical-allrepos/libation.svg)](https://repology.org/project/libation/versions)
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the Libation repository's releases page.
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
- Debian
### Debian
```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
sudo apt install ./libation.deb
```
- Redhat and CentOS
### Redhat and CentOS
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
sudo yum install ./libation.rpm
```
### Fedora
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
sudo dnf5 install ./libation.rpm
```
---
### Arch Linux
```Console
yay -S libation
```
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
### NixOS
- Install via `nix-shell`
```Console
nix-shell -p libation
```
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
- Install via NixOS configuration
```Console
environment.systemPackages = [
pkgs.libation
];
```
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
- On NixOS via via `nix-env`
```Console
nix-env -iA nixos.libation
```
- On Non NixOS via `nix-env`
```Console
nix-env -iA nixpkgs.libation
```
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
If your desktop uses gtk, you should now see Libation among your applications.

View File

@@ -8,12 +8,12 @@
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Supports macOS 10.15 (Catalina) and above
## Supports macOS 13 (Ventura) and above
## Install Libation
- Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder.
- Right-click on Libation and then click on open
@@ -40,6 +40,15 @@ You can add Libation as a safe app without touching Gatekeeper.
* Close the terminal and use Libation!
## "Apple can't check app for malicious software"
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
* Go to Security, then click Open.
* Click Open Anyway. This button is available for about an hour after you try to open the app.
* Enter your login password, then click OK.
## Troubleshooting
If Libation fails to start after completing the above steps, try the following:

View File

@@ -1,34 +1,36 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<defs>
<g id="glass">
<path fill-rule="evenodd" d=
"M262,8
h-117
a 192,200 0 0 0 -36,82
a 222,334 41 0 0 138,236
v158
h-81
a 16,16 0 0 0 0,32
h192
a 16 16 0 0 0 0,-32
h-81
v-158
a 222,334 -41 0 0 138,-236
a 192,200 0 0 0 -36,-82
h-117
m-99,30
a 192,200 0 0 0 -26,95
a 187.5,334 35 0 0 125,159
a 187.5,334 -35 0 0 125,-159
a 192,200 0 0 0 -26,-95
h-198
<path transform="translate(16 16)" fill-rule="evenodd" d=
"M177,16
H79
A 32.0781 63.7932 -1.5106 0 0 66 80
A 158.789 471.1259 41.9466 0 0 90 131
A 81.7197 122.0515 35.3745 0 0 128 143.3484
A 81.7197 122.0515 -35.3745 0 0 166 131
A 158.789 471.1259 -41.9466 0 0 190 80
A 32.0781 63.7932 1.5106 0 0 177 16
L 184 0
A 44.7901 78.5247 1.1521 0 1 194 122
A 97.0039 135.3148 -36.2124 0 1 136 159
V 240
H 176
A 8 8 0 0 1 176 256
H 80
A 8 8 0 0 1 80 240
H 120
V 159
A 97.0039 135.3148 36.2124 0 1 62 122
A 44.7901 78.5247 -1.1521 0 1 72 0
H184
z"/>
</g>
<g id="wine-level">
<g transform="translate(16 16)" id="wine-level">
<path d=
"M158,136
a 168,305 35 0 0 104,136
a 168,305 -35 0 0 104,-136
"M182,64
H 74
A 115.9979 308.8033 38.9474 0 0 128 134.4277
A 115.9979 308.8033 -38.9474 0 0 182,64
z"/>
</g>
</defs>

Before

Width:  |  Height:  |  Size: 968 B

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,30 +1,31 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<g transform="translate(0 80) rotate(90 256,256)">
<path id="glass" d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
z" />
<path id="wine-level" d=
"M345,44
A 192,184 0 0 1 366,126
A 320,180 55 0 1 345,226
z"/>
</g>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<g>
<path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
"M177,16
H79
A 32.0781 63.7932 -1.5106 0 0 66 80
A 158.789 471.1259 41.9466 0 0 90 131
A 81.7197 122.0515 35.3745 0 0 128 143.3484
A 81.7197 122.0515 -35.3745 0 0 166 131
A 158.789 471.1259 -41.9466 0 0 190 80
A 32.0781 63.7932 1.5106 0 0 177 16
L 184 0
A 44.7901 78.5247 1.1521 0 1 194 122
A 97.0039 135.3148 -36.2124 0 1 136 159
V 240
H 176
A 8 8 0 0 1 176 256
H 80
A 8 8 0 0 1 80 240
H 120
V 159
A 97.0039 135.3148 36.2124 0 1 62 122
A 44.7901 78.5247 -1.1521 0 1 72 0
H184
M170,115
V24
A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
A 19.5181 45.9183 3.3549 0 1 170 115
z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 936 B

View File

@@ -32,6 +32,7 @@
- [Settings](Documentation/Advanced.md#settings)
- [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
- [Docker](Documentation/Docker.md)
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="1.1.3" />
<PackageReference Include="AAXClean.Codecs" Version="1.1.4" />
</ItemGroup>
<ItemGroup>

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>11.6.2.1</Version>
<Version>12.2.0.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="13.0.1" />
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks -->
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

View File

@@ -89,7 +89,9 @@ namespace AppScaffolding
Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_to_v11_5_0(config);
}
Migrations.migrate_to_v11_6_5(config);
Migrations.migrate_to_v12_0_1(config);
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
@@ -278,8 +280,11 @@ namespace AppScaffolding
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
=> SearchEngineCommands.UpdateBooks(books);
}
public static UpgradeProperties GetLatestRelease()
@@ -413,7 +418,92 @@ namespace AppScaffolding
public List<string> Filters { get; set; } = new();
}
public static void migrate_to_v11_5_0(Configuration config)
public static void migrate_to_v12_0_1(Configuration config)
{
#nullable enable
//Migrate from version 1 file cache to the dictionary-based version 2 cache
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{
try
{
//FilePathCache loads the cache in its static constructor,
//so perform migration without using FilePathCache.CacheEntry
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
return;
Dictionary<string, JArray> cache = new();
//Convert to c# objects to speed up searching by ID inside the iterator
var allItems
= v1Cache
.Select(i => new
{
Id = i["Id"]?.Value<string>(),
Path = i["Path"]?["Path"]?.Value<string>()
}).Where(i => i.Id != null)
.ToArray();
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
{
//Use this opportunity to purge non-existent files and re-classify file types
//(due to *.aax files previously not being classified as FileType.AAXC)
var items = allItems
.Where(i => i.Id == id && File.Exists(i.Path))
.Select(i => new JObject
{
{ "Id", i.Id },
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) },
{ "Path", new JObject{ { "Path", i.Path } } }
})
.ToArray();
if (items.Length == 0)
continue;
cache[id] = new JArray(items);
}
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
var cacheFileText = cacheJson.ToString(Formatting.Indented);
void migrate()
{
File.WriteAllText(jsonFileV2, cacheFileText);
File.Delete(jsonFileV1);
}
try { migrate(); }
catch (IOException)
{
try { migrate(); }
catch (IOException)
{
migrate();
}
}
}
catch { /* eat */ }
}
#nullable restore
}
public static void migrate_to_v11_6_5(Configuration config)
{
//Settings migration for unsupported sample rates (#1116)
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
}
public static void migrate_to_v11_5_0(Configuration config)
{
// Read file, but convert old format to new (with Name field) as necessary.
if (!File.Exists(QuickFilters.JsonFile))

View File

@@ -15,12 +15,13 @@ using Newtonsoft.Json.Linq;
using Serilog;
using static DtoImporterService.PerfLogger;
#nullable enable
namespace ApplicationServices
{
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler<int> ScanEnd;
public static event EventHandler<int>? ScanBegin;
public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
@@ -100,7 +101,7 @@ namespace ApplicationServices
}
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[]? accounts)
{
logRestart();
@@ -222,7 +223,7 @@ namespace ApplicationServices
{
int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0)
await Task.Run(finalizeLibrarySizeChange);
await Task.Run(() => finalizeLibrarySizeChange(context));
return qtyChanged;
}
catch (Exception ex)
@@ -232,13 +233,42 @@ namespace ApplicationServices
}
}
private static LogArchiver? openLogArchive(string? archivePath)
{
if (string.IsNullOrWhiteSpace(archivePath))
return null;
try
{
return new LogArchiver(archivePath);
}
catch (System.IO.InvalidDataException)
{
try
{
Log.Logger.Warning($"Deleting corrupted {nameof(LogArchiver)} at {archivePath}");
FileUtility.SaferDelete(archivePath);
return new LogArchiver(archivePath);
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
await using LogArchiver archiver
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);
@@ -266,13 +296,13 @@ namespace ApplicationServices
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
Account = account.MaskedLogEntry ?? "[null]"
});
logTime($"pre scanAccountAsync {account.AccountName}");
@@ -294,7 +324,7 @@ namespace ApplicationServices
throw new AggregateException(ex.InnerExceptions);
}
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null)
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception>? exceptions = null)
{
if (archiver is not null)
{
@@ -329,7 +359,7 @@ namespace ApplicationServices
// this is any changes at all to the database, not just new books
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
await Task.Run(() => finalizeLibrarySizeChange(context));
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount;
@@ -369,16 +399,16 @@ namespace ApplicationServices
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
lb.IsDeleted = true;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
lb.IsDeleted = true;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -398,16 +428,16 @@ namespace ApplicationServices
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -432,7 +462,7 @@ namespace ApplicationServices
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -445,31 +475,35 @@ namespace ApplicationServices
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
private static void finalizeLibrarySizeChange(LibationContext context)
{
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
LibrarySizeChanged?.Invoke(null, library);
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
public static event EventHandler<List<LibraryBook>>? LibrarySizeChanged;
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(
this LibraryBook lb,
string tags = null,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
Rating? rating = null)
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem(
this IEnumerable<LibraryBook> lb,
string tags = null,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
Rating? rating = null)
=> updateUserDefinedItem(
lb,
udi => {
@@ -518,17 +552,19 @@ namespace ApplicationServices
if (libraryBooks is null || !libraryBooks.Any())
return 0;
foreach (var book in libraryBooks)
action?.Invoke(book.Book.UserDefinedItem);
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var book in libraryBooks)
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
action?.Invoke(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
@@ -593,13 +629,15 @@ namespace ApplicationServices
return sb.ToString();
}
}
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.WithoutParents()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.ToList();
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);

View File

@@ -144,7 +144,7 @@ namespace ApplicationServices
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,

View File

@@ -48,6 +48,8 @@ namespace ApplicationServices
}
public static void FullReIndex() => performSafeCommand(fullReIndex);
public static void FullReIndex(List<LibraryBook> libraryBooks)
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
{
@@ -94,8 +96,11 @@ namespace ApplicationServices
private static void fullReIndex(SearchEngine engine)
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
fullReIndex(engine, library);
}
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
=> engine.CreateNewIndex(libraryBooks);
#endregion
}
}

View File

@@ -5,19 +5,58 @@ using Newtonsoft.Json;
namespace AudibleUtilities
{
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
{
/// <summary>
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// The file path of the AccountsSettings.json file
/// </summary>
public string SettingsFilePath { get; }
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
: base(exception)
{
SettingsFilePath = path;
}
}
public static class AudibleApiStorage
{
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
public static void EnsureAccountsSettingsFileExists()
{
// saves. BEWARE: this will overwrite an existing file
if (!File.Exists(AccountsSettingsFile))
_ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile);
{
//Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved
//are not fired. There's no need to fire those events on an empty AccountsSettings file.
var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings();
File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings));
}
}
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile);
public static AccountsSettingsPersister GetAccountsSettingsPersister()
{
try
{
return new AccountsSettingsPersister(AccountsSettingsFile);
}
catch (Exception ex)
{
var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex);
LoadError?.Invoke(null, args);
if (args.Handled)
return GetAccountsSettingsPersister();
throw;
}
}
public static string GetIdentityTokensJsonPath(this Account account)
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.3.0.1" />
<PackageReference Include="AudibleApi" Version="9.3.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -6,6 +6,7 @@ namespace DataLayer
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
{
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}
}

View File

@@ -44,7 +44,11 @@ namespace DataLayer
public static bool IsEpisodeParent(this Book book)
=> book.ContentType is ContentType.Parent;
public static bool HasLiberated(this Book book)
public static IEnumerable<LibraryBook> WithoutParents(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => !lb.Book.IsEpisodeParent());
public static bool HasLiberated(this Book book)
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
}

View File

@@ -21,8 +21,8 @@ namespace DataLayer
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.AsEnumerable()
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
.ToList();
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
@@ -91,7 +91,7 @@ namespace DataLayer
}
#nullable disable
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
public static List<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList
.Where(
lb =>

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
<PackageReference Include="Polly" Version="8.5.0" />
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Polly" Version="8.5.2" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -157,7 +157,7 @@ namespace FileManager
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
/// <returns>The actual destination filename</returns>
public static string SaferMoveToValidPath(
public static LongPath SaferMoveToValidPath(
LongPath source,
LongPath destination,
ReplacementCharacters replacements,

View File

@@ -88,7 +88,7 @@ namespace FileManager
Replacement.OtherQuote(""),
Replacement.OpenAngleBracket(""),
Replacement.CloseAngleBracket(""),
Replacement.Colon(""),
Replacement.Colon("_"),
Replacement.Asterisk("✱"),
Replacement.QuestionMark(""),
Replacement.Pipe("⏐"),

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -71,13 +71,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.1" />
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.1" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.1" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 601 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LibationAvalonia"
xmlns:controls="using:LibationAvalonia.Controls"
xmlns:dialogs="using:LibationAvalonia.Dialogs"
x:Class="LibationAvalonia.App"
Name="Libation">
@@ -12,6 +13,18 @@
<Application.Resources>
<ResourceDictionary>
<ControlTheme x:Key="{x:Type TextBlock}" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
<Setter Property="Background" Value="Transparent" />
</ControlTheme>
<ControlTheme x:Key="{x:Type DataGridCell}" TargetType="DataGridCell" BasedOn="{StaticResource {x:Type DataGridCell}}">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
</ControlTheme>
<ControlTheme x:Key="{x:Type DataGridColumnHeader}" TargetType="DataGridColumnHeader" BasedOn="{StaticResource {x:Type DataGridColumnHeader}}">
<Setter Property="Padding" Value="6,0,0,0" />
</ControlTheme>
<x:Double x:Key="DataGridSortIconMinWidth">0</x:Double>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" />
@@ -20,17 +33,11 @@
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
<SolidColorBrush x:Key="HyperlinkNew" Color="Blue" />
<SolidColorBrush x:Key="HyperlinkVisited" Color="Purple" />
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="White" />
<SolidColorBrush x:Key="SystemOpaqueBase" Color="White" />
<SolidColorBrush x:Key="CancelRed" Color="FireBrick" />
<SolidColorBrush x:Key="IconFill" Color="#231F20" />
<SolidColorBrush x:Key="StoplightRed" Color="#F06060" />
<SolidColorBrush x:Key="StoplightYellow" Color="#F0E160" />
<SolidColorBrush x:Key="StoplightGreen" Color="#70FA70" />
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#bed2fa" />
@@ -39,35 +46,32 @@
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="#4e4b15" />
<SolidColorBrush x:Key="HyperlinkNew" Color="CornflowerBlue" />
<SolidColorBrush x:Key="HyperlinkVisited" Color="Orchid" />
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="Black" />
<SolidColorBrush x:Key="SystemOpaqueBase" Color="Black" />
<SolidColorBrush x:Key="CancelRed" Color="#802727" />
<SolidColorBrush x:Key="IconFill" Color="#DCE0DF" />
<SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" />
<SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" />
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Themes.Fluent/FluentTheme.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
<StyleInclude Source="/Assets/LibationVectorIcons.xaml"/>
<StyleInclude Source="/Assets/DataGridColumnHeader.xaml"/>
<FluentTheme>
<FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" />
<ColorPaletteResources x:Key="Dark" />
</FluentTheme.Palettes>
</FluentTheme>
<Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
<Style Selector="^ /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
</Style>
</Style>
<Style Selector="controls|LinkLabel">
@@ -76,11 +80,24 @@
</Style>
<Style Selector="Button">
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Style Selector="^">
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style>
<Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
</Style>
<Style Selector="dialogs|DialogWindow">
<Setter Property="SystemDecorations" Value="Full"/>
<Setter Property="Icon" Value="/Assets/libation.ico"/>
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Background="{DynamicResource SystemRegionColor}" Content="{TemplateBinding Content}" />
</ControlTemplate>
</Setter>
</Style>
</Application.Styles>
<NativeMenu.Menu>

View File

@@ -1,10 +1,7 @@
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Styling;
using LibationAvalonia.Dialogs;
@@ -14,21 +11,23 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ReactiveUI;
using DataLayer;
using Avalonia.Threading;
using Dinah.Core;
using LibationAvalonia.Themes;
using Avalonia.Data.Core.Plugins;
using System.Linq;
#nullable enable
namespace LibationAvalonia
{
public class App : Application
{
public static Window MainWindow { get; private set; }
public static IBrush ProcessQueueBookFailedBrush { get; private set; }
public static IBrush ProcessQueueBookCompletedBrush { get; private set; }
public static IBrush ProcessQueueBookCancelledBrush { get; private set; }
public static IBrush ProcessQueueBookDefaultBrush { get; private set; }
public static IBrush SeriesEntryGridBackgroundBrush { get; private set; }
public static Task<List<DataLayer.LibraryBook>>? LibraryTask { get; set; }
public static ChardonnayTheme? DefaultThemeColors { get; private set; }
public static MainWindow? MainWindow { get; private set; }
public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/");
public static new Application Current => Application.Current ?? throw new InvalidOperationException("The Avalonia app hasn't started yet.");
public static readonly Uri AssetUriBase = new("avares://Libation/Assets/");
public static Stream OpenAsset(string assetRelativePath)
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
@@ -37,17 +36,21 @@ namespace LibationAvalonia
AvaloniaXamlLoader.Load(this);
}
public static Task<List<DataLayer.LibraryBook>> LibraryTask;
public override void OnFrameworkInitializationCompleted()
{
DefaultThemeColors = ChardonnayTheme.GetLiveTheme();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
var config = Configuration.Instance;
if (!config.LibationSettingsAreValid)
{
var defaultLibationFilesDir = Configuration.UserProfile;
var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
@@ -72,11 +75,23 @@ namespace LibationAvalonia
base.OnFrameworkInitializationCompleted();
}
private async void Setup_Closing(object sender, System.ComponentModel.CancelEventArgs e)
private void DisableAvaloniaDataAnnotationValidation()
{
var setupDialog = sender as SetupDialog;
var desktop = ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
private async void Setup_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (sender is not SetupDialog setupDialog || ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return;
try
{
@@ -85,12 +100,12 @@ namespace LibationAvalonia
// - error message, Exit()
if (setupDialog.IsNewUser)
{
Configuration.SetLibationFiles(Configuration.UserProfile);
setupDialog.Config.Books = Path.Combine(Configuration.UserProfile, nameof(Configuration.Books));
Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory);
setupDialog.Config.Books = Configuration.DefaultBooksDirectory;
if (setupDialog.Config.LibationSettingsAreValid)
{
string theme = setupDialog.SelectedTheme.Content as string;
string? theme = setupDialog.SelectedTheme.Content as string;
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
@@ -146,7 +161,7 @@ namespace LibationAvalonia
desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Show();
void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e)
void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
libationFilesDialog.Closing -= WindowClosing;
e.Cancel = true;
@@ -177,7 +192,7 @@ namespace LibationAvalonia
if (continueResult == DialogResult.Yes)
{
config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books));
config.Books = Configuration.DefaultBooksDirectory;
if (config.LibationSettingsAreValid)
{
@@ -204,30 +219,33 @@ namespace LibationAvalonia
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
{
Current.RequestedThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) switch
{
nameof(ThemeVariant.Dark) => ThemeVariant.Dark,
nameof(ThemeVariant.Light) => ThemeVariant.Light,
// "System"
_ => ThemeVariant.Default
};
Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged;
OpenAndApplyTheme(Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)));
//Reload colors for current theme
LoadStyles();
var mainWindow = new MainWindow();
desktop.MainWindow = MainWindow = mainWindow;
mainWindow.OnLibraryLoaded(LibraryTask.GetAwaiter().GetResult());
mainWindow.Loaded += MainWindow_Loaded;
mainWindow.RestoreSizeAndLocation(Configuration.Instance);
mainWindow.Show();
}
private static void LoadStyles()
[PropertyChangeFilter(nameof(ThemeVariant))]
private static void ThemeVariant_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
=> OpenAndApplyTheme(e.NewValue as string);
private static void OpenAndApplyTheme(string? themeVariant)
{
ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookFailedBrush));
ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCompletedBrush));
ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCancelledBrush));
SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources(nameof(SeriesEntryGridBackgroundBrush));
ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookDefaultBrush));
using var themePersister = ChardonnayThemePersister.Create();
themePersister?.Target.ApplyTheme(themeVariant);
}
private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (LibraryTask is not null && MainWindow is not null)
{
var library = await LibraryTask;
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
}
}
}
}

View File

@@ -1,104 +0,0 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:collections="using:Avalonia.Collections">
<Styles.Resources>
<!--
Based on Fluent template from v11.0.0-preview8
Modified sort arrow positioning to make more room for header text
-->
<ControlTheme x:Key="{x:Type DataGridColumnHeader}" TargetType="DataGridColumnHeader">
<Setter Property="Foreground" Value="{DynamicResource DataGridColumnHeaderForegroundBrush}" />
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderBackgroundBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="SeparatorBrush" Value="{DynamicResource DataGridGridLinesBrush}" />
<Setter Property="Padding" Value="8,0,0,0" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="HeaderBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid Name="PART_ColumnHeaderRoot" ColumnDefinitions="*,Auto">
<Grid Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
</Grid.ColumnDefinitions>
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
<Path Name="SortIcon"
IsVisible="False"
Grid.Column="1"
Height="12"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Fill="{TemplateBinding Foreground}"
Stretch="Uniform" />
</Grid>
<Rectangle Name="VerticalSeparator"
Grid.Column="1"
Width="1"
VerticalAlignment="Stretch"
Fill="{TemplateBinding SeparatorBrush}"
IsVisible="{TemplateBinding AreSeparatorsVisible}" />
<Grid x:Name="FocusVisual" IsHitTestVisible="False"
IsVisible="False">
<Rectangle x:Name="FocusVisualPrimary"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Fill="Transparent"
IsHitTestVisible="False"
Stroke="{DynamicResource DataGridCellFocusVisualPrimaryBrush}"
StrokeThickness="2" />
<Rectangle x:Name="FocusVisualSecondary"
Margin="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Fill="Transparent"
IsHitTestVisible="False"
Stroke="{DynamicResource DataGridCellFocusVisualSecondaryBrush}"
StrokeThickness="1" />
</Grid>
</Grid>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:focus-visible /template/ Grid#FocusVisual">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:pointerover /template/ Grid#PART_ColumnHeaderRoot">
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderHoveredBackgroundBrush}" />
</Style>
<Style Selector="^:pressed /template/ Grid#PART_ColumnHeaderRoot">
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderPressedBackgroundBrush}" />
</Style>
<Style Selector="^:dragIndicator">
<Setter Property="Opacity" Value="0.5" />
</Style>
<Style Selector="^:sortascending /template/ Path#SortIcon">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Data" Value="{StaticResource DataGridSortIconAscendingPath}" />
</Style>
<Style Selector="^:sortdescending /template/ Path#SortIcon">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Data" Value="{StaticResource DataGridSortIconDescendingPath}" />
</Style>
</ControlTheme>
</Styles.Resources>
</Styles>

View File

@@ -91,6 +91,32 @@
S 192,128 147,147
</StreamGeometry>
<StreamGeometry x:Key="LibationGlassIcon">
M262,8
h-117
a 192,200 0 0 0 -36,82
a 222,334 41 0 0 138,236
v158
h-81
a 16,16 0 0 0 0,32
h192
a 16 16 0 0 0 0,-32
h-81
v-158
a 222,334 -41 0 0 138,-236
a 192,200 0 0 0 -36,-82
h-117
m-99,30
a 192,200 0 0 0 -26,95
a 187.5,334 35 0 0 125,159
a 187.5,334 -35 0 0 125,-159
a 192,200 0 0 0 -26,-95
h-198
M158,136
a 168,305 35 0 0 104,136
a 168,305 -35 0 0 104,-136
</StreamGeometry>
</ResourceDictionary>
</Styles.Resources>
</Styles>

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,31 +1,36 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml.MarkupExtensions;
using Avalonia.Media.Imaging;
using Avalonia.VisualTree;
using LibationAvalonia.Dialogs;
using LibationFileManager;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia
{
internal static class AvaloniaUtils
{
public static IBrush GetBrushFromResources(string name)
=> GetBrushFromResources(name, Brushes.Transparent);
public static IBrush GetBrushFromResources(string name, IBrush defaultBrush)
public static T DynamicResource<T>(this T control, AvaloniaProperty prop, object resourceKey) where T : Control
{
if (App.Current.TryGetResource(name, App.Current.ActualThemeVariant, out var value) && value is IBrush brush)
return brush;
return defaultBrush;
control[!prop] = new DynamicResourceExtension(resourceKey);
return control;
}
public static Task<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null)
=> dialogWindow.ShowDialog<DialogResult>(owner ?? App.MainWindow);
public static Task<DialogResult> ShowDialogAsync(this Dialogs.DialogWindow dialogWindow, Window? owner = null)
=> ((owner ?? App.MainWindow) is Window window)
? dialogWindow.ShowDialog<DialogResult>(window)
: Task.FromResult(DialogResult.None);
public static Window GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
public static Task<DialogResult> ShowDialogAsync(this Dialogs.Login.WebLoginDialog dialogWindow, Window? owner = null)
=> ((owner ?? App.MainWindow) is Window window)
? dialogWindow.ShowDialog<DialogResult>(window)
: Task.FromResult(DialogResult.None);
public static Window? GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
private static Bitmap defaultImage;
private static Bitmap? defaultImage;
public static Bitmap TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native)
{
try

View File

@@ -2,6 +2,7 @@
using Avalonia.Controls;
using LibationUiBase.GridView;
using System;
using System.Linq;
using System.Reflection;
namespace LibationAvalonia.Controls
@@ -12,11 +13,13 @@ namespace LibationAvalonia.Controls
private static readonly ContextMenu ContextMenu = new();
private static readonly AvaloniaList<Control> MenuItems = new();
private static readonly PropertyInfo OwningColumnProperty;
private static readonly PropertyInfo OwningGridProperty;
static DataGridContextMenus()
{
ContextMenu.ItemsSource = MenuItems;
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic);
OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic);
}
public static void AttachContextMenu(this DataGridCell cell)
@@ -30,19 +33,35 @@ namespace LibationAvalonia.Controls
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell && cell.DataContext is IGridEntry entry)
if (sender is DataGridCell cell &&
cell.DataContext is IGridEntry clickedEntry &&
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
OwningGridProperty.GetValue(column) is DataGrid grid)
{
var allSelected = grid.SelectedItems.OfType<IGridEntry>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
{
//User didn't right-click on a selected cell
grid.SelectedItem = clickedEntry;
allSelected = [clickedEntry];
}
else if (clickedIndex > 0)
{
//Ensure the clicked entry is first in the list
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
}
var args = new DataGridCellContextMenuStripNeededEventArgs
{
Column = OwningColumnProperty.GetValue(cell) as DataGridColumn,
GridEntry = entry,
Column = column,
Grid = grid,
GridEntries = allSelected,
ContextMenu = ContextMenu
};
args.ContextMenuItems.Clear();
CellContextMenuStripNeeded?.Invoke(sender, args);
e.Handled = args.ContextMenuItems.Count == 0;
}
else
@@ -61,10 +80,37 @@ namespace LibationAvalonia.Controls
private static string GetCellValue(DataGridColumn column, object item)
=> GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? "";
public string CellClipboardContents => GetCellValue(Column, GridEntry);
public DataGridColumn Column { get; init; }
public IGridEntry GridEntry { get; init; }
public ContextMenu ContextMenu { get; init; }
public string CellClipboardContents => GetCellValue(Column, GridEntries[0]);
public string GetRowClipboardContents()
{
if (GridEntries is null || GridEntries.Length == 0)
return string.Empty;
else if (GridEntries.Length == 1)
return HeaderNames + Environment.NewLine + GetRowClipboardContents(GridEntries[0]);
else
return string.Join(Environment.NewLine, GridEntries.Select(GetRowClipboardContents).Prepend(HeaderNames));
}
private string HeaderNames
=> string.Join("\t",
Grid.Columns
.Where(c => c.IsVisible)
.OrderBy(c => c.DisplayIndex)
.Select(c => RemoveLineBreaks(c.Header.ToString())));
private static string RemoveLineBreaks(string text)
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
private string GetRowClipboardContents(IGridEntry gridEntry)
{
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
return string.Join("\t", contents);
}
public required DataGrid Grid { get; init; }
public required DataGridColumn Column { get; init; }
public required IGridEntry[] GridEntries { get; init; }
public required ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.ItemsSource as AvaloniaList<Control>;
}

View File

@@ -51,7 +51,9 @@ namespace LibationAvalonia.Controls
{
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.ApplicationData,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyMusic,
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.LibationFiles
};

View File

@@ -8,7 +8,7 @@
<ContentControl.Styles>
<Style Selector="controls|GroupBox">
<Setter Property="BorderBrush" Value="{DynamicResource SystemBaseMediumLowColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemBaseMediumColor}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Template">
@@ -28,7 +28,7 @@
<TextBlock
Name="PART_Label"
Padding="4,0"
Background="{DynamicResource SystemAltHighColor}"
Background="{DynamicResource SystemRegionColor}"
Text="{TemplateBinding Label}"
/>
</Grid>

View File

@@ -2,15 +2,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="650"
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="700"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudioSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Audio">
<Grid
Margin="5"
RowDefinitions="Auto,*,Auto"
RowDefinitions="Auto,Auto"
ColumnDefinitions="*,*">
<Grid.Styles>
@@ -28,9 +28,12 @@
</Style>
</Grid.Styles>
<!--Left Column-->
<StackPanel
Grid.Row="0"
Grid.Column="0">
Grid.Column="0"
Margin="0,0,10,0"
>
<Grid ColumnDefinitions="*,Auto">
<TextBlock
@@ -65,11 +68,15 @@
SelectedItem="{CompiledBinding ClipBookmarkFormat}"/>
</Grid>
<CheckBox IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}">
<CheckBox
IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding RetainAaxFileTip}">
<TextBlock Text="{CompiledBinding RetainAaxFileText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}">
<CheckBox
IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MergeOpeningAndEndCreditsTip}">
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
</CheckBox>
@@ -84,137 +91,214 @@
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
</CheckBox>
</StackPanel>
<controls:GroupBox
Grid.Row="1"
Label="Audiobook Fix-ups"
IsEnabled="{CompiledBinding AllowLibationFixup}">
<controls:GroupBox
Grid.Row="1"
Label="Audiobook Fix-ups"
IsEnabled="{CompiledBinding AllowLibationFixup}">
<StackPanel Orientation="Vertical">
<StackPanel Orientation="Vertical">
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
</CheckBox>
<RadioButton IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}">
<StackPanel VerticalAlignment="Center">
<TextBlock
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
IsEnabled="{CompiledBinding !DecryptToLossy}"
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<RadioButton IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books as .MP3 files (transcode if necessary)" />
</RadioButton>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox
Grid.Column="1"
Grid.RowSpan="2"
Margin="10,0,0,0"
Label="Mp3 Encoding Options">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<Grid
Margin="0,5"
ColumnDefinitions="Auto,*">
<controls:GroupBox
Grid.Column="0"
Label="Target">
<Grid ColumnDefinitions="Auto,Auto">
<RadioButton
Margin="5"
Content="Bitrate"
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
<RadioButton
Grid.Column="1"
Margin="5"
Content="Quality"
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
</Grid>
</controls:GroupBox>
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
</CheckBox>
<CheckBox
HorizontalAlignment="Right"
Grid.Column="1"
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Downsample to mono? (Recommended)" />
IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripAudibleBrandAudioTip}">
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
</CheckBox>
</Grid>
<CheckBox
IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripUnabridgedTip}">
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
</CheckBox>
</StackPanel>
</controls:GroupBox>
</StackPanel>
<!--Right Column-->
<StackPanel
Grid.Row="0"
Grid.Column="1"
Margin="10,0,0,0">
<RadioButton
IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
<Grid Grid.Row="1" Margin="0,5" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<StackPanel VerticalAlignment="Center">
<TextBlock
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
IsEnabled="{CompiledBinding !DecryptToLossy}"
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MoveMoovToBeginningTip}">
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding SampleRates}"
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
<RadioButton
IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books as .MP3 files (transcode if necessary)" />
</RadioButton>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
<controls:GroupBox
Grid.Column="1"
IsEnabled="{CompiledBinding DecryptToLossy}"
Label="Mp3 Encoding Options">
<controls:WheelComboBox
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding EncoderQualities}"
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<controls:GroupBox
Grid.Row="2"
Margin="0,5"
Label="Bitrate"
IsEnabled="{CompiledBinding LameTargetBitrate}" >
<Grid
Margin="0,5"
ColumnDefinitions="Auto,*">
<StackPanel>
<Grid ColumnDefinitions="*,25,Auto">
<controls:GroupBox
Grid.Column="0"
Label="Target">
<Grid ColumnDefinitions="Auto,Auto">
<RadioButton
Margin="5"
Content="Bitrate"
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
<RadioButton
Grid.Column="1"
Margin="5"
Content="Quality"
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
</Grid>
</controls:GroupBox>
<CheckBox
HorizontalAlignment="Right"
Grid.Column="1"
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding LameDownsampleMonoTip}">
<TextBlock
TextWrapping="Wrap"
Text="Downsample to mono? (Recommended)" />
</CheckBox>
</Grid>
<Grid Grid.Row="1" Margin="0,5" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding SampleRates}"
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
<controls:WheelComboBox
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding EncoderQualities}"
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<controls:GroupBox
Grid.Row="2"
Margin="0,5"
Label="Bitrate"
IsEnabled="{CompiledBinding LameTargetBitrate}" >
<StackPanel>
<Grid ColumnDefinitions="*,25,Auto">
<Slider
Grid.Column="0"
IsEnabled="{CompiledBinding !LameMatchSource}"
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
Minimum="16"
Maximum="320"
IsSnapToTickEnabled="True" TickFrequency="16"
Ticks="16,32,48,64,80,96,112,128,144,160,176,192,208,224,240,256,272,288,304,320"
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
</Style>
</Slider.Styles>
</Slider>
<TextBlock
Grid.Column="1"
HorizontalAlignment="Right"
Text="{CompiledBinding LameBitrate}" />
<TextBlock
Grid.Column="2"
Text=" Kbps" />
</Grid>
<Grid ColumnDefinitions="*,*">
<CheckBox
Grid.Column="0"
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Restrict Encoder to Constant Bitrate?" />
</CheckBox>
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Match Source Bitrate?" />
</CheckBox>
</Grid>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox
Grid.Row="3"
Margin="0,5"
Label="Quality"
IsEnabled="{CompiledBinding !LameTargetBitrate}">
<Grid
ColumnDefinitions="*,Auto,25"
RowDefinitions="*,Auto">
<Slider
Grid.Column="0"
IsEnabled="{CompiledBinding !LameMatchSource}"
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
Minimum="16"
Maximum="320"
IsSnapToTickEnabled="True" TickFrequency="16"
Ticks="16,32,48,64,80,96,112,128,144,160,176,192,208,224,240,256,272,288,304,320"
Grid.ColumnSpan="2"
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
Minimum="0"
Maximum="9"
IsSnapToTickEnabled="True" TickFrequency="1"
Ticks="0,1,2,3,4,5,6,7,8,9"
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
@@ -222,105 +306,42 @@
</Slider.Styles>
</Slider>
<TextBlock
Grid.Column="1"
HorizontalAlignment="Right"
Text="{CompiledBinding LameBitrate}" />
<TextBlock
<StackPanel
Grid.Column="2"
Text=" Kbps" />
</Grid>
<Grid ColumnDefinitions="*,*">
<CheckBox
Grid.Column="0"
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Restrict Encoder to Constant Bitrate?" />
</CheckBox>
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
Orientation="Horizontal">
<TextBlock
TextWrapping="Wrap"
Text="Match Source Bitrate?" />
<TextBlock Text="V" />
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
</StackPanel>
<TextBlock
Grid.Column="0"
Grid.Row="1"
Text="Higher" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
HorizontalAlignment="Right"
Text="Lower" />
</CheckBox>
</Grid>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox
Grid.Row="3"
Margin="0,5"
Label="Quality"
IsEnabled="{CompiledBinding !LameTargetBitrate}">
</controls:GroupBox>
<Grid
ColumnDefinitions="*,Auto,25"
RowDefinitions="*,Auto">
<TextBlock
Grid.Row="4"
Margin="0,5"
VerticalAlignment="Bottom"
Text="Using L.A.M.E encoding engine"
FontStyle="Oblique" />
</Grid>
</controls:GroupBox>
<Slider
Grid.Column="0"
Grid.ColumnSpan="2"
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
Minimum="0"
Maximum="9"
IsSnapToTickEnabled="True" TickFrequency="1"
Ticks="0,1,2,3,4,5,6,7,8,9"
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
</Style>
</Slider.Styles>
</Slider>
<StackPanel
Grid.Column="2"
HorizontalAlignment="Right"
Orientation="Horizontal">
<TextBlock Text="V" />
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
</StackPanel>
<TextBlock
Grid.Column="0"
Grid.Row="1"
Text="Higher" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
HorizontalAlignment="Right"
Text="Lower" />
</Grid>
</controls:GroupBox>
<TextBlock
Grid.Row="4"
Margin="0,5"
VerticalAlignment="Bottom"
Text="Using L.A.M.E encoding engine"
FontStyle="Oblique" />
</Grid>
</controls:GroupBox>
</StackPanel>
<!--Bottom Row-->
<controls:GroupBox
Grid.Row="2"
Grid.ColumnSpan="2"

View File

@@ -158,24 +158,24 @@
<TextBlock
Grid.Column="0"
FontSize="16"
Margin="0,0,15,0"
VerticalAlignment="Center"
Text="Theme: "/>
Text="Theme:"/>
<controls:WheelComboBox
Name="ThemeComboBox"
Grid.Column="1"
MinWidth="80"
SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}"
ItemsSource="{CompiledBinding Themes}"/>
<TextBlock
<Button
Grid.Column="2"
FontSize="16"
FontWeight="Bold"
Margin="10,0"
VerticalAlignment="Center"
IsVisible="{CompiledBinding SelectionChanged}"
Text="Theme change takes effect on restart"/>
HorizontalAlignment="Right"
Padding="20,0"
VerticalAlignment="Stretch"
Content="Edit Theme Colors"
Click="EditThemeColors_Click"/>
</Grid>
</Grid>
</UserControl>

View File

@@ -1,13 +1,18 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Dinah.Core;
using FileManager;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Controls.Settings
{
public partial class Important : UserControl
{
private ImportantSettingsVM? ViewModel => DataContext as ImportantSettingsVM;
public Important()
{
InitializeComponent();
@@ -16,6 +21,42 @@ namespace LibationAvalonia.Controls.Settings
_ = Configuration.Instance.LibationFiles;
DataContext = new ImportantSettingsVM(Configuration.Instance);
}
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
}
private void EditThemeColors_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
{
//Only allow a single instance of the theme picker
//Show it as a window, not a dialog, so users can preview
//their changes throughout the entire app.
if (lifetime.Windows.OfType<ThemePickerDialog>().FirstOrDefault() is ThemePickerDialog dialog)
{
dialog.BringIntoView();
}
else
{
var themePicker = new ThemePickerDialog();
themePicker.Show();
}
}
}
private void ThemeComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
//Remove the combo box before changing the theme, then re-add it.
//This is a workaround to a crash that will happen if the theme
//is changed while the combo box is open
ThemeComboBox.SelectionChanged -= ThemeComboBox_SelectionChanged;
var parent = ThemeComboBox.Parent as Panel;
if (parent?.Children.Remove(ThemeComboBox) ?? false)
{
Configuration.Instance.SetString(ViewModel?.ThemeVariant, nameof(ViewModel.ThemeVariant));
parent.Children.Add(ThemeComboBox);
}
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
}
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)

View File

@@ -0,0 +1,52 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="650"
xmlns:views="clr-namespace:LibationAvalonia.Views"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
x:Class="LibationAvalonia.Controls.ThemePreviewControl">
<Grid RowDefinitions="Auto,Auto,*">
<controls:GroupBox>
<WrapPanel>
<RadioButton Margin="5,0" Content="This is an option" IsChecked="True" />
<RadioButton Margin="5,0" Content="This is another option" />
<CheckBox Margin="5,0" Content="This is a check box" />
<controls:WheelComboBox
Margin="5,0"
ItemsSource="{Binding ComboBoxItems}"
SelectedIndex="{Binding ComboBoxSelectedIndex}" />
<TextBox Margin="5,0" Text="This is an editable text box" />
<TextBox Margin="5,0" Text="This is a read-only text box" IsReadOnly="True" />
<NumericUpDown Margin="5,0" Value="100" />
<controls:LinkLabel VerticalAlignment="Center" Margin="5,5" Text="This is an unvisited link" />
<controls:LinkLabel VerticalAlignment="Center" Margin="5,5" Text="This is a visited link" Foreground="{DynamicResource HyperlinkVisited}" />
<StackPanel Margin="5,0" Height="25" Orientation="Horizontal">
<StackPanel.Styles>
<Style Selector="Path">
<Setter Property="Stretch" Value="Uniform" />
<Setter Property="Margin" Value="3,5" />
<Setter Property="Fill" Value="{DynamicResource IconFill}" />
</Style>
</StackPanel.Styles>
<Path Data="{StaticResource QueuedIcon}" />
<Path Data="{StaticResource QueueCompletedIcon}" />
<Path Data="{StaticResource QueueErrorIcon}"/>
</StackPanel>
</WrapPanel>
</controls:GroupBox>
<WrapPanel Orientation="Horizontal" Grid.Row="1">
<views:ProcessBookControl DataContext="{Binding QueuedBook}" ProcessBookStatus="{Binding Status}" />
<views:ProcessBookControl DataContext="{Binding WorkingBook}" ProcessBookStatus="{Binding Status}" />
<views:ProcessBookControl DataContext="{Binding CompletedBook}" ProcessBookStatus="{Binding Status}" />
<views:ProcessBookControl DataContext="{Binding CancelledBook}" ProcessBookStatus="{Binding Status}" />
<views:ProcessBookControl DataContext="{Binding FailedBook}" ProcessBookStatus="{Binding Status}" />
</WrapPanel>
<views:ProductsDisplay
Grid.Row="2"
DataContext="{Binding ProductsDisplay}" />
</Grid>
</UserControl>

View File

@@ -0,0 +1,94 @@
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using DataLayer;
using Dinah.Core.ErrorHandling;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using NPOI.Util.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
namespace LibationAvalonia.Controls;
public partial class ThemePreviewControl : UserControl
{
public ProductsDisplayViewModel ProductsDisplay { get; set; }
public string[] ComboBoxItems { get; } = Enumerable.Range(1, 9).Select(n => $"Combo box item {n}").ToArray();
public int ComboBoxSelectedIndex { get; set; }
public ProcessBookViewModel QueuedBook { get; }
public ProcessBookViewModel WorkingBook { get; }
public ProcessBookViewModel CompletedBook { get; }
public ProcessBookViewModel CancelledBook { get; }
public ProcessBookViewModel FailedBook { get; }
public ThemePreviewControl()
{
InitializeComponent();
List<LibraryBook> sampleEntries;
sampleEntries = CreateMockBooks().ToList();
if (Design.IsDesignMode)
{
using var ms1 = new MemoryStream();
App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1);
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
}
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued };
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working };
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed };
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled };
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed };
//Set the current processable so that the empty queue doesn't try to advance.
QueuedBook.AddDownloadPdf();
WorkingBook.AddDownloadPdf();
typeof(ProcessBookViewModel).GetProperty(nameof(ProcessBookViewModel.Progress)).SetValue(WorkingBook, 50);
ProductsDisplay = new ProductsDisplayViewModel();
_ = ProductsDisplay.BindToGridAsync(sampleEntries);
DataContext = this;
}
private IEnumerable<LibraryBook> CreateMockBooks()
{
var author = new Contributor("Some Author", "asin_contributor");
var narrator = new Contributor("Some Narrator", "asin_narrator");
var book1 = new Book(new AudibleProductId("asin_book1"), "Some Book 1", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var book2 = new Book(new AudibleProductId("asin_book2"), "Some Book 2", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var book3 = new Book(new AudibleProductId("asin_book3"), "Some Book 3", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var book4 = new Book(new AudibleProductId("asin_book4"), "Some Book 4", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var seriesParent = new Book(new AudibleProductId("asin_series"), "Some Series", "", "Demo Series Entry", 0, ContentType.Parent, [author], [narrator], "us");
var episode = new Book(new AudibleProductId("asin_episode"), "Some Episode", "Episode 1", "Demo Episode Entry", 56, ContentType.Episode, [author], [narrator], "us");
var series = new Series(new AudibleSeriesId(seriesParent.AudibleProductId), seriesParent.Title);
seriesParent.UpsertSeries(series, "");
episode.UpsertSeries(series, "1");
book1.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
book4.UserDefinedItem.BookStatus = LiberatedStatus.Error;
//Set the backing field directly to preserve LiberatedStatus.PartialDownload
typeof(UserDefinedItem).GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(book2.UserDefinedItem, LiberatedStatus.PartialDownload);
yield return new LibraryBook(book1, System.DateTime.Now.AddDays(4), "someone@email.co");
yield return new LibraryBook(book2, System.DateTime.Now.AddDays(3), "someone@email.co");
yield return new LibraryBook(book3, System.DateTime.Now.AddDays(2), "someone@email.co") { AbsentFromLastScan = true };
yield return new LibraryBook(book4, System.DateTime.Now.AddDays(1), "someone@email.co");
yield return new LibraryBook(seriesParent, System.DateTime.Now, "someone@email.co");
yield return new LibraryBook(episode, System.DateTime.Now, "someone@email.co");
}
private class MockProcessable : FileLiberator.Processable
{
public override string Name => nameof(MockProcessable);
public override Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) => Task.FromResult(new StatusHandler());
public override bool Validate(LibraryBook libraryBook) => false;
}
}

View File

@@ -2,13 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="520"
MinWidth="400" MinHeight="520"
Width="400" Height="520"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="520"
MinWidth="450" MinHeight="520"
Width="450" Height="520"
x:Class="LibationAvalonia.Dialogs.AboutDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="About Libation"
Icon="/Assets/libation.ico">
Title="About Libation">
<Grid Margin="10" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,*">
@@ -65,6 +64,15 @@
<controls:LinkLabel Text="wtanksleyjr" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="Dr.Blank" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="CharlieRussel" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="cbordeman" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="jwillikers" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="Shuvashish76" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="RokeJulianLockhart" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="maaximal" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="muchtall" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="ScubyG" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="patienttruth" Tapped="Link_GithubUser" />
<controls:LinkLabel Text="stickystyle" Tapped="Link_GithubUser" />
</WrapPanel>
</StackPanel>
</controls:GroupBox>

View File

@@ -5,8 +5,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
Width="800" Height="450"
x:Class="LibationAvalonia.Dialogs.AccountsDialog"
Title="Audible Accounts"
Icon="/Assets/libation.ico">
Title="Audible Accounts">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>

View File

@@ -7,8 +7,7 @@
Width="650" Height="500"
x:Class="LibationAvalonia.Dialogs.BookDetailsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="Book Details" Name="BookDetails"
Icon="/Assets/libation.ico">
Title="Book Details" Name="BookDetails">
<Grid RowDefinitions="*,Auto,Auto,40">
<Grid.Styles>

View File

@@ -5,8 +5,7 @@
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="450"
Width="700" Height="450"
x:Class="LibationAvalonia.Dialogs.BookRecordsDialog"
Title="BookRecordsDialog"
Icon="/Assets/libation.ico">
Title="BookRecordsDialog">
<Grid RowDefinitions="*,Auto">

View File

@@ -1,4 +1,7 @@
using Avalonia.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Styling;
using LibationFileManager;
using System;
using System.Threading.Tasks;
@@ -7,19 +10,41 @@ namespace LibationAvalonia.Dialogs
{
public abstract class DialogWindow : Window
{
public bool SaveAndRestorePosition { get; set; } = true;
protected bool CancelOnEscape { get; set; } = true;
protected bool SaveOnEnter { get; set; } = true;
public bool SaveAndRestorePosition { get; set; }
public Control ControlToFocusOnShow { get; set; }
public DialogWindow()
{
this.HideMinMaxBtns();
this.KeyDown += DialogWindow_KeyDown;
this.Initialized += DialogWindow_Initialized;
this.Opened += DialogWindow_Opened;
this.Closing += DialogWindow_Closing;
}
public DialogWindow(bool saveAndRestorePosition) : this()
protected override Type StyleKeyOverride => typeof(DialogWindow);
public DialogWindow(bool saveAndRestorePosition = true)
{
SaveAndRestorePosition = saveAndRestorePosition;
KeyDown += DialogWindow_KeyDown;
Initialized += DialogWindow_Initialized;
Opened += DialogWindow_Opened;
Loaded += DialogWindow_Loaded;
Closing += DialogWindow_Closing;
if (Design.IsDesignMode)
RequestedThemeVariant = ThemeVariant.Dark;
}
private void DialogWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (!CanResize)
this.HideMinMaxBtns();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (MinHeight == MaxHeight && MinWidth == MaxWidth)
{
CanResize = false;
Height = MinHeight;
Width = MinWidth;
}
}
private void DialogWindow_Initialized(object sender, EventArgs e)
@@ -47,9 +72,9 @@ namespace LibationAvalonia.Dialogs
private async void DialogWindow_KeyDown(object sender, Avalonia.Input.KeyEventArgs e)
{
if (e.Key == Avalonia.Input.Key.Escape)
if (CancelOnEscape && e.Key == Avalonia.Input.Key.Escape)
await CancelAndCloseAsync();
else if (e.Key == Avalonia.Input.Key.Return)
else if (SaveOnEnter && e.Key == Avalonia.Input.Key.Return)
await SaveAndCloseAsync();
}
}

View File

@@ -7,7 +7,6 @@
Width="800" Height="450"
x:Class="LibationAvalonia.Dialogs.EditQuickFilters"
Title="Audible Accounts"
Icon="/Assets/libation.ico"
x:DataType="dialogs:EditQuickFilters">
<Grid RowDefinitions="*,Auto">

View File

@@ -42,6 +42,17 @@ namespace LibationAvalonia.Dialogs
public EditQuickFilters()
{
InitializeComponent();
if (Design.IsDesignMode)
{
Filters = new ObservableCollection<Filter>([
new Filter { Name = "Filter 1", FilterString = "[filter1 string]" },
new Filter { Name = "Filter 2", FilterString = "[filter2 string]" },
new Filter { Name = "Filter 3", FilterString = "[filter3 string]" },
new Filter { Name = "Filter 4", FilterString = "[filter4 string]" }
]);
DataContext = this;
return;
}
// WARNING: accounts persister will write ANY EDIT to object immediately to file
// here: copy strings and dispose of persister

View File

@@ -6,8 +6,7 @@
MinWidth="500" MinHeight="450"
Width="500" Height="450"
x:Class="LibationAvalonia.Dialogs.EditReplacementChars"
Title="Illegal Character Replacement"
Icon="/Assets/libation.ico">
Title="Illegal Character Replacement">
<Grid
RowDefinitions="*,Auto"

View File

@@ -6,7 +6,6 @@
Width="800" Height="450"
x:Class="LibationAvalonia.Dialogs.EditTemplateDialog"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
Icon="/Assets/libation.ico"
Title="EditTemplateDialog">
<Grid RowDefinitions="Auto,*,Auto">
@@ -51,7 +50,7 @@
<DataGridTemplateColumn Width="Auto" Header="Tag">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -59,7 +58,7 @@
<DataGridTemplateColumn Width="Auto" Header="Description">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter
<TextBlock
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center" Text="{Binding Item2}" />

View File

@@ -1,8 +1,8 @@
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
@@ -11,175 +11,175 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
namespace LibationAvalonia.Dialogs;
public partial class EditTemplateDialog : DialogWindow
{
public partial class EditTemplateDialog : DialogWindow
private EditTemplateViewModel _viewModel;
public EditTemplateDialog()
{
private EditTemplateViewModel _viewModel;
InitializeComponent();
public EditTemplateDialog()
if (Design.IsDesignMode)
{
InitializeComponent();
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.TemplateName}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {templateEditor.TemplateName}";
_ = Configuration.Instance.LibationFiles;
RequestedThemeVariant = ThemeVariant.Dark;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.TemplateName}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {templateEditor.TemplateName}";
DataContext = _viewModel;
}
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
{
var dataGrid = sender as DataGrid;
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
if (string.IsNullOrWhiteSpace(item)) return;
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
{
if (!await _viewModel.Validate())
return;
await base.SaveAndCloseAsync();
}
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public InlineCollection Inlines { get; } = new();
public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
{
var dataGrid = sender as DataGrid;
config = configuration;
TemplateEditor = templates;
Description = templates.TemplateDescription;
ListItems
= new AvaloniaList<Tuple<string, string, string>>(
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{t.TagName}>",
t.Description,
t.DefaultValue)
)
);
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
if (string.IsNullOrWhiteSpace(item)) return;
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
// hold the work-in-progress value. not guaranteed to be valid
private string _userTemplateText;
public string UserTemplateText
{
if (!await _viewModel.Validate())
return;
await base.SaveAndCloseAsync();
}
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public InlineCollection Inlines { get; } = new();
public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
get => _userTemplateText;
set
{
config = configuration;
TemplateEditor = templates;
Description = templates.TemplateDescription;
ListItems
= new AvaloniaList<Tuple<string, string, string>>(
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
templateTb_TextChanged();
}
}
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
public string Description { get; }
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
public void ResetTextBox(string value) => UserTemplateText = value;
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
public async Task<bool> Validate()
{
if (TemplateEditor.EditingTemplate.IsValid)
return true;
var errors
= TemplateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
private void templateTb_TextChanged()
{
TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
// result: can wrap long paths. eg:
// |-- LINE WRAP BOUNDARIES --|
// \books\author with a very <= normal line break on space between words
// long name\narrator narrator
// \title <= line break on the zero-with space we added before slashes
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{t.TagName}>",
t.Description,
t.DefaultValue)
)
);
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
}
var bold = FontWeight.Bold;
var reg = FontWeight.Normal;
// hold the work-in-progress value. not guaranteed to be valid
private string _userTemplateText;
public string UserTemplateText
Inlines.Clear();
if (!TemplateEditor.IsFilePath)
{
get => _userTemplateText;
set
{
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
templateTb_TextChanged();
}
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
var folder = TemplateEditor.GetFolderName();
var file = TemplateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
public string Description { get; }
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
public void ResetTextBox(string value) => UserTemplateText = value;
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
Inlines.Add(new Run(sing));
public async Task<bool> Validate()
{
if (TemplateEditor.EditingTemplate.IsValid)
return true;
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
var errors
= TemplateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
private void templateTb_TextChanged()
{
TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
// result: can wrap long paths. eg:
// |-- LINE WRAP BOUNDARIES --|
// \books\author with a very <= normal line break on space between words
// long name\narrator narrator
// \title <= line break on the zero-with space we added before slashes
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
TemplateEditor
.EditingTemplate
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
var bold = FontWeight.Bold;
var reg = FontWeight.Normal;
Inlines.Clear();
if (!TemplateEditor.IsFilePath)
{
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
var folder = TemplateEditor.GetFolderName();
var file = TemplateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
Inlines.Add(new Run(sing));
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}
Inlines.Add(new Run($".{ext}"));
}
}
}

View File

@@ -7,8 +7,7 @@
MinWidth="500" MinHeight="500"
Width="500" Height="520"
Title="Cover"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Image Stretch="Uniform" Source="{Binding CoverImage}">
<Image.ContextMenu>

View File

@@ -5,11 +5,11 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="165"
MinHeight="165" MaxHeight="165"
MinWidth="800" MaxWidth="800"
Width="800" Height="165"
x:Class="LibationAvalonia.Dialogs.LibationFilesDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
WindowStartupLocation="CenterScreen"
Title="Book Details"
Icon="/Assets/libation.ico">
Title="Book Details">
<Grid
RowDefinitions="Auto,Auto">

View File

@@ -9,8 +9,7 @@
MinHeight="135" MaxHeight="135"
MinWidth="550" MaxWidth="550"
Width="550" Height="135"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid Margin="10" RowDefinitions="Auto,Auto">

View File

@@ -9,8 +9,7 @@
MinWidth="400" MinHeight="100"
MaxWidth="400" MaxHeight="100"
Width="400" Height="100"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">

View File

@@ -6,8 +6,7 @@
Width="600" Height="450"
x:Class="LibationAvalonia.Dialogs.LocateAudiobooksDialog"
Title="Locate Audiobooks"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid Margin="5" ColumnDefinitions="*,Auto" RowDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="Found Audiobooks" />

View File

@@ -8,8 +8,7 @@
Width="240" Height="140"
WindowStartupLocation="CenterOwner"
x:Class="LibationAvalonia.Dialogs.Login.ApprovalNeededDialog"
Title="Approval Alert Detected"
Icon="/Assets/libation.ico">
Title="Approval Alert Detected">
<Grid RowDefinitions="Auto,Auto,*">

View File

@@ -1,10 +1,10 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia.Threading;
using LibationFileManager;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.Dialogs.Login
{
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
@@ -23,14 +23,14 @@ namespace LibationAvalonia.Dialogs.Login
LoginCallback = new AvaloniaLoginCallback(_account);
}
public async Task<ChoiceOut> StartAsync(ChoiceIn choiceIn)
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
{
if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10)
{
try
{
var weblogin = new WebLoginDialog(_account.AccountId, choiceIn.LoginUrl);
if (await weblogin.ShowDialog<DialogResult>(App.MainWindow) is DialogResult.OK)
if (await weblogin.ShowDialogAsync(App.MainWindow) is DialogResult.OK)
return ChoiceOut.External(weblogin.ResponseUrl);
}
catch (Exception ex)

View File

@@ -8,8 +8,7 @@
Width="220" Height="250"
WindowStartupLocation="CenterOwner"
x:Class="LibationAvalonia.Dialogs.Login.CaptchaDialog"
Title="CAPTCHA"
Icon="/Assets/libation.ico">
Title="CAPTCHA">
<Grid
RowDefinitions="Auto,Auto,Auto,Auto,*"

View File

@@ -7,8 +7,7 @@
Width="300" Height="120"
WindowStartupLocation="CenterOwner"
x:Class="LibationAvalonia.Dialogs.Login.LoginCallbackDialog"
Title="Audible Login"
Icon="/Assets/libation.ico">
Title="Audible Login">
<Grid RowDefinitions="Auto,Auto,Auto,*" ColumnDefinitions="*" Margin="5">

View File

@@ -8,8 +8,7 @@
WindowStartupLocation="CenterOwner"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
x:Class="LibationAvalonia.Dialogs.Login.LoginChoiceEagerDialog"
Title="Audible Login"
Icon="/Assets/libation.ico" >
Title="Audible Login">
<Grid RowDefinitions="Auto,Auto,Auto,*" ColumnDefinitions="*" Margin="5">

View File

@@ -6,8 +6,7 @@
Width="650" Height="500"
WindowStartupLocation="CenterOwner"
x:Class="LibationAvalonia.Dialogs.Login.LoginExternalDialog"
Title="Audible Login External"
Icon="/Assets/libation.ico">
Title="Audible Login External">
<Grid RowDefinitions="Auto,Auto,*,Auto,*" ColumnDefinitions="*" Margin="5">

View File

@@ -8,8 +8,7 @@
Width="400" Height="200"
WindowStartupLocation="CenterOwner"
x:Class="LibationAvalonia.Dialogs.Login.MfaDialog"
Title="Two-Step Verification"
Icon="/Assets/libation.ico">
Title="Two-Step Verification">
<Grid RowDefinitions="*,Auto">

View File

@@ -7,7 +7,6 @@
x:Class="LibationAvalonia.Dialogs.Login.WebLoginDialog"
Width="500" Height="800"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico"
Title="Audible Login">
<controls:NativeWebView Name="webView" />
</Window>

View File

@@ -8,8 +8,7 @@
Width="200" Height="200"
WindowStartupLocation="CenterOwner"
x:Class="LibationAvalonia.Dialogs.Login._2faCodeDialog"
Title="2FA Code"
Icon="/Assets/libation.ico">
Title="2FA Code">
<Grid
VerticalAlignment="Stretch"

View File

@@ -5,11 +5,11 @@
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
MinWidth="600" MinHeight="450"
MaxWidth="600" MaxHeight="450"
Width="600" Height="450"
x:Class="LibationAvalonia.Dialogs.MessageBoxAlertAdminDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="MessageBoxAlertAdminDialog"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto">

View File

@@ -6,8 +6,8 @@
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
MinWidth="265" MinHeight="110"
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
Title="{Binding Caption}" ShowInTaskbar="True"
Icon="/Assets/1x1.png">
Title="{Binding Caption}" ShowInTaskbar="True">
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
<DockPanel Margin="5,10,10,20" Grid.Row="0">

View File

@@ -7,8 +7,7 @@
MinWidth="500" MinHeight="160"
Width="500" Height="200"
Title="Which Accounts?"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,*,Auto">

View File

@@ -25,7 +25,6 @@ namespace LibationAvalonia.Dialogs
{
InitializeComponent();
this.HideMinMaxBtns();
ControlToFocusOnShow = this.FindControl<Button>(nameof(ImportButton));
LoadAccounts();

View File

@@ -8,8 +8,7 @@
Width="800" Height="650"
x:Class="LibationAvalonia.Dialogs.SearchSyntaxDialog"
Title="Filter Options"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid
Margin="10,0,10,10"

View File

@@ -12,8 +12,6 @@ namespace LibationAvalonia.Dialogs
{
InitializeComponent();
this.HideMinMaxBtns();
StringFields = @"
Search for wizard of oz:
title:oz

View File

@@ -10,8 +10,7 @@
xmlns:settings="clr-namespace:LibationAvalonia.Controls.Settings"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:SettingsVM"
Title="Edit Settings"
Icon="/Assets/libation.ico">
Title="Edit Settings">
<Grid RowDefinitions="*,Auto">

View File

@@ -8,8 +8,7 @@
MaxWidth="630" MaxHeight="90"
Width="630" Height="110"
Title="Replace Tags"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto" Margin="10">
<TextBlock

View File

@@ -0,0 +1,78 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="965" d:DesignHeight="850"
Width="965" Height="850"
x:Class="LibationAvalonia.Dialogs.ThemePickerDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="Theme Editor">
<Grid ColumnDefinitions="Auto,*">
<controls:ThemePreviewControl Name="ThemePickerPreviewControl" Margin="5" Grid.Column="1" />
<Grid
RowDefinitions="*,Auto,Auto">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="Height" Value="30" />
<Setter Property="Padding" Value="20,0" />
<Setter Property="Margin" Value="5" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
GridLinesVisibility="All"
Margin="5"
IsReadOnly="False"
ItemsSource="{Binding ThemeColors}">
<DataGrid.Columns>
<DataGridTemplateColumn Width="Auto" Header="Color">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<ColorPicker
IsHexInputVisible="True"
Color="{Binding ThemeColor, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
Width="Auto"
Binding="{Binding ThemeItemName, Mode=TwoWay}"
Header="Theme Item"/>
</DataGrid.Columns>
</DataGrid>
<Grid
Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<Button
Grid.Column="0"
Content="Import Theme"
Command="{Binding ImportTheme}" />
<Button
Grid.Column="2"
Content="Export Theme"
Command="{Binding ExportTheme}" />
</Grid>
<Grid
Grid.Row="2"
ColumnDefinitions="Auto,Auto,Auto,*,Auto">
<Button
Grid.Column="0"
Content="Cancel"
Command="{Binding CancelAndClose}" />
<Button
Grid.Column="1"
Content="Reset"
Command="{Binding ResetColors}" />
<Button
Grid.Column="2"
Content="Defaults"
Command="{Binding LoadDefaultColors}" />
<Button
Grid.Column="4"
Content="Save"
Command="{Binding SaveAndCloseAsync}" />
</Grid>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,195 @@
using Avalonia.Collections;
using Avalonia.Media;
using ReactiveUI;
using Avalonia.Styling;
using System;
using LibationAvalonia.Themes;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Platform.Storage;
#nullable enable
namespace LibationAvalonia.Dialogs;
public partial class ThemePickerDialog : DialogWindow
{
protected DataGridCollectionView ThemeColors { get; }
private ChardonnayTheme ExistingTheme { get; } = ChardonnayTheme.GetLiveTheme();
private ChardonnayTheme WorkingTheme { get; set; }
public ThemePickerDialog()
{
InitializeComponent();
CancelOnEscape = false;
WorkingTheme = (ChardonnayTheme)ExistingTheme.Clone();
ThemeColors = new(EnumerateThemeItemColors());
DataContext = this;
Closing += ThemePickerDialog_Closing;
}
private void ThemePickerDialog_Closing(object? sender, Avalonia.Controls.WindowClosingEventArgs e)
{
if (!e.IsProgrammatic)
{
CancelAndClose();
e.Cancel = true;
}
}
protected async Task ImportTheme()
{
try
{
var openFileDialogOptions = new FilePickerOpenOptions
{
Title = $"Select the ChardonnayTheme.json file",
AllowMultiple = false,
FileTypeFilter =
[
new("JSON files (*.json)")
{
Patterns = ["*.json"],
AppleUniformTypeIdentifiers = ["public.json"]
}
]
};
var selectedFiles = await StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath();
if (selectedFile is null) return;
using (var theme = new ChardonnayThemePersister(selectedFile))
{
ResetTheme(theme.Target);
}
await MessageBox.Show(this, "Theme imported and applied", "Theme Imported");
}
catch (Exception ex)
{
await MessageBox.ShowAdminAlert(this, "Error attempting to import your chardonnay theme.", "Error Importing", ex);
}
}
protected async Task ExportTheme()
{
try
{
var options = new FilePickerSaveOptions
{
Title = "Where to export Library",
SuggestedFileName = $"ChardonnayTheme",
DefaultExtension = "json",
ShowOverwritePrompt = true,
FileTypeChoices =
[
new("JSON files (*.json)")
{
Patterns = ["*.json"],
AppleUniformTypeIdentifiers = ["public.json"]
},
new("All files (*.*)") { Patterns = ["*"] }
]
};
var selectedFile = (await StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath();
if (selectedFile is null) return;
using (var theme = new ChardonnayThemePersister(WorkingTheme, selectedFile))
theme.Target.Save();
await MessageBox.Show(this, "Theme exported to:\r\n" + selectedFile, "Theme Exported");
}
catch (Exception ex)
{
await MessageBox.ShowAdminAlert(this, "Error attempting to export your chardonnay theme.", "Error Exporting", ex);
}
}
protected override void CancelAndClose()
{
ExistingTheme.ApplyTheme(ActualThemeVariant);
base.CancelAndClose();
}
protected void ResetColors()
=> ResetTheme(ExistingTheme);
protected void LoadDefaultColors()
{
if (App.DefaultThemeColors is ChardonnayTheme defaults)
ResetTheme(defaults);
}
protected override async Task SaveAndCloseAsync()
{
using (var themePersister = ChardonnayThemePersister.Create())
{
if (themePersister is null)
{
await MessageBox.Show(this, "Failed to save the theme.", "Error saving theme", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
else
{
foreach (var i in ThemeColors.OfType<ThemeItemColor>())
{
themePersister.Target.SetColor(ActualThemeVariant, i.ThemeItemName, i.ThemeColor);
}
themePersister.Target.Save();
}
}
await base.SaveAndCloseAsync();
}
private void ResetTheme(ChardonnayTheme theme)
{
WorkingTheme = (ChardonnayTheme)theme.Clone();
WorkingTheme.ApplyTheme(ActualThemeVariant);
foreach (var i in ThemeColors.OfType<ThemeItemColor>())
{
i.ColorSetter = null;
i.ThemeColor = WorkingTheme.GetColor(ActualThemeVariant, i.ThemeItemName);
i.ColorSetter = ColorSetter;
}
}
private IEnumerable<ThemeItemColor> EnumerateThemeItemColors()
=> WorkingTheme
.GetThemeColors(ActualThemeVariant)
.Select(kvp => new ThemeItemColor
{
ThemeItemName = kvp.Key,
ThemeColor = kvp.Value,
ColorSetter = ColorSetter
});
private void ColorSetter(Color color, string colorName)
{
WorkingTheme.SetColor(ActualThemeVariant, colorName, color);
WorkingTheme.ApplyTheme(ActualThemeVariant);
}
private class ThemeItemColor : ViewModels.ViewModelBase
{
public required string ThemeItemName { get; init; }
public required Action<Color, string>? ColorSetter { get; set; }
private Color _themeColor;
public Color ThemeColor
{
get => _themeColor;
set
{
var setColors = !_themeColor.Equals(value);
this.RaiseAndSetIfChanged(ref _themeColor, value);
if (setColors)
ColorSetter?.Invoke(_themeColor, ThemeItemName);
}
}
}
}

View File

@@ -7,10 +7,8 @@
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
MinWidth="500" MinHeight="400"
Height="450" Width="550"
WindowStartupLocation="CenterOwner"
Title="Upgrade Available"
Icon="/Assets/libation.ico">
Title="Upgrade Available">
<Grid Margin="6" RowDefinitions="Auto,*,Auto">
<TextBlock

View File

@@ -2,21 +2,22 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform;
using Dinah.Core;
using LibationFileManager;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia
{
public static class FormSaveExtension
{
static readonly WindowIcon WindowIcon;
static readonly WindowIcon? WindowIcon;
static FormSaveExtension()
{
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow is not null)
WindowIcon = desktop.MainWindow.Icon;
else
WindowIcon = null;
WindowIcon = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow?.Icon is WindowIcon icon
? icon
: null;
}
public static void SetLibationIcon(this Window form)
@@ -29,7 +30,7 @@ namespace LibationAvalonia
if (Design.IsDesignMode) return;
try
{
var savedState = config.GetNonString<FormSizeAndPosition>(defaultValue: null, form.GetType().Name);
var savedState = config.GetNonString<FormSizeAndPosition?>(defaultValue: null, form.GetType().Name);
if (savedState is null)
return;
@@ -40,12 +41,14 @@ namespace LibationAvalonia
savedState.Width = (int)form.Width;
savedState.Height = (int)form.Height;
}
// Fit to the current screen size in case the screen resolution changed since the size was last persisted
if (savedState.Width > form.Screens.Primary.WorkingArea.Width)
savedState.Width = form.Screens.Primary.WorkingArea.Width;
if (savedState.Height > form.Screens.Primary.WorkingArea.Height)
savedState.Height = form.Screens.Primary.WorkingArea.Height;
if (form.Screens.Primary is Screen primaryScreen)
{
// Fit to the current screen size in case the screen resolution changed since the size was last persisted
if (savedState.Width > primaryScreen.WorkingArea.Width)
savedState.Width = primaryScreen.WorkingArea.Width;
if (savedState.Height > primaryScreen.WorkingArea.Height)
savedState.Height = primaryScreen.WorkingArea.Height;
}
var rect = new PixelRect(savedState.X, savedState.Y, savedState.Width, savedState.Height);
@@ -115,17 +118,57 @@ namespace LibationAvalonia
if (Design.IsDesignMode || !Configuration.IsWindows || form.TryGetPlatformHandle() is not IPlatformHandle handle)
return;
var currentStyle = GetWindowLong(handle.Handle, GWL_STYLE);
var windowStyle
= GetWindowStyle(handle.Handle)
.Remove(WINDOW_STYLE.WS_MINIMIZEBOX)
.Remove(WINDOW_STYLE.WS_MAXIMIZEBOX);
SetWindowLong(handle.Handle, GWL_STYLE, currentStyle & ~WS_MAXIMIZEBOX & ~WS_MINIMIZEBOX);
SetWindowStyle(handle.Handle, windowStyle);
}
const long WS_MINIMIZEBOX = 0x00020000L;
const long WS_MAXIMIZEBOX = 0x10000L;
const int GWL_STYLE = -16;
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLong")]
static extern long GetWindowLong(IntPtr hWnd, int nIndex);
[System.Runtime.InteropServices.DllImport("user32.dll")]
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLong")]
static extern int SetWindowLong(IntPtr hWnd, int nIndex, long dwNewLong);
static WINDOW_STYLE GetWindowStyle(IntPtr hWnd) => (WINDOW_STYLE)GetWindowLong(hWnd, GWL_STYLE);
static void SetWindowStyle(IntPtr hWnd, WINDOW_STYLE style) => SetWindowLong(hWnd, GWL_STYLE, (long)style);
[Flags]
enum WINDOW_STYLE : long
{
WS_OVERLAPPED = 0x0,
WS_TILED = 0x0,
WS_ACTIVECAPTION = 0x1,
WS_MAXIMIZEBOX = 0x10000,
WS_TABSTOP = 0x10000,
WS_MINIMIZEBOX = 0x20000,
WS_GROUP = 0x20000,
WS_THICKFRAME = 0x40000,
WS_SIZEBOX = 0x40000,
WS_SYSMENU = 0x80000,
WS_HSCROLL = 0x100000,
WS_VSCROLL = 0x200000,
WS_DLGFRAME = 0x400000,
WS_BORDER = 0x800000,
WS_CAPTION = 0xc00000,
WS_OVERLAPPEDWINDOW = 0xcf0000,
WS_TILEDWINDOW = 0xcf0000,
WS_MAXIMIZE = 0x1000000,
WS_CLIPCHILDREN = 0x2000000,
WS_CLIPSIBLINGS = 0x4000000,
WS_DISABLED = 0x8000000,
WS_VISIBLE = 0x10000000,
WS_ICONIC = 0x20000000,
WS_MINIMIZE = 0x20000000,
WS_CHILD = 0x40000000,
WS_CHILDWINDOW = 0x40000000,
WS_POPUP = 0x80000000,
WS_POPUPWINDOW = 0x80880000
}
}
}

View File

@@ -37,7 +37,6 @@
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<None Remove=".gitignore" />
<None Remove="Assets\DataGridColumnHeader.xaml" />
<None Remove="Assets\img-coverart-prod-unavailable_300x300.jpg" />
<None Remove="Assets\img-coverart-prod-unavailable_500x500.jpg" />
<None Remove="Assets\img-coverart-prod-unavailable_80x80.jpg" />
@@ -74,13 +73,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.2.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.2" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.2.5" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
</ItemGroup>
<ItemGroup>

View File

@@ -165,8 +165,6 @@ Libation.
var dialog = new MessageBoxWindow(saveAndRestorePosition);
dialog.HideMinMaxBtns();
var vm = new MessageBoxViewModel(message, caption, buttons, icon, defaultButton);
dialog.DataContext = vm;
dialog.ControlToFocusOnShow = dialog.FindControl<Control>(defaultButton.ToString());
@@ -190,11 +188,13 @@ Libation.
tbx.Height = tbx.DesiredSize.Height;
tbx.Width = tbx.DesiredSize.Width;
dialog.MinHeight = vm.FormHeightFromTboxHeight((int)tbx.DesiredSize.Height);
var absoluteHeight = vm.FormHeightFromTboxHeight((int)tbx.DesiredSize.Height);
dialog.MinHeight = absoluteHeight;
dialog.MinWidth = vm.FormWidthFromTboxWidth((int)tbx.DesiredSize.Width);
dialog.MaxHeight = dialog.MinHeight;
dialog.MaxHeight = absoluteHeight;
dialog.MaxWidth = dialog.MinWidth;
dialog.Height = dialog.MinHeight;
dialog.Height = absoluteHeight;
dialog.Width = dialog.MinWidth;
return dialog;
}

View File

@@ -5,10 +5,10 @@ using System.Threading.Tasks;
using ApplicationServices;
using AppScaffolding;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
using LibationFileManager;
#nullable enable
namespace LibationAvalonia
{
static class Program
@@ -57,7 +57,7 @@ namespace LibationAvalonia
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(null);
BuildAvaloniaApp().StartWithClassicDesktopLifetime([]);
}
catch (Exception ex)
{
@@ -77,27 +77,27 @@ namespace LibationAvalonia
private static void LogError(object exceptionObject)
{
var logError = $"""
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
var crashLog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log");
using var sw = new StreamWriter(crashLog, true);
sw.WriteLine(logError);
static string getConfigValue(Func<Configuration, string> selector)
static string getConfigValue(Func<Configuration, string?> selector)
{
try
{
return selector(Configuration.Instance);
return selector(Configuration.Instance) ?? "[null]";
}
catch (Exception ex)
{

View File

@@ -0,0 +1,206 @@
using Avalonia.Media;
using System.Collections.Generic;
using Avalonia.Styling;
using System;
using Dinah.Core;
using Newtonsoft.Json;
using Avalonia.Controls;
using Avalonia.Themes.Fluent;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Collections.Frozen;
#nullable enable
namespace LibationAvalonia;
public class ChardonnayTheme : IUpdatable, ICloneable
{
public event EventHandler? Updated;
/// <summary>Theme color overrides</summary>
[JsonProperty]
private readonly Dictionary<ThemeVariant, Dictionary<string, Color>> ThemeColors;
/// <summary>The two theme variants supported by Fluent themes</summary>
private static readonly FrozenSet<ThemeVariant> FluentVariants = [ThemeVariant.Light, ThemeVariant.Dark];
/// <summary>Reusable color pallets for the two theme variants</summary>
private static readonly FrozenDictionary<ThemeVariant, ColorPaletteResources> ColorPalettes
= FluentVariants.ToFrozenDictionary(t => t, _ => new ColorPaletteResources());
private ChardonnayTheme()
{
ThemeColors = FluentVariants.ToDictionary(t => t, _ => new Dictionary<string, Color>());
}
/// <summary> Invoke <see cref="IUpdatable.Updated"/> </summary>
public void Save() => Updated?.Invoke(this, EventArgs.Empty);
public Color GetColor(string? themeVariant, string itemName)
=> GetColor(FromVariantName(themeVariant), itemName);
public Color GetColor(ThemeVariant themeVariant, string itemName)
{
ValidateThemeVariant(themeVariant);
return ThemeColors[themeVariant].TryGetValue(itemName, out var color) ? color : default;
}
public ChardonnayTheme SetColor(string? themeVariant, Expression<Func<ColorPaletteResources, Color>> colorSelector, Color color)
=> SetColor(FromVariantName(themeVariant), colorSelector, color);
public ChardonnayTheme SetColor(ThemeVariant themeVariant, Expression<Func<ColorPaletteResources, Color>> colorSelector, Color color)
{
if (colorSelector.Body.NodeType is ExpressionType.MemberAccess &&
colorSelector.Body is MemberExpression memberExpression &&
memberExpression.Member is PropertyInfo colorProperty &&
colorProperty.DeclaringType == typeof(ColorPaletteResources))
return SetColor(themeVariant, colorProperty.Name, color);
return this;
}
public ChardonnayTheme SetColor(string? themeVariant, string itemName, Color itemColor)
=> SetColor(FromVariantName(themeVariant), itemName, itemColor);
public ChardonnayTheme SetColor(ThemeVariant themeVariant, string itemName, Color itemColor)
{
ValidateThemeVariant(themeVariant);
ThemeColors[themeVariant][itemName] = itemColor;
return this;
}
public FrozenDictionary<string, Color> GetThemeColors(string? themeVariant)
=> GetThemeColors(FromVariantName(themeVariant));
public FrozenDictionary<string, Color> GetThemeColors(ThemeVariant themeVariant)
{
ValidateThemeVariant(themeVariant);
return ThemeColors[themeVariant].ToFrozenDictionary();
}
public void ApplyTheme(string? themeVariant)
=> ApplyTheme(FromVariantName(themeVariant));
public void ApplyTheme(ThemeVariant themeVariant)
{
App.Current.RequestedThemeVariant = themeVariant;
themeVariant = App.Current.ActualThemeVariant;
ValidateThemeVariant(themeVariant);
bool fluentColorChanged = false;
//Set the Libation-specific brushes
var themeBrushes = (ResourceDictionary)App.Current.Resources.ThemeDictionaries[themeVariant];
foreach (var colorName in themeBrushes.Keys.OfType<string>())
{
if (ThemeColors[themeVariant].TryGetValue(colorName, out var color) && color != default)
{
if (themeBrushes[colorName] is ISolidColorBrush brush && brush.Color != color)
themeBrushes[colorName] = new SolidColorBrush(color);
}
}
//Set the fluent theme colors
foreach (var p in GetColorResourceProperties())
{
if (ThemeColors[themeVariant].TryGetValue(p.Name, out var color) && color != default)
{
if (p.GetValue(ColorPalettes[themeVariant]) is not Color c || c != color)
{
p.SetValue(ColorPalettes[themeVariant], color);
fluentColorChanged = true;
}
}
}
if (fluentColorChanged)
{
var oldFluent = App.Current.Styles.OfType<FluentTheme>().Single();
App.Current.Styles.Remove(oldFluent);
//We must make a new fluent theme and add it to the app for
//the changes to the ColorPaletteResources to take effect.
//Changes to the Libation-specific resources are instant.
var newFluent = new FluentTheme();
foreach (var kvp in ColorPalettes)
newFluent.Palettes[kvp.Key] = kvp.Value;
App.Current.Styles.Add(newFluent);
}
}
/// <summary> Get the currently-active theme colors. </summary>
public static ChardonnayTheme GetLiveTheme()
{
var theme = new ChardonnayTheme();
foreach (var themeVariant in FluentVariants)
{
//Get the Libation-specific brushes
var themeBrushes = (ResourceDictionary)App.Current.Resources.ThemeDictionaries[themeVariant];
foreach (var colorName in themeBrushes.Keys.OfType<string>())
{
if (themeBrushes[colorName] is ISolidColorBrush brush)
{
//We're only working with colors, so convert the Brush's opacity to an alpha value
var color = Color.FromArgb
(
(byte)Math.Round(brush.Color.A * brush.Opacity, 0),
brush.Color.R,
brush.Color.G,
brush.Color.B
);
theme.ThemeColors[themeVariant][colorName] = color;
}
}
//Get the fluent theme colors
foreach (var p in GetColorResourceProperties())
{
var color = (Color)p.GetValue(ColorPalettes[themeVariant])!;
//The color isn't being overridden, so get the static resource value.
if (color == default)
{
var staticResourceName = p.Name == nameof(ColorPaletteResources.RegionColor) ? "SystemRegionColor" : $"System{p.Name}Color";
if (App.Current.TryGetResource(staticResourceName, themeVariant, out var colorObj) && colorObj is Color c)
color = c;
}
theme.ThemeColors[themeVariant][p.Name] = color;
}
}
return theme;
}
public object Clone()
{
var clone = new ChardonnayTheme();
foreach (var t in ThemeColors)
{
clone.ThemeColors[t.Key] = t.Value.ToDictionary();
}
return clone;
}
private static IEnumerable<PropertyInfo> GetColorResourceProperties()
=> typeof(ColorPaletteResources).GetProperties().Where(p => p.PropertyType == typeof(Color));
[System.Diagnostics.StackTraceHidden]
private static void ValidateThemeVariant(ThemeVariant themeVariant)
{
if (!FluentVariants.Contains(themeVariant))
throw new InvalidOperationException("FluentTheme.Palettes only supports Light and Dark variants.");
}
private static ThemeVariant FromVariantName(string? variantName)
=> variantName switch
{
nameof(ThemeVariant.Dark) => ThemeVariant.Dark,
nameof(ThemeVariant.Light) => ThemeVariant.Light,
// "System"
_ => ThemeVariant.Default
};
}

View File

@@ -0,0 +1,61 @@
using Avalonia.Media;
using Dinah.Core.IO;
using FileManager;
using LibationFileManager;
using Newtonsoft.Json;
using System;
#nullable enable
namespace LibationAvalonia.Themes;
public class ChardonnayThemePersister : JsonFilePersister<ChardonnayTheme>
{
public static string jsonPath = System.IO.Path.Combine(Configuration.Instance.LibationFiles, "ChardonnayTheme.json");
public ChardonnayThemePersister(string path)
: base(path, null) { }
public ChardonnayThemePersister(ChardonnayTheme target, string path)
: base(target, path, null) { }
protected override JsonSerializerSettings GetSerializerSettings()
=> new JsonSerializerSettings { Converters = { new ColorConverter() } };
public static ChardonnayThemePersister? Create()
{
try
{
return System.IO.File.Exists(jsonPath)
? new ChardonnayThemePersister(jsonPath)
: new ChardonnayThemePersister(ChardonnayTheme.GetLiveTheme(), jsonPath);
}
catch (Exception ex)
{
try
{
Serilog.Log.Logger.Error(ex, $"Failed to open {jsonPath}. Overwriting with empty file.");
FileUtility.SaferDelete(jsonPath);
return new ChardonnayThemePersister(ChardonnayTheme.GetLiveTheme(), jsonPath);
}
catch (Exception ex2)
{
Serilog.Log.Logger.Error(ex2, $"Failed to open {jsonPath}.");
return null;
}
}
}
/// <summary> Store colors as #ARGB values so that the json file is easier to manually edit </summary>
private class ColorConverter : JsonConverter
{
public override bool CanConvert(Type objectType) => objectType == typeof(Color);
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
=> reader.Value is string hexColor && Color.TryParse(hexColor, out var color) ? color : default;
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value is Color color)
writer.WriteValue(color.ToString());
}
}
}

Some files were not shown because too many files have changed in this diff Show More