55 Commits

Author SHA1 Message Date
austinried
fd800b0e12 flutter 3.38 upgrade 2025-11-23 11:41:20 +09:00
austinried
b6153ce3b6 refresh lists on source change and sync 2025-11-23 10:59:01 +09:00
austinried
798a907cca active source switching and reactivity 2025-11-22 17:00:14 +09:00
austinried
de9bc98044 active source/id from db watch 2025-11-20 22:04:07 +09:00
austinried
b5d52a034d library tabs translations 2025-11-18 21:59:13 +09:00
austinried
51b9f3f1a8 migrate l10n, state preloading 2025-11-18 19:01:25 +09:00
austinried
8c3979ca8b library screen refactor 2025-11-09 21:41:41 +09:00
austinried
aaab1d1278 refactor artist to use coverArt
fix cover art image caching
2025-11-09 17:11:35 +09:00
austinried
42ff02f88e don't need these prints now 2025-11-09 17:04:53 +09:00
austinried
d18ca13f48 artist list with images 2025-11-09 15:48:20 +09:00
austinried
ee2a276f2f display albums from db 2025-11-09 09:26:45 +09:00
austinried
0c80dbdba5 sync the rest of the source models
refactor music download/storage to avoid re-download during reset
add palylist to test server setup
2025-11-07 15:24:51 +09:00
austinried
0e6acbed0f bring in database
switch to just using source models (no extra db fields)
start re-implementing sync service
2025-11-07 11:45:13 +09:00
austinried
f1c734d432 reorg ui into app 2025-11-03 10:35:44 +09:00
austinried
2df86f4faa improve album tests 2025-11-02 18:35:13 +09:00
austinried
c900c9750a stop streaming iterables
add gonic to test servers setup
gather artist image URLs on allArtists to remove weird Future<Uri> interface for artist images
move source options around
2025-11-02 11:56:17 +09:00
austinried
3408a3988e music source and client for subsonic
test fixture setup for navidrome
2025-11-02 10:35:22 +09:00
austinried
9f05ebb201 albums grid, pagination 2025-10-31 15:17:09 +09:00
austinried
cc168eefcd routing scaffolding 2025-10-19 18:48:53 +09:00
austinried
9bd0e07c44 lints 2025-10-19 18:48:04 +09:00
austinried
9f98304e0a new home screen design 2025-10-19 12:31:38 +09:00
austinried
d59b2afe37 temp use a different app ID 2025-10-18 15:02:43 +09:00
austinried
319a82c25a fix bad formatting 2025-10-18 10:50:20 +09:00
austinried
7f592c7db1 reboot 2025-10-16 20:06:17 +09:00
austinried
b0bb26f84b Fix initial server ping/feature tests always using token auth 2023-05-18 06:42:29 +09:00
austinried
e94fcf3128 bump version 2023-05-16 18:59:16 +09:00
josé m
bd6e818f36 Translated using Weblate (Galician)
Currently translated at 100.0% (94 of 94 strings)

Co-authored-by: josé m <correoxm@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/gl/
Translation: Subtracks/subtracks
2023-05-16 18:57:04 +09:00
Max Smith
96d0c35c31 Translated using Weblate (Russian)
Currently translated at 100.0% (94 of 94 strings)

Co-authored-by: Max Smith <sevinfolds@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ru/
Translation: Subtracks/subtracks
2023-05-16 18:57:04 +09:00
Tim Schneeberger
4ef3281a0b Translated using Weblate (German)
Currently translated at 100.0% (92 of 92 strings)

Co-authored-by: Tim Schneeberger <thebone.main@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/
Translation: Subtracks/subtracks
2023-05-16 18:57:04 +09:00
austinried
c56e3dba0f remove todo 2023-05-16 09:34:39 +09:00
austinried
53d284ace4 redact error too
create log file if it doesn't exist first
2023-05-16 09:34:39 +09:00
austinried
c2733482e5 show snackbar error for sync
log http errors
log sync errors
2023-05-16 09:34:39 +09:00
austinried
67f0c926c4 add snackbar method for errors
test (ping) server before saving source
display error message when saving source
2023-05-16 09:34:39 +09:00
Joel Calado
889be2ff2c return null 2023-05-15 07:11:58 +09:00
Joel Calado
52b51954aa improve url validation in settings 2023-05-15 07:11:58 +09:00
austinried
1c76293559 default force plaintext password off 2023-05-14 14:35:19 +09:00
austinried
250d6793a2 wording 2023-05-14 14:29:04 +09:00
austinried
121af2bca3 audio playback error logging
subsonic error logging
source save error logging
2023-05-14 14:29:04 +09:00
austinried
e410dcb2eb log sql exceptions 2023-05-14 14:29:04 +09:00
austinried
63ff9772e5 initial console/file logging framework 2023-05-14 14:29:04 +09:00
Vojtěch Fošnár
1ae29c5ade Translated using Weblate (Czech)
Currently translated at 85.8% (79 of 92 strings)

Co-authored-by: Vojtěch Fošnár <vfosnar@fosny.eu>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/cs/
Translation: Subtracks/subtracks
2023-05-11 10:07:23 +09:00
Joel Calado
fedd6a71bb Translated using Weblate (Portuguese)
Currently translated at 88.0% (81 of 92 strings)

Co-authored-by: Joel Calado <joelcalado@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/pt/
Translation: Subtracks/subtracks
2023-05-11 10:07:23 +09:00
austinried
8f64cfcbca bump version 2023-05-08 06:39:17 +09:00
austinried
1edb2c13da update todo 2023-05-08 06:37:10 +09:00
austinried
7f83204b24 don't pass all ids as params
instead, only pass ids to delete and chunk those by the param limit
2023-05-07 13:56:05 +09:00
austinried
0fe52494d0 update todo 2023-05-07 13:54:56 +09:00
Daniel Playfair Cal
56dbcde3b4 add autofill hints for source page 2023-05-07 13:54:26 +09:00
austinried
8fbc5e6ce4 add artist radio 2023-05-07 13:28:15 +09:00
austinried
979a4b7c73 add plaintext password option
fixes #161
2023-05-06 17:56:03 +09:00
josé m
7b1da24748 Translated using Weblate (Galician)
Currently translated at 100.0% (92 of 92 strings)

Co-authored-by: josé m <correoxm@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/gl/
Translation: Subtracks/subtracks
2023-05-06 09:11:25 +09:00
Sacelo
7014aa85d1 Translated using Weblate (Spanish)
Currently translated at 77.1% (71 of 92 strings)

Co-authored-by: Sacelo <rion020806@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/es/
Translation: Subtracks/subtracks
2023-05-06 09:11:25 +09:00
雨杉叶
abab674322 Translated using Weblate (Chinese (Simplified))
Currently translated at 88.0% (81 of 92 strings)

Co-authored-by: 雨杉叶 <yushaye@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/zh_Hans/
Translation: Subtracks/subtracks
2023-05-06 09:11:25 +09:00
daniel-225
498bb22a69 Translated using Weblate (German)
Currently translated at 75.0% (69 of 92 strings)

Co-authored-by: daniel-225 <dsoukup@outlook.de>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/
Translation: Subtracks/subtracks
2023-05-06 09:11:25 +09:00
austinried
11fe43a750 add server info fields 2023-04-28 21:59:33 +09:00
austinried
2a60a7306c use english as last fallback locale
fixes #160
2023-04-28 21:45:59 +09:00
217 changed files with 18582 additions and 27997 deletions

View File

@@ -1,4 +0,0 @@
TEST_SERVER_NAME=Subsonic Demo
TEST_SERVER_URL=http://demo.subsonic.org
TEST_SERVER_USERNAME=guest
TEST_SERVER_PASSWORD=guest

View File

@@ -1,4 +0,0 @@
{
"flutterSdkVersion": "3.7.11",
"flavors": {}
}

View File

@@ -23,10 +23,14 @@ A clear and concise description of what you expected to happen.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):** **Device**
- Device: [e.g. Pixel 4] - Model: [e.g. Pixel 4]
- OS: [e.g. Android 12] - OS: [e.g. Android 12]
- Subtracks version [e.g. 1.2.0] - Subtracks version [e.g. 1.2.0]
**Server**
- Software: [e.g. Navidrome]
- Version: [e.g. 0.49.3]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

14
.gitignore vendored
View File

@@ -5,9 +5,11 @@
*.swp *.swp
.DS_Store .DS_Store
.atom/ .atom/
.build/
.buildlog/ .buildlog/
.history .history
.svn/ .svn/
.swiftpm/
migrate_working_dir/ migrate_working_dir/
# IntelliJ related # IntelliJ related
@@ -25,12 +27,11 @@ migrate_working_dir/
**/doc/api/ **/doc/api/
**/ios/Flutter/.last_build_id **/ios/Flutter/.last_build_id
.dart_tool/ .dart_tool/
.flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.packages
.pub-cache/ .pub-cache/
.pub/ .pub/
/build/ /build/
/coverage/
# Symbolication related # Symbolication related
app.*.symbols app.*.symbols
@@ -43,7 +44,8 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
/.env # VSCode
*.sqlite* .vscode/settings.json
/.fvm/flutter_sdk
*.keystore # subtracks
/music

View File

@@ -1,11 +1,11 @@
# This file tracks properties of this Flutter project. # This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc. # Used by Flutter tool to assess capabilities and perform upgrades etc.
# #
# This file should be version controlled. # This file should be version controlled and should not be manually edited.
version: version:
revision: 9944297138845a94256f1cf37beb88ff9a8e811a revision: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: stable channel: "stable"
project_type: app project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: 9944297138845a94256f1cf37beb88ff9a8e811a create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9944297138845a94256f1cf37beb88ff9a8e811a base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android - platform: android
create_revision: 9944297138845a94256f1cf37beb88ff9a8e811a create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9944297138845a94256f1cf37beb88ff9a8e811a base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# User provided section # User provided section

View File

@@ -7,6 +7,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -22,6 +26,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -38,6 +44,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -53,6 +63,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -62,32 +74,16 @@
], ],
"cs": [ "cs": [
"actionsCancel", "navigationTabsAlbums",
"actionsDelete", "navigationTabsArtists",
"actionsDownload", "navigationTabsPlaylists",
"actionsDownloadCancel", "navigationTabsSongs",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount", "resourcesPlaylistCount",
"resourcesSongCount", "resourcesSongCount",
"resourcesSongListDeleteAllContent", "settingsAboutShareLogs",
"resourcesSongListDeleteAllTitle", "settingsAboutChooseLog",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsLicenses",
"settingsAboutActionsProjectHomepage",
"settingsAboutActionsSupport",
"settingsAboutName",
"settingsAboutVersion",
"settingsMusicName", "settingsMusicName",
"settingsMusicOptionsScrobbleDescriptionOff", "settingsMusicOptionsScrobbleDescriptionOff",
"settingsMusicOptionsScrobbleDescriptionOn", "settingsMusicOptionsScrobbleDescriptionOn",
@@ -96,12 +92,7 @@
"settingsNetworkOptionsMinBufferTitle", "settingsNetworkOptionsMinBufferTitle",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn"
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsResetActionsClearImageCache",
"settingsResetName",
"settingsServersFieldsName"
], ],
"da": [ "da": [
@@ -114,6 +105,10 @@
"actionsStar", "actionsStar",
"actionsUnstar", "actionsUnstar",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -133,6 +128,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsMusicOptionsScrobbleDescriptionOff", "settingsMusicOptionsScrobbleDescriptionOff",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
@@ -146,44 +143,19 @@
], ],
"de": [ "de": [
"actionsCancel", "navigationTabsAlbums",
"actionsDelete", "navigationTabsArtists",
"actionsDownload", "navigationTabsPlaylists",
"actionsDownloadCancel", "navigationTabsSongs",
"actionsDownloadDelete", "settingsAboutShareLogs",
"actionsOk", "settingsAboutChooseLog"
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
], ],
"es": [ "es": [
"actionsCancel", "navigationTabsAlbums",
"actionsDelete", "navigationTabsArtists",
"actionsDownload", "navigationTabsPlaylists",
"actionsDownloadCancel", "navigationTabsSongs",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -199,6 +171,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -215,6 +189,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -230,6 +208,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -239,34 +219,10 @@
], ],
"gl": [ "gl": [
"actionsCancel", "navigationTabsAlbums",
"actionsDelete", "navigationTabsArtists",
"actionsDownload", "navigationTabsPlaylists",
"actionsDownloadCancel", "navigationTabsSongs"
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
], ],
"it": [ "it": [
@@ -277,6 +233,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -292,6 +252,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -311,6 +273,10 @@
"actionsUnstar", "actionsUnstar",
"controlsShuffle", "controlsShuffle",
"messagesNothingHere", "messagesNothingHere",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumActionsPlay", "resourcesAlbumActionsPlay",
"resourcesAlbumActionsView", "resourcesAlbumActionsView",
"resourcesAlbumCount", "resourcesAlbumCount",
@@ -343,6 +309,8 @@
"settingsAboutActionsLicenses", "settingsAboutActionsLicenses",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutName", "settingsAboutName",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsAboutVersion", "settingsAboutVersion",
"settingsMusicOptionsScrobbleDescriptionOff", "settingsMusicOptionsScrobbleDescriptionOff",
"settingsMusicOptionsScrobbleDescriptionOn", "settingsMusicOptionsScrobbleDescriptionOn",
@@ -384,6 +352,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -399,6 +371,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -415,6 +389,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -430,6 +408,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -446,6 +426,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -461,6 +445,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -470,65 +456,30 @@
], ],
"pt": [ "pt": [
"actionsCancel", "navigationTabsAlbums",
"actionsDelete", "navigationTabsArtists",
"actionsDownload", "navigationTabsPlaylists",
"actionsDownloadCancel", "navigationTabsSongs",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner", "resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount", "resourcesPlaylistCount",
"resourcesSongCount", "resourcesSongCount",
"resourcesSongListDeleteAllContent", "resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle", "resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount", "resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutShareLogs",
"settingsNetworkOptionsOfflineMode", "settingsAboutChooseLog",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault", "settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName" "settingsServersFieldsName"
], ],
"ru": [ "ru": [
"actionsCancel", "navigationTabsAlbums",
"actionsDelete", "navigationTabsArtists",
"actionsDownload", "navigationTabsPlaylists",
"actionsDownloadCancel", "navigationTabsSongs"
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
], ],
"tr": [ "tr": [
@@ -539,6 +490,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -554,6 +509,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -570,6 +527,10 @@
"actionsDownloadDelete", "actionsDownloadDelete",
"actionsOk", "actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum", "resourcesFilterAlbum",
@@ -585,6 +546,8 @@
"resourcesSortByTitle", "resourcesSortByTitle",
"resourcesSortByUpdated", "resourcesSortByUpdated",
"settingsAboutActionsSupport", "settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",
@@ -594,28 +557,17 @@
], ],
"zh": [ "zh": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle", "controlsShuffle",
"navigationTabsAlbums",
"navigationTabsArtists",
"navigationTabsPlaylists",
"navigationTabsSongs",
"resourcesAlbumCount", "resourcesAlbumCount",
"resourcesArtistCount", "resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount", "resourcesPlaylistCount",
"resourcesSongCount", "resourcesSongCount",
"resourcesSongListDeleteAllContent", "settingsAboutShareLogs",
"resourcesSongListDeleteAllTitle", "settingsAboutChooseLog",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode", "settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff", "settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn", "settingsNetworkOptionsOfflineModeOn",

62
TODO.md
View File

@@ -1,34 +1,30 @@
## To-do ## To-do
- [ ] Star/unstar - Star/unstar
- [ ] Context menus - Context menus
- [ ] Download actions for song - Download actions for song
- [ ] Playlist management - Playlist management
- [ ] Add to playlist (from context) - Add to playlist (from context)
- [ ] Queue management - Queue management
- [ ] View playing queue - View playing queue
- [ ] Re-order queue - Re-order queue
- [ ] Add to queue (from context) - Add to queue (from context)
- [ ] Remove from queue - Remove from queue
- [ ] Scrobbling - Scrobbling
- [ ] Library filters (year/genre/etc) - Library filters (year/genre/etc)
- [ ] Library list display modes - Library list display modes
- [ ] Search - Search
- [ ] Individual "more" results pages - Individual "more" results pages
- [ ] Radio modes - Now playing gestures
- [ ] Artist - Swipe bar/album to skip
- [ ] Now playing gestures - Double-tap to seek forward/back (bar only)
- [ ] Swipe bar/album to skip - Settings
- [ ] Double-tap to seek forward/back (bar only) - Music
- [ ] Settings - Scrobble
- [ ] Sources - Downloads
- [ ] Use plaintext password - Used/available space
- [ ] Music - Clear downloads
- [ ] Scrobble - Clear images
- [ ] Downloads - About
- [ ] Used/available space - Licenses
- [ ] Clear downloads - Welcome/setup flow
- [ ] Clear images - Proper loading screen/animation
- [ ] About
- [ ] Licenses
- [ ] Welcome/setup flow
- [ ] Proper loading screen/animation

View File

@@ -1,19 +1,14 @@
include: package:flutter_lints/flutter.yaml include:
- package:flutter_lints/flutter.yaml
linter: linter:
rules: rules:
prefer_relative_imports: true prefer_relative_imports: true
prefer_single_quotes: true
formatter:
trailing_commas: preserve
analyzer: analyzer:
exclude:
- '**.freezed.dart'
- '**.g.dart'
- '**.gr.dart'
plugins: plugins:
# broken currently and may not get fixed - custom_lint
# https://github.com/simolus3/drift/issues/2342
# - drift
# also broken but only recently reported
# https://github.com/rrousselGit/riverpod/issues/2180
# - custom_lint

3
android/.gitignore vendored
View File

@@ -5,9 +5,10 @@ gradle-wrapper.jar
/gradlew.bat /gradlew.bat
/local.properties /local.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app # See https://flutter.dev/to/reference-keystore
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks

View File

@@ -1,88 +0,0 @@
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
compileSdkVersion flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.subtracks2"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion 19
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
if (project.hasProperty("signRelease")) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
}
}
}
flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

