41 Commits

Author SHA1 Message Date
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
191 changed files with 3088 additions and 32463 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**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. Pixel 4]
**Device**
- Model: [e.g. Pixel 4]
- OS: [e.g. Android 12]
- Subtracks version [e.g. 1.2.0]
**Server**
- Software: [e.g. Navidrome]
- Version: [e.g. 0.49.3]
**Additional context**
Add any other context about the problem here.

11
.gitignore vendored
View File

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

View File

@@ -1,11 +1,11 @@
# This file tracks properties of this Flutter project.
# 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:
revision: 9944297138845a94256f1cf37beb88ff9a8e811a
channel: stable
revision: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable"
project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
base_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android
create_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
base_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# User provided section

View File

@@ -1,626 +0,0 @@
{
"ar": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"ca": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"cs": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsLicenses",
"settingsAboutActionsProjectHomepage",
"settingsAboutActionsSupport",
"settingsAboutName",
"settingsAboutVersion",
"settingsMusicName",
"settingsMusicOptionsScrobbleDescriptionOff",
"settingsMusicOptionsScrobbleDescriptionOn",
"settingsMusicOptionsScrobbleTitle",
"settingsNetworkOptionsMaxBufferTitle",
"settingsNetworkOptionsMinBufferTitle",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsResetActionsClearImageCache",
"settingsResetName",
"settingsServersFieldsName"
],
"da": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"actionsStar",
"actionsUnstar",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterStarred",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAdded",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByFrequentlyPlayed",
"resourcesSortByRecentlyPlayed",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsMusicOptionsScrobbleDescriptionOff",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName",
"settingsServersOptionsForcePlaintextPasswordDescriptionOff",
"settingsServersOptionsForcePlaintextPasswordDescriptionOn",
"settingsServersOptionsForcePlaintextPasswordTitle"
],
"de": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"es": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"fr": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"gl": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"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": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"ja": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"actionsStar",
"actionsUnstar",
"controlsShuffle",
"messagesNothingHere",
"resourcesAlbumActionsPlay",
"resourcesAlbumActionsView",
"resourcesAlbumCount",
"resourcesAlbumListsSort",
"resourcesArtistActionsView",
"resourcesArtistCount",
"resourcesArtistListsSort",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterGenre",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistActionsPlay",
"resourcesPlaylistCount",
"resourcesQueueName",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAdded",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByArtist",
"resourcesSortByName",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"resourcesSortByYear",
"searchHeaderTitle",
"searchMoreResults",
"searchNowPlayingContext",
"settingsAboutActionsLicenses",
"settingsAboutActionsSupport",
"settingsAboutName",
"settingsAboutVersion",
"settingsMusicOptionsScrobbleDescriptionOff",
"settingsMusicOptionsScrobbleDescriptionOn",
"settingsMusicOptionsScrobbleTitle",
"settingsNetworkOptionsMaxBitrateMobileTitle",
"settingsNetworkOptionsMaxBitrateWifiTitle",
"settingsNetworkOptionsMaxBufferTitle",
"settingsNetworkOptionsMinBufferTitle",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsNetworkValuesKbps",
"settingsNetworkValuesSeconds",
"settingsNetworkValuesUnlimitedKbps",
"settingsResetActionsClearImageCache",
"settingsServersActionsAdd",
"settingsServersActionsDelete",
"settingsServersActionsEdit",
"settingsServersActionsSave",
"settingsServersActionsTestConnection",
"settingsServersFieldsAddress",
"settingsServersFieldsName",
"settingsServersFieldsPassword",
"settingsServersFieldsUsername",
"settingsServersMessagesConnectionFailed",
"settingsServersMessagesConnectionOk",
"settingsServersOptionsForcePlaintextPasswordDescriptionOff",
"settingsServersOptionsForcePlaintextPasswordDescriptionOn",
"settingsServersOptionsForcePlaintextPasswordTitle"
],
"nb": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"pa": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"pl": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"pt": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"ru": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"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": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"vi": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"zh": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
]
}

62
TODO.md
View File