View File

@@ -0,0 +1,41 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.subtracks2_1"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
defaultConfig {
// TODO: Specify your own unique Application ID
// (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.subtracks2_1"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter { source = "../.." }

View File

@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.subtracks2">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.

View File

@@ -1,16 +1,13 @@
<manifest <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.subtracks2">
<application <application
android:label="subtracks" android:label="subtracks2_1"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher">
android:usesCleartextTraffic="true">
<activity <activity
android:name="com.ryanheise.audioservice.AudioServiceActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -21,52 +18,28 @@
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" /> android:resource="@style/NormalTheme"
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
<uses-permission android:name="android.permission.INTERNET" /> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- audio_service --> <intent>
<uses-permission android:name="android.permission.WAKE_LOCK" /> <action android:name="android.intent.action.PROCESS_TEXT"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest> </manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,25 +0,0 @@
// Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app;
import android.app.Application;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}

View File

@@ -1,6 +0,0 @@
package com.subtracks2
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}

View File

@@ -0,0 +1,5 @@
package com.subtracks2_1
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="299.84"
android:viewportHeight="219.51"
android:tint="#FFFFFF">
<group android:scaleX="0.92"
android:scaleY="0.6735232"
android:translateX="11.9936"
android:translateY="35.83246">
<path
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
android:strokeWidth="0.9"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</group>
</vector>

View File

@@ -1,29 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="299.84"
android:viewportHeight="219.51"
android:tint="#333333"
android:alpha="0.6">
<group android:scaleY="0.7320905"
android:translateY="29.404413">
<path
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
android:strokeWidth="0.9"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 B

View File