@@ -1,34 +1,30 @@
## To-do
- [ ] Star/unstar
- [ ] Context menus
- [ ] Download actions for song
- [ ] Playlist management
- [ ] Add to playlist (from context)
- [ ] Queue management
- [ ] View playing queue
- [ ] Re-order queue
- [ ] Add to queue (from context)
- [ ] Remove from queue
- [ ] Scrobbling
- [ ] Library filters (year/genre/etc)
- [ ] Library list display modes
- [ ] Search
- [ ] Individual "more" results pages
- [ ] Radio modes
- [ ] Artist
- [ ] Now playing gestures
- [ ] Swipe bar/album to skip
- [ ] Double-tap to seek forward/back (bar only)
- [ ] Settings
- [ ] Sources
- [ ] Use plaintext password
- [ ] Music
- [ ] Scrobble
- [ ] Downloads
- [ ] Used/available space
- [ ] Clear downloads
- [ ] Clear images
- [ ] About
- [ ] Licenses
- [ ] Welcome/setup flow
- [ ] Proper loading screen/animation
- Star/unstar
- Context menus
- Download actions for song
- Playlist management
- Add to playlist (from context)
- Queue management
- View playing queue
- Re-order queue
- Add to queue (from context)
- Remove from queue
- Scrobbling
- Library filters (year/genre/etc)
- Library list display modes
- Search
- Individual "more" results pages
- Now playing gestures
- Swipe bar/album to skip
- Double-tap to seek forward/back (bar only)
- Settings
- Music
- Scrobble
- Downloads
- Used/available space
- Clear downloads
- Clear images
- 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:
rules:
prefer_relative_imports: true
prefer_single_quotes: true
formatter:
trailing_commas: preserve
analyzer:
exclude:
- '**.freezed.dart'
- '**.g.dart'
- '**.gr.dart'
plugins:
# broken currently and may not get fixed
# https://github.com/simolus3/drift/issues/2342
# - drift
# also broken but only recently reported
# https://github.com/rrousselGit/riverpod/issues/2180
# - custom_lint
- custom_lint

3
android/.gitignore vendored
View File

@@ -5,9 +5,10 @@ gradle-wrapper.jar
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# 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
**/*.keystore
**/*.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"
package="com.subtracks2">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.

View File

@@ -1,16 +1,13 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.subtracks2">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="subtracks"
android:label="subtracks2_1"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
android:icon="@mipmap/ic_launcher">
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -21,52 +18,28 @@
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</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.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
</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" />
<!-- audio_service -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</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"
package="com.subtracks2">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
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.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
zipStoreBase=GRADLE_USER_HOME
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,44 @@
#!/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";
await new Deno.Command("rm", { args: ["-rf", path.join(MUSIC_DIR, "*")] })
.output();
const client = new SubsonicClient(
"http://demo.subsonic.org",
"guest1",
"guest",
);
for (const id of ["197", "199", "321"]) {
const { res } = await client.get("download", { 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,65 @@
#!/usr/bin/env -S deno --allow-all
import { SubsonicClient } from "./util/subsonic.ts";
import { sleep } from "./util/util.ts";
async function scrobbleTrack(
client: SubsonicClient,
album: string,
track: number,
) {
const { xml: albumsXml } = await client.get("getAlbumList2", {
type: "newest",
});
const albumId = albumsXml.querySelector(`album[name='${album}']`)?.id;
const { xml: songsXml } = await client.get("getAlbum", { id: albumId! });
const songId = songsXml.querySelector(`song[track='${track}']`)?.id;
await client.get("scrobble", {
id: songId!,
submission: "true",
});
}
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);
}
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?: Record<string, string>,
): Promise<{ res: Response; xml: undefined }>;
async get(
method: string,
params?: Record<string, string>,
): Promise<{ res: Response; xml: Document }>;
async get(
method: string,
params?: Record<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) {
Object.entries(params).forEach(([key, value]) =>
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 +0,0 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
nullable-getter: false
untranslated-messages-file: .untranslated-messages.json

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

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

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

@@ -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

View File

@@ -1,429 +0,0 @@
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:text_scroll/text_scroll.dart';
import '../../cache/image_cache.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../state/audio.dart';
import '../../state/theme.dart';
import '../context_menus.dart';
import '../gradient.dart';
import '../images.dart';
import '../now_playing_bar.dart';
class NowPlayingPage extends HookConsumerWidget {
const NowPlayingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final itemData = ref.watch(mediaItemDataProvider);
final theme = Theme.of(context);
final scaffold = AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
systemNavigationBarColor: colors?.gradientLow,
statusBarColor: Colors.transparent,
),
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
itemData?.contextType.value ?? '',
style: theme.textTheme.labelMedium,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
// Text(
// itemData?.contextTitle ?? '',
// style: theme.textTheme.titleMedium,
// maxLines: 1,
// softWrap: false,
// overflow: TextOverflow.fade,
// ),
],
),
),
body: Stack(
children: [
const MediaItemGradient(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Column(
children: const [
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _Art(),
),
),
SizedBox(height: 24),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _TrackInfo(),
),
SizedBox(height: 8),
_Progress(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _Controls(),
),
SizedBox(height: 64),
],
),
),
],
),
),
);
if (colors != null) {
return Theme(data: colors.theme, child: scaffold);
} else {
return scaffold;
}
}
}
class _Art extends HookConsumerWidget {
const _Art();
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemData = ref.watch(mediaItemDataProvider);
final imageCache = ref.watch(imageCacheProvider);
UriCacheInfo? cacheInfo;
if (itemData?.artCache != null) {
cacheInfo = UriCacheInfo(
uri: itemData!.artCache!.fullArtUri,
cacheKey: itemData.artCache!.fullArtCacheKey,
cacheManager: imageCache,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: CardClip(
key: ValueKey(cacheInfo?.cacheKey ?? 'default'),
child: cacheInfo != null
? CardClip(
square: false,
child: UriCacheInfoImage(
// height: 300,
fit: BoxFit.contain,
placeholderStyle: PlaceholderStyle.spinner,
cache: cacheInfo,
),
)
: const PlaceholderImage(thumbnail: false),
),
);
}
}
class _TrackInfo extends HookConsumerWidget {
const _TrackInfo();
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(mediaItemProvider).valueOrNull;
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScrollableText(
item?.title ?? '',
style: theme.textTheme.headlineSmall,
speed: 50,
),
Text(
item?.artist ?? '',
style: theme.textTheme.titleMedium!,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
],
),
),
IconButton(
icon: const Icon(
Icons.star_outline_rounded,
size: 36,
),
onPressed: () {},
),
],
);
}
}
class ScrollableText extends StatelessWidget {
final String text;
final TextStyle? style;
final double speed;
const ScrollableText(
this.text, {
super.key,
this.style,
this.speed = 35,
});
@override
Widget build(BuildContext context) {
final defaultStyle = DefaultTextStyle.of(context);
return AutoSizeText(
text,
presetFontSizes: style != null && style?.fontSize != null
? [style!.fontSize!]
: [defaultStyle.style.fontSize ?? 12],
style: style,
maxLines: 1,
// softWrap: false,
overflowReplacement: TextScroll(
'$text ',
style: style,
delayBefore: const Duration(seconds: 3),
pauseBetween: const Duration(seconds: 4),
mode: TextScrollMode.endless,
velocity: Velocity(pixelsPerSecond: Offset(speed, 0)),
),
);
}
}
class _Progress extends HookConsumerWidget {
const _Progress();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider);
final audio = ref.watch(audioControlProvider);
final changeValue = useState(position.toDouble());
final changing = useState(false);
return Column(
children: [
Slider(
value: changing.value ? changeValue.value : position.toDouble(),
min: 0,
max: max(duration.toDouble(), position.toDouble()),
thumbColor: colors?.theme.colorScheme.onBackground,
activeColor: colors?.theme.colorScheme.onBackground,
inactiveColor: colors?.theme.colorScheme.surface,
onChanged: (value) {
changeValue.value = value;
},
onChangeStart: (value) {
changing.value = true;
},
onChangeEnd: (value) {
changing.value = false;
audio.seek(Duration(seconds: value.toInt()));
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(Duration(
seconds: changing.value
? changeValue.value.toInt()
: position)
.toString()
.substring(2, 7)),
Text(Duration(seconds: duration).toString().substring(2, 7)),
],
),
),
)
],
);
}
}
class RepeatButton extends HookConsumerWidget {
final double size;
const RepeatButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audio = ref.watch(audioControlProvider);
final repeat = ref.watch(repeatModeProvider);
IconData icon;
void Function() action;
switch (repeat) {
case AudioServiceRepeatMode.all:
case AudioServiceRepeatMode.group:
icon = Icons.repeat_on_rounded;
action = () => audio.setRepeatMode(AudioServiceRepeatMode.one);
break;
case AudioServiceRepeatMode.one:
icon = Icons.repeat_one_on_rounded;
action = () => audio.setRepeatMode(AudioServiceRepeatMode.none);
break;
default:
icon = Icons.repeat_rounded;
action = () => audio.setRepeatMode(AudioServiceRepeatMode.all);
break;
}
return IconButton(
icon: Icon(icon),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: action,
);
}
}
class ShuffleButton extends HookConsumerWidget {
final double size;
const ShuffleButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audio = ref.watch(audioControlProvider);
final shuffle = ref.watch(shuffleModeProvider);
final queueMode = ref.watch(queueModeProvider).valueOrNull;
IconData icon;
void Function() action;
switch (shuffle) {
case AudioServiceShuffleMode.all:
case AudioServiceShuffleMode.group:
icon = Icons.shuffle_on_rounded;
action = () => audio.setShuffleMode(AudioServiceShuffleMode.none);
break;
default:
icon = Icons.shuffle_rounded;
action = () => audio.setShuffleMode(AudioServiceShuffleMode.all);
break;
}
return IconButton(
icon: Icon(queueMode == QueueMode.radio ? Icons.radio_rounded : icon),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: queueMode == QueueMode.radio ? null : action,
);
}
}
class _Controls extends HookConsumerWidget {
const _Controls();
@override
Widget build(BuildContext context, WidgetRef ref) {
final base = ref.watch(baseThemeProvider);
final audio = ref.watch(audioControlProvider);
return IconTheme(
data: IconThemeData(color: base.theme.colorScheme.onBackground),
child: Column(
children: [
SizedBox(
height: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const RepeatButton(size: 30),
IconButton(
icon: const Icon(Icons.skip_previous_rounded),
padding: EdgeInsets.zero,
iconSize: 60,
onPressed: () => audio.skipToPrevious(),
),
const PlayPauseButton(size: 90),
IconButton(
icon: const Icon(Icons.skip_next_rounded),
padding: EdgeInsets.zero,
iconSize: 60,
onPressed: () => audio.skipToNext(),
),
const ShuffleButton(size: 30),
],
),
),
SizedBox(
height: 40,
child: Row(
// crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.queue_music_rounded),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: () {},
),
const _MoreButton(),
],
),
),
],
),
);
}
}
class _MoreButton extends HookConsumerWidget {
const _MoreButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final song = ref.watch(mediaItemSongProvider).valueOrNull;
return IconButton(
icon: const Icon(Icons.more_horiz),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: song != null
? () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: SongContextMenu(song: song),
),
);
}
: null,
);
}
}