@@ -1,29 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="299.84"
android:viewportHeight="219.51">
<group android:scaleX="0.43"
android:scaleY="0.3147989"
android:translateX="85.4544"
android:translateY="75.20425">
<path
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
android:strokeWidth="0.9"
android:fillColor="#f4d9ff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#f4d9ff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#f4d9ff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</group>
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/*" />

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#6A1B9A</color>
</resources>

View File

@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.subtracks2">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.

View File

@@ -1,31 +0,0 @@
buildscript {
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
delete rootProject.buildDir
}

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true

0
android/gradle/wrapper/gradle-wrapper.jar vendored Executable file → Normal file
View File

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

0
android/gradlew.bat vendored Executable file → Normal file
View File

View File

@@ -1,11 +0,0 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 96 960 960" width="24"><path d="M290 896q-19 0-31-15t-7-33l28-112h-89q-20 0-32-15.5t-7-34.5q3-14 14-22t25-8h109l40-160H231q-20 0-32-15.5t-7-34.5q3-14 14-22t25-8h129l33-131q3-13 13-21t24-8q19 0 31 15t7 33l-28 112h160l33-131q3-13 13-21t24-8q19 0 31 15t7 33l-28 112h89q20 0 32 15.5t7 34.5q-3 14-14 22t-25 8H660l-40 160h109q20 0 32 15.5t7 34.5q-3 14-14 22t-25 8H600l-33 131q-3 13-13 21t-24 8q-19 0-31-15t-7-33l28-112H360l-33 131q-3 13-13 21t-24 8Zm90-240h160l40-160H420l-40 160Z"/></svg>

Before

Width:  |  Height:  |  Size: 546 B

View File

@@ -1,11 +0,0 @@
targets:
$default:
builders:
drift_dev:
options:
sql:
dialect: sqlite
options:
version: "3.38"
modules:
- fts5

41
compose.yaml Normal file
View File

@@ -0,0 +1,41 @@
services:
library-manager:
build: ./docker/library-manager
volumes:
- deno-dir:/deno-dir
- ./music:/music
navidrome:
image: deluan/navidrome:latest
ports:
- 4533:4533
restart: unless-stopped
environment:
ND_LOGLEVEL: debug
volumes:
- navidrome-data:/data
- ./music:/music:ro
gonic:
image: sentriz/gonic:latest
environment:
- TZ
- GONIC_SCAN_AT_START_ENABLED=true
- GONIC_SCAN_WATCHER_ENABLED=true
ports:
- 4747:80
volumes:
- gonic-data:/data
- ./music:/music:ro
- gonic-podcasts:/podcasts
- gonic-playlists:/playlists
- gonic-cache:/cache
volumes:
deno-dir:
music:
navidrome-data:
gonic-data:
gonic-podcasts:
gonic-playlists:
gonic-cache:

View File

@@ -0,0 +1,8 @@
FROM denoland/deno:debian
ENV DENO_DIR=/deno-dir
RUN apt-get update && \
apt-get install -y unzip
COPY scripts /usr/bin

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env -S deno --allow-all
import * as path from "jsr:@std/path@1.1.2";
import { MUSIC_DIR } from "./util/env.ts";
import { SubsonicClient } from "./util/subsonic.ts";
const client = new SubsonicClient(
"http://demo.subsonic.org",
"guest1",
"guest",
);
for (const id of ["197", "199", "321"]) {
const { res } = await client.get("download", [["id", id]]);
let filename = res.headers.get("Content-Disposition")
?.split(";")[1];
filename = (filename?.includes("*=")
? decodeURIComponent(filename.split("''")[1])
: filename?.split("=")[1]) ?? `${id}.zip`;
console.log("downloading album:", filename);
const downloadPath = path.join(MUSIC_DIR, filename);
const file = await Deno.open(downloadPath, {
write: true,
create: true,
});
await res.body?.pipeTo(file.writable);
await new Deno.Command("unzip", {
args: [
downloadPath,
"-d",
path.join(MUSIC_DIR, filename.split(".")[0]),
],
}).output();
await Deno.remove(downloadPath);
}
console.log("music-download complete");

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env -S deno --allow-all
import { SubsonicClient } from "./util/subsonic.ts";
import { sleep } from "./util/util.ts";
async function getArtistId(
client: SubsonicClient,
artist: string,
): Promise<string> {
const { xml } = await client.get("getArtists");
return xml.querySelector(
`artist[name='${artist.replaceAll("'", "\\'")}']`,
)?.id!;
}
async function getAlbumId(
client: SubsonicClient,
album: string,
): Promise<string> {
const { xml } = await client.get("getAlbumList2", [
["type", "newest"],
]);
return xml.querySelector(
`album[name='${album.replaceAll("'", "\\'")}']`,
)?.id!;
}
async function getSongId(
client: SubsonicClient,
album: string,
track: number,
): Promise<string> {
const albumId = await getAlbumId(client, album);
const { xml } = await client.get("getAlbum", [["id", albumId!]]);
return xml.querySelector(`song[track='${track}']`)?.id!;
}
async function scrobbleTrack(
client: SubsonicClient,
album: string,
track: number,
) {
const songId = await getSongId(client, album, track);
await client.get("scrobble", [
["id", songId!],
["submission", "true"],
]);
}
async function starAlbum(client: SubsonicClient, album: string) {
const albumId = await getAlbumId(client, album);
await client.get("star", [["albumId", albumId]]);
}
async function starArtist(client: SubsonicClient, artist: string) {
const artistId = await getArtistId(client, artist);
await client.get("star", [["artistId", artistId]]);
}
async function createPlaylist(
client: SubsonicClient,
name: string,
songs: { album: string; track: number }[],
) {
const songIds = await Promise.all(songs.map(({ album, track }) => {
return getSongId(client, album, track);
}));
await client.get("createPlaylist", [
["name", name],
...songIds.map((songId) => ["songId", songId] as [string, string]),
]);
}
async function setupTestData(client: SubsonicClient) {
await scrobbleTrack(client, "Retroconnaissance EP", 1);
await sleep(1_000);
await scrobbleTrack(client, "Retroconnaissance EP", 2);
await sleep(1_000);
await scrobbleTrack(client, "Kosmonaut", 1);
await createPlaylist(client, "Playlist 1", [
{ album: "Retroconnaissance EP", track: 2 },
{ album: "Retroconnaissance EP", track: 1 },
{ album: "Kosmonaut", track: 2 },
{ album: "Kosmonaut", track: 4 },
{ album: "I Don't Know What I'm Doing", track: 9 },
{ album: "I Don't Know What I'm Doing", track: 10 },
{ album: "I Don't Know What I'm Doing", track: 11 },
]);
await starAlbum(client, "Kosmonaut");
await starArtist(client, "Ugress");
}
async function setupNavidrome() {
console.log("setting up navidrome...");
const baseUrl = "http://navidrome:4533";
const username = "admin";
const password = "password";
await fetch("http://navidrome:4533/auth/createAdmin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const client = new SubsonicClient(baseUrl, username, password);
await setupTestData(client);
}
async function setupGonic() {
console.log("setting up gonic...");
const baseUrl = "http://gonic";
const username = "admin";
const password = "admin";
const client = new SubsonicClient(baseUrl, username, password);
await setupTestData(client);
}
await Promise.all([
setupNavidrome(),
setupGonic(),
]);
console.log("setup-servers complete");

View File

@@ -0,0 +1 @@
export const MUSIC_DIR = Deno.env.get("MUSIC_DIR") ?? "/music";

View File

@@ -0,0 +1,58 @@
// @deno-types="npm:@types/jsdom@27.0.0"
import { JSDOM } from "npm:jsdom@27.1.0";
export class SubsonicClient {
constructor(
readonly baseUrl: string,
readonly username: string,
readonly password: string,
) {}
async get(
method: "download",
params?: [string, string][],
): Promise<{ res: Response; xml: undefined }>;
async get(
method: string,
params?: [string, string][],
): Promise<{ res: Response; xml: Document }>;
async get(
method: string,
params?: [string, string][],
): Promise<{ res: Response; xml: Document | undefined }> {
const url = new URL(`rest/${method}.view`, this.baseUrl);
url.searchParams.set("u", this.username);
url.searchParams.set("p", this.password);
url.searchParams.set("v", "1.13.0");
url.searchParams.set("c", "subtracks-test-fixture");
if (params) {
for (const [key, value] of params) {
url.searchParams.append(key, value);
}
}
const res = await fetch(url);
let xml: Document | undefined;
if (res.headers.get("content-type")?.includes("xml")) {
xml = new JSDOM(await res.text(), {
contentType: "text/xml",
}).window.document;
}
if (!res.ok) {
let message = `HTTP error ${res.status}`;
if (xml) {
const error = xml.querySelector("error");
const errorCode = error?.getAttribute("code");
const errorMessage = error?.getAttribute("message");
message += `\nSubsonic error${errorCode}: ${errorMessage}`;
}
throw new Error(message);
}
return { res, xml };
}
}

View File

@@ -0,0 +1,3 @@
export function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

View File

@@ -1,4 +1,6 @@
arb-dir: lib/l10n arb-dir: lib/l10n
template-arb-file: app_en.arb template-arb-file: app_en.arb
nullable-getter: false output-dir: lib/l10n/generated
output-localization-file: app_localizations.dart
untranslated-messages-file: .untranslated-messages.json untranslated-messages-file: .untranslated-messages.json
nullable-getter: false

View File

@@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../services/settings_service.dart';
import '../state/init.dart';
import '../state/theme.dart';
part 'app.g.dart';
class MyApp extends HookConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final init = ref.watch(initProvider);
return init.when(
data: (_) => const App(),
error: (e, s) => Directionality(
textDirection: TextDirection.ltr,
child: Container(
color: Colors.red[900],
child: Column(children: [
const SizedBox(height: 100),
Text(e.toString()),
Text(s.toString()),
]),
),
),
loading: () => const CircularProgressIndicator(),
);
}
}
@Riverpod(keepAlive: true)
class LastPath extends _$LastPath {
@override
String build() {
return '/settings';
}
Future<void> init() async {
final db = ref.read(databaseProvider);
final lastBottomNav = await db.getLastBottomNavState().getSingleOrNull();
final lastLibrary = await db.getLastLibraryState().getSingleOrNull();
if (lastBottomNav == null || lastLibrary == null) return;
// TODO: replace this with a proper first-time setup flow
final hasActiveSource = ref.read(settingsServiceProvider.select(
(value) => value.activeSource != null,
));
if (!hasActiveSource) return;
state = lastBottomNav.tab == 'library'
? '/library/${lastLibrary.tab}'
: '/${lastBottomNav.tab}';
}
}
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appRouter = ref.watch(routerProvider);
final base = ref.watch(baseThemeProvider);
final lastPath = ref.watch(lastPathProvider);
return MaterialApp.router(
theme: base.theme,
debugShowCheckedModeBanner: false,
routerDelegate: appRouter.delegate(
initialDeepLink: lastPath,
),
routeInformationParser: appRouter.defaultRouteParser(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
);
}
}

View File

@@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$lastPathHash() => r'25ba5df6bd984fcce011eec40a12fb74627a790a';
/// See also [LastPath].
@ProviderFor(LastPath)
final lastPathProvider = NotifierProvider<LastPath, String>.internal(
LastPath.new,
name: r'lastPathProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$lastPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastPath = Notifier<String>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -1,140 +0,0 @@
// ignore_for_file: use_key_in_widget_constructors
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'pages/artist_page.dart';
import 'pages/bottom_nav_page.dart';
import 'pages/browse_page.dart';
import 'pages/library_albums_page.dart';
import 'pages/library_artists_page.dart';
import 'pages/library_page.dart';
import 'pages/library_playlists_page.dart';
import 'pages/library_songs_page.dart';
import 'pages/now_playing_page.dart';
import 'pages/search_page.dart';
import 'pages/settings_page.dart';
import 'pages/songs_page.dart';
import 'pages/source_page.dart';
part 'app_router.gr.dart';
const kCustomTransitionBuilder = TransitionsBuilders.slideRightWithFade;
const kCustomTransitionDuration = 160;
const itemRoutes = [
CustomRoute(
path: 'album/:id',
page: AlbumSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'artist/:id',
page: ArtistPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'playlist/:id',
page: PlaylistSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'genre/:genre',
page: GenreSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
];
class EmptyRouterPage extends AutoRouter {
const EmptyRouterPage({Key? key})
: super(
key: key,
inheritNavigatorObservers: false,
);
}
@MaterialAutoRouter(
replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[
AutoRoute(path: '/', name: 'RootRouter', page: EmptyRouterPage, children: [
AutoRoute(path: '', page: BottomNavTabsPage, children: [
AutoRoute(
path: 'library',
name: 'LibraryRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: LibraryTabsPage, children: [
AutoRoute(path: 'albums', page: LibraryAlbumsPage),
AutoRoute(path: 'artists', page: LibraryArtistsPage),
AutoRoute(path: 'playlists', page: LibraryPlaylistsPage),
AutoRoute(path: 'songs', page: LibrarySongsPage),
]),
...itemRoutes,
]),
AutoRoute(
path: 'browse',
name: 'BrowseRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: BrowsePage),
...itemRoutes,
]),
AutoRoute(
path: 'search',
name: 'SearchRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: SearchPage),
...itemRoutes,
]),
AutoRoute(
path: 'settings',
name: 'SettingsRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: SettingsPage),
CustomRoute(
path: 'source/:id',
page: SourcePage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
]),
]),
]),
CustomRoute(
path: '/now-playing',
page: NowPlayingPage,
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
reverseDurationInMilliseconds: 160,
),
],
)
class AppRouter extends _$AppRouter {}
class TabObserver extends AutoRouterObserver {
final StreamController<String> _controller = StreamController.broadcast();
Stream<String> get path => _controller.stream;
@override
void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) {
_controller.add(route.path);
}
@override
void didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) {
_controller.add(route.path);
}
}

View File

@@ -1,720 +0,0 @@
// **************************************************************************
// AutoRouteGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// AutoRouteGenerator
// **************************************************************************
//
// ignore_for_file: type=lint
part of 'app_router.dart';
class _$AppRouter extends RootStackRouter {
_$AppRouter([GlobalKey<NavigatorState>? navigatorKey]) : super(navigatorKey);
@override
final Map<String, PageFactory> pagesMap = {
RootRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
NowPlayingRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const NowPlayingPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
BottomNavTabsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BottomNavTabsPage(),
);
},
LibraryRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
BrowseRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
SearchRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
SettingsRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
LibraryTabsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryTabsPage(),
);
},
AlbumSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<AlbumSongsRouteArgs>(
orElse: () => AlbumSongsRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: AlbumSongsPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
ArtistRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<ArtistRouteArgs>(
orElse: () => ArtistRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: ArtistPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
PlaylistSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<PlaylistSongsRouteArgs>(
orElse: () => PlaylistSongsRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: PlaylistSongsPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
GenreSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<GenreSongsRouteArgs>(
orElse: () =>
GenreSongsRouteArgs(genre: pathParams.getString('genre')));
return CustomPage<dynamic>(
routeData: routeData,
child: GenreSongsPage(
key: args.key,
genre: args.genre,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
LibraryAlbumsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryAlbumsPage(),
);
},
LibraryArtistsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryArtistsPage(),
);
},
LibraryPlaylistsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryPlaylistsPage(),
);
},
LibrarySongsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibrarySongsPage(),
);
},
BrowseRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BrowsePage(),
);
},
SearchRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SearchPage(),
);
},
SettingsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SettingsPage(),
);
},
SourceRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<SourceRouteArgs>(
orElse: () => SourceRouteArgs(id: pathParams.optInt('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: SourcePage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
};
@override
List<RouteConfig> get routes => [
RouteConfig(
RootRouter.name,
path: '/',
children: [
RouteConfig(
BottomNavTabsRoute.name,
path: '',
parent: RootRouter.name,
children: [
RouteConfig(
LibraryRouter.name,
path: 'library',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
LibraryTabsRoute.name,
path: '',
parent: LibraryRouter.name,
children: [
RouteConfig(
LibraryAlbumsRoute.name,
path: 'albums',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibraryArtistsRoute.name,
path: 'artists',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibraryPlaylistsRoute.name,
path: 'playlists',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibrarySongsRoute.name,
path: 'songs',
parent: LibraryTabsRoute.name,
),
],
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: LibraryRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: LibraryRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: LibraryRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: LibraryRouter.name,
),
],
),
RouteConfig(
BrowseRouter.name,
path: 'browse',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
BrowseRoute.name,
path: '',
parent: BrowseRouter.name,
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: BrowseRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: BrowseRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: BrowseRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: BrowseRouter.name,
),
],
),
RouteConfig(
SearchRouter.name,
path: 'search',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
SearchRoute.name,
path: '',
parent: SearchRouter.name,
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: SearchRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: SearchRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: SearchRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: SearchRouter.name,
),
],
),
RouteConfig(
SettingsRouter.name,
path: 'settings',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
SettingsRoute.name,
path: '',
parent: SettingsRouter.name,
),
RouteConfig(
SourceRoute.name,
path: 'source/:id',
parent: SettingsRouter.name,
),
],
),
],
)
],
),
RouteConfig(
NowPlayingRoute.name,
path: '/now-playing',
),
];
}
/// generated route for
/// [EmptyRouterPage]
class RootRouter extends PageRouteInfo<void> {
const RootRouter({List<PageRouteInfo>? children})
: super(
RootRouter.name,
path: '/',
initialChildren: children,
);
static const String name = 'RootRouter';
}
/// generated route for
/// [NowPlayingPage]
class NowPlayingRoute extends PageRouteInfo<void> {
const NowPlayingRoute()
: super(
NowPlayingRoute.name,
path: '/now-playing',
);
static const String name = 'NowPlayingRoute';
}
/// generated route for
/// [BottomNavTabsPage]
class BottomNavTabsRoute extends PageRouteInfo<void> {
const BottomNavTabsRoute({List<PageRouteInfo>? children})
: super(
BottomNavTabsRoute.name,
path: '',
initialChildren: children,
);
static const String name = 'BottomNavTabsRoute';
}
/// generated route for
/// [EmptyRouterPage]
class LibraryRouter extends PageRouteInfo<void> {
const LibraryRouter({List<PageRouteInfo>? children})
: super(
LibraryRouter.name,
path: 'library',
initialChildren: children,
);
static const String name = 'LibraryRouter';
}
/// generated route for
/// [EmptyRouterPage]
class BrowseRouter extends PageRouteInfo<void> {
const BrowseRouter({List<PageRouteInfo>? children})
: super(
BrowseRouter.name,
path: 'browse',
initialChildren: children,
);
static const String name = 'BrowseRouter';
}
/// generated route for
/// [EmptyRouterPage]
class SearchRouter extends PageRouteInfo<void> {
const SearchRouter({List<PageRouteInfo>? children})
: super(
SearchRouter.name,
path: 'search',
initialChildren: children,
);
static const String name = 'SearchRouter';
}
/// generated route for
/// [EmptyRouterPage]
class SettingsRouter extends PageRouteInfo<void> {
const SettingsRouter({List<PageRouteInfo>? children})
: super(
SettingsRouter.name,
path: 'settings',
initialChildren: children,
);
static const String name = 'SettingsRouter';
}
/// generated route for
/// [LibraryTabsPage]
class LibraryTabsRoute extends PageRouteInfo<void> {
const LibraryTabsRoute({List<PageRouteInfo>? children})
: super(
LibraryTabsRoute.name,
path: '',
initialChildren: children,
);
static const String name = 'LibraryTabsRoute';
}
/// generated route for
/// [AlbumSongsPage]
class AlbumSongsRoute extends PageRouteInfo<AlbumSongsRouteArgs> {
AlbumSongsRoute({
Key? key,
required String id,
}) : super(
AlbumSongsRoute.name,
path: 'album/:id',
args: AlbumSongsRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'AlbumSongsRoute';
}
class AlbumSongsRouteArgs {
const AlbumSongsRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'AlbumSongsRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [ArtistPage]
class ArtistRoute extends PageRouteInfo<ArtistRouteArgs> {
ArtistRoute({
Key? key,
required String id,
}) : super(
ArtistRoute.name,
path: 'artist/:id',
args: ArtistRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'ArtistRoute';
}
class ArtistRouteArgs {
const ArtistRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'ArtistRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [PlaylistSongsPage]
class PlaylistSongsRoute extends PageRouteInfo<PlaylistSongsRouteArgs> {
PlaylistSongsRoute({
Key? key,
required String id,
}) : super(
PlaylistSongsRoute.name,
path: 'playlist/:id',
args: PlaylistSongsRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'PlaylistSongsRoute';
}
class PlaylistSongsRouteArgs {
const PlaylistSongsRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'PlaylistSongsRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [GenreSongsPage]
class GenreSongsRoute extends PageRouteInfo<GenreSongsRouteArgs> {
GenreSongsRoute({
Key? key,
required String genre,
}) : super(
GenreSongsRoute.name,
path: 'genre/:genre',
args: GenreSongsRouteArgs(
key: key,
genre: genre,
),
rawPathParams: {'genre': genre},
);
static const String name = 'GenreSongsRoute';
}
class GenreSongsRouteArgs {
const GenreSongsRouteArgs({
this.key,
required this.genre,
});
final Key? key;
final String genre;
@override
String toString() {
return 'GenreSongsRouteArgs{key: $key, genre: $genre}';
}
}
/// generated route for
/// [LibraryAlbumsPage]
class LibraryAlbumsRoute extends PageRouteInfo<void> {
const LibraryAlbumsRoute()
: super(
LibraryAlbumsRoute.name,
path: 'albums',
);
static const String name = 'LibraryAlbumsRoute';
}
/// generated route for
/// [LibraryArtistsPage]
class LibraryArtistsRoute extends PageRouteInfo<void> {
const LibraryArtistsRoute()
: super(
LibraryArtistsRoute.name,
path: 'artists',
);
static const String name = 'LibraryArtistsRoute';
}
/// generated route for
/// [LibraryPlaylistsPage]
class LibraryPlaylistsRoute extends PageRouteInfo<void> {
const LibraryPlaylistsRoute()
: super(
LibraryPlaylistsRoute.name,
path: 'playlists',
);
static const String name = 'LibraryPlaylistsRoute';
}
/// generated route for
/// [LibrarySongsPage]
class LibrarySongsRoute extends PageRouteInfo<void> {
const LibrarySongsRoute()
: super(
LibrarySongsRoute.name,
path: 'songs',
);
static const String name = 'LibrarySongsRoute';
}
/// generated route for
/// [BrowsePage]
class BrowseRoute extends PageRouteInfo<void> {
const BrowseRoute()
: super(
BrowseRoute.name,
path: '',
);
static const String name = 'BrowseRoute';
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<void> {
const SearchRoute()
: super(
SearchRoute.name,
path: '',
);
static const String name = 'SearchRoute';
}
/// generated route for
/// [SettingsPage]
class SettingsRoute extends PageRouteInfo<void> {
const SettingsRoute()
: super(
SettingsRoute.name,
path: '',
);
static const String name = 'SettingsRoute';
}
/// generated route for
/// [SourcePage]
class SourceRoute extends PageRouteInfo<SourceRouteArgs> {
SourceRoute({
Key? key,
int? id,
}) : super(
SourceRoute.name,
path: 'source/:id',
args: SourceRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'SourceRoute';
}
class SourceRouteArgs {
const SourceRouteArgs({
this.key,
this.id,
});
final Key? key;
final int? id;
@override
String toString() {
return 'SourceRouteArgs{key: $key, id: $id}';
}
}

View File

@@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ShuffleFab extends StatelessWidget {
final void Function()? onPressed;
const ShuffleFab({
super.key,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return FloatingActionButton(
heroTag: null,
onPressed: onPressed,
tooltip: l.actionsCancel,
child: const Icon(Icons.shuffle_rounded),
);
}
}
class RadioPlayFab extends StatelessWidget {
final void Function()? onPressed;
const RadioPlayFab({
super.key,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
heroTag: null,
onPressed: onPressed,
child: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.radio_rounded),
Positioned(
bottom: -11,
right: -10,
child: Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.primaryContainer,
size: 26,
),
),
const Positioned(
bottom: -6,
right: -5,
child: Icon(
Icons.play_arrow_rounded,
size: 16,
),
),
],
),
);
}
}

View File

@@ -1,414 +0,0 @@
// ignore_for_file: use_build_context_synchronously
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/music.dart';
import '../services/cache_service.dart';
import '../state/theme.dart';
import 'app_router.dart';
import 'hooks/use_download_actions.dart';
import 'images.dart';
enum MenuSize {
small,
medium,
}
Future<T?> showContextMenu<T>({
required BuildContext context,
required WidgetRef ref,
required WidgetBuilder builder,
}) {
return showModalBottomSheet<T>(
backgroundColor: ref.read(baseThemeProvider).theme.colorScheme.background,
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: builder,
);
}
class BottomSheetMenu extends HookConsumerWidget {
final Widget child;
final MenuSize size;
const BottomSheetMenu({
super.key,
required this.child,
this.size = MenuSize.medium,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(baseThemeProvider);
final height = size == MenuSize.medium ? 0.4 : 0.25;
return Theme(
data: theme.theme,
child: DraggableScrollableSheet(
expand: false,
initialChildSize: height,
maxChildSize: height,
minChildSize: height - 0.05,
snap: true,
snapSizes: [height - 0.05, height],
builder: (context, scrollController) {
return PrimaryScrollController(
controller: scrollController,
child: SizedBox(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: child,
),
),
);
},
),
);
}
}
class AlbumContextMenu extends HookConsumerWidget {
final Album album;
const AlbumContextMenu({
super.key,
required this.album,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadActions = useAlbumDownloadActions(
context: context,
ref: ref,
album: album,
);
return ListView(
children: [
_AlbumHeader(album: album),
const SizedBox(height: 8),
const _Star(),
if (album.artistId != null) _ViewArtist(id: album.artistId!),
for (var action in downloadActions)
_DownloadAction(key: ValueKey(action.type), downloadAction: action),
],
);
}
}
class SongContextMenu extends HookConsumerWidget {
final Song song;
const SongContextMenu({
super.key,
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
_SongHeader(song: song),
const SizedBox(height: 8),
const _Star(),
if (song.artistId != null) _ViewArtist(id: song.artistId!),
if (song.albumId != null) _ViewAlbum(id: song.albumId!),
// const _DownloadAction(),
],
);
}
}
class ArtistContextMenu extends HookConsumerWidget {
final Artist artist;
const ArtistContextMenu({
super.key,
required this.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
_ArtistHeader(artist: artist),
const SizedBox(height: 8),
const _Star(),
// const _Download(),
],
);
}
}
class PlaylistContextMenu extends HookConsumerWidget {
final Playlist playlist;
const PlaylistContextMenu({
super.key,
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadActions = usePlaylistDownloadActions(
context: context,
ref: ref,
playlist: playlist,
);
return ListView(
children: [
_PlaylistHeader(playlist: playlist),
const SizedBox(height: 8),
for (var action in downloadActions)
_DownloadAction(key: ValueKey(action.type), downloadAction: action),
],
);
}
}
class _AlbumHeader extends HookConsumerWidget {
final Album album;
const _AlbumHeader({
required this.album,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
return _Header(
title: album.name,
subtitle: album.albumArtist,
image: CardClip(
child: UriCacheInfoImage(
cache: cache.albumArt(album, thumbnail: true),
),
),
);
}
}
class _SongHeader extends HookConsumerWidget {
final Song song;
const _SongHeader({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Header(
title: song.title,
subtitle: song.artist,
image: SongAlbumArt(song: song, square: false),
);
}
}
class _ArtistHeader extends HookConsumerWidget {
final Artist artist;
const _ArtistHeader({
required this.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Header(
title: artist.name,
subtitle: l.resourcesAlbumCount(artist.albumCount),
image: CircleClip(child: ArtistArtImage(artistId: artist.id)),
);
}
}
class _PlaylistHeader extends HookConsumerWidget {
final Playlist playlist;
const _PlaylistHeader({
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
final l = AppLocalizations.of(context);
return _Header(
title: playlist.name,
subtitle: l.resourcesSongCount(playlist.songCount),
image: CardClip(
child: UriCacheInfoImage(
cache: cache.playlistArt(playlist, thumbnail: true),
),
),
);
}
}
class _Header extends HookConsumerWidget {
final String title;
final String? subtitle;
final Widget image;
const _Header({
required this.title,
this.subtitle,
required this.image,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 80, width: 80, child: image),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleLarge),
if (subtitle != null)
Text(subtitle!, style: theme.textTheme.titleSmall),
],
),
)
],
),
);
}
}
class _Star extends HookConsumerWidget {
const _Star();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.actionsStar,
icon: const Icon(Icons.star_outline_rounded),
onTap: () {},
);
}
}
class _DownloadAction extends HookConsumerWidget {
final DownloadAction downloadAction;
const _DownloadAction({
super.key,
required this.downloadAction,
});
String _actionText(AppLocalizations l) {
switch (downloadAction.type) {
case DownloadActionType.download:
return l.actionsDownload;
case DownloadActionType.cancel:
return l.actionsDownloadCancel;
case DownloadActionType.delete:
return l.actionsDownloadDelete;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return _MenuItem(
title: _actionText(AppLocalizations.of(context)),
icon: downloadAction.iconBuilder(context),
onTap: downloadAction.action,
);
}
}
class _ViewArtist extends HookConsumerWidget {
final String id;
const _ViewArtist({
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.resourcesArtistActionsView,
icon: const Icon(Icons.person_rounded),
onTap: () async {
final router = context.router;
await router.pop();
if (router.currentPath == '/now-playing') {
await router.pop();
await router.navigate(const LibraryRouter());
}
await router.navigate(ArtistRoute(id: id));
},
);
}
}
class _ViewAlbum extends HookConsumerWidget {
final String id;
const _ViewAlbum({
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.resourcesAlbumActionsView,
icon: const Icon(Icons.album_rounded),
onTap: () async {
final router = context.router;
await router.pop();
if (router.currentPath == '/now-playing') {
await router.pop();
await router.navigate(const LibraryRouter());
}
await router.navigate(AlbumSongsRoute(id: id));
},
);
}
}
class _MenuItem extends StatelessWidget {
final String title;
final Widget icon;
final FutureOr<void> Function()? onTap;
const _MenuItem({
required this.title,
required this.icon,
this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
leading: Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: icon,
),
onTap: onTap,
);
}
}

View File

@@ -1,96 +0,0 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/support.dart';
import '../state/theme.dart';
class DeleteDialog extends HookConsumerWidget {
const DeleteDialog({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(baseThemeProvider);
final l = AppLocalizations.of(context);
return Theme(
data: theme.theme,
child: AlertDialog(
title: Text(l.resourcesSongListDeleteAllTitle),
content: Text(l.resourcesSongListDeleteAllContent),
actions: [
FilledButton.tonal(
onPressed: () => Navigator.pop(context, false),
child: Text(l.actionsCancel),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, true),
label: Text(l.actionsDelete),
icon: const Icon(Icons.delete_forever_rounded),
),
],
),
);
}
}
class MultipleChoiceDialog<T> extends HookConsumerWidget {
final String title;
final T current;
final IList<MultiChoiceOption> options;
const MultipleChoiceDialog({
super.key,
required this.title,
required this.current,
required this.options,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final state = useState<T>(current);
List<Widget> choices = [];
for (var opt in options) {
final value = opt.map(
(value) => null,
int: (value) => value.option,
string: (value) => value.option,
) as T;
choices.add(RadioListTile<T>(
value: value,
groupValue: state.value,
title: Text(opt.title),
onChanged: (value) => state.value = value as T,
));
}
return AlertDialog(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(vertical: 20),
content: Material(
type: MaterialType.transparency,
child: SingleChildScrollView(
child: Column(children: choices),
),
),
actions: [
FilledButton.tonal(
onPressed: () => Navigator.pop(context, null),
child: Text(l.actionsCancel),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, state.value),
label: Text(l.actionsOk),
icon: const Icon(Icons.check_rounded),
),
],
);
}
}

View File

@@ -1,76 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/support.dart';
import '../state/theme.dart';
class MediaItemGradient extends ConsumerWidget {
const MediaItemGradient({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class AlbumArtGradient extends ConsumerWidget {
final String id;
const AlbumArtGradient({
super.key,
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(albumArtThemeProvider(id)).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class PlaylistArtGradient extends ConsumerWidget {
final String id;
const PlaylistArtGradient({
super.key,
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(playlistArtThemeProvider(id)).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class BackgroundGradient extends HookConsumerWidget {
final ColorTheme? colors;
const BackgroundGradient({
super.key,
this.colors,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final base = ref.watch(baseThemeProvider);
return SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors?.gradientHigh ?? base.gradientHigh,
colors?.gradientLow ?? base.gradientLow,
],
),
),
),
);
}
}

View File

@@ -1,149 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../models/music.dart';
import '../../models/support.dart';
import '../../services/download_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../dialogs.dart';
enum DownloadActionType {
download,
cancel,
delete,
}
class DownloadAction {
final DownloadActionType type;
final WidgetBuilder iconBuilder;
final FutureOr<void> Function()? action;
const DownloadAction({
required this.type,
required this.iconBuilder,
this.action,
});
}
List<DownloadAction> useAlbumDownloadActions({
required BuildContext context,
required WidgetRef ref,
required Album album,
}) {
final status = ref.watch(albumDownloadStatusProvider(album.id)).valueOrNull;
return useListDownloadActions(
context: context,
ref: ref,
list: album,
status: status,
onDownload: () =>
ref.read(downloadServiceProvider.notifier).downloadAlbum(album),
onDelete: () =>
ref.read(downloadServiceProvider.notifier).deleteAlbum(album),
onCancel: () =>
ref.read(downloadServiceProvider.notifier).cancelAlbum(album),
);
}
List<DownloadAction> usePlaylistDownloadActions({
required BuildContext context,
required WidgetRef ref,
required Playlist playlist,
}) {
final status =
ref.watch(playlistDownloadStatusProvider(playlist.id)).valueOrNull;
return useListDownloadActions(
context: context,
ref: ref,
list: playlist,
status: status,
onDownload: () =>
ref.read(downloadServiceProvider.notifier).downloadPlaylist(playlist),
onDelete: () =>
ref.read(downloadServiceProvider.notifier).deletePlaylist(playlist),
onCancel: () =>
ref.read(downloadServiceProvider.notifier).cancelPlaylist(playlist),
);
}
List<DownloadAction> useListDownloadActions({
required BuildContext context,
required WidgetRef ref,
required SourceIdentifiable list,
required ListDownloadStatus? status,
required FutureOr<void> Function() onDelete,
required FutureOr<void> Function() onCancel,
required FutureOr<void> Function() onDownload,
}) {
status ??= const ListDownloadStatus(total: 0, downloaded: 0, downloading: 0);
final sourceId = SourceId.from(list);
final offline = ref.watch(offlineModeProvider);
final listDownloadInProgress = ref.watch(downloadServiceProvider
.select((value) => value.listDownloads.contains(sourceId)));
final listDeleteInProgress = ref.watch(downloadServiceProvider
.select((value) => value.deletes.contains(sourceId)));
final listCancelInProgress = ref.watch(downloadServiceProvider
.select((value) => value.listCancels.contains(sourceId)));
DownloadAction delete() {
return DownloadAction(
type: DownloadActionType.delete,
iconBuilder: (context) => const Icon(Icons.delete_forever_rounded),
action: listDeleteInProgress
? null
: () async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => const DeleteDialog(),
);
if (ok == true) {
await onDelete();
}
},
);
}
DownloadAction cancel() {
return DownloadAction(
type: DownloadActionType.cancel,
iconBuilder: (context) => Stack(
alignment: Alignment.center,
children: const [
Icon(Icons.cancel_rounded),
SizedBox(
height: 32,
width: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
],
),
action: listCancelInProgress ? null : onCancel,
);
}
DownloadAction download() {
return DownloadAction(
type: DownloadActionType.download,
iconBuilder: (context) => const Icon(Icons.download_rounded),
action: !offline ? onDownload : null,
);
}
if (status.total == status.downloaded) {
return [delete()];
} else if (status.downloading == 0 && status.downloaded > 0) {
return [download(), delete()];
} else if (listDownloadInProgress || status.downloading > 0) {
return [cancel()];
} else {
return [download()];
}
}

View File

@@ -1,91 +0,0 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../models/query.dart';
import '../../services/sync_service.dart';
import '../../state/settings.dart';
import '../pages/library_page.dart';
import 'use_paging_controller.dart';
PagingController<int, T> useLibraryPagingController<T>(
WidgetRef ref, {
required int libraryTabIndex,
required FutureOr<List<T>> Function(ListQuery query) getItems,
}) {
final queryProvider = libraryListQueryProvider(libraryTabIndex).select(
(value) => value.query,
);
final query = useState(ref.read(queryProvider));
final onPageRequest = useCallback(
(int pageKey, PagingController<int, T> pagingController) =>
_pageRequest(getItems, query.value, pageKey, pagingController),
[query.value],
);
final pagingController = usePagingController<int, T>(
firstPageKey: query.value.page.offset,
onPageRequest: onPageRequest,
);
ref.listen(queryProvider, (_, next) {
query.value = next;
pagingController.refresh();
});
ref.listen(syncServiceProvider, (_, __) => pagingController.refresh());
ref.listen(sourceIdProvider, (_, __) => pagingController.refresh());
ref.listen(offlineModeProvider, (_, __) => pagingController.refresh());
return pagingController;
}
PagingController<int, T> useListQueryPagingController<T>(
WidgetRef ref, {
required ListQuery query,
required FutureOr<List<T>> Function(ListQuery query) getItems,
}) {
final onPageRequest = useCallback(
(int pageKey, PagingController<int, T> pagingController) =>
_pageRequest(getItems, query, pageKey, pagingController),
[query],
);
final pagingController = usePagingController<int, T>(
firstPageKey: query.page.offset,
onPageRequest: onPageRequest,
);
return pagingController;
}
Future<void> _pageRequest<T>(
FutureOr<List<T>> Function(ListQuery query) getItems,
ListQuery query,
int pageKey,
PagingController<int, dynamic> pagingController,
) async {
try {
final newItems = await getItems(query.copyWith.page(offset: pageKey));
final isFirstPage = newItems.isNotEmpty && pageKey == 0;
final alreadyHasItems = pagingController.itemList != null &&
pagingController.itemList!.isNotEmpty;
if (isFirstPage && alreadyHasItems) {
return;
}
final isLastPage = newItems.length < query.page.limit;
if (isLastPage) {
pagingController.appendLastPage(newItems);
} else {
final nextPageKey = pageKey + newItems.length;
pagingController.appendPage(newItems, nextPageKey);
}
} catch (error) {
pagingController.error = error;
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../state/services.dart';
import '../state/source.dart';
void useOnSourceChange(WidgetRef ref, void Function(int sourceId) callback) {
final sourceId = ref.watch(sourceIdProvider);
useEffect(() {
callback(sourceId);
return;
}, [sourceId]);
}
void useOnSourceSync(WidgetRef ref, void Function() callback) {
final syncService = ref.watch(syncServiceProvider);
useOnListenableChange(syncService, () {
callback();
});
}

View File

@@ -5,54 +5,49 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
PagingController<PageKeyType, ItemType> PagingController<PageKeyType, ItemType>
usePagingController<PageKeyType, ItemType>({ usePagingController<PageKeyType, ItemType>({
required final PageKeyType firstPageKey, required PageKeyType? Function(PagingState<PageKeyType, ItemType>)
final int? invisibleItemsThreshold, getNextPageKey,
List<Object?>? keys, required FutureOr<List<ItemType>> Function(PageKeyType) fetchPage,
FutureOr<void> Function(PageKeyType pageKey,
PagingController<PageKeyType, ItemType> pagingController)?
onPageRequest,
}) { }) {
final controller = use( return use(
_PagingControllerHook<PageKeyType, ItemType>( _PagingControllerHook<PageKeyType, ItemType>(
firstPageKey: firstPageKey, getNextPageKey: getNextPageKey,
invisibleItemsThreshold: invisibleItemsThreshold, fetchPage: fetchPage,
keys: keys,
), ),
); );
useEffect(() {
listener(PageKeyType pageKey) => onPageRequest?.call(pageKey, controller);
controller.addPageRequestListener(listener);
return () => controller.removePageRequestListener(listener);
}, [onPageRequest]);
return controller;
} }
class _PagingControllerHook<PageKeyType, ItemType> class _PagingControllerHook<PageKeyType, ItemType>
extends Hook<PagingController<PageKeyType, ItemType>> { extends Hook<PagingController<PageKeyType, ItemType>> {
const _PagingControllerHook({ const _PagingControllerHook({
required this.firstPageKey, super.keys,
this.invisibleItemsThreshold, required this.getNextPageKey,
List<Object?>? keys, required this.fetchPage,
}) : super(keys: keys); });
final PageKeyType firstPageKey; final PageKeyType? Function(PagingState<PageKeyType, ItemType>)
final int? invisibleItemsThreshold; getNextPageKey;
final FutureOr<List<ItemType>> Function(PageKeyType) fetchPage;
@override @override
HookState<PagingController<PageKeyType, ItemType>, HookState<
Hook<PagingController<PageKeyType, ItemType>>> PagingController<PageKeyType, ItemType>,
Hook<PagingController<PageKeyType, ItemType>>
>
createState() => _PagingControllerHookState<PageKeyType, ItemType>(); createState() => _PagingControllerHookState<PageKeyType, ItemType>();
} }
class _PagingControllerHookState<PageKeyType, ItemType> extends HookState< class _PagingControllerHookState<PageKeyType, ItemType>
extends
HookState<
PagingController<PageKeyType, ItemType>, PagingController<PageKeyType, ItemType>,
_PagingControllerHook<PageKeyType, ItemType>> { _PagingControllerHook<PageKeyType, ItemType>
> {
late final controller = PagingController<PageKeyType, ItemType>( late final controller = PagingController<PageKeyType, ItemType>(
firstPageKey: hook.firstPageKey, getNextPageKey: hook.getNextPageKey,
invisibleItemsThreshold: hook.invisibleItemsThreshold); fetchPage: hook.fetchPage,
);
@override @override
PagingController<PageKeyType, ItemType> build(BuildContext context) => PagingController<PageKeyType, ItemType> build(BuildContext context) =>

View File

@@ -1,368 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/music.dart';
import '../models/support.dart';
import '../services/cache_service.dart';
import '../state/music.dart';
import '../state/settings.dart';
import '../state/theme.dart';
part 'images.g.dart';
@riverpod
CacheInfo _artistArtCacheInfo(
_ArtistArtCacheInfoRef ref, {
required String artistId,
bool thumbnail = true,
}) {
final cache = ref.watch(cacheServiceProvider);
return cache.artistArtCacheInfo(artistId, thumbnail: thumbnail);
}
@riverpod
FutureOr<String?> _artistArtCachedUrl(
_ArtistArtCachedUrlRef ref, {
required String artistId,
bool thumbnail = true,
}) async {
final cache = ref.watch(_artistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
final file = await cache.cacheManager.getFileFromCache(cache.cacheKey);
return file?.originalUrl;
}
@riverpod
FutureOr<UriCacheInfo> _artistArtUriCacheInfo(
_ArtistArtUriCacheInfoRef ref, {
required String artistId,
bool thumbnail = true,
}) async {
final cache = ref.watch(cacheServiceProvider);
final info = ref.watch(_artistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
final cachedUrl = await ref.watch(_artistArtCachedUrlProvider(
artistId: artistId,
thumbnail: thumbnail,
).future);
final offline = ref.watch(offlineModeProvider);
// already cached, don't try to get the real url again
if (cachedUrl != null) {
return UriCacheInfo(
uri: Uri.parse(cachedUrl),
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
}
if (offline) {
final file = await cache.imageCache.getFileFromCache(info.cacheKey);
if (file != null) {
return UriCacheInfo(
uri: Uri.parse(file.originalUrl),
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
} else {
return cache.placeholder(thumbnail: thumbnail);
}
}
// assume the url is good or let this fail
return UriCacheInfo(
uri: (await cache.artistArtUri(artistId, thumbnail: thumbnail))!,
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
}
class ArtistArtImage extends HookConsumerWidget {
final String artistId;
final bool thumbnail;
final BoxFit fit;
final PlaceholderStyle placeholderStyle;
final double? height;
final double? width;
const ArtistArtImage({
super.key,
required this.artistId,
this.thumbnail = true,
this.fit = BoxFit.cover,
this.placeholderStyle = PlaceholderStyle.color,
this.height,
this.width,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(_artistArtUriCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
// TODO: figure out how to animate this without messing up with boxfit/ratio
return cache.when(
data: (data) => UriCacheInfoImage(
cache: data,
fit: fit,
placeholderStyle: placeholderStyle,
height: height,
width: width,
),
error: (_, __) => Container(
color: Colors.red,
height: height,
width: width,
),
loading: () => Container(
color: Theme.of(context).colorScheme.secondaryContainer,
height: height,
width: width,
),
);
}
}
class SongAlbumArt extends HookConsumerWidget {
final Song song;
final bool square;
const SongAlbumArt({
super.key,
required this.song,
this.square = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(song.albumId!)).valueOrNull;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: album != null ? AlbumArt(album: album) : const PlaceholderImage(),
);
}
}
class AlbumArt extends HookConsumerWidget {
final Album album;
final bool square;
const AlbumArt({
super.key,
required this.album,
this.square = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(albumArtPaletteProvider(album.id));
final cache = ref.watch(cacheServiceProvider);
Widget image = UriCacheInfoImage(cache: cache.albumArt(album));
if (square) {
image = AspectRatio(aspectRatio: 1.0, child: image);
}
return CardClip(child: image);
}
}
class CircleClip extends StatelessWidget {
final Widget child;
const CircleClip({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return ClipOval(
clipBehavior: Clip.antiAlias,
child: AspectRatio(
aspectRatio: 1.0,
child: child,
),
);
}
}
class CardClip extends StatelessWidget {
final Widget child;
final bool square;
const CardClip({
super.key,
required this.child,
this.square = true,
});
@override
Widget build(BuildContext context) {
final cardShape = Theme.of(context).cardTheme.shape;
return ClipRRect(
borderRadius:
cardShape is RoundedRectangleBorder ? cardShape.borderRadius : null,
child: !square
? child
: AspectRatio(
aspectRatio: 1.0,
child: child,
),
);
}
}
enum PlaceholderStyle {
color,
spinner,
}
class UriCacheInfoImage extends StatelessWidget {
final UriCacheInfo cache;
final BoxFit fit;
final PlaceholderStyle placeholderStyle;
final double? height;
final double? width;
const UriCacheInfoImage({
super.key,
required this.cache,
this.fit = BoxFit.cover,
this.placeholderStyle = PlaceholderStyle.color,
this.height,
this.width,
});
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: cache.uri.toString(),
cacheKey: cache.cacheKey,
cacheManager: cache.cacheManager,
fit: fit,
height: height,
width: width,
fadeInDuration: const Duration(milliseconds: 300),
fadeOutDuration: const Duration(milliseconds: 500),
placeholder: (context, url) =>
placeholderStyle == PlaceholderStyle.spinner
? Container()
: Container(
color: Theme.of(context).colorScheme.secondaryContainer,
),
errorWidget: (context, url, error) => PlaceholderImage(
fit: fit,
height: height,
width: width,
),
);
}
}
class PlaceholderImage extends HookConsumerWidget {
final BoxFit fit;
final double? height;
final double? width;
final bool thumbnail;
const PlaceholderImage({
super.key,
this.fit = BoxFit.cover,
this.height,
this.width,
this.thumbnail = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Image.asset(
thumbnail ? 'assets/placeholder_thumb.png' : 'assets/placeholder.png',
fit: fit,
height: height,
width: width,
);
}
}
class _ExpandedRatio extends StatelessWidget {
final Widget child;
final double aspectRatio;
const _ExpandedRatio({
required this.child,
this.aspectRatio = 1.0,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: AspectRatio(
aspectRatio: aspectRatio,
child: child,
),
);
}
}
class MultiImage extends HookConsumerWidget {
final Iterable<UriCacheInfo> cacheInfo;
const MultiImage({
super.key,
required this.cacheInfo,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final images = cacheInfo.map((cache) => UriCacheInfoImage(cache: cache));
final row1 = <Widget>[];
final row2 = <Widget>[];
if (images.length >= 4) {
row1.addAll([
_ExpandedRatio(child: images.elementAt(0)),
_ExpandedRatio(child: images.elementAt(1)),
]);
row2.addAll([
_ExpandedRatio(child: images.elementAt(2)),
_ExpandedRatio(child: images.elementAt(3)),
]);
}
if (images.length == 3) {
row1.addAll([
_ExpandedRatio(child: images.elementAt(0)),
_ExpandedRatio(child: images.elementAt(1)),
]);
row2.addAll([
_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(2)),
]);
}
if (images.length == 2) {
row1.add(_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(0)));
row2.add(_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(1)));
}
if (images.length == 1) {
row1.addAll([_ExpandedRatio(child: images.elementAt(0))]);
}
return Column(
children: [
Row(children: row1),
Row(children: row2),
],
);
}
}

View File

@@ -1,307 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'images.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$artistArtCacheInfoHash() =>
r'f82d3e91aa1596939e376c6a7ea7d3e974c6f0fc';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef _ArtistArtCacheInfoRef = AutoDisposeProviderRef<CacheInfo>;
/// See also [_artistArtCacheInfo].
@ProviderFor(_artistArtCacheInfo)
const _artistArtCacheInfoProvider = _ArtistArtCacheInfoFamily();
/// See also [_artistArtCacheInfo].
class _ArtistArtCacheInfoFamily extends Family<CacheInfo> {
/// See also [_artistArtCacheInfo].
const _ArtistArtCacheInfoFamily();
/// See also [_artistArtCacheInfo].
_ArtistArtCacheInfoProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtCacheInfoProvider getProviderOverride(
covariant _ArtistArtCacheInfoProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'_artistArtCacheInfoProvider';
}
/// See also [_artistArtCacheInfo].
class _ArtistArtCacheInfoProvider extends AutoDisposeProvider<CacheInfo> {
/// See also [_artistArtCacheInfo].
_ArtistArtCacheInfoProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtCacheInfo(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtCacheInfoProvider,
name: r'_artistArtCacheInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtCacheInfoHash,
dependencies: _ArtistArtCacheInfoFamily._dependencies,
allTransitiveDependencies:
_ArtistArtCacheInfoFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtCacheInfoProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
String _$artistArtCachedUrlHash() =>
r'2a5e0fea614ff12a1d562faccec6cfe98394af42';
typedef _ArtistArtCachedUrlRef = AutoDisposeFutureProviderRef<String?>;
/// See also [_artistArtCachedUrl].
@ProviderFor(_artistArtCachedUrl)
const _artistArtCachedUrlProvider = _ArtistArtCachedUrlFamily();
/// See also [_artistArtCachedUrl].
class _ArtistArtCachedUrlFamily extends Family<AsyncValue<String?>> {
/// See also [_artistArtCachedUrl].
const _ArtistArtCachedUrlFamily();
/// See also [_artistArtCachedUrl].
_ArtistArtCachedUrlProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtCachedUrlProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtCachedUrlProvider getProviderOverride(
covariant _ArtistArtCachedUrlProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'_artistArtCachedUrlProvider';
}
/// See also [_artistArtCachedUrl].
class _ArtistArtCachedUrlProvider extends AutoDisposeFutureProvider<String?> {
/// See also [_artistArtCachedUrl].
_ArtistArtCachedUrlProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtCachedUrl(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtCachedUrlProvider,
name: r'_artistArtCachedUrlProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtCachedUrlHash,
dependencies: _ArtistArtCachedUrlFamily._dependencies,
allTransitiveDependencies:
_ArtistArtCachedUrlFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtCachedUrlProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
String _$artistArtUriCacheInfoHash() =>
r'9bdc0f5654882265236ef746ea697a6d107a4b6f';
typedef _ArtistArtUriCacheInfoRef = AutoDisposeFutureProviderRef<UriCacheInfo>;
/// See also [_artistArtUriCacheInfo].
@ProviderFor(_artistArtUriCacheInfo)
const _artistArtUriCacheInfoProvider = _ArtistArtUriCacheInfoFamily();
/// See also [_artistArtUriCacheInfo].
class _ArtistArtUriCacheInfoFamily extends Family<AsyncValue<UriCacheInfo>> {
/// See also [_artistArtUriCacheInfo].
const _ArtistArtUriCacheInfoFamily();
/// See also [_artistArtUriCacheInfo].
_ArtistArtUriCacheInfoProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtUriCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtUriCacheInfoProvider getProviderOverride(
covariant _ArtistArtUriCacheInfoProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'_artistArtUriCacheInfoProvider';
}
/// See also [_artistArtUriCacheInfo].
class _ArtistArtUriCacheInfoProvider
extends AutoDisposeFutureProvider<UriCacheInfo> {
/// See also [_artistArtUriCacheInfo].
_ArtistArtUriCacheInfoProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtUriCacheInfo(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtUriCacheInfoProvider,
name: r'_artistArtUriCacheInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtUriCacheInfoHash,
dependencies: _ArtistArtUriCacheInfoFamily._dependencies,
allTransitiveDependencies:
_ArtistArtUriCacheInfoFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtUriCacheInfoProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -1,433 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/music.dart';
import '../services/cache_service.dart';
import '../services/download_service.dart';
import '../state/audio.dart';
import '../state/music.dart';
import '../state/theme.dart';
import 'context_menus.dart';
import 'images.dart';
import 'pages/songs_page.dart';
enum CardStyle {
imageOnly,
withText,
}
enum AlbumSubtitle {
artist,
year,
}
class AlbumCard extends HookConsumerWidget {
final Album album;
final void Function()? onTap;
final CardStyle style;
final AlbumSubtitle subtitle;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.style = CardStyle.withText,
this.subtitle = AlbumSubtitle.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(albumArtPaletteProvider(album.id));
final cache = ref.watch(cacheServiceProvider);
final info = cache.albumArt(album);
final image = CardClip(child: UriCacheInfoImage(cache: info));
Widget content;
if (style == CardStyle.imageOnly) {
content = image;
} else {
content = Column(
children: [
image,
_AlbumCardText(album: album, subtitle: subtitle),
],
);
}
return ImageCard(
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: AlbumContextMenu(album: album),
),
);
},
child: content,
);
}
}
class ImageCard extends StatelessWidget {
final Widget child;
final void Function()? onTap;
final void Function()? onLongPress;
const ImageCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return Card(
surfaceTintColor: Colors.transparent,
margin: const EdgeInsets.all(0),
child: Stack(
fit: StackFit.passthrough,
alignment: Alignment.bottomCenter,
children: [
child,
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
),
],
),
);
}
}
class _AlbumCardText extends StatelessWidget {
final Album album;
final AlbumSubtitle subtitle;
const _AlbumCardText({
required this.album,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.only(top: 4, bottom: 8),
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: Text(
album.name,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
(subtitle == AlbumSubtitle.artist
? album.albumArtist
: album.year?.toString()) ??
'',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: textTheme.bodySmall,
),
],
),
);
}
}
class AlbumListTile extends HookConsumerWidget {
final Album album;
final void Function()? onTap;
const AlbumListTile({
super.key,
required this.album,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final artist = ref.watch(albumProvider(album.artistId!)).valueOrNull;
return ListTile(
leading: AlbumArt(album: album),
title: Text(album.name),
subtitle: Text(album.albumArtist ?? artist!.name),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: AlbumContextMenu(album: album),
),
);
},
);
}
}
class ArtistListTile extends HookConsumerWidget {
final Artist artist;
final void Function()? onTap;
const ArtistListTile({
super.key,
required this.artist,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: CircleClip(
child: ArtistArtImage(artistId: artist.id),
),
title: Text(artist.name),
subtitle: Text(AppLocalizations.of(context).resourcesAlbumCount(
artist.albumCount,
)),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: ArtistContextMenu(artist: artist),
),
);
},
);
}
}
class PlaylistListTile extends HookConsumerWidget {
final Playlist playlist;
final void Function()? onTap;
const PlaylistListTile({
super.key,
required this.playlist,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(playlistArtPaletteProvider(playlist.id));
final cache = ref.watch(cacheServiceProvider).playlistArt(playlist);
return ListTile(
leading: CardClip(
child: UriCacheInfoImage(cache: cache),
),
title: Text(playlist.name),
subtitle: Text(AppLocalizations.of(context).resourcesSongCount(
playlist.songCount,
)),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: PlaylistContextMenu(playlist: playlist),
),
);
},
);
}
}
class SongListTile extends HookConsumerWidget {
final Song song;
final void Function()? onTap;
final bool image;
const SongListTile({
super.key,
required this.song,
this.onTap,
this.image = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Material(
type: MaterialType.transparency,
child: ListTile(
title: _SongTitle(song: song),
subtitle: _SongSubtitle(song: song),
leading: image ? SongAlbumArt(song: song) : null,
trailing: IconButton(
icon: const Icon(
Icons.star_outline_rounded,
size: 36,
),
onPressed: () {},
),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: SongContextMenu(song: song),
),
);
},
),
);
}
}
class _SongSubtitle extends HookConsumerWidget {
final Song song;
const _SongSubtitle({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadTaskId = ref.watch(songProvider(song.id).select(
(value) => value.valueOrNull?.downloadTaskId,
));
final downloadFilePath = ref.watch(songProvider(song.id).select(
(value) => value.valueOrNull?.downloadFilePath,
));
final download = ref.watch(downloadServiceProvider.select(
(value) => value.downloads.firstWhereOrNull(
(e) => e.taskId == downloadTaskId,
),
));
final inheritedStyle = DefaultTextStyle.of(context).style;
Widget? downloadIndicator;
if (downloadFilePath != null) {
downloadIndicator = const Padding(
padding: EdgeInsetsDirectional.only(end: 3),
child: Icon(
Icons.download_done_rounded,
size: 20,
),
);
} else if (downloadTaskId != null || download != null) {
downloadIndicator = Padding(
padding: const EdgeInsetsDirectional.only(start: 4, end: 9),
child: SizedBox(
height: 10,
width: 10,
child: CircularProgressIndicator(
strokeWidth: 2,
value: download != null && download.progress > 0
? download.progress / 100
: null,
),
),
);
}
return Row(
children: [
if (downloadIndicator != null) downloadIndicator,
Expanded(
child: Text(
song.artist ?? song.album ?? '',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: TextStyle(
color: inheritedStyle.color,
),
),
),
],
);
}
}
class _SongTitle extends HookConsumerWidget {
final Song song;
const _SongTitle({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaItem = ref.watch(mediaItemProvider).valueOrNull;
final mediaItemData = ref.watch(mediaItemDataProvider);
final inheritedStyle = DefaultTextStyle.of(context).style;
final theme = Theme.of(context);
final queueContext = QueueContext.maybeOf(context);
final playing = mediaItem != null &&
mediaItemData != null &&
mediaItem.id == song.id &&
mediaItemData.contextId == queueContext?.id &&
mediaItemData.contextType == queueContext?.type;
return Row(
children: [
if (playing)
Padding(
padding: const EdgeInsetsDirectional.only(end: 2),
child: Icon(
Icons.play_arrow_rounded,
size: 18,
color: theme.colorScheme.primary,
),
),
Expanded(
child: Text(
song.title,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: TextStyle(
color: playing ? theme.colorScheme.primary : inheritedStyle.color,
),
),
),
],
);
}
}
class FabPadding extends StatelessWidget {
const FabPadding({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(height: 86);
}
}

View File

@@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../services/sync_service.dart';
import 'items.dart';
class PagedListQueryView<T> extends HookConsumerWidget {
final PagingController<int, T> pagingController;
final bool refreshSyncAll;
final bool fabPadding;
final bool useSliver;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
const PagedListQueryView({
super.key,
required this.pagingController,
this.refreshSyncAll = false,
this.fabPadding = true,
this.useSliver = false,
required this.itemBuilder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final builderDelegate = PagedChildBuilderDelegate<T>(
itemBuilder: (context, item, index) => itemBuilder(context, item, index),
noMoreItemsIndicatorBuilder:
fabPadding ? (context) => const FabPadding() : null,
);
final listView = useSliver
? PagedSliverList<int, T>(
pagingController: pagingController,
builderDelegate: builderDelegate,
)
: PagedListView<int, T>(
pagingController: pagingController,
builderDelegate: builderDelegate,
);
if (refreshSyncAll) {
return SyncAllRefresh(child: listView);
} else {
return listView;
}
}
}
enum GridSize {
small,
large,
}
class PagedGridQueryView<T> extends HookConsumerWidget {
final PagingController<int, T> pagingController;
final bool refreshSyncAll;
final bool fabPadding;
final GridSize size;
final Widget Function(BuildContext context, T item, int index, GridSize size)
itemBuilder;
const PagedGridQueryView({
super.key,
required this.pagingController,
this.refreshSyncAll = false,
this.fabPadding = true,
this.size = GridSize.small,
required this.itemBuilder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
SliverGridDelegate gridDelegate;
double spacing;
if (size == GridSize.small) {
spacing = 4;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
);
} else {
spacing = 12;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
);
}
final listView = PagedGridView<int, T>(
padding: MediaQuery.of(context).padding + EdgeInsets.all(spacing),
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) =>
itemBuilder(context, item, index, size),
noMoreItemsIndicatorBuilder:
fabPadding ? (context) => const FabPadding() : null,
),
gridDelegate: gridDelegate,
showNoMoreItemsIndicatorAsGridChild: false,
);
if (refreshSyncAll) {
return SyncAllRefresh(child: listView);
} else {
return listView;
}
}
}
class SyncAllRefresh extends HookConsumerWidget {
final Widget child;
const SyncAllRefresh({
super.key,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return RefreshIndicator(
onRefresh: () => ref.read(syncServiceProvider.notifier).syncAll(),
child: child,
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../sources/models.dart';
import '../hooks/use_on_source.dart';
import '../hooks/use_paging_controller.dart';
import '../state/database.dart';
import 'list_items.dart';
const kPageSize = 60;
class AlbumsGrid extends HookConsumerWidget {
const AlbumsGrid({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final controller = usePagingController<int, Album>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listAlbums(
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: PagedSliverGrid(
state: state,
fetchNextPage: fetchNextPage,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
builderDelegate: PagedChildBuilderDelegate<Album>(
itemBuilder: (context, item, index) => AlbumGridTile(
album: item,
onTap: () async {
context.push('/album/${item.id}');
},
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../database/dao/library_dao.dart';
import '../hooks/use_on_source.dart';
import '../hooks/use_paging_controller.dart';
import '../state/database.dart';
import 'list_items.dart';
const kPageSize = 30;
class ArtistsList extends HookConsumerWidget {
const ArtistsList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final controller = usePagingController<int, AristListItem>(
getNextPageKey: (state) =>
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listArtists(
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
return PagingListener(
controller: controller,
builder: (context, state, fetchNextPage) {
return PagedSliverList(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<AristListItem>(
itemBuilder: (context, item, index) {
final (:artist, :albumCount) = item;
return ArtistListTile(
artist: artist,
albumCount: albumCount,
onTap: () async {
context.push('/artist/${artist.id}');
},
);
},
),
);
},
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../images/images.dart';
import '../../sources/models.dart';
import '../util/clip.dart';
class AlbumGridTile extends HookConsumerWidget {
const AlbumGridTile({
super.key,
required this.album,
this.onTap,
});
final Album album;
final void Function()? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
return CardTheme(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadiusGeometry.circular(3),
),
margin: EdgeInsets.all(2),
child: ImageCard(
onTap: onTap,
child: CoverArtImage(coverArt: album.coverArt),
),
);
}
}
class ArtistListTile extends StatelessWidget {
const ArtistListTile({
super.key,
required this.artist,
this.albumCount,
this.onTap,
});
final Artist artist;
final int? albumCount;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleClip(
child: CoverArtImage(coverArt: artist.coverArt),
),
title: Text(artist.name),
subtitle: albumCount != null ? Text('$albumCount albums') : null,
onTap: onTap,
);
}
}
class ImageCard extends StatelessWidget {
const ImageCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
});
final Widget child;
final void Function()? onTap;
final void Function()? onLongPress;
@override
Widget build(BuildContext context) {
return Card(
child: Stack(
fit: StackFit.passthrough,
alignment: Alignment.center,
children: [
child,
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
),
],
),
);
}
}

View File

@@ -1,226 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../cache/image_cache.dart';
import '../models/support.dart';
import '../services/audio_service.dart';
import '../state/audio.dart';
import '../state/theme.dart';
import 'app_router.dart';
import 'images.dart';
import 'pages/now_playing_page.dart';
class NowPlayingBar extends HookConsumerWidget {
const NowPlayingBar({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final noItem = ref.watch(mediaItemProvider).valueOrNull == null;
final widget = GestureDetector(
onTap: () {
context.navigateTo(const NowPlayingRoute());
},
child: Material(
elevation: 3,
color: colors?.darkBackground,
// surfaceTintColor: theme?.colorScheme.background,
child: Column(
children: [
SizedBox(
height: 70,
child: Row(
mainAxisSize: MainAxisSize.max,
children: const [
Padding(
padding: EdgeInsets.all(10),
child: _ArtImage(),
),
Expanded(
child: Padding(
padding: EdgeInsets.only(right: 4),
child: _TrackInfo(),
),
),
Padding(
padding: EdgeInsets.only(right: 16, top: 2),
child: PlayPauseButton(size: 48),
),
],
),
),
const _ProgressBar(),
],
),
),
);
if (noItem) {
return Container();
}
if (colors != null) {
return Theme(data: colors.theme, child: widget);
} else {
return widget;
}
}
}
class _ArtImage extends HookConsumerWidget {
const _ArtImage();
@override
Widget build(BuildContext context, WidgetRef ref) {
final imageCache = ref.watch(imageCacheProvider);
final uri =
ref.watch(mediaItemProvider.select((e) => e.valueOrNull?.artUri));
final cacheKey = ref.watch(mediaItemDataProvider.select(
(value) => value?.artCache?.thumbnailArtCacheKey,
));
UriCacheInfo? cache;
if (uri != null && cacheKey != null) {
cache = UriCacheInfo(
uri: uri,
cacheKey: cacheKey,
cacheManager: imageCache,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: CardClip(
key: ValueKey(cacheKey ?? 'default'),
child: cache == null
? const PlaceholderImage()
: UriCacheInfoImage(
cache: cache,
),
),
);
}
}
class _TrackInfo extends HookConsumerWidget {
const _TrackInfo();
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(mediaItemProvider);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.when(
data: (data) => [
// Text(
// data?.title ?? 'Nothing!!!',
// maxLines: 1,
// softWrap: false,
// overflow: TextOverflow.fade,
// style: Theme.of(context).textTheme.labelLarge,
// ),
ScrollableText(
data?.title ?? 'Nothing!!!',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 2),
Text(
data?.artist ?? 'Nothing!!!',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.labelMedium,
),
],
error: (_, __) => const [Text('Error!')],
loading: () => const [Text('loading.....')],
),
],
);
}
}
class PlayPauseButton extends HookConsumerWidget {
final double size;
const PlayPauseButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playing = ref.watch(playingProvider);
final state = ref.watch(processingStateProvider);
Widget icon;
if (state == AudioProcessingState.loading ||
state == AudioProcessingState.buffering) {
icon = Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.circle),
SizedBox(
height: size / 3,
width: size / 3,
child: CircularProgressIndicator(
strokeWidth: size / 16,
color: Theme.of(context).colorScheme.background,
),
),
],
);
} else if (playing) {
icon = const Icon(Icons.pause_circle_rounded);
} else {
icon = const Icon(Icons.play_circle_rounded);
}
return IconButton(
iconSize: size,
padding: EdgeInsets.zero,
onPressed: () {
if (playing) {
ref.read(audioControlProvider).pause();
} else {
ref.read(audioControlProvider).play();
}
},
icon: icon,
color: Theme.of(context).colorScheme.onBackground,
);
}
}
class _ProgressBar extends HookConsumerWidget {
const _ProgressBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider);
return Container(
height: 4,
color: colors?.darkerBackground,
child: Row(
children: [
Flexible(
flex: position,
child: Container(color: colors?.onDarkerBackground),
),
Flexible(flex: duration - position, child: Container()),
],
),
);
}
}

View File

@@ -1,126 +0,0 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../images.dart';
import '../items.dart';
class ArtistPage extends HookConsumerWidget {
final String id;
const ArtistPage({
super.key,
@pathParam required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(sourceIdProvider, (_, __) => context.router.popUntilRoot());
final artist = ref.watch(artistProvider(id));
final albums = ref.watch(albumsByArtistIdProvider(id));
return Scaffold(
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Stack(
alignment: Alignment.bottomCenter,
fit: StackFit.passthrough,
children: [
ArtistArtImage(
artistId: id,
thumbnail: false,
height: 400,
),
Padding(
padding: const EdgeInsets.all(12),
child: _Title(text: artist.valueOrNull?.name ?? ''),
),
],
),
),
albums.when(
data: (albums) {
albums = albums.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
return SliverPadding(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
sliver: SliverAlignedGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 24,
itemCount: albums.length,
itemBuilder: (context, i) {
final album = albums.elementAt(i);
return AlbumCard(
album: album,
subtitle: AlbumSubtitle.year,
onTap: () => context.navigateTo(AlbumSongsRoute(
id: album.id,
)),
);
},
),
);
},
error: (_, __) => SliverToBoxAdapter(
child: Container(color: Colors.red),
),
loading: () => const SliverToBoxAdapter(
child: CircularProgressIndicator(),
),
),
],
),
);
}
}
class _Title extends StatelessWidget {
final String text;
const _Title({
required this.text,
});
@override
Widget build(BuildContext context) {
return Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayMedium!.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: Offset.fromDirection(pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
Shadow(
offset: Offset.fromDirection(3 * pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
Shadow(
offset: Offset.fromDirection(5 * pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
Shadow(
offset: Offset.fromDirection(7 * pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
],
),
);
}
}

View File

@@ -1,216 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../services/settings_service.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../now_playing_bar.dart';
part 'bottom_nav_page.g.dart';
@Riverpod(keepAlive: true)
TabObserver bottomTabObserver(BottomTabObserverRef ref) {
return TabObserver();
}
@Riverpod(keepAlive: true)
Stream<String> bottomTabPath(BottomTabPathRef ref) async* {
final observer = ref.watch(bottomTabObserverProvider);
await for (var tab in observer.path) {
yield tab;
}
}
@Riverpod(keepAlive: true)
class LastBottomNavStateService extends _$LastBottomNavStateService {
@override
Future<void> build() async {
final db = ref.watch(databaseProvider);
final tab = ref.watch(bottomTabPathProvider).valueOrNull;
if (tab == null || tab == 'settings' || tab == 'search') {
return;
}
await db.saveLastBottomNavState(LastBottomNavStateData(id: 1, tab: tab));
}
}
class BottomNavTabsPage extends HookConsumerWidget {
const BottomNavTabsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final observer = ref.watch(bottomTabObserverProvider);
const navElevation = 3.0;
return AutoTabsRouter(
lazyLoad: false,
inheritNavigatorObservers: false,
navigatorObservers: () => [observer],
routes: const [
LibraryRouter(),
BrowseRouter(),
SearchRouter(),
SettingsRouter(),
],
builder: (context, child, animation) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
systemNavigationBarColor: ElevationOverlay.applySurfaceTint(
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceTint,
navElevation,
),
statusBarColor: Colors.transparent,
),
child: Scaffold(
body: Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
FadeTransition(
opacity: animation,
child: child,
),
const OfflineIndicator(),
],
),
bottomNavigationBar: const _BottomNavBar(
navElevation: navElevation,
),
),
);
},
);
}
}
class OfflineIndicator extends HookConsumerWidget {
const OfflineIndicator({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offline = ref.watch(offlineModeProvider);
final testing = useState(false);
if (!offline) {
return Container();
}
return Padding(
padding: const EdgeInsetsDirectional.only(
start: 20,
bottom: 20,
),
child: FilledButton.tonal(
style: const ButtonStyle(
padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsets.zero,
),
fixedSize: MaterialStatePropertyAll<Size>(
Size(42, 42),
),
minimumSize: MaterialStatePropertyAll<Size>(
Size(42, 42),
),
),
onPressed: () async {
testing.value = true;
await ref.read(offlineModeProvider.notifier).setMode(false);
testing.value = false;
},
child: testing.value
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
: const Padding(
padding: EdgeInsets.only(left: 2, bottom: 2),
child: Icon(
Icons.cloud_off_rounded,
// color: Theme.of(context).colorScheme.secondary,
size: 20,
),
),
),
);
}
}
class _BottomNavBar extends HookConsumerWidget {
final double navElevation;
const _BottomNavBar({
required this.navElevation,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabsRouter = AutoTabsRouter.of(context);
useListenableSelector(tabsRouter, () => tabsRouter.activeIndex);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const NowPlayingBar(),
NavigationBar(
elevation: navElevation,
height: 50,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) {
// TODO: replace this with a proper first-time setup flow
final hasActiveSource = ref.read(settingsServiceProvider.select(
(value) => value.activeSource != null,
));
if (!hasActiveSource) {
tabsRouter.setActiveIndex(3);
} else {
tabsRouter.setActiveIndex(index);
}
},
destinations: [
const NavigationDestination(
icon: Icon(Icons.music_note),
label: 'Library',
),
NavigationDestination(
icon: Builder(builder: (context) {
return SvgPicture.asset(
'assets/tag_FILL0_wght400_GRAD0_opsz24.svg',
colorFilter: ColorFilter.mode(
IconTheme.of(context).color!.withOpacity(
IconTheme.of(context).opacity ?? 1,
),
BlendMode.srcIn,
),
height: 28,
);
}),
label: 'Browse',
),
const NavigationDestination(
icon: Icon(Icons.search_rounded),
label: 'Search',
),
const NavigationDestination(
icon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
],
);
}
}

View File

@@ -1,56 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bottom_nav_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$bottomTabObserverHash() => r'e10c0b870f9b9052ad85fea4342569932edfeefb';
/// See also [bottomTabObserver].
@ProviderFor(bottomTabObserver)
final bottomTabObserverProvider = Provider<TabObserver>.internal(
bottomTabObserver,
name: r'bottomTabObserverProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bottomTabObserverHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef BottomTabObserverRef = ProviderRef<TabObserver>;
String _$bottomTabPathHash() => r'62539f7bf5b8f7e5f0531f564e634228ba1506bf';
/// See also [bottomTabPath].
@ProviderFor(bottomTabPath)
final bottomTabPathProvider = StreamProvider<String>.internal(
bottomTabPath,
name: r'bottomTabPathProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bottomTabPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef BottomTabPathRef = StreamProviderRef<String>;
String _$lastBottomNavStateServiceHash() =>
r'487cb94cbb70884642c05a72524eb6fd7a4d12ce';
/// See also [LastBottomNavStateService].
@ProviderFor(LastBottomNavStateService)
final lastBottomNavStateServiceProvider =
AsyncNotifierProvider<LastBottomNavStateService, void>.internal(
LastBottomNavStateService.new,
name: r'lastBottomNavStateServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$lastBottomNavStateServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastBottomNavStateService = AsyncNotifier<void>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -1,281 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sliver_tools/sliver_tools.dart';
import '../../database/database.dart';
import '../../models/music.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../services/cache_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../buttons.dart';
import '../images.dart';
import '../items.dart';
part 'browse_page.g.dart';
@riverpod
Stream<List<Album>> albumsCategoryList(
AlbumsCategoryListRef ref,
ListQuery opt,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.albumsList(sourceId, opt).watch();
}
class BrowsePage extends HookConsumerWidget {
const BrowsePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final frequent = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'frequent_rank'),
filters: IListConst([
FilterWith.isNull(column: 'frequent_rank', invert: true),
]),
)))
.valueOrNull;
final recent = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'recent_rank'),
filters: IListConst([
FilterWith.isNull(column: 'recent_rank', invert: true),
]),
)))
.valueOrNull;
final starred = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'starred'),
filters: IListConst([
FilterWith.isNull(column: 'starred', invert: true),
]),
)))
.valueOrNull;
final random = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'RANDOM()'),
)))
.valueOrNull;
final genres = ref
.watch(albumGenresProvider(const Pagination(
limit: 20,
)))
.valueOrNull;
return Scaffold(
floatingActionButton: RadioPlayFab(
onPressed: () {
ref.read(audioControlProvider).playRadio(
context: QueueContextType.library,
getSongs: (query) => ref
.read(databaseProvider)
.songsList(ref.read(sourceIdProvider), query)
.get(),
);
},
),
body: CustomScrollView(
slivers: [
const SliverSafeArea(
sliver: SliverPadding(padding: EdgeInsets.only(top: 8)),
),
_GenreCategory(
title: 'Genres',
items: genres?.toList() ?? [],
),
_AlbumCategory(
title: l.resourcesSortByFrequentlyPlayed,
items: frequent ?? [],
),
_AlbumCategory(
title: l.resourcesSortByRecentlyPlayed,
items: recent ?? [],
),
_AlbumCategory(
title: l.resourcesFilterStarred,
items: starred ?? [],
),
_AlbumCategory(
title: l.resourcesSortByRandom,
items: random ?? [],
),
const SliverToBoxAdapter(child: FabPadding()),
],
),
);
}
}
class _GenreCategory extends HookConsumerWidget {
final String title;
final List<String> items;
const _GenreCategory({
required this.title,
required this.items,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverPadding(
padding: const EdgeInsets.only(bottom: 16),
sliver: _Category(
title: title,
height: 140,
itemWidth: 140,
items: items.map((genre) => _GenreItem(genre: genre)).toList(),
),
);
}
}
class _GenreItem extends HookConsumerWidget {
final String genre;
const _GenreItem({
required this.genre,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref
.watch(albumsByGenreProvider(
genre,
const Pagination(limit: 4),
))
.valueOrNull;
final cache = ref.watch(cacheServiceProvider);
final theme = Theme.of(context);
if (albums == null) {
return Container();
}
return ImageCard(
onTap: () {
context.navigateTo(GenreSongsRoute(genre: genre));
},
child: Stack(
alignment: AlignmentDirectional.center,
children: [
CardClip(
child: MultiImage(
cacheInfo: albums.map((album) => cache.albumArt(album)),
),
),
Material(
type: MaterialType.canvas,
color: theme.colorScheme.secondaryContainer,
elevation: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
child: SizedBox(
width: double.infinity,
child: Text(
genre,
textAlign: TextAlign.center,
style: theme.textTheme.labelLarge,
),
),
),
),
],
),
);
}
}
class _AlbumCategory extends HookConsumerWidget {
final String title;
final List<Album> items;
const _AlbumCategory({
required this.title,
required this.items,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Category(
title: title,
height: 190,
itemWidth: 140,
items: items
.map(
(album) => AlbumCard(
album: album,
onTap: () => context.navigateTo(
AlbumSongsRoute(
id: album.id,
),
),
),
)
.toList(),
);
}
}
class _Category extends HookConsumerWidget {
final String title;
final List<Widget> items;
final double height;
final double itemWidth;
const _Category({
required this.title,
required this.items,
required this.height,
required this.itemWidth,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MultiSliver(
children: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
title,
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: height,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: items.length,
itemBuilder: (context, index) => SizedBox(
width: itemWidth,
child: items[index],
),
separatorBuilder: (context, index) => const SizedBox(width: 8),
),
),
),
],
);
}
}

View File

@@ -1,114 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browse_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$albumsCategoryListHash() =>
r'e0516a585bf39e8140c72c08fd41f33a817c747d';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef AlbumsCategoryListRef = AutoDisposeStreamProviderRef<List<Album>>;
/// See also [albumsCategoryList].
@ProviderFor(albumsCategoryList)
const albumsCategoryListProvider = AlbumsCategoryListFamily();
/// See also [albumsCategoryList].
class AlbumsCategoryListFamily extends Family<AsyncValue<List<Album>>> {
/// See also [albumsCategoryList].
const AlbumsCategoryListFamily();
/// See also [albumsCategoryList].
AlbumsCategoryListProvider call(
ListQuery opt,
) {
return AlbumsCategoryListProvider(
opt,
);
}
@override
AlbumsCategoryListProvider getProviderOverride(
covariant AlbumsCategoryListProvider provider,
) {
return call(
provider.opt,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'albumsCategoryListProvider';
}
/// See also [albumsCategoryList].
class AlbumsCategoryListProvider
extends AutoDisposeStreamProvider<List<Album>> {
/// See also [albumsCategoryList].
AlbumsCategoryListProvider(
this.opt,
) : super.internal(
(ref) => albumsCategoryList(
ref,
opt,
),
from: albumsCategoryListProvider,
name: r'albumsCategoryListProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$albumsCategoryListHash,
dependencies: AlbumsCategoryListFamily._dependencies,
allTransitiveDependencies:
AlbumsCategoryListFamily._allTransitiveDependencies,
);
final ListQuery opt;
@override
bool operator ==(Object other) {
return other is AlbumsCategoryListProvider && other.opt == opt;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, opt.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -1,41 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
class LibraryAlbumsPage extends HookConsumerWidget {
const LibraryAlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 0,
getItems: (query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.albumsListDownloaded(sourceId, query).get()
: db.albumsList(sourceId, query).get();
},
);
return PagedGridQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index, size) => AlbumCard(
album: item,
style:
size == GridSize.small ? CardStyle.imageOnly : CardStyle.withText,
onTap: () => context.navigateTo(AlbumSongsRoute(id: item.id)),
),
);
}
}

View File

@@ -1,39 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
class LibraryArtistsPage extends HookConsumerWidget {
const LibraryArtistsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 1,
getItems: (query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.artistsListDownloaded(sourceId, query).get()
: db.artistsList(sourceId, query).get();
},
);
return PagedListQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index) => ArtistListTile(
artist: item,
onTap: () => context.navigateTo(ArtistRoute(id: item.id)),
),
);
}
}

View File

@@ -1,635 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../models/query.dart';
import '../app_router.dart';
import '../context_menus.dart';
part 'library_page.g.dart';
@Riverpod(keepAlive: true)
TabObserver libraryTabObserver(LibraryTabObserverRef ref) {
return TabObserver();
}
@Riverpod(keepAlive: true)
Stream<String> libraryTabPath(LibraryTabPathRef ref) async* {
final observer = ref.watch(libraryTabObserverProvider);
await for (var tab in observer.path) {
yield tab;
}
}
@Riverpod(keepAlive: true)
class LastLibraryStateService extends _$LastLibraryStateService {
@override
Future<void> build() async {
final db = ref.watch(databaseProvider);
final tab = await ref.watch(libraryTabPathProvider.future);
await db.saveLastLibraryState(LastLibraryStateData(
id: 1,
tab: tab,
albumsList: ref.watch(libraryListQueryProvider(0)).query,
artistsList: ref.watch(libraryListQueryProvider(1)).query,
playlistsList: ref.watch(libraryListQueryProvider(2)).query,
songsList: ref.watch(libraryListQueryProvider(3)).query,
));
}
}
@Riverpod(keepAlive: true)
class LibraryLists extends _$LibraryLists {
@override
IList<LibraryListQuery> build() {
return const IListConst([
/// Albums
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'albums.name',
'albums.created',
'albums.album_artist',
'albums.year',
]),
filterColumns: IListConst([
'albums.starred',
'albums.album_artist',
'albums.year',
'albums.genre',
]),
),
query: ListQuery(
page: Pagination(limit: 60),
sort: SortBy(column: 'albums.name'),
),
),
/// Artists
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'artists.name',
'artists.album_count',
]),
filterColumns: IListConst([
'artists.starred',
]),
),
query: ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'artists.name'),
),
),
/// Playlists
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'playlists.name',
'playlists.created',
'playlists.changed',
]),
filterColumns: IListConst([
'playlists.owner',
]),
),
query: ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'playlists.name'),
),
),
/// Songs
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'songs.album',
'songs.artist',
'songs.created',
'songs.title',
'songs.year',
]),
filterColumns: IListConst([
'songs.starred',
'songs.artist',
'songs.album',
'songs.year',
'songs.genre',
]),
),
query: ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'songs.album'),
),
),
]);
}
Future<void> init() async {
final db = ref.read(databaseProvider);
final last = await db.getLastLibraryState().getSingleOrNull();
if (last == null) {
return;
}
state = state
.replace(0, state[0].copyWith(query: last.albumsList))
.replace(1, state[1].copyWith(query: last.artistsList))
.replace(2, state[2].copyWith(query: last.playlistsList))
.replace(3, state[3].copyWith(query: last.songsList));
}
void setSortColumn(int index, String column) {
state = state.replace(
index,
state[index].copyWith.query.sort!(column: column),
);
}
void toggleDirection(int index) {
final toggled = state[index].query.sort?.dir == SortDirection.asc
? SortDirection.desc
: SortDirection.asc;
state = state.replace(
index,
state[index].copyWith.query.sort!(dir: toggled),
);
}
void setFilter(int index, FilterWith filter) {
state = state.replace(
index,
state[index].copyWith.query(
filters: state[index].query.filters.updateById(
[filter],
(e) => e.column,
),
),
);
}
void removeFilter(int index, String column) {
state = state.replace(
index,
state[index].copyWith.query(
filters: state[index]
.query
.filters
.removeWhere((f) => f.column == column)),
);
}
void clearFilters(int index) {
state = state.replace(index, state[index].copyWith.query(filters: IList()));
}
}
@Riverpod(keepAlive: true)
LibraryListQuery libraryListQuery(LibraryListQueryRef ref, int index) {
return ref.watch(libraryListsProvider.select((value) => value[index]));
}
class LibraryTabsPage extends HookConsumerWidget {
const LibraryTabsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final observer = ref.watch(libraryTabObserverProvider);
return AutoTabsRouter.tabBar(
inheritNavigatorObservers: false,
navigatorObservers: () => [observer],
routes: const [
LibraryAlbumsRoute(),
LibraryArtistsRoute(),
LibraryPlaylistsRoute(),
LibrarySongsRoute(),
],
builder: (context, child, tabController) {
return Scaffold(
body: child,
floatingActionButton: const _LibraryFilterFab(),
);
},
);
}
}
class _LibraryFilterFab extends HookConsumerWidget {
const _LibraryFilterFab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabsRouter = AutoTabsRouter.of(context);
final activeIndex =
useListenableSelector(tabsRouter, () => tabsRouter.activeIndex);
final tabHasFilters = ref.watch(libraryListQueryProvider(activeIndex)
.select((value) => value.query.filters.isNotEmpty));
List<Widget> dot = [];
if (tabHasFilters) {
dot.addAll([
PositionedDirectional(
top: 3,
end: 0,
child: Icon(
Icons.circle,
color: Theme.of(context).colorScheme.primaryContainer,
size: 11,
),
),
const PositionedDirectional(
top: 5,
end: 1,
child: Icon(
Icons.circle,
size: 7,
),
),
]);
}
return FloatingActionButton(
heroTag: null,
onPressed: () async {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: LibraryMenu(
tabsRouter: tabsRouter,
),
),
);
},
tooltip: 'List',
child: Stack(
children: [
const Icon(
Icons.sort_rounded,
size: 28,
),
...dot,
],
),
);
}
}
class LibraryMenu extends HookConsumerWidget {
final TabsRouter tabsRouter;
const LibraryMenu({
super.key,
required this.tabsRouter,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
children: [
FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
onSelected: (value) {},
selected: true,
label: const Icon(
Icons.grid_on,
size: 20,
),
),
const SizedBox(width: 6),
FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
onSelected: (value) {},
label: const Icon(
Icons.grid_view_outlined,
size: 20,
),
),
const SizedBox(width: 6),
FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
onSelected: (value) {},
label: const Icon(
Icons.format_list_bulleted,
size: 20,
),
),
],
),
_FilterToggleButton(tabsRouter: tabsRouter),
],
),
),
),
const SliverPadding(padding: EdgeInsets.only(top: 8)),
ListSortFilterOptions(index: tabsRouter.activeIndex),
const SliverPadding(padding: EdgeInsets.only(top: 16)),
],
);
}
}
class _FilterToggleButton extends HookConsumerWidget {
final TabsRouter tabsRouter;
const _FilterToggleButton({
required this.tabsRouter,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabHasFilters = ref.watch(
libraryListQueryProvider(tabsRouter.activeIndex)
.select((value) => value.query.filters.isNotEmpty));
return FilledButton(
onPressed: tabHasFilters
? () {
ref
.read(libraryListsProvider.notifier)
.clearFilters(tabsRouter.activeIndex);
}
: null,
child: const Icon(Icons.filter_list_off_rounded),
);
}
}
class ListSortFilterOptions extends HookConsumerWidget {
final int index;
const ListSortFilterOptions({
super.key,
required this.index,
});
void Function()? _filterOnEdit(
String column,
BuildContext context,
WidgetRef ref,
) {
final type = column.split('.').last;
switch (type) {
case 'year':
return () {
// TODO: year filter dialog
// showDialog(
// context: context,
// builder: (context) {
// return Dialog(
// child: Text('adsf'),
// );
// },
// );
};
case 'genre':
case 'album_artist':
case 'owner':
case 'album':
case 'artist':
// TODO: other filter dialogs
return () {};
default:
return null;
}
}
void Function(bool? value)? _filterOnChanged(String column, WidgetRef ref) {
final type = column.split('.').last;
switch (type) {
case 'starred':
return (value) {
if (value!) {
ref.read(libraryListsProvider.notifier).setFilter(
index,
FilterWith.isNull(column: column, invert: true),
);
} else {
ref.read(libraryListsProvider.notifier).removeFilter(index, column);
}
};
case 'year':
// TODO: add/remove filter
return null;
case 'genre':
case 'album_artist':
case 'owner':
case 'album':
case 'artist':
// TODO: add/remove filter
return null;
default:
return null;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final list = ref.watch(libraryListQueryProvider(index));
return SliverList(
delegate: SliverChildListDelegate.fixed([
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Sort by',
style: Theme.of(context).textTheme.titleLarge,
),
),
const SizedBox(height: 8),
for (var column in list.options.sortColumns)
SortOptionTile(
column: column,
value: list.query.sort!.copyWith(column: column),
groupValue: list.query.sort!,
onColumnChanged: (column) {
if (column != null) {
ref
.read(libraryListsProvider.notifier)
.setSortColumn(index, column);
}
},
onDirectionToggle: () =>
ref.read(libraryListsProvider.notifier).toggleDirection(index),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Filter',
style: Theme.of(context).textTheme.titleLarge,
),
),
for (var column in list.options.filterColumns)
FilterOptionTile(
column: column,
state: list.query.filters.singleWhereOrNull(
(e) => e.column == column,
),
onEdit: _filterOnEdit(column, context, ref),
onChanged: _filterOnChanged(column, ref),
)
]),
);
}
}
class SortOptionTile extends HookConsumerWidget {
final String column;
final SortBy value;
final SortBy groupValue;
final void Function(String? value) onColumnChanged;
final void Function() onDirectionToggle;
const SortOptionTile({
super.key,
required this.column,
required this.value,
required this.groupValue,
required this.onColumnChanged,
required this.onDirectionToggle,
});
String _sortTitle(AppLocalizations l, String type) {
type = type.split('.').last;
switch (type) {
case 'name':
return l.resourcesSortByName;
case 'album_artist':
return l.resourcesSortByArtist;
case 'created':
return l.resourcesSortByAdded;
case 'year':
return l.resourcesSortByYear;
case 'album_count':
return l.resourcesSortByAlbumCount;
case 'changed':
return l.resourcesSortByUpdated;
case 'album':
return l.resourcesSortByAlbum;
case 'artist':
return l.resourcesSortByArtist;
case 'title':
return l.resourcesSortByTitle;
default:
return '';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return RadioListTile<String?>(
value: value.column,
groupValue: groupValue.column,
onChanged: onColumnChanged,
selected: value.column == groupValue.column,
title: Text(_sortTitle(l, column)),
secondary: value.column == groupValue.column
? IconButton(
icon: Icon(
value.dir == SortDirection.desc
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
),
onPressed: onDirectionToggle,
)
: null,
);
}
}
class FilterOptionTile extends HookConsumerWidget {
final String column;
final FilterWith? state;
final void Function(bool? value)? onChanged;
final void Function()? onEdit;
const FilterOptionTile({
super.key,
required this.column,
required this.state,
required this.onChanged,
this.onEdit,
});
String _filterTitle(AppLocalizations l, String type) {
type = type.split('.').last;
switch (type) {
case 'starred':
return l.resourcesFilterStarred;
case 'year':
return l.resourcesFilterYear;
case 'genre':
return l.resourcesFilterGenre;
case 'album_artist':
return l.resourcesFilterArtist;
case 'owner':
return l.resourcesFilterOwner;
case 'album':
return l.resourcesFilterAlbum;
case 'artist':
return l.resourcesFilterArtist;
default:
return '';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return CheckboxListTile(
value: state == null
? false
: state!.map(
equals: (value) => value.invert ? null : true,
greaterThan: (value) => true,
isNull: (_) => true,
betweenInt: (_) => true,
isIn: (value) => value.invert ? null : true,
),
tristate: state?.map(
equals: (value) => true,
greaterThan: (value) => false,
isNull: (_) => false,
betweenInt: (_) => false,
isIn: (_) => true,
) ??
false,
title: Text(_filterTitle(l, column)),
secondary: onEdit == null
? null
: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: onEdit,
),
controlAffinity: ListTileControlAffinity.leading,
onChanged: onChanged,
);
}
}

View File

@@ -1,176 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$libraryTabObserverHash() =>
r'a976ea55e2168e4684114c47592f25a2b187f15f';
/// See also [libraryTabObserver].
@ProviderFor(libraryTabObserver)
final libraryTabObserverProvider = Provider<TabObserver>.internal(
libraryTabObserver,
name: r'libraryTabObserverProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryTabObserverHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef LibraryTabObserverRef = ProviderRef<TabObserver>;
String _$libraryTabPathHash() => r'fe60984ea9d629683d344f809749b1b9362735fa';
/// See also [libraryTabPath].
@ProviderFor(libraryTabPath)
final libraryTabPathProvider = StreamProvider<String>.internal(
libraryTabPath,
name: r'libraryTabPathProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryTabPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef LibraryTabPathRef = StreamProviderRef<String>;
String _$libraryListQueryHash() => r'6079338e19e0249aaa09868dd405fd3aefc42c2b';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef LibraryListQueryRef = ProviderRef<LibraryListQuery>;
/// See also [libraryListQuery].
@ProviderFor(libraryListQuery)
const libraryListQueryProvider = LibraryListQueryFamily();
/// See also [libraryListQuery].
class LibraryListQueryFamily extends Family<LibraryListQuery> {
/// See also [libraryListQuery].
const LibraryListQueryFamily();
/// See also [libraryListQuery].
LibraryListQueryProvider call(
int index,
) {
return LibraryListQueryProvider(
index,
);
}
@override
LibraryListQueryProvider getProviderOverride(
covariant LibraryListQueryProvider provider,
) {
return call(
provider.index,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'libraryListQueryProvider';
}
/// See also [libraryListQuery].
class LibraryListQueryProvider extends Provider<LibraryListQuery> {
/// See also [libraryListQuery].
LibraryListQueryProvider(
this.index,
) : super.internal(
(ref) => libraryListQuery(
ref,
index,
),
from: libraryListQueryProvider,
name: r'libraryListQueryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryListQueryHash,
dependencies: LibraryListQueryFamily._dependencies,
allTransitiveDependencies:
LibraryListQueryFamily._allTransitiveDependencies,
);
final int index;
@override
bool operator ==(Object other) {
return other is LibraryListQueryProvider && other.index == index;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, index.hashCode);
return _SystemHash.finish(hash);
}
}
String _$lastLibraryStateServiceHash() =>
r'a49e26b5dc0fcb0f697ec2def08e7336f64c4cb3';
/// See also [LastLibraryStateService].
@ProviderFor(LastLibraryStateService)
final lastLibraryStateServiceProvider =
AsyncNotifierProvider<LastLibraryStateService, void>.internal(
LastLibraryStateService.new,
name: r'lastLibraryStateServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$lastLibraryStateServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastLibraryStateService = AsyncNotifier<void>;
String _$libraryListsHash() => r'7c9fd1ca3b0d70253e0f5d8197abf18b3a18c995';
/// See also [LibraryLists].
@ProviderFor(LibraryLists)
final libraryListsProvider =
NotifierProvider<LibraryLists, IList<LibraryListQuery>>.internal(
LibraryLists.new,
name: r'libraryListsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$libraryListsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LibraryLists = Notifier<IList<LibraryListQuery>>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -1,39 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
class LibraryPlaylistsPage extends HookConsumerWidget {
const LibraryPlaylistsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 2,
getItems: (query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.playlistsListDownloaded(sourceId, query).get()
: db.playlistsList(sourceId, query).get();
},
);
return PagedListQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index) => PlaylistListTile(
playlist: item,
onTap: () => context.navigateTo(PlaylistSongsRoute(id: item.id)),
),
);
}
}

View File

@@ -1,81 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../models/music.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../state/settings.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
import 'library_page.dart';
import 'songs_page.dart';
part 'library_songs_page.g.dart';
@riverpod
Future<List<Song>> songsList(SongsListRef ref, ListQuery opt) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.songsList(sourceId, opt).get();
}
class LibrarySongsPage extends HookConsumerWidget {
const LibrarySongsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audio = ref.watch(audioControlProvider);
final query = ref.watch(libraryListQueryProvider(3).select(
(value) => value.query,
));
final getSongs = useCallback(
(ListQuery query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.songsListDownloaded(sourceId, query).get()
: db.songsList(sourceId, query).get();
},
[],
);
final play = useCallback(
({int? index, bool? shuffle}) => audio.playSongs(
query: query,
getSongs: getSongs,
startIndex: index,
context: QueueContextType.song,
shuffle: shuffle,
),
[query, getSongs],
);
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 3,
getItems: getSongs,
);
return PagedListQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index) => QueueContext(
type: QueueContextType.song,
child: SongListTile(
song: item,
image: true,
onTap: () => play(index: index),
),
),
);
}
}

View File

@@ -1,111 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_songs_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$songsListHash() => r'a3149eb61f8f1ff326e9b1de0ac1c02d7baa831f';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef SongsListRef = AutoDisposeFutureProviderRef<List<Song>>;
/// See also [songsList].
@ProviderFor(songsList)
const songsListProvider = SongsListFamily();
/// See also [songsList].
class SongsListFamily extends Family<AsyncValue<List<Song>>> {
/// See also [songsList].
const SongsListFamily();
/// See also [songsList].
SongsListProvider call(
ListQuery opt,
) {
return SongsListProvider(
opt,
);
}
@override
SongsListProvider getProviderOverride(
covariant SongsListProvider provider,
) {
return call(
provider.opt,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'songsListProvider';
}
/// See also [songsList].
class SongsListProvider extends AutoDisposeFutureProvider<List<Song>> {
/// See also [songsList].
SongsListProvider(
this.opt,
) : super.internal(
(ref) => songsList(
ref,
opt,
),
from: songsListProvider,
name: r'songsListProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$songsListHash,
dependencies: SongsListFamily._dependencies,
allTransitiveDependencies: SongsListFamily._allTransitiveDependencies,
);
final ListQuery opt;
@override
bool operator ==(Object other) {
return other is SongsListProvider && other.opt == opt;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, opt.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

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