View File

@@ -1,247 +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:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.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/music.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../items.dart';
import 'songs_page.dart';
part 'search_page.g.dart';
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String? build() {
return null;
}
void setQuery(String query) {
state = query;
}
}
@riverpod
FutureOr<SearchResults> searchResult(SearchResultRef ref) async {
final query = ref.watch(searchQueryProvider);
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final ftsQuery = '(source_id : $sourceId) AND (- source_id : "$query"*)';
final songRowIds = await db.searchSongs(ftsQuery, 5, 0).get();
final songs = await db.songsInRowIds(songRowIds).get();
final albumRowIds = await db.searchAlbums(ftsQuery, 5, 0).get();
final albums = await db.albumsInRowIds(albumRowIds).get();
final artistRowIds = await db.searchArtists(ftsQuery, 5, 0).get();
final artists = await db.artistsInRowIds(artistRowIds).get();
return SearchResults(
songs: songs.lock,
albums: albums.lock,
artists: artists.lock,
);
}
class SearchPage extends HookConsumerWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchResultProvider).valueOrNull;
return KeyboardDismissOnTap(
dismissOnCapturedTaps: true,
child: Scaffold(
body: SafeArea(
child: CustomScrollView(
reverse: true,
slivers: [
const SliverToBoxAdapter(child: _SearchBar()),
if (results != null && results.songs.isNotEmpty)
_SongsSection(songs: results.songs),
if (results != null && results.albums.isNotEmpty)
_AlbumsSection(albums: results.albums),
if (results != null && results.artists.isNotEmpty)
_ArtistsSection(artists: results.artists),
if (results != null)
const SliverPadding(padding: EdgeInsets.only(top: 96))
],
),
),
),
);
}
}
class _SearchBar extends HookConsumerWidget {
const _SearchBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController(text: '');
final theme = Theme.of(context);
final l = AppLocalizations.of(context);
return Container(
color: ElevationOverlay.applySurfaceTint(
theme.colorScheme.surface,
theme.colorScheme.surfaceTint,
1,
),
child: Padding(
padding: const EdgeInsets.only(
right: 24,
left: 24,
bottom: 24,
top: 8,
),
child: IgnoreKeyboardDismiss(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
hintText: l.searchInputPlaceholder,
),
onChanged: (value) {
ref.read(searchQueryProvider.notifier).setQuery(value);
},
),
),
),
);
}
}
class _SectionHeader extends HookConsumerWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(title, style: theme.headlineMedium),
);
}
}
class _Section extends HookConsumerWidget {
final String title;
final Iterable<Widget> children;
const _Section({
required this.title,
required this.children,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverList(
delegate: SliverChildListDelegate([
const SizedBox(height: 16),
...children.toList().reversed,
_SectionHeader(title: title),
]),
);
}
}
class _SongsSection extends HookConsumerWidget {
final IList<Song>? songs;
const _SongsSection({required this.songs});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Section(
title: l.resourcesSongName(100),
children: (songs ?? <Song>[]).map(
(song) => QueueContext(
type: QueueContextType.album,
id: song.albumId!,
child: SongListTile(
song: song,
image: true,
onTap: () async {
const query = ListQuery(
sort: SortBy(column: 'disc, track'),
);
final albumSongs = await ref.read(
albumSongsListProvider(song.albumId!, query).future,
);
ref.read(audioControlProvider).playSongs(
context: QueueContextType.album,
contextId: song.albumId!,
shuffle: true,
startIndex: albumSongs.indexOf(song),
query: query,
getSongs: (query) => ref.read(
albumSongsListProvider(song.albumId!, query).future),
);
},
),
),
),
);
}
}
class _AlbumsSection extends HookConsumerWidget {
final IList<Album>? albums;
const _AlbumsSection({required this.albums});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Section(
title: l.resourcesAlbumName(100),
children: (albums ?? <Album>[]).map(
(album) => AlbumListTile(
album: album,
onTap: () => context.navigateTo(AlbumSongsRoute(id: album.id)),
),
),
);
}
}
class _ArtistsSection extends HookConsumerWidget {
final IList<Artist>? artists;
const _ArtistsSection({required this.artists});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Section(
title: l.resourcesArtistName(100),
children: (artists ?? <Artist>[]).map(
(artist) => ArtistListTile(
artist: artist,
onTap: () => context.navigateTo(ArtistRoute(id: artist.id)),
),
),
);
}
}

View File

@@ -1,38 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$searchResultHash() => r'e5240c0c51937e1e946138d27aeaea93dc0231c3';
/// See also [searchResult].
@ProviderFor(searchResult)
final searchResultProvider = AutoDisposeFutureProvider<SearchResults>.internal(
searchResult,
name: r'searchResultProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$searchResultHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef SearchResultRef = AutoDisposeFutureProviderRef<SearchResults>;
String _$searchQueryHash() => r'f7624215b3d5a8b917cb0af239666a19a18d91d5';
/// See also [SearchQuery].
@ProviderFor(SearchQuery)
final searchQueryProvider =
AutoDisposeNotifierProvider<SearchQuery, String?>.internal(
SearchQuery.new,
name: r'searchQueryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$searchQueryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SearchQuery = AutoDisposeNotifier<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,395 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/support.dart';
import '../../services/settings_service.dart';
import '../../state/init.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../dialogs.dart';
const kHorizontalPadding = 16.0;
class SettingsPage extends HookConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
// final downloads = ref.watch(downloadServiceProvider.select(
// (value) => value.downloads,
// ));
return Scaffold(
body: ListView(
children: [
const SizedBox(height: 96),
_SectionHeader(l.settingsServersName),
const _Sources(),
_SectionHeader(l.settingsNetworkName),
const _Network(),
_SectionHeader(l.settingsAboutName),
_About(),
// const _SectionHeader('Downloads'),
// _Section(
// children: downloads
// .map(
// (e) => ListTile(
// isThreeLine: true,
// title: Text(e.filename ?? e.url),
// subtitle: Column(
// mainAxisAlignment: MainAxisAlignment.start,
// children: [
// Row(children: [Text('Progress: ${e.progress}%')]),
// Row(children: [Text('Status: ${e.status})')]),
// Text('Status: ${e.savedDir}'),
// ],
// ),
// trailing:
// CircularProgressIndicator(value: e.progress / 100),
// ),
// )
// .toList(),
// ),
],
),
);
}
}
class _Section extends StatelessWidget {
final List<Widget> children;
const _Section({required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...children,
const SizedBox(height: 32),
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
child: Text(
title,
style: theme.textTheme.displaySmall,
),
),
),
const SizedBox(height: 12),
],
);
}
}
class _Network extends StatelessWidget {
const _Network();
@override
Widget build(BuildContext context) {
return const _Section(
children: [
_OfflineMode(),
_MaxBitrateWifi(),
_MaxBitrateMobile(),
_StreamFormat(),
],
);
}
}
class _About extends HookConsumerWidget {
_About();
final _homepage = Uri.parse('https://github.com/austinried/subtracks');
final _donate = Uri.parse('https://ko-fi.com/austinried');
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final pkg = ref.watch(packageInfoProvider).requireValue;
return _Section(
children: [
ListTile(
title: const Text('subtracks'),
subtitle: Text(l.settingsAboutVersion(pkg.version)),
),
ListTile(
title: Text(l.settingsAboutActionsLicenses),
// trailing: const Icon(Icons.open_in_new_rounded),
onTap: () {},
),
ListTile(
title: Text(l.settingsAboutActionsProjectHomepage),
subtitle: Text(_homepage.toString()),
trailing: const Icon(Icons.open_in_new_rounded),
onTap: () => launchUrl(
_homepage,
mode: LaunchMode.externalApplication,
),
),
ListTile(
title: Text(l.settingsAboutActionsSupport),
subtitle: Text(_donate.toString()),
trailing: const Icon(Icons.open_in_new_rounded),
onTap: () => launchUrl(
_donate,
mode: LaunchMode.externalApplication,
),
),
],
);
}
}
class _MaxBitrateWifi extends HookConsumerWidget {
const _MaxBitrateWifi();
@override
Widget build(BuildContext context, WidgetRef ref) {
final bitrate = ref.watch(settingsServiceProvider.select(
(value) => value.app.maxBitrateWifi,
));
final l = AppLocalizations.of(context);
return _MaxBitrateOption(
title: l.settingsNetworkOptionsMaxBitrateWifiTitle,
bitrate: bitrate,
onChange: (value) {
ref.read(settingsServiceProvider.notifier).setMaxBitrateWifi(value);
},
);
}
}
class _MaxBitrateMobile extends HookConsumerWidget {
const _MaxBitrateMobile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final bitrate = ref.watch(settingsServiceProvider.select(
(value) => value.app.maxBitrateMobile,
));
final l = AppLocalizations.of(context);
return _MaxBitrateOption(
title: l.settingsNetworkOptionsMaxBitrateMobileTitle,
bitrate: bitrate,
onChange: (value) {
ref.read(settingsServiceProvider.notifier).setMaxBitrateMobile(value);
},
);
}
}
class _MaxBitrateOption extends HookConsumerWidget {
final String title;
final int bitrate;
final void Function(int value) onChange;
const _MaxBitrateOption({
required this.title,
required this.bitrate,
required this.onChange,
});
static const options = [0, 24, 32, 64, 96, 128, 192, 256, 320];
String _bitrateText(AppLocalizations l, int bitrate) {
return bitrate == 0
? l.settingsNetworkValuesUnlimitedKbps
: l.settingsNetworkValuesKbps(bitrate.toString());
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return ListTile(
title: Text(title),
subtitle: Text(_bitrateText(l, bitrate)),
onTap: () async {
final value = await showDialog<int>(
context: context,
builder: (context) => MultipleChoiceDialog<int>(
title: title,
current: bitrate,
options: options
.map((opt) => MultiChoiceOption.int(
title: _bitrateText(l, opt),
option: opt,
))
.toIList(),
),
);
if (value != null) {
onChange(value);
}
},
);
}
}
class _StreamFormat extends HookConsumerWidget {
const _StreamFormat();
static const options = ['', 'mp3', 'opus', 'ogg'];
@override
Widget build(BuildContext context, WidgetRef ref) {
final streamFormat = ref.watch(
settingsServiceProvider.select((value) => value.app.streamFormat),
);
final l = AppLocalizations.of(context);
return ListTile(
title: Text(l.settingsNetworkOptionsStreamFormat),
subtitle: Text(
streamFormat ?? l.settingsNetworkOptionsStreamFormatServerDefault,
),
onTap: () async {
final value = await showDialog<String>(
context: context,
builder: (context) => MultipleChoiceDialog<String>(
title: l.settingsNetworkOptionsStreamFormat,
current: streamFormat ?? '',
options: options
.map((opt) => MultiChoiceOption.string(
title: opt == ''
? l.settingsNetworkOptionsStreamFormatServerDefault
: opt,
option: opt,
))
.toIList(),
),
);
if (value != null) {
ref
.read(settingsServiceProvider.notifier)
.setStreamFormat(value == '' ? null : value);
}
},
);
}
}
class _OfflineMode extends HookConsumerWidget {
const _OfflineMode();
@override
Widget build(BuildContext context, WidgetRef ref) {
final offline = ref.watch(offlineModeProvider);
final l = AppLocalizations.of(context);
return SwitchListTile(
value: offline,
title: Text(l.settingsNetworkOptionsOfflineMode),
subtitle: offline
? Text(l.settingsNetworkOptionsOfflineModeOn)
: Text(l.settingsNetworkOptionsOfflineModeOff),
onChanged: (value) {
ref.read(offlineModeProvider.notifier).setMode(value);
},
);
}
}
class _Sources extends HookConsumerWidget {
const _Sources();
@override
Widget build(BuildContext context, WidgetRef ref) {
final sources = ref.watch(settingsServiceProvider.select(
(value) => value.sources,
));
final activeSource = ref.watch(settingsServiceProvider.select(
(value) => value.activeSource,
));
final l = AppLocalizations.of(context);
return _Section(
children: [
for (var source in sources)
RadioListTile<int>(
value: source.id,
groupValue: activeSource?.id,
onChanged: (value) {
ref
.read(settingsServiceProvider.notifier)
.setActiveSource(source.id);
},
title: Text(source.name),
subtitle: Text(
source.address.toString(),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
secondary: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: () {
context.pushRoute(SourceRoute(id: source.id));
},
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add_rounded),
label: Text(l.settingsServersActionsAdd),
onPressed: () {
context.pushRoute(SourceRoute());
},
),
],
),
// TODO: remove
if (kDebugMode)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add_rounded),
label: const Text('Add TEST'),
onPressed: () {
ref
.read(settingsServiceProvider.notifier)
.addTestSource('TEST');
},
),
],
),
],
);
}
}

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