From f37ac06e1a0b9e0f8a4f309f110224d38422b82b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 13 Mar 2024 14:30:11 +0600 Subject: [PATCH 01/17] chore: unnecessary test files --- server/.pocketbase | 1 - .../1675256468_created_tracks.js | 63 ------------------- .../1675256557_updated_tracks.js | 17 ----- .../pb_migrations/1675256593_updated_users.js | 19 ------ .../1675256678_updated_tracks.js | 17 ----- .../1675257121_updated_tracks.js | 17 ----- .../1675257148_updated_tracks.js | 39 ------------ 7 files changed, 173 deletions(-) delete mode 100644 server/.pocketbase delete mode 100644 server/pb_migrations/1675256468_created_tracks.js delete mode 100644 server/pb_migrations/1675256557_updated_tracks.js delete mode 100644 server/pb_migrations/1675256593_updated_users.js delete mode 100644 server/pb_migrations/1675256678_updated_tracks.js delete mode 100644 server/pb_migrations/1675257121_updated_tracks.js delete mode 100644 server/pb_migrations/1675257148_updated_tracks.js diff --git a/server/.pocketbase b/server/.pocketbase deleted file mode 100644 index bcb8312e..00000000 --- a/server/.pocketbase +++ /dev/null @@ -1 +0,0 @@ -version=0.12.1 \ No newline at end of file diff --git a/server/pb_migrations/1675256468_created_tracks.js b/server/pb_migrations/1675256468_created_tracks.js deleted file mode 100644 index 46d03fbb..00000000 --- a/server/pb_migrations/1675256468_created_tracks.js +++ /dev/null @@ -1,63 +0,0 @@ -migrate((db) => { - const collection = new Collection({ - "id": "pevn93oxbnovw0s", - "created": "2023-02-01 13:01:08.893Z", - "updated": "2023-02-01 13:01:08.893Z", - "name": "tracks", - "type": "base", - "system": false, - "schema": [ - { - "system": false, - "id": "ycnix0ai", - "name": "spotify_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 20, - "max": 22, - "pattern": "" - } - }, - { - "system": false, - "id": "ih8fxzgh", - "name": "youtube_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 10, - "max": 11, - "pattern": "" - } - }, - { - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - } - ], - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "options": {} - }); - - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s"); - - return dao.deleteCollection(collection); -}) diff --git a/server/pb_migrations/1675256557_updated_tracks.js b/server/pb_migrations/1675256557_updated_tracks.js deleted file mode 100644 index cdcf19bc..00000000 --- a/server/pb_migrations/1675256557_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = "" - collection.viewRule = "" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = null - collection.viewRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256593_updated_users.js b/server/pb_migrations/1675256593_updated_users.js deleted file mode 100644 index 5643c3a0..00000000 --- a/server/pb_migrations/1675256593_updated_users.js +++ /dev/null @@ -1,19 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = null - collection.updateRule = null - collection.deleteRule = null - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = "" - collection.updateRule = "id = @request.auth.id" - collection.deleteRule = "id = @request.auth.id" - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256678_updated_tracks.js b/server/pb_migrations/1675256678_updated_tracks.js deleted file mode 100644 index 4b472ad1..00000000 --- a/server/pb_migrations/1675256678_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != ''" - collection.updateRule = "@request.auth.id != ''" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257121_updated_tracks.js b/server/pb_migrations/1675257121_updated_tracks.js deleted file mode 100644 index a1b7604f..00000000 --- a/server/pb_migrations/1675257121_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - collection.updateRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257148_updated_tracks.js b/server/pb_migrations/1675257148_updated_tracks.js deleted file mode 100644 index 544d0e85..00000000 --- a/server/pb_migrations/1675257148_updated_tracks.js +++ /dev/null @@ -1,39 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": false, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}) From 35e9920b516440ece0f2ed47f747ce7874b9ee2a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 13 Mar 2024 14:34:51 +0600 Subject: [PATCH 02/17] chore: add riverpod lint --- analysis_options.yaml | 2 ++ lib/main.dart | 29 ++++++++++---------- pubspec.lock | 64 +++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 ++ 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 5f2cbbe1..748fc015 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -34,3 +34,5 @@ analyzer: - patterns errors: invalid_annotation_target: ignore + plugins: + - custom_lint diff --git a/lib/main.dart b/lib/main.dart index 01e418dd..3281f85f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; @@ -135,21 +136,21 @@ Future main(List rawArgs) async { ), runAppFunction: () { runApp( - DevicePreview( - availableLocales: L10n.all, - enabled: false, - data: const DevicePreviewData( - isEnabled: false, - orientation: Orientation.portrait, - ), - builder: (context) { - return ProviderScope( - child: QueryClientProvider( + ProviderScope( + child: DevicePreview( + availableLocales: L10n.all, + enabled: false, + data: const DevicePreviewData( + isEnabled: false, + orientation: Orientation.portrait, + ), + builder: (context) { + return QueryClientProvider( staleDuration: const Duration(minutes: 30), child: const Spotube(), - ), - ); - }, + ); + }, + ), ), ); }, @@ -157,7 +158,7 @@ Future main(List rawArgs) async { } class Spotube extends StatefulHookConsumerWidget { - const Spotube({Key? key}) : super(key: key); + const Spotube({super.key}); @override SpotubeState createState() => SpotubeState(); diff --git a/pubspec.lock b/pubspec.lock index cc69663d..4485b118 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" ansicolor: dependency: transitive description: @@ -313,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_util: dependency: transitive description: @@ -401,6 +417,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" + url: "https://pub.dev" + source: hosted + version: "0.5.11" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "0d48e002438950f9582e574ef806b2bea5719d8d14c0f9f754fbad729bcf3b19" + url: "https://pub.dev" + source: hosted + version: "0.5.14" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" + url: "https://pub.dev" + source: hosted + version: "0.5.14" dart_des: dependency: transitive description: @@ -1098,6 +1138,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.10" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" html: dependency: "direct main" description: @@ -1752,6 +1800,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 + url: "https://pub.dev" + source: hosted + version: "0.3.4" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" + url: "https://pub.dev" + source: hosted + version: "2.1.1" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 04d3f1a4..e055c9d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -143,6 +143,8 @@ dev_dependencies: pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 freezed: ^2.4.6 + custom_lint: ^0.5.11 + riverpod_lint: ^2.1.1 dependency_overrides: system_tray: 2.0.2 From 6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 20 Mar 2024 23:38:39 +0600 Subject: [PATCH 03/17] feat: improved caching based on riverpod (#1343) * feat: add riverpod based favorite album provider * feat: add album is saved, new releases and tracks providers * feat: add artist related providers * feat: add all categories providers * feat: add lyrics provider * feat: add playlist related providers * feat: add search provider * feat: add view and spotify friends provider * feat: add playlist create and update and favorite handlers * feat: use providers in home screen * chore: fix dart lint issues * feat: use new providers for playlist and albums screen * feat: use providers in artist page * feat: use providers on library page * feat: use provider for playlist and album card and heart button * feat: use provider in search page * feat: use providers in generate playlist * feat: use provider in lyrics screen * feat: use provider for create playlist * feat: use provider in add track dialog * feat: use providers in remaining pages and remove fl_query * fix: remove direct access to provider.value * fix: glitching when loading * fix: user album loading next page indicator * feat: make many provider autoDispose after 5 minutes of no usage * fix: ignore episodes in tracks --- .vscode/settings.json | 1 + .vscode/snippets.code-snippets | 170 ++++ analysis_options.yaml | 1 + lib/collections/routes.dart | 4 +- lib/components/album/album_card.dart | 27 +- lib/components/artist/artist_album_list.dart | 21 +- lib/components/artist/artist_card.dart | 2 +- lib/components/desktop_login/login_form.dart | 4 +- lib/components/home/sections/featured.dart | 27 +- lib/components/home/sections/friends.dart | 14 +- .../home/sections/friends/friend_item.dart | 28 +- lib/components/home/sections/genres.dart | 24 +- .../home/sections/made_for_user.dart | 10 +- .../home/sections/new_releases.dart | 45 +- .../playlist_generate/multi_select_field.dart | 8 +- .../recommendation_attribute_dials.dart | 4 +- .../recommendation_attribute_fields.dart | 4 +- .../seeds_multi_autocomplete.dart | 4 +- .../playlist_generate/simple_track_tile.dart | 4 +- lib/components/library/user_albums.dart | 60 +- lib/components/library/user_artists.dart | 19 +- lib/components/library/user_downloads.dart | 2 +- .../library/user_downloads/download_item.dart | 4 +- lib/components/library/user_local_tracks.dart | 8 +- lib/components/library/user_playlists.dart | 28 +- lib/components/lyrics/zoom_controls.dart | 4 +- lib/components/player/player.dart | 4 +- lib/components/player/player_actions.dart | 6 +- lib/components/player/player_controls.dart | 6 +- lib/components/player/player_overlay.dart | 4 +- lib/components/player/player_queue.dart | 4 +- .../player/player_track_details.dart | 3 +- .../player/sibling_tracks_sheet.dart | 4 +- lib/components/player/volume_slider.dart | 4 +- lib/components/playlist/playlist_card.dart | 42 +- .../playlist/playlist_create_dialog.dart | 53 +- lib/components/root/bottom_player.dart | 2 +- lib/components/root/sidebar.dart | 16 +- .../root/spotube_navigation_bar.dart | 4 +- .../settings/color_scheme_picker_dialog.dart | 10 +- .../adaptive/adaptive_popup_menu_button.dart | 4 +- lib/components/shared/animated_gradient.dart | 5 +- lib/components/shared/compact_search.dart | 4 +- .../dialogs/confirm_download_dialog.dart | 4 +- .../shared/dialogs/piped_down_dialog.dart | 2 +- .../dialogs/playlist_add_track_dialog.dart | 48 +- .../dialogs/replace_downloaded_dialog.dart | 3 +- .../shared/dialogs/track_details_dialog.dart | 4 +- .../expandable_search/expandable_search.dart | 8 +- .../shared/fallbacks/anonymous_fallback.dart | 4 +- .../shared/fallbacks/not_found.dart | 2 +- lib/components/shared/heart_button.dart | 186 +---- .../horizontal_playbutton_card_view.dart | 15 +- lib/components/shared/hover_builder.dart | 4 +- .../shared/image/universal_image.dart | 4 +- .../shared/links/anchor_button.dart | 4 +- lib/components/shared/links/hyper_link.dart | 4 +- lib/components/shared/links/link_text.dart | 4 +- .../shared/page_window_title_bar.dart | 67 +- lib/components/shared/panels/helpers.dart | 3 +- .../shared/panels/sliding_up_panel.dart | 5 +- lib/components/shared/playbutton_card.dart | 4 +- .../shared/shimmers/shimmer_lyrics.dart | 2 +- .../shared/sort_tracks_dropdown.dart | 4 +- .../shared/themed_button_tab_bar.dart | 2 +- .../shared/track_tile/track_options.dart | 48 +- .../shared/track_tile/track_tile.dart | 4 +- .../sections/body/track_view_body.dart | 2 +- .../body/track_view_body_headers.dart | 4 +- .../sections/body/track_view_options.dart | 2 +- .../sections/body/use_is_user_playlist.dart | 14 +- .../sections/header/flexible_header.dart | 2 +- .../sections/header/header_actions.dart | 2 +- .../sections/header/header_buttons.dart | 4 +- .../shared/tracks_view/track_view.dart | 2 +- .../shared/tracks_view/track_view_props.dart | 14 - lib/components/shared/waypoint.dart | 4 +- lib/extensions/infinite_query.dart | 34 - lib/hooks/configurators/use_deep_linking.dart | 26 +- .../configurators/use_endless_playback.dart | 16 +- .../use_auto_scroll_controller.dart | 4 +- lib/hooks/controllers/use_package_info.dart | 4 +- .../controllers/use_sidebarx_controller.dart | 4 +- .../spotify/use_spotify_infinite_query.dart | 53 -- lib/hooks/spotify/use_spotify_mutation.dart | 36 - lib/hooks/spotify/use_spotify_query.dart | 52 -- lib/l10n/l10n.dart | 1 + lib/main.dart | 14 +- lib/models/spotify/recommendation_seeds.dart | 40 + .../spotify/recommendation_seeds.freezed.dart | 756 ++++++++++++++++++ .../spotify/recommendation_seeds.g.dart | 45 ++ lib/pages/album/album.dart | 71 +- lib/pages/artist/artist.dart | 12 +- lib/pages/artist/section/footer.dart | 21 +- lib/pages/artist/section/header.dart | 90 +-- lib/pages/artist/section/related_artists.dart | 64 +- lib/pages/artist/section/top_tracks.dart | 14 +- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/desktop_login/login_tutorial.dart | 2 +- lib/pages/home/genres/genre_playlists.dart | 39 +- lib/pages/home/genres/genres.dart | 17 +- lib/pages/home/home.dart | 2 +- lib/pages/lastfm_login/lastfm_login.dart | 2 +- lib/pages/library/library.dart | 2 +- .../playlist_generate/playlist_generate.dart | 295 +++++-- .../playlist_generate_result.dart | 394 +++++---- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/mini_lyrics.dart | 2 +- lib/pages/lyrics/plain_lyrics.dart | 15 +- lib/pages/lyrics/synced_lyrics.dart | 41 +- lib/pages/mobile_login/mobile_login.dart | 2 +- lib/pages/playlist/liked_playlist.dart | 12 +- lib/pages/playlist/playlist.dart | 102 +-- lib/pages/root/root_app.dart | 11 +- lib/pages/search/search.dart | 86 +- lib/pages/search/sections/albums.dart | 30 +- lib/pages/search/sections/artists.dart | 27 +- lib/pages/search/sections/playlists.dart | 29 +- lib/pages/search/sections/tracks.dart | 38 +- lib/pages/settings/about.dart | 2 +- lib/pages/settings/blacklist.dart | 2 +- lib/pages/settings/logs.dart | 2 +- lib/pages/settings/sections/about.dart | 2 +- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/settings/sections/appearance.dart | 4 +- lib/pages/settings/sections/desktop.dart | 2 +- lib/pages/settings/sections/developers.dart | 2 +- lib/pages/settings/sections/downloads.dart | 2 +- lib/pages/settings/sections/playback.dart | 2 +- lib/pages/settings/settings.dart | 2 +- lib/pages/track/track.dart | 10 +- lib/provider/authentication_provider.dart | 4 +- lib/provider/blacklist_provider.dart | 2 +- .../custom_spotify_endpoint_provider.dart | 2 + lib/provider/spotify/album/favorite.dart | 86 ++ lib/provider/spotify/album/is_saved.dart | 10 + lib/provider/spotify/album/releases.dart | 90 +++ lib/provider/spotify/album/tracks.dart | 58 ++ lib/provider/spotify/artist/albums.dart | 62 ++ lib/provider/spotify/artist/artist.dart | 10 + lib/provider/spotify/artist/following.dart | 104 +++ lib/provider/spotify/artist/is_following.dart | 10 + lib/provider/spotify/artist/related.dart | 11 + lib/provider/spotify/artist/top_tracks.dart | 15 + lib/provider/spotify/artist/wikipedia.dart | 12 + lib/provider/spotify/category/categories.dart | 20 + lib/provider/spotify/category/genres.dart | 6 + lib/provider/spotify/category/playlists.dart | 67 ++ lib/provider/spotify/lyrics/synced.dart | 77 ++ lib/provider/spotify/playlist/favorite.dart | 122 +++ lib/provider/spotify/playlist/featured.dart | 58 ++ lib/provider/spotify/playlist/generate.dart | 40 + lib/provider/spotify/playlist/liked.dart | 49 ++ lib/provider/spotify/playlist/playlist.dart | 90 +++ lib/provider/spotify/playlist/tracks.dart | 64 ++ lib/provider/spotify/search/search.dart | 76 ++ lib/provider/spotify/spotify.dart | 73 ++ lib/provider/spotify/tracks/track.dart | 10 + lib/provider/spotify/user/friends.dart | 7 + lib/provider/spotify/user/me.dart | 6 + lib/provider/spotify/utils/async.dart | 5 + lib/provider/spotify/utils/mixin.dart | 24 + lib/provider/spotify/utils/persistence.dart | 40 + lib/provider/spotify/utils/provider.dart | 6 + .../spotify/utils/provider/cursor.dart | 56 ++ .../spotify/utils/provider/cursor_family.dart | 113 +++ .../spotify/utils/provider/paginated.dart | 63 ++ .../utils/provider/paginated_family.dart | 113 +++ lib/provider/spotify/utils/state.dart | 56 ++ lib/provider/spotify/views/view.dart | 19 + .../audio_services/linux_audio_service.dart | 2 +- .../audio_services/mobile_audio_service.dart | 2 +- lib/services/connectivity_adapter.dart | 17 +- .../download_manager/download_manager.dart | 35 +- .../download_manager/download_task.dart | 7 +- lib/services/mutations/album.dart | 31 - lib/services/mutations/mutations.dart | 12 - lib/services/mutations/playlist.dart | 147 ---- lib/services/mutations/track.dart | 32 - lib/services/queries/album.dart | 114 --- lib/services/queries/artist.dart | 151 ---- lib/services/queries/category.dart | 120 --- lib/services/queries/lyrics.dart | 114 --- lib/services/queries/playlist.dart | 318 -------- lib/services/queries/queries.dart | 24 - lib/services/queries/search.dart | 60 -- lib/services/queries/tracks.dart | 16 - lib/services/queries/user.dart | 53 -- lib/services/queries/views.dart | 47 -- lib/utils/persisted_state_notifier.dart | 2 +- lib/utils/type_conversion_utils.dart | 3 + pubspec.lock | 40 - pubspec.yaml | 3 - 193 files changed, 3862 insertions(+), 2954 deletions(-) create mode 100644 .vscode/snippets.code-snippets delete mode 100644 lib/extensions/infinite_query.dart delete mode 100644 lib/hooks/spotify/use_spotify_infinite_query.dart delete mode 100644 lib/hooks/spotify/use_spotify_mutation.dart delete mode 100644 lib/hooks/spotify/use_spotify_query.dart create mode 100644 lib/models/spotify/recommendation_seeds.dart create mode 100644 lib/models/spotify/recommendation_seeds.freezed.dart create mode 100644 lib/models/spotify/recommendation_seeds.g.dart create mode 100644 lib/provider/spotify/album/favorite.dart create mode 100644 lib/provider/spotify/album/is_saved.dart create mode 100644 lib/provider/spotify/album/releases.dart create mode 100644 lib/provider/spotify/album/tracks.dart create mode 100644 lib/provider/spotify/artist/albums.dart create mode 100644 lib/provider/spotify/artist/artist.dart create mode 100644 lib/provider/spotify/artist/following.dart create mode 100644 lib/provider/spotify/artist/is_following.dart create mode 100644 lib/provider/spotify/artist/related.dart create mode 100644 lib/provider/spotify/artist/top_tracks.dart create mode 100644 lib/provider/spotify/artist/wikipedia.dart create mode 100644 lib/provider/spotify/category/categories.dart create mode 100644 lib/provider/spotify/category/genres.dart create mode 100644 lib/provider/spotify/category/playlists.dart create mode 100644 lib/provider/spotify/lyrics/synced.dart create mode 100644 lib/provider/spotify/playlist/favorite.dart create mode 100644 lib/provider/spotify/playlist/featured.dart create mode 100644 lib/provider/spotify/playlist/generate.dart create mode 100644 lib/provider/spotify/playlist/liked.dart create mode 100644 lib/provider/spotify/playlist/playlist.dart create mode 100644 lib/provider/spotify/playlist/tracks.dart create mode 100644 lib/provider/spotify/search/search.dart create mode 100644 lib/provider/spotify/spotify.dart create mode 100644 lib/provider/spotify/tracks/track.dart create mode 100644 lib/provider/spotify/user/friends.dart create mode 100644 lib/provider/spotify/user/me.dart create mode 100644 lib/provider/spotify/utils/async.dart create mode 100644 lib/provider/spotify/utils/mixin.dart create mode 100644 lib/provider/spotify/utils/persistence.dart create mode 100644 lib/provider/spotify/utils/provider.dart create mode 100644 lib/provider/spotify/utils/provider/cursor.dart create mode 100644 lib/provider/spotify/utils/provider/cursor_family.dart create mode 100644 lib/provider/spotify/utils/provider/paginated.dart create mode 100644 lib/provider/spotify/utils/provider/paginated_family.dart create mode 100644 lib/provider/spotify/utils/state.dart create mode 100644 lib/provider/spotify/views/view.dart delete mode 100644 lib/services/mutations/album.dart delete mode 100644 lib/services/mutations/mutations.dart delete mode 100644 lib/services/mutations/playlist.dart delete mode 100644 lib/services/mutations/track.dart delete mode 100644 lib/services/queries/album.dart delete mode 100644 lib/services/queries/artist.dart delete mode 100644 lib/services/queries/category.dart delete mode 100644 lib/services/queries/lyrics.dart delete mode 100644 lib/services/queries/playlist.dart delete mode 100644 lib/services/queries/queries.dart delete mode 100644 lib/services/queries/search.dart delete mode 100644 lib/services/queries/tracks.dart delete mode 100644 lib/services/queries/user.dart delete mode 100644 lib/services/queries/views.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e6a4294..472520ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Buildless", "danceability", "instrumentalness", "Mpris", diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets new file mode 100644 index 00000000..9a18929b --- /dev/null +++ b/.vscode/snippets.code-snippets @@ -0,0 +1,170 @@ +{ + "PaginatedState": { + "scope": "dart", + "prefix": "paginatedState", + "description": "Generate a PaginatedState", + "body": [ + "class ${1:Model}State extends PaginatedState<${2:Model}> {", + " ${1:Model}State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " ${1:Model}State copyWith({", + " List<${2:Model}>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return ${1:Model}State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}" + ] + }, + "PaginatedAsyncNotifier": { + "scope": "dart", + "prefix": "paginatedAsyncNotifier", + "description": "Generate a PaginatedAsyncNotifier", + "body": [ + "class ${1:NotifierName}Notifier extends PaginatedAsyncNotifier<${3:Item}, ${2:Model}State> {", + " ${1:NotifierName}Notifier() : super();", + " ", + " @override", + " fetch(int offset, int limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build() async {", + " throw UnimplementedError();", + " }", + "}" + ] + }, + "PaginaitedNotifierWithState": { + "scope": "dart", + "prefix": "paginatedNotifierWithState", + "description": "Generate a PaginatedNotifier with PaginatedState", + "body": [ + "class $1State extends PaginatedState<$2> {", + " $1State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " $1State copyWith({", + " List<$2>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return $1State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}", + " ", + "class $1Notifier", + " extends PaginatedAsyncNotifier<$2, $1State> {", + " $1Notifier() : super();", + " ", + " @override", + " fetch(int offset, int limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build() async {", + " throw UnimplementedError();", + " }", + "}", + " ", + "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProvider<$1Notifier, $1State>(", + " ()=> $1Notifier(),", + ");" + ] + }, + "FamilyPaginatedAsyncNotifier": { + "scope": "dart", + "prefix": "familyPaginatedAsyncNotifier", + "description": "Generate a FamilyPaginatedAsyncNotifier", + "body": [ + "class ${1:NotifierName}Notifier extends FamilyPaginatedAsyncNotifier<${3:Item}, ${2:Model}State, {$4:Arg}> {", + " ${1:NotifierName}Notifier() : super();", + " ", + " @override", + " fetch(arg, offset, limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build(arg) async {", + " throw UnimplementedError();", + " }", + "}" + ] + }, + "FamilyPaginaitedNotifierWithState": { + "scope": "dart", + "prefix": "familyPaginatedNotifierWithState", + "description": "Generate a FamilyPaginatedAsyncNotifier with PaginatedState", + "body": [ + "class $1State extends PaginatedState<$2> {", + " $1State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " $1State copyWith({", + " List<$2>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return $1State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}", + " ", + "class $1Notifier", + " extends FamilyPaginatedAsyncNotifier<$2, $1State, $3> {", + " $1Notifier() : super();", + " ", + " @override", + " fetch(arg, offset, limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build(arg) async {", + " throw UnimplementedError();", + " }", + "}", + " ", + "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProviderFamily<$1Notifier, $1State, $3>(", + " ()=> $1Notifier(),", + ");" + ] + }, +} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 748fc015..4ba476e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -25,6 +25,7 @@ linter: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule file_names: false + avoid_renaming_method_parameters: false # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 43d0cf2e..8428aaf3 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; @@ -96,8 +97,7 @@ final routerProvider = Provider((ref) { path: "result", pageBuilder: (context, state) => SpotubePage( child: PlaylistGenerateResultPage( - state: - state.extra as PlaylistGenerateResultRouteState, + state: state.extra as GeneratePlaylistProviderInput, ), ), ), diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 4d2e12d6..3838b7a4 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -1,15 +1,12 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/album.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -31,15 +28,12 @@ class AlbumCard extends HookConsumerWidget { useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final queryClient = useQueryClient(); - bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), [playlist, album.id], ); final updating = useState(false); - final spotify = ref.watch(spotifyProvider); final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); @@ -50,23 +44,8 @@ class AlbumCard extends HookConsumerWidget { TypeConversionUtils.simpleTrack_X_Track(track, album)) .toList(); } - final job = AlbumQueries.tracksOfJob(album.id!); - - final query = queryClient.createInfiniteQuery( - job.queryKey, - (page) => job.task(page, (spotify: spotify, album: album)), - initialPage: 0, - nextPage: job.nextPage, - ); - - return await query.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - return res - .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList(); - }, - ); + await ref.read(albumTracksProvider(album).future); + return ref.read(albumTracksProvider(album).notifier).fetchAll(); } return PlaybuttonCard( diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart index 5114170c..a91327ce 100644 --- a/lib/components/artist/artist_album_list.dart +++ b/lib/components/artist/artist_album_list.dart @@ -1,38 +1,35 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistAlbumList extends HookConsumerWidget { final String artistId; ArtistAlbumList( this.artistId, { - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(ArtistAlbumList); @override Widget build(BuildContext context, ref) { - final albumsQuery = useQueries.artist.albumsOf(ref, artistId); + final albumsQuery = ref.watch(artistAlbumsProvider(artistId)); + final albumsQueryNotifier = + ref.watch(artistAlbumsProvider(artistId).notifier); - final albums = useMemoized(() { - return albumsQuery.pages - .expand((page) => page.items ?? const Iterable.empty()) - .toList(); - }, [albumsQuery.pages]); + final albums = albumsQuery.asData?.value.items ?? []; final theme = Theme.of(context); return HorizontalPlaybuttonCardView( isLoadingNextPage: albumsQuery.isLoadingNextPage, - hasNextPage: albumsQuery.hasNextPage, + hasNextPage: albumsQuery.asData?.value.hasMore ?? false, items: albums, - onFetchMore: albumsQuery.fetchNext, + onFetchMore: albumsQueryNotifier.fetchMore, title: Text( context.l10n.albums, style: theme.textTheme.headlineSmall, diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 3526e88f..ac3e9bec 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -14,7 +14,7 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistCard extends HookConsumerWidget { final Artist artist; - const ArtistCard(this.artist, {Key? key}) : super(key: key); + const ArtistCard(this.artist, {super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 5abb9524..a3deb54a 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -8,9 +8,9 @@ import 'package:spotube/provider/authentication_provider.dart'; class TokenLoginForm extends HookConsumerWidget { final void Function()? onDone; const TokenLoginForm({ - Key? key, + super.key, this.onDone, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/home/sections/featured.dart b/lib/components/home/sections/featured.dart index 8a7c2c95..0db5a1e8 100644 --- a/lib/components/home/sections/featured.dart +++ b/lib/components/home/sections/featured.dart @@ -1,35 +1,28 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeFeaturedSection extends HookConsumerWidget { - const HomeFeaturedSection({Key? key}) : super(key: key); + const HomeFeaturedSection({super.key}); @override Widget build(BuildContext context, ref) { - final featuredPlaylistsQuery = useQueries.playlist.featured(ref); - final playlists = useMemoized( - () => featuredPlaylistsQuery.pages - .whereType>() - .expand((page) => page.items ?? const []), - [featuredPlaylistsQuery.pages], - ); - final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData && - !featuredPlaylistsQuery.isLoadingNextPage; + final featuredPlaylists = ref.watch(featuredPlaylistsProvider); + final featuredPlaylistsNotifier = + ref.watch(featuredPlaylistsProvider.notifier); return Skeletonizer( - enabled: isLoadingFeaturedPlaylists, + enabled: featuredPlaylists.isLoading, child: HorizontalPlaybuttonCardView( - items: playlists.toList(), + items: featuredPlaylists.asData?.value.items ?? [], title: Text(context.l10n.featured), - isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, + isLoadingNextPage: featuredPlaylists.isLoadingNextPage, + hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false, + onFetchMore: featuredPlaylistsNotifier.fetchMore, ), ); } diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart index 6382f6fd..35ec09b0 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/components/home/sections/friends.dart @@ -1,4 +1,3 @@ -import 'dart:ffi'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -8,15 +7,16 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { - const HomePageFriendsSection({Key? key}) : super(key: key); + const HomePageFriendsSection({super.key}); @override Widget build(BuildContext context, ref) { - final friendsQuery = useQueries.user.friendActivity(ref); - final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; + final friendsQuery = ref.watch(friendsProvider); + final friends = + friendsQuery.asData?.value.friends ?? FakeData.friends.friends; final groupCount = useBreakpointValue( sm: 3, @@ -51,8 +51,8 @@ class HomePageFriendsSection extends HookConsumerWidget { }, ); - if (!friendsQuery.isLoading && - (!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { + if (friendsQuery.isLoading || + friendsQuery.asData?.value.friends.isEmpty == true) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index fcdadab7..b883e2cc 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -1,10 +1,8 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; @@ -13,9 +11,9 @@ import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { final SpotifyFriendActivity friend; const FriendItem({ - Key? key, + super.key, required this.friend, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -24,7 +22,6 @@ class FriendItem extends HookConsumerWidget { colorScheme: colorScheme, ) = Theme.of(context); - final queryClient = useQueryClient(); final spotify = ref.watch(spotifyProvider); return Container( @@ -86,15 +83,11 @@ class FriendItem extends HookConsumerWidget { ..onTap = () async { context.push( "/${friend.track.context.path}", - extra: !friend.track.context.path - .startsWith("album") - ? null - : await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ), + extra: + !friend.track.context.path.startsWith("album") + ? null + : await spotify.albums + .get(friend.track.context.id), ); }, ), @@ -110,12 +103,7 @@ class FriendItem extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { final album = - await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ); + await spotify.albums.get(friend.track.album.id); if (context.mounted) { context.push( "/album/${friend.track.album.id}", diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 41ba235c..87f28821 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,28 +13,26 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({Key? key}) : super(key: key); + const HomeGenresSection({super.key}); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + final categoriesQuery = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.value + ?.where((c) => (c.icons?.length ?? 0) > 0) + .take(mediaQuery.mdAndDown ? 6 : 10) + .toList() ?? + [], + [mediaQuery.mdAndDown, categoriesQuery.value], ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data - ?.where((c) => (c.icons?.length ?? 0) > 0) - .take(mediaQuery.mdAndDown ? 6 : 10) - .toList() ?? - []; return SliverMainAxisGroup( slivers: [ diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart index a3f96899..439d9c38 100644 --- a/lib/components/home/sections/made_for_user.dart +++ b/lib/components/home/sections/made_for_user.dart @@ -2,19 +2,19 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeMadeForUserSection extends HookConsumerWidget { - const HomeMadeForUserSection({Key? key}) : super(key: key); + const HomeMadeForUserSection({super.key}); @override Widget build(BuildContext context, ref) { - final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); + final madeForUser = ref.watch(viewProvider("made-for-x-hub")); return SliverList.builder( - itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, + itemCount: madeForUser.value?["content"]?["items"]?.length ?? 0, itemBuilder: (context, index) { - final item = madeForUser.data?["content"]?["items"]?[index]; + final item = madeForUser.value?["content"]?["items"]?[index]; final playlists = item["content"]?["items"] ?.where((itemL2) => itemL2["type"] == "playlist") .map((itemL2) => PlaylistSimple.fromJson(itemL2)) diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart index 0f4a046a..57af12fd 100644 --- a/lib/components/home/sections/new_releases.dart +++ b/lib/components/home/sections/new_releases.dart @@ -1,56 +1,35 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeNewReleasesSection extends HookConsumerWidget { - const HomeNewReleasesSection({Key? key}) : super(key: key); + const HomeNewReleasesSection({super.key}); @override Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - final newReleases = useQueries.album.newReleases(ref); - final userArtistsQuery = useQueries.artist.followedByMeAll(ref); - final userArtists = - userArtistsQuery.data?.map((s) => s.id!).toList() ?? const []; + final newReleases = ref.watch(albumReleasesProvider); + final newReleasesNotifier = ref.read(albumReleasesProvider.notifier); - final albums = useMemoized( - () { - final allReleases = newReleases.pages - .whereType>() - .expand((page) => page.items ?? const []) - .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album)); + final albums = ref.watch(userArtistAlbumReleasesProvider); - final userArtistReleases = allReleases.where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }).toList(); - - if (userArtistReleases.isEmpty) return allReleases.toList(); - return userArtistReleases; - }, - [newReleases.pages], - ); - - final hasNewReleases = newReleases.hasPageData && - userArtistsQuery.hasData && - !newReleases.isLoadingNextPage; - - if (auth == null || !hasNewReleases) return const SizedBox.shrink(); + if (auth == null || + newReleases.isLoading || + newReleases.asData?.value.items.isEmpty == true) { + return const SizedBox.shrink(); + } return HorizontalPlaybuttonCardView( items: albums, title: Text(context.l10n.new_releases), isLoadingNextPage: newReleases.isLoadingNextPage, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, + hasNextPage: newReleases.asData?.value.hasMore ?? false, + onFetchMore: newReleasesNotifier.fetchMore, ); } } diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index ed5eb38f..e54fc2ba 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -25,7 +25,7 @@ class MultiSelectField extends HookWidget { final bool enabled; const MultiSelectField({ - Key? key, + super.key, required this.options, required this.selectedOptions, required this.getValueForOption, @@ -36,7 +36,7 @@ class MultiSelectField extends HookWidget { this.dialogTitle, this.helperText, this.enabled = true, - }) : super(key: key); + }); Widget defaultSelectedOptionBuilder(T option) { return Chip( @@ -134,14 +134,14 @@ class _MultiSelectDialog extends HookWidget { final String? helperText; const _MultiSelectDialog({ - Key? key, + super.key, required this.dialogTitle, required this.options, required this.getValueForOption, this.optionBuilder, this.initialSelection = const [], this.helperText, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart index 87f7cb1b..d7f51ffb 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart +++ b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart @@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget { final double base; const RecommendationAttributeDials({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.base = 1, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart index de169147..75437360 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart +++ b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart @@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget { final Map? presets; const RecommendationAttributeFields({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.presets, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart index b1665d32..73c58deb 100644 --- a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart +++ b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart @@ -26,7 +26,7 @@ class SeedsMultiAutocomplete extends HookWidget { final SelectedItemDisplayType selectedItemDisplayType; const SeedsMultiAutocomplete({ - Key? key, + super.key, required this.seeds, required this.fetchSeeds, required this.autocompleteOptionBuilder, @@ -35,7 +35,7 @@ class SeedsMultiAutocomplete extends HookWidget { this.inputDecoration, this.enabled = true, this.selectedItemDisplayType = SelectedItemDisplayType.wrap, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index 86800d06..e592969e 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -10,10 +10,10 @@ class SimpleTrackTile extends HookWidget { final Track track; final VoidCallback? onDelete; const SimpleTrackTile({ - Key? key, + super.key, required this.track, this.onDelete, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 200d1c59..07ba7a40 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -15,42 +14,38 @@ import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class UserAlbums extends HookConsumerWidget { - const UserAlbums({Key? key}) : super(key: key); + const UserAlbums({super.key}); @override Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - final albumsQuery = useQueries.album.ofMine(ref); + final albumsQuery = ref.watch(favoriteAlbumsProvider); + final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier); final controller = useScrollController(); final searchText = useState(''); - final allAlbums = useMemoized( - () => albumsQuery.pages - .expand((element) => element.items ?? []), - [albumsQuery.pages], - ); - final albums = useMemoized(() { if (searchText.value.isEmpty) { - return allAlbums; + return albumsQuery.asData?.value.items ?? []; } - return allAlbums - .map((e) => ( - weightedRatio(e.name!, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [allAlbums, searchText.value]); + return albumsQuery.asData?.value.items + .map((e) => ( + weightedRatio(e.name!, searchText.value), + e, + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() ?? + []; + }, [albumsQuery.value, searchText.value]); if (auth == null) { return const AnonymousFallback(); @@ -60,7 +55,7 @@ class UserAlbums extends HookConsumerWidget { return RefreshIndicator( onRefresh: () async { - await albumsQuery.refresh(); + ref.invalidate(favoriteAlbumsProvider); }, child: SafeArea( child: Scaffold( @@ -85,7 +80,7 @@ class UserAlbums extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), controller: controller, child: Skeletonizer( - enabled: albumsQuery.pages.isEmpty, + enabled: albumsQuery.isLoading, child: Center( child: Wrap( runSpacing: 20, @@ -93,7 +88,8 @@ class UserAlbums extends HookConsumerWidget { runAlignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (albumsQuery.pages.isEmpty) + if (albumsQuery.value == null || + albumsQuery.value!.items.isEmpty) ...List.generate( 10, (index) => AlbumCard(FakeData.album), @@ -107,12 +103,16 @@ class UserAlbums extends HookConsumerWidget { AlbumCard( TypeConversionUtils.simpleAlbum_X_Album(album), ), - if (albums.isNotEmpty && albumsQuery.hasNextPage) - Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQuery.fetchNext, - child: AlbumCard(FakeData.album), + if (albums.isNotEmpty && + albumsQuery.asData?.value.hasMore == true) + Skeletonizer( + enabled: true, + child: Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: albumsQueryNotifier.fetchMore, + child: AlbumCard(FakeData.album), + ), ) ], ), diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index 36b8528e..de6830c8 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -13,22 +13,22 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserArtists extends HookConsumerWidget { - const UserArtists({Key? key}) : super(key: key); + const UserArtists({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final auth = ref.watch(AuthenticationNotifier.provider); - final artistQuery = useQueries.artist.followedByMeAll(ref); + final artistQuery = ref.watch(followedArtistsProvider); final searchText = useState(''); final filteredArtists = useMemoized(() { - final artists = artistQuery.data ?? []; + final artists = artistQuery.asData?.value.items ?? []; if (searchText.value.isEmpty) { return artists.toList(); @@ -42,7 +42,7 @@ class UserArtists extends HookConsumerWidget { .where((e) => e.$1 > 50) .map((e) => e.$2) .toList(); - }, [artistQuery.data, searchText.value]); + }, [artistQuery.asData?.value.items, searchText.value]); final controller = useScrollController(); @@ -66,7 +66,7 @@ class UserArtists extends HookConsumerWidget { ), ), backgroundColor: theme.scaffoldBackgroundColor, - body: artistQuery.data?.isEmpty == true + body: artistQuery.asData?.value.items.isEmpty == true ? Padding( padding: const EdgeInsets.all(20), child: Row( @@ -80,7 +80,7 @@ class UserArtists extends HookConsumerWidget { ) : RefreshIndicator( onRefresh: () async { - await artistQuery.refresh(); + ref.invalidate(followedArtistsProvider); }, child: InterScrollbar( controller: controller, @@ -109,8 +109,9 @@ class UserArtists extends HookConsumerWidget { ) ] : filteredArtists - .mapIndexed((index, artist) => - ArtistCard(artist)) + .mapIndexed( + (index, artist) => ArtistCard(artist), + ) .toList(), ), ), diff --git a/lib/components/library/user_downloads.dart b/lib/components/library/user_downloads.dart index c8ceee66..3a1162e6 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/components/library/user_downloads.dart @@ -7,7 +7,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class UserDownloads extends HookConsumerWidget { - const UserDownloads({Key? key}) : super(key: key); + const UserDownloads({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 10dec410..1cb5e559 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -13,9 +13,9 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; const DownloadItem({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 095e6e97..b8f647a5 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -129,7 +129,7 @@ final localTracksProvider = FutureProvider>((ref) async { }); class UserLocalTracks extends HookConsumerWidget { - const UserLocalTracks({Key? key}) : super(key: key); + const UserLocalTracks({super.key}); Future playLocalTracks( WidgetRef ref, @@ -178,7 +178,7 @@ class UserLocalTracks extends HookConsumerWidget { FilledButton( onPressed: trackSnapshot.value != null ? () async { - if (trackSnapshot.value?.isNotEmpty == true) { + if (trackSnapshot.asData?.value.isNotEmpty == true) { if (!isPlaylistPlaying) { await playLocalTracks( ref, @@ -217,7 +217,7 @@ class UserLocalTracks extends HookConsumerWidget { FilledButton( child: const Icon(SpotubeIcons.refresh), onPressed: () { - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); }, ) ], @@ -269,7 +269,7 @@ class UserLocalTracks extends HookConsumerWidget { return Expanded( child: RefreshIndicator( onRefresh: () async { - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); }, child: InterScrollbar( controller: controller, diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 32e91ed6..3ff028b6 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -17,10 +17,10 @@ import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserPlaylists extends HookConsumerWidget { - const UserPlaylists({Key? key}) : super(key: key); + const UserPlaylists({super.key}); @override Widget build(BuildContext context, ref) { @@ -28,13 +28,9 @@ class UserPlaylists extends HookConsumerWidget { final auth = ref.watch(AuthenticationNotifier.provider); - final playlistsQuery = useQueries.playlist.ofMine(ref); - - final pagePlaylists = useMemoized( - () => playlistsQuery.pages - .expand((page) => page.items?.toList() ?? []), - [playlistsQuery.pages], - ); + final playlistsQuery = ref.watch(favoritePlaylistsProvider); + final playlistsQueryNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final likedTracksPlaylist = useMemoized( () => PlaylistSimple() @@ -58,12 +54,12 @@ class UserPlaylists extends HookConsumerWidget { if (searchText.value.isEmpty) { return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ]; } return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ] .map((e) => (weightedRatio(e.name!, searchText.value), e)) .sorted((a, b) => b.$1.compareTo(a.$1)) @@ -71,7 +67,7 @@ class UserPlaylists extends HookConsumerWidget { .map((e) => e.$2) .toList(); }, - [pagePlaylists, searchText.value], + [playlistsQuery, searchText.value], ); final controller = useScrollController(); @@ -81,7 +77,9 @@ class UserPlaylists extends HookConsumerWidget { } return RefreshIndicator( - onRefresh: playlistsQuery.refresh, + onRefresh: () async { + ref.invalidate(favoritePlaylistsProvider); + }, child: SafeArea( child: InterScrollbar( controller: controller, @@ -132,14 +130,14 @@ class UserPlaylists extends HookConsumerWidget { ), itemBuilder: (context, index) { if (playlists.isNotEmpty && index == playlists.length) { - if (!playlistsQuery.hasNextPage) { + if (playlistsQuery.asData?.value.hasMore != true) { return const SizedBox.shrink(); } return Waypoint( controller: controller, isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, + onTouchEdge: playlistsQueryNotifier.fetchMore, child: Skeletonizer( enabled: true, child: PlaylistCard(FakeData.playlistSimple), diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/components/lyrics/zoom_controls.dart index f50ea71d..73beb4ae 100644 --- a/lib/components/lyrics/zoom_controls.dart +++ b/lib/components/lyrics/zoom_controls.dart @@ -17,7 +17,7 @@ class ZoomControls extends HookWidget { final String unit; const ZoomControls({ - Key? key, + super.key, required this.value, required this.onChanged, this.min, @@ -27,7 +27,7 @@ class ZoomControls extends HookWidget { this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), this.direction = Axis.horizontal, this.unit = "%", - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 458676e3..5d5a39af 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -32,10 +32,10 @@ class PlayerView extends HookConsumerWidget { final PanelController panelController; final ScrollController scrollController; const PlayerView({ - Key? key, + super.key, required this.panelController, required this.scrollController, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 7a248aa5..18168af1 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -8,7 +8,6 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -29,13 +28,12 @@ class PlayerActions extends HookConsumerWidget { this.floatingQueue = true, this.showQueue = true, this.extraActions, - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(PlayerActions); @override Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 1000af18..02cbfff5 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -21,8 +21,8 @@ class PlayerControls extends HookConsumerWidget { PlayerControls({ this.palette, this.compact = false, - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(PlayerControls); @@ -256,7 +256,7 @@ class PlayerControls extends HookConsumerWidget { onPressed: playlist.isFetching == true ? null : () async { - switch (await audioPlayer.loopMode) { + switch (audioPlayer.loopMode) { case PlaybackLoopMode.all: audioPlayer .setLoopMode(PlaybackLoopMode.one); diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 2d63811e..1ad91a52 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -19,8 +19,8 @@ class PlayerOverlay extends HookConsumerWidget { const PlayerOverlay({ required this.albumArt, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 2784fb5f..449b6c2e 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -22,8 +22,8 @@ class PlayerQueue extends HookConsumerWidget { final bool floating; const PlayerQueue({ this.floating = true, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 66cb9ef5..fd97fd74 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -13,8 +13,7 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { final String? albumArt; final Color? color; - const PlayerTrackDetails({Key? key, this.albumArt, this.color}) - : super(key: key); + const PlayerTrackDetails({super.key, this.albumArt, this.color}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 58b1ca8c..c805cb42 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -45,9 +45,9 @@ final sourceInfoToIconMap = { class SiblingTracksSheet extends HookConsumerWidget { final bool floating; const SiblingTracksSheet({ - Key? key, + super.key, this.floating = true, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 75445125..7596a347 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -8,9 +8,9 @@ import 'package:spotube/provider/volume_provider.dart'; class VolumeSlider extends HookConsumerWidget { final bool fullWidth; const VolumeSlider({ - Key? key, + super.key, this.fullWidth = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index f429a0ab..ffbfbae9 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -1,14 +1,11 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -16,48 +13,30 @@ class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; const PlaylistCard( this.playlist, { - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final queryClient = QueryClient.of(context); - final tracks = useState?>(null); bool isPlaylistPlaying = useMemoized( () => playlistQueue.containsCollection(playlist.id!), [playlistQueue, playlist.id], ); final updating = useState(false); - final spotify = ref.watch(spotifyProvider); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); Future> fetchAllTracks() async { if (playlist.id == 'user-liked-tracks') { - return await queryClient.fetchQuery( - "user-liked-tracks", - () => useQueries.playlist.likedTracks(spotify), - ) ?? - []; + return await ref.read(likedTracksProvider.future); } - final query = queryClient.createInfiniteQuery, dynamic, int>( - "playlist-tracks/${playlist.id}", - (page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!), - initialPage: 0, - nextPage: useQueries.playlist.tracksOfQueryNextPage, - ); + await ref.read(playlistTracksProvider(playlist.id!).future); - return await query.fetchAllTracks( - getAllTracks: () async { - final res = - await spotify.playlists.getTracksByPlaylistId(playlist.id!).all(); - return res.toList(); - }, - ); + return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } return PlaybuttonCard( @@ -71,7 +50,8 @@ class PlaylistCard extends HookConsumerWidget { isPlaying: isPlaylistPlaying, isLoading: (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, - isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, + isOwner: playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null, onTap: () { ServiceUtils.push( context, @@ -94,7 +74,6 @@ class PlaylistCard extends HookConsumerWidget { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; } finally { if (context.mounted) { updating.value = false; @@ -112,10 +91,9 @@ class PlaylistCard extends HookConsumerWidget { playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${tracks.value?.length} tracks to queue"), + content: Text("Added ${fetchedTracks.length} tracks to queue"), action: SnackBarAction( label: "Undo", onPressed: () { diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index 2e11a209..669dce51 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:form_validator/form_validator.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:spotify/spotify.dart'; @@ -13,10 +14,8 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { @@ -24,10 +23,10 @@ class PlaylistCreateDialog extends HookConsumerWidget { final List trackIds; final String? playlistId; PlaylistCreateDialog({ - Key? key, + super.key, this.trackIds = const [], this.playlistId, - }) : super(key: key); + }); final formKey = GlobalKey(); @@ -37,13 +36,16 @@ class PlaylistCreateDialog extends HookConsumerWidget { child: Scaffold( backgroundColor: Colors.transparent, body: HookBuilder(builder: (context) { - final userPlaylists = useQueries.playlist.ofMine(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final playlist = ref.watch(playlistProvider(playlistId ?? "")); + final playlistNotifier = + ref.watch(playlistProvider(playlistId ?? "").notifier); + final updatingPlaylist = useMemoized( - () => userPlaylists.pages - .expand((p) => p.items ?? []) + () => userPlaylists.asData?.value.items .firstWhereOrNull((playlist) => playlist.id == playlistId), [ - userPlaylists.pages, + userPlaylists.asData?.value.items, playlistId, ], ); @@ -84,28 +86,10 @@ class PlaylistCreateDialog extends HookConsumerWidget { } }, [scaffold, l10n, theme]); - final playlistCreateMutation = useMutations.playlist.create( - ref, - trackIds: trackIds, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - - final playlistUpdateMutation = useMutations.playlist.update( - ref, - playlistId: playlistId, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - Future onCreate() async { if (!formKey.currentState!.validate()) return; - final PlaylistCRUDVariables payload = ( + final PlaylistInput payload = ( playlistName: playlistName.text, collaborative: collaborative.value, public: public.value, @@ -118,9 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget { ); if (isUpdatingPlaylist) { - await playlistUpdateMutation.mutate(payload); + await playlistNotifier.modify(payload, onError); } else { - await playlistCreateMutation.mutate(payload); + await playlistNotifier.create(payload, onError); + } + + if (context.mounted && + !ref.read(playlistProvider(playlistId ?? "")).hasError) { + context.pop(); } } @@ -138,7 +127,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { }, ), FilledButton( - onPressed: onCreate, + onPressed: playlist.isLoading ? null : onCreate, child: Text( isUpdatingPlaylist ? context.l10n.update @@ -275,7 +264,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { } class PlaylistCreateDialogButton extends HookConsumerWidget { - const PlaylistCreateDialogButton({Key? key}) : super(key: key); + const PlaylistCreateDialogButton({super.key}); showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 617e760b..3f70490a 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -25,7 +25,7 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class BottomPlayer extends HookConsumerWidget { - BottomPlayer({Key? key}) : super(key: key); + BottomPlayer({super.key}); final logger = getLogger(BottomPlayer); @override diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index a55ef947..21259a94 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -15,10 +15,10 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -31,8 +31,8 @@ class Sidebar extends HookConsumerWidget { required this.selectedIndex, required this.onSelectedIndexChanged, required this.child, - Key? key, - }) : super(key: key); + super.key, + }); static Widget brandLogo() { return Container( @@ -195,7 +195,7 @@ class Sidebar extends HookConsumerWidget { } class SidebarHeader extends HookWidget { - const SidebarHeader({Key? key}) : super(key: key); + const SidebarHeader({super.key}); @override Widget build(BuildContext context) { @@ -234,15 +234,15 @@ class SidebarHeader extends HookWidget { class SidebarFooter extends HookConsumerWidget { const SidebarFooter({ - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final me = useQueries.user.me(ref); - final data = me.data; + final me = ref.watch(meProvider); + final data = me.asData?.value; final avatarImg = TypeConversionUtils.image_X_UrlString( data?.images, diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 0853c60c..489399e5 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -23,8 +23,8 @@ class SpotubeNavigationBar extends HookConsumerWidget { const SpotubeNavigationBar({ required this.selectedIndex, required this.onSelectedIndexChanged, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index e0c3d618..8d098375 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -8,9 +8,9 @@ import 'package:system_theme/system_theme.dart'; class SpotubeColor extends Color { final String name; - const SpotubeColor(int color, {required this.name}) : super(color); + const SpotubeColor(super.color, {required this.name}); - const SpotubeColor.from(int value, {required this.name}) : super(value); + const SpotubeColor.from(super.value, {required this.name}); factory SpotubeColor.fromString(String string) { final slices = string.split(":"); @@ -44,7 +44,7 @@ final Set colorsMap = { }; class ColorSchemePickerDialog extends HookConsumerWidget { - const ColorSchemePickerDialog({Key? key}) : super(key: key); + const ColorSchemePickerDialog({super.key}); @override Widget build(BuildContext context, ref) { @@ -119,8 +119,8 @@ class ColorTile extends StatelessWidget { this.onPressed, this.tooltip = "", this.isCompact = false, - Key? key, - }) : super(key: key); + super.key, + }); factory ColorTile.compact({ required Color color, diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart index 45f22825..02fced52 100644 --- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart +++ b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart @@ -12,13 +12,13 @@ class Action extends StatelessWidget { final bool isExpanded; final Color? backgroundColor; const Action({ - Key? key, + super.key, required this.icon, required this.text, required this.onPressed, this.isExpanded = true, this.backgroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/shared/animated_gradient.dart index b6485f6b..aaba2ff9 100644 --- a/lib/components/shared/animated_gradient.dart +++ b/lib/components/shared/animated_gradient.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; class AnimateGradient extends HookWidget { const AnimateGradient({ - Key? key, + super.key, required this.primaryColors, required this.secondaryColors, this.child, @@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget { this.reverse = true, }) : assert(primaryColors.length >= 2), assert(primaryColors.length == secondaryColors.length), - _controller = controller, - super(key: key); + _controller = controller; /// [controller]: pass this to have a fine control over the [Animation] final AnimationController? _controller; diff --git a/lib/components/shared/compact_search.dart b/lib/components/shared/compact_search.dart index 70815291..d37cb673 100644 --- a/lib/components/shared/compact_search.dart +++ b/lib/components/shared/compact_search.dart @@ -11,12 +11,12 @@ class CompactSearch extends HookWidget { final Color? iconColor; const CompactSearch({ - Key? key, + super.key, this.onChanged, this.placeholder = "Search...", this.icon = SpotubeIcons.search, this.iconColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/shared/dialogs/confirm_download_dialog.dart index c371e803..486310a7 100644 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ b/lib/components/shared/dialogs/confirm_download_dialog.dart @@ -5,7 +5,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class ConfirmDownloadDialog extends StatelessWidget { - const ConfirmDownloadDialog({Key? key}) : super(key: key); + const ConfirmDownloadDialog({super.key}); @override Widget build(BuildContext context) { @@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget { class BulletPoint extends StatelessWidget { final String text; - const BulletPoint(this.text, {Key? key}) : super(key: key); + const BulletPoint(this.text, {super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart index 6220adeb..b1717a2a 100644 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ b/lib/components/shared/dialogs/piped_down_dialog.dart @@ -5,7 +5,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class PipedDownDialog extends HookConsumerWidget { - const PipedDownDialog({Key? key}) : super(key: key); + const PipedDownDialog({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 51b77c76..1f1807da 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -1,4 +1,3 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -8,8 +7,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { @@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { const PlaylistAddTrackDialog({ required this.tracks, required this.openFromPlaylist, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme) = Theme.of(context); - final spotify = ref.watch(spotifyProvider); - final userPlaylists = useQueries.playlist.ofMineAll(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); final filteredPlaylists = useMemoized( () => - userPlaylists.data - ?.where( + userPlaylists.asData?.value.items + .where( (playlist) => playlist.owner?.id != null && - playlist.owner!.id == me.data?.id && + playlist.owner!.id == me.asData?.value.id && playlist.id != openFromPlaylist, ) .toList() ?? [], - [userPlaylists.data, me.data?.id, openFromPlaylist], + [userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist], ); final playlistsCheck = useState({}); - final queryClient = useQueryClient(); + + useEffect(() { + if (userPlaylists.asData?.value != null) { + favoritePlaylistsNotifier.fetchAll(); + } + return null; + }, [userPlaylists.asData?.value]); Future onAdd() async { final selectedPlaylists = playlistsCheck.value.entries @@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { await Future.wait( selectedPlaylists.map( - (playlistId) => spotify.playlists.addTracks( - tracks - .map( - (track) => track.uri!, - ) - .toList(), - playlistId), + (playlistId) => favoritePlaylistsNotifier.addTracks( + playlistId, + tracks.map((e) => e.id!).toList(), + ), ), ).then((_) => Navigator.pop(context, true)); - - await queryClient.refreshQueries( - selectedPlaylists - .map((playlistId) => "playlist-tracks/$playlistId") - .toList(), - ); } return AlertDialog( diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/shared/dialogs/replace_downloaded_dialog.dart index 77721041..00461d34 100644 --- a/lib/components/shared/dialogs/replace_downloaded_dialog.dart +++ b/lib/components/shared/dialogs/replace_downloaded_dialog.dart @@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider((ref) => null); class ReplaceDownloadedDialog extends ConsumerWidget { final Track track; - const ReplaceDownloadedDialog({required this.track, Key? key}) - : super(key: key); + const ReplaceDownloadedDialog({required this.track, super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 8634776f..4e65b8e5 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -13,9 +13,9 @@ import 'package:spotube/extensions/duration.dart'; class TrackDetailsDialog extends HookWidget { final Track track; const TrackDetailsDialog({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart index 75ac6841..157e180f 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/shared/expandable_search/expandable_search.dart @@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget { final FocusNode searchFocus; const ExpandableSearchField({ - Key? key, + super.key, required this.isFiltering, required this.onChangeFiltering, required this.searchController, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -60,12 +60,12 @@ class ExpandableSearchButton extends StatelessWidget { final ValueChanged? onPressed; const ExpandableSearchButton({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, this.icon = const Icon(SpotubeIcons.filter), this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index aea7bf38..ace7ec64 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -8,9 +8,9 @@ import 'package:spotube/utils/service_utils.dart'; class AnonymousFallback extends ConsumerWidget { final Widget? child; const AnonymousFallback({ - Key? key, + super.key, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/shared/fallbacks/not_found.dart index f45573ad..5a74f672 100644 --- a/lib/components/shared/fallbacks/not_found.dart +++ b/lib/components/shared/fallbacks/not_found.dart @@ -3,7 +3,7 @@ import 'package:spotube/collections/assets.gen.dart'; class NotFound extends StatelessWidget { final bool vertical; - const NotFound({Key? key, this.vertical = false}) : super(key: key); + const NotFound({super.key, this.vertical = false}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index 81ccffdb..a733c36c 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -1,5 +1,3 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,8 +6,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HeartButton extends HookConsumerWidget { final bool isLiked; @@ -23,8 +20,8 @@ class HeartButton extends HookConsumerWidget { this.color, this.tooltip, this.icon, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -60,90 +57,50 @@ class HeartButton extends HookConsumerWidget { typedef UseTrackToggleLike = ({ bool isLiked, - Mutation toggleTrackLike, - Query me, + Future Function(Track track) toggleTrackLike, }); UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { - final me = useQueries.user.me(ref); - - final savedTracks = useQueries.playlist.likedTracksQuery(ref); + final savedTracks = ref.watch(likedTracksProvider); + final savedTracksNotifier = ref.watch(likedTracksProvider.notifier); final isLiked = useMemoized( - () => savedTracks.data?.any((element) => element.id == track.id) ?? false, - [savedTracks.data, track.id], + () => + savedTracks.asData?.value.any((element) => element.id == track.id) ?? + false, + [savedTracks.value, track.id], ); - final mounted = useIsMounted(); - final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - final toggleTrackLike = useMutations.track.toggleFavorite( - ref, - track.id!, - onMutate: (isLiked) { - if (isLiked) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - return isLiked; - }, - onData: (isLiked, recoveryData) async { - await savedTracks.refresh(); - if (isLiked) { + return ( + isLiked: isLiked, + toggleTrackLike: (track) async { + await savedTracksNotifier.toggleFavorite(track); + + if (!isLiked) { await scrobblerNotifier.love(track); } else { await scrobblerNotifier.unlove(track); } }, - onError: (payload, isLiked) { - if (!mounted()) return; - - if (isLiked != true) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - }, ); - - return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me); } class TrackHeartButton extends HookConsumerWidget { final Track track; const TrackHeartButton({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final savedTracks = useQueries.playlist.likedTracksQuery(ref); - final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); + final savedTracks = ref.watch(likedTracksProvider); + final me = ref.watch(meProvider); + final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); - if (me.isLoading || !me.hasData) { + if (me.isLoading) { return const CircularProgressIndicator(); } @@ -152,104 +109,9 @@ class TrackHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, isLiked: isLiked, - onPressed: savedTracks.hasData + onPressed: savedTracks.value != null ? () { - toggleTrackLike.mutate(isLiked); - } - : null, - ); - } -} - -class PlaylistHeartButton extends HookConsumerWidget { - final PlaylistSimple playlist; - final IconData? icon; - final ValueChanged? onData; - - const PlaylistHeartButton({ - required this.playlist, - Key? key, - this.icon, - this.onData, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - onData: onData, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLikedQuery.data ?? false, - tooltip: isLikedQuery.data ?? false - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - icon: icon, - onPressed: isLikedQuery.hasData - ? () { - togglePlaylistLike.mutate(isLikedQuery.data!); - } - : null, - ); - } -} - -class AlbumHeartButton extends HookConsumerWidget { - final AlbumSimple album; - - const AlbumHeartButton({ - required this.album, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final client = useQueryClient(); - final me = useQueries.user.me(ref); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLiked, - tooltip: isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - onPressed: albumIsSaved.hasData - ? () { - toggleAlbumLike.mutate(isLiked); + toggleTrackLike(track); } : null, ); diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index dc9d30da..8f0e6048 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -24,13 +24,12 @@ class HorizontalPlaybuttonCardView extends HookWidget { required this.hasNextPage, required this.onFetchMore, required this.isLoadingNextPage, - Key? key, - }) : assert( + super.key, + }) : assert( items is List || items is List || items is List, - ), - super(key: key); + ); @override Widget build(BuildContext context) { @@ -85,11 +84,11 @@ class HorizontalPlaybuttonCardView extends HookWidget { itemBuilder: (context, index) { final item = items[index]; - return switch (item.runtimeType) { - PlaylistSimple => + return switch (item) { + PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - Album => AlbumCard(item as Album), - Artist => Padding( + Album() => AlbumCard(item as Album), + Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), child: ArtistCard(item as Artist), diff --git a/lib/components/shared/hover_builder.dart b/lib/components/shared/hover_builder.dart index ec60848e..7793e744 100644 --- a/lib/components/shared/hover_builder.dart +++ b/lib/components/shared/hover_builder.dart @@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget { const HoverBuilder({ required this.builder, this.permanentState, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/shared/image/universal_image.dart index 04c62478..d8902e63 100644 --- a/lib/components/shared/image/universal_image.dart +++ b/lib/components/shared/image/universal_image.dart @@ -20,8 +20,8 @@ class UniversalImage extends HookWidget { this.placeholder, this.fit, this.scale = 1, - Key? key, - }) : super(key: key); + super.key, + }); static ImageProvider imageProvider( String path, { diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index b1b1cfea..d78bbf96 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -11,13 +11,13 @@ class AnchorButton extends HookWidget { const AnchorButton( this.text, { - Key? key, + super.key, this.onTap, this.textAlign, this.overflow, this.maxLines, this.style = const TextStyle(), - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/hyper_link.dart b/lib/components/shared/links/hyper_link.dart index fd31298e..f84517b4 100644 --- a/lib/components/shared/links/hyper_link.dart +++ b/lib/components/shared/links/hyper_link.dart @@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget { const Hyperlink( this.text, this.url, { - Key? key, + super.key, this.textAlign, this.overflow, this.style = const TextStyle(), this.maxLines, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/link_text.dart b/lib/components/shared/links/link_text.dart index d7b00b72..db7b6358 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/shared/links/link_text.dart @@ -15,14 +15,14 @@ class LinkText extends StatelessWidget { const LinkText( this.text, this.route, { - Key? key, + super.key, this.textAlign, this.extra, this.overflow, this.style = const TextStyle(), this.maxLines, this.push = false, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 9aa2d4a8..ff40bac7 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -27,7 +27,7 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget final Widget? title; const PageWindowTitleBar({ - Key? key, + super.key, this.actions, this.title, this.toolbarOpacity = 1, @@ -42,7 +42,7 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget this.titleTextStyle, this.titleWidth, this.toolbarTextStyle, - }) : super(key: key); + }); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -107,9 +107,9 @@ class _PageWindowTitleBarState extends ConsumerState { class WindowTitleBarButtons extends HookConsumerWidget { final Color? foregroundColor; const WindowTitleBarButtons({ - Key? key, + super.key, this.foregroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -277,14 +277,13 @@ class WindowButton extends StatelessWidget { final VoidCallback? onPressed; WindowButton( - {Key? key, + {super.key, WindowButtonColors? colors, this.builder, @required this.iconBuilder, this.padding, this.onPressed, - this.animate = false}) - : super(key: key) { + this.animate = false}) { this.colors = colors ?? _defaultButtonColors; } @@ -350,49 +349,40 @@ class WindowButton extends StatelessWidget { class MinimizeWindowButton extends WindowButton { MinimizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, + {super.key, + super.colors, + super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => MinimizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } class MaximizeWindowButton extends WindowButton { MaximizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, + {super.key, + super.colors, + super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => MaximizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } class RestoreWindowButton extends WindowButton { RestoreWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, + {super.key, + super.colors, + super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => RestoreIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } @@ -404,17 +394,15 @@ final _defaultCloseButtonColors = WindowButtonColors( class CloseWindowButton extends WindowButton { CloseWindowButton( - {Key? key, + {super.key, WindowButtonColors? colors, - VoidCallback? onPressed, + super.onPressed, bool? animate}) : super( - key: key, colors: colors ?? _defaultCloseButtonColors, animate: animate ?? false, iconBuilder: (buttonContext) => CloseIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } @@ -423,7 +411,7 @@ class CloseWindowButton extends WindowButton { /// Close class CloseIcon extends StatelessWidget { final Color color; - const CloseIcon({Key? key, required this.color}) : super(key: key); + const CloseIcon({super.key, required this.color}); @override Widget build(BuildContext context) => Align( alignment: Alignment.topLeft, @@ -444,13 +432,13 @@ class CloseIcon extends StatelessWidget { /// Maximize class MaximizeIcon extends StatelessWidget { final Color color; - const MaximizeIcon({Key? key, required this.color}) : super(key: key); + const MaximizeIcon({super.key, required this.color}); @override Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); } class _MaximizePainter extends _IconPainter { - _MaximizePainter(Color color) : super(color); + _MaximizePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -462,15 +450,15 @@ class _MaximizePainter extends _IconPainter { class RestoreIcon extends StatelessWidget { final Color color; const RestoreIcon({ - Key? key, + super.key, required this.color, - }) : super(key: key); + }); @override Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); } class _RestorePainter extends _IconPainter { - _RestorePainter(Color color) : super(color); + _RestorePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -487,13 +475,13 @@ class _RestorePainter extends _IconPainter { /// Minimize class MinimizeIcon extends StatelessWidget { final Color color; - const MinimizeIcon({Key? key, required this.color}) : super(key: key); + const MinimizeIcon({super.key, required this.color}); @override Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); } class _MinimizePainter extends _IconPainter { - _MinimizePainter(Color color) : super(color); + _MinimizePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -512,7 +500,7 @@ abstract class _IconPainter extends CustomPainter { } class _AlignedPaint extends StatelessWidget { - const _AlignedPaint(this.painter, {Key? key}) : super(key: key); + const _AlignedPaint(this.painter); final CustomPainter painter; @override @@ -547,8 +535,7 @@ T? _ambiguate(T? value) => value; class MouseStateBuilder extends StatefulWidget { final MouseStateBuilderCB builder; final VoidCallback? onPressed; - const MouseStateBuilder({Key? key, required this.builder, this.onPressed}) - : super(key: key); + const MouseStateBuilder({super.key, required this.builder, this.onPressed}); @override _MouseStateBuilderState createState() => _MouseStateBuilderState(); } diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart index 2e754bdf..7dad96d5 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/shared/panels/helpers.dart @@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener { /// To make [ForceDraggableWidget] work in [Scrollable] widgets class PanelScrollPhysics extends ScrollPhysics { final PanelController controller; - const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) - : super(parent: parent); + const PanelScrollPhysics({required this.controller, super.parent}); @override PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { return PanelScrollPhysics( diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart index 137d5eb7..e99fe261 100644 --- a/lib/components/shared/panels/sliding_up_panel.dart +++ b/lib/components/shared/panels/sliding_up_panel.dart @@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget { final BoxDecoration? panelDecoration; const SlidingUpPanel( - {Key? key, + {super.key, this.body, this.collapsed, this.minHeight = 100.0, @@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget { this.panelBuilder}) : assert(panelBuilder != null), assert(0 <= backdropOpacity && backdropOpacity <= 1.0), - assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), - super(key: key); + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0); @override SlidingUpPanelState createState() => SlidingUpPanelState(); diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index a8a75d30..80a27eb0 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -43,8 +43,8 @@ class PlaybuttonCard extends HookWidget { this.onAddToQueuePressed, this.onTap, this.isOwner = false, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shared/shimmers/shimmer_lyrics.dart index b225c008..03816202 100644 --- a/lib/components/shared/shimmers/shimmer_lyrics.dart +++ b/lib/components/shared/shimmers/shimmer_lyrics.dart @@ -5,7 +5,7 @@ import 'package:gap/gap.dart'; import 'package:skeletonizer/skeletonizer.dart'; class ShimmerLyrics extends HookWidget { - const ShimmerLyrics({Key? key}) : super(key: key); + const ShimmerLyrics({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/shared/sort_tracks_dropdown.dart index ab35b2e3..be72d689 100644 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ b/lib/components/shared/sort_tracks_dropdown.dart @@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget { const SortTracksDropdown({ this.onChanged, this.value, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index d5798189..017f04aa 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key); + const ThemedButtonsTabBar({super.key, required this.tabs}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index a094259d..8522738d 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -23,9 +22,8 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/search.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -53,13 +51,13 @@ class TrackOptions extends HookConsumerWidget { final ObjectRef?>? showMenuCbRef; final Widget? icon; const TrackOptions({ - Key? key, + super.key, required this.track, this.showMenuCbRef, this.userPlaylist = false, this.playlistId, this.icon, - }) : super(key: key); + }); void actionShare(BuildContext context, Track track) { final data = "https://open.spotify.com/track/${track.id}"; @@ -99,21 +97,10 @@ class TrackOptions extends HookConsumerWidget { final playlist = ref.read(ProxyPlaylistNotifier.provider); final spotify = ref.read(spotifyProvider); final query = "${track.name} Radio"; - final pages = await QueryClient.of(context) - .fetchInfiniteQueryJob, dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query, - ), - ) ?? - []; + final pages = + await spotify.search.get(query, types: [SearchType.playlist]).first(); - final radios = pages - .expand((e) => e.items?.toList() ?? []) - .toList() - .cast(); + final radios = pages.map((e) => e.items).toList().cast(); final artists = track.artists!.map((e) => e.name); @@ -176,6 +163,7 @@ class TrackOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider.notifier); final blacklist = ref.watch(BlackListNotifier.provider); + final me = ref.watch(meProvider); final favorites = useTrackToggleLike(track, ref); @@ -190,10 +178,8 @@ class TrackOptions extends HookConsumerWidget { ); final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isInQueue = useMemoized(() { if (playlist.activeTrack == null) return false; @@ -220,7 +206,7 @@ class TrackOptions extends HookConsumerWidget { break; case TrackOptionValue.delete: await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); break; case TrackOptionValue.addToQueue: await playback.addTrack(track); @@ -257,14 +243,15 @@ class TrackOptions extends HookConsumerWidget { ); break; case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); + favorites.toggleTrackLike(track); break; case TrackOptionValue.addToPlaylist: actionAddToPlaylist(context, track); break; case TrackOptionValue.removeFromPlaylist: removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); + favoritePlaylistsNotifier + .removeTracks(playlistId ?? "", [track.id!]); break; case TrackOptionValue.blacklist: if (isBlackListed) { @@ -328,7 +315,7 @@ class TrackOptions extends HookConsumerWidget { ), ], children: switch (track.runtimeType) { - LocalTrack => [ + LocalTrack() => [ PopSheetEntry( value: TrackOptionValue.delete, leading: const Icon(SpotubeIcons.trash), @@ -361,7 +348,7 @@ class TrackOptions extends HookConsumerWidget { leading: const Icon(SpotubeIcons.queueRemove), title: Text(context.l10n.remove_from_queue), ), - if (favorites.me.hasData) + if (me.value != null) PopSheetEntry( value: TrackOptionValue.favorite, leading: favorites.isLiked @@ -391,10 +378,7 @@ class TrackOptions extends HookConsumerWidget { if (userPlaylist && auth != null) PopSheetEntry( value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), + leading: const Icon(SpotubeIcons.removeFilled), title: Text(context.l10n.remove_from_playlist), ), PopSheetEntry( diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index d268c783..ecadc1c6 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -32,7 +32,7 @@ class TrackTile extends HookConsumerWidget { final List? leadingActions; const TrackTile({ - Key? key, + super.key, this.index, required this.track, this.selected = false, @@ -42,7 +42,7 @@ class TrackTile extends HookConsumerWidget { this.userPlaylist = false, this.playlistId, this.leadingActions, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index 33c8fa82..661e5af4 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -19,7 +19,7 @@ import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class TrackViewBodySection extends HookConsumerWidget { - const TrackViewBodySection({Key? key}) : super(key: key); + const TrackViewBodySection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart index 7e4522a0..3a1538a3 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart @@ -13,10 +13,10 @@ class TrackViewBodyHeaders extends HookConsumerWidget { final FocusNode searchFocus; const TrackViewBodyHeaders({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index 583c9107..5560ef3f 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -13,7 +13,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({Key? key}) : super(key: key); + const TrackViewBodyOptions({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart index ca3c6706..d32efed2 100644 --- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart +++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart @@ -1,18 +1,18 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; bool useIsUserPlaylist(WidgetRef ref, String playlistId) { - final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); - final me = useQueries.user.me(ref); + final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider); + final me = ref.watch(meProvider); return useMemoized( () => - userPlaylistsQuery.data?.any((e) => + userPlaylistsQuery.asData?.value.items.any((e) => e.id == playlistId && - me.data != null && - e.owner?.id == me.data?.id) ?? + me.value != null && + e.owner?.id == me.asData?.value.id) ?? false, - [userPlaylistsQuery.data, playlistId, me.data], + [userPlaylistsQuery.value, playlistId, me.value], ); } diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart index 19241dc6..4a704302 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -14,7 +14,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({Key? key}) : super(key: key); + const TrackViewFlexHeader({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index 75aa3f61..a16dd750 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({Key? key}) : super(key: key); + const TrackViewHeaderActions({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index bae47f12..513f7aaa 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -15,10 +15,10 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final PaletteColor color; final bool compact; const TrackViewHeaderButtons({ - Key? key, + super.key, required this.color, this.compact = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart index 4103573c..eb8f6871 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/shared/tracks_view/track_view.dart @@ -10,7 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/track_view_b import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; class TrackView extends HookConsumerWidget { - const TrackView({Key? key}) : super(key: key); + const TrackView({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index 21bbaec7..a1a07f84 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:spotify/spotify.dart'; @@ -19,19 +18,6 @@ class PaginationProps { required this.onRefresh, }); - factory PaginationProps.fromQuery( - InfiniteQuery, dynamic, int> query, { - required Future> Function() onFetchAll, - }) { - return PaginationProps( - hasNextPage: query.hasNextPage, - isLoading: query.isLoadingNextPage, - onFetchMore: query.fetchNext, - onFetchAll: onFetchAll, - onRefresh: query.refreshAll, - ); - } - @override operator ==(Object other) { return other is PaginationProps && diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index abd9f98d..08e9088a 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -11,12 +11,12 @@ class Waypoint extends HookWidget { final bool isGrid; const Waypoint({ - Key? key, + super.key, required this.controller, this.isGrid = false, this.onTouchEdge, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart deleted file mode 100644 index 2181ab3c..00000000 --- a/lib/extensions/infinite_query.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:spotify/spotify.dart'; - -extension FetchAllTracks on InfiniteQuery, dynamic, int> { - Future> fetchAllTracks({ - required Future> Function() getAllTracks, - }) async { - if (pages.isNotEmpty && !hasNextPage) { - return pages.expand((page) => page).toList(); - } - final tracks = await getAllTracks(); - - final numOfPages = (tracks.length / 20).round(); - - final Map> pagedTracks = {}; - - for (var i = 0; i < numOfPages; i++) { - if (i == numOfPages - 1) { - final pageTracks = tracks.sublist(i * 20); - pagedTracks[i] = pageTracks; - break; - } - - final pageTracks = tracks.sublist(i * 20, (i + 1) * 20); - pagedTracks[i] = pageTracks; - } - - for (final group in pagedTracks.entries) { - setPageData(group.key, group.value); - } - - return tracks.toList(); - } -} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index f11a1cff..2650b05c 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -1,10 +1,8 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; @@ -17,8 +15,6 @@ final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); void useDeepLinking(WidgetRef ref) { // single instance no worries final spotify = ref.watch(spotifyProvider); - final queryClient = useQueryClient(); - final router = ref.watch(routerProvider); useEffect(() { @@ -32,10 +28,7 @@ void useDeepLinking(WidgetRef ref) { case "album": router.push( "/album/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "album/${url.pathSegments.last}", - () => spotify.albums.get(url.pathSegments.last), - ), + extra: await spotify.albums.get(url.pathSegments.last), ); break; case "artist": @@ -44,10 +37,7 @@ void useDeepLinking(WidgetRef ref) { case "playlist": router.push( "/playlist/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "playlist/${url.pathSegments.last}", - () => spotify.playlists.get(url.pathSegments.last), - ), + extra: await spotify.playlists.get(url.pathSegments.last), ); break; case "track": @@ -78,10 +68,7 @@ void useDeepLinking(WidgetRef ref) { case "spotify:album": await router.push( "/album/$endSegment", - extra: await queryClient.fetchQuery( - "album/$endSegment", - () => spotify.albums.get(endSegment), - ), + extra: await spotify.albums.get(endSegment), ); break; case "spotify:artist": @@ -93,10 +80,7 @@ void useDeepLinking(WidgetRef ref) { case "spotify:playlist": await router.push( "/playlist/$endSegment", - extra: await queryClient.fetchQuery( - "playlist/$endSegment", - () => spotify.playlists.get(endSegment), - ), + extra: await spotify.playlists.get(endSegment), ); break; default: @@ -108,5 +92,5 @@ void useDeepLinking(WidgetRef ref) { mediaStream?.cancel(); subscription.cancel(); }; - }, [spotify, queryClient]); + }, [spotify]); } diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index f5d11829..3cd55e40 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -1,5 +1,4 @@ import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -8,7 +7,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/search.dart'; void useEndlessPlayback(WidgetRef ref) { final auth = ref.watch(AuthenticationNotifier.provider); @@ -18,7 +16,6 @@ void useEndlessPlayback(WidgetRef ref) { final endlessPlayback = ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); - final queryClient = useQueryClient(); useEffect( () { @@ -32,16 +29,8 @@ void useEndlessPlayback(WidgetRef ref) { final track = playlist.tracks.last; final query = "${track.name} Radio"; - final pages = await queryClient.fetchInfiniteQueryJob, - dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query - ), - ) ?? - []; + final pages = await spotify.search + .get(query, types: [SearchType.playlist]).first(); final radios = pages .expand((e) => e.items?.toList() ?? []) @@ -94,7 +83,6 @@ void useEndlessPlayback(WidgetRef ref) { [ spotify, playback, - queryClient, playlist.tracks, endlessPlayback, auth, diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart index 8edfb041..0c7119e4 100644 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ b/lib/hooks/controllers/use_auto_scroll_controller.dart @@ -39,8 +39,8 @@ class _AutoScrollControllerHook extends Hook { this.copyTagsFrom, this.suggestedRowHeight, this.debugLabel, - List? keys, - }) : super(keys: keys); + super.keys, + }); final double initialScrollOffset; final bool keepScrollOffset; diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart index 9b142ced..b3c05665 100644 --- a/lib/hooks/controllers/use_package_info.dart +++ b/lib/hooks/controllers/use_package_info.dart @@ -44,8 +44,8 @@ class _PackageInfoHook extends Hook { required this.version, required this.buildNumber, this.buildSignature = '', - List? keys, - }) : super(keys: keys); + super.keys, + }); @override HookState> createState() => diff --git a/lib/hooks/controllers/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart index 5af921b7..a14c3305 100644 --- a/lib/hooks/controllers/use_sidebarx_controller.dart +++ b/lib/hooks/controllers/use_sidebarx_controller.dart @@ -24,8 +24,8 @@ class _SidebarXControllerHook extends Hook { const _SidebarXControllerHook({ required this.selectedIndex, this.extended, - List? keys, - }) : super(keys: keys); + super.keys, + }); final int selectedIndex; final bool? extended; diff --git a/lib/hooks/spotify/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart deleted file mode 100644 index 2063b083..00000000 --- a/lib/hooks/spotify/use_spotify_infinite_query.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -InfiniteQuery - useSpotifyInfiniteQuery( - String queryKey, - FutureOr Function(PageType page, SpotifyApi spotify) queryFn, { - required WidgetRef ref, - required InfiniteQueryNextPage nextPage, - required PageType initialPage, - RetryConfig? retryConfig, - RefreshConfig? refreshConfig, - JsonConfig? jsonConfig, - ValueChanged>? onData, - ValueChanged>? onError, - bool enabled = true, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQuery( - queryKey, - (page) => queryFn(page, spotify), - nextPage: nextPage, - initialPage: initialPage, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - keys: keys, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/hooks/spotify/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart deleted file mode 100644 index 637f778f..00000000 --- a/lib/hooks/spotify/use_spotify_mutation.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -Mutation - useSpotifyMutation( - String mutationKey, - Future Function(VariablesType variables, SpotifyApi spotify) - mutationFn, { - required WidgetRef ref, - RetryConfig? retryConfig, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - MutationOnMutationFn? onMutate, - List? refreshQueries, - List? refreshInfiniteQueries, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final mutation = - useMutation( - mutationKey, - (variables) => mutationFn(variables, spotify), - retryConfig: retryConfig, - onData: onData, - onError: onError, - onMutate: onMutate, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - keys: keys, - ); - - return mutation; -} diff --git a/lib/hooks/spotify/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart deleted file mode 100644 index 0c79de91..00000000 --- a/lib/hooks/spotify/use_spotify_query.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SpotifyQueryFn = FutureOr Function( - SpotifyApi spotify); - -Query useSpotifyQuery( - final String queryKey, - final SpotifyQueryFn queryFn, { - required WidgetRef ref, - final DataType? initial, - final RetryConfig? retryConfig, - final RefreshConfig? refreshConfig, - final JsonConfig? jsonConfig, - final ValueChanged? onData, - final ValueChanged? onError, - final bool enabled = true, -}) { - final spotify = ref.watch(spotifyProvider); - - final query = useQuery( - queryKey, - () => queryFn(spotify), - initial: initial, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7aec682a..31eecc99 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -11,6 +11,7 @@ /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean +library; import 'package:flutter/material.dart'; class L10n { diff --git a/lib/main.dart b/lib/main.dart index 3281f85f..5c100fd3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,12 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:device_preview/device_preview.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; @@ -29,7 +27,6 @@ import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; -import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -75,11 +72,7 @@ Future main(List rawArgs) async { final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; - await QueryClient.initialize( - cachePrefix: "oss.krtirtho.spotube", - cacheDir: hiveCacheDir, - connectivity: FlQueryInternetConnectionCheckerAdapter(), - ); + Hive.init(hiveCacheDir); Hive.registerAdapter(SkipSegmentAdapter()); @@ -145,10 +138,7 @@ Future main(List rawArgs) async { orientation: Orientation.portrait, ), builder: (context) { - return QueryClientProvider( - staleDuration: const Duration(minutes: 30), - child: const Spotube(), - ); + return const Spotube(); }, ), ), diff --git a/lib/models/spotify/recommendation_seeds.dart b/lib/models/spotify/recommendation_seeds.dart new file mode 100644 index 00000000..0d874ad6 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recommendation_seeds.freezed.dart'; +part 'recommendation_seeds.g.dart'; + +@freezed +class GeneratePlaylistProviderInput with _$GeneratePlaylistProviderInput { + factory GeneratePlaylistProviderInput({ + Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + required int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target, + }) = _GeneratePlaylistProviderInput; +} + +@freezed +class RecommendationSeeds with _$RecommendationSeeds { + factory RecommendationSeeds({ + num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence, + }) = _RecommendationSeeds; + + factory RecommendationSeeds.fromJson(Map json) => + _$RecommendationSeedsFromJson(json); +} diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart new file mode 100644 index 00000000..4cfcce12 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -0,0 +1,756 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +mixin _$GeneratePlaylistProviderInput { + Iterable? get seedArtists => throw _privateConstructorUsedError; + Iterable? get seedGenres => throw _privateConstructorUsedError; + Iterable? get seedTracks => throw _privateConstructorUsedError; + int get limit => throw _privateConstructorUsedError; + RecommendationSeeds? get max => throw _privateConstructorUsedError; + RecommendationSeeds? get min => throw _privateConstructorUsedError; + RecommendationSeeds? get target => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $GeneratePlaylistProviderInputCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GeneratePlaylistProviderInputCopyWith<$Res> { + factory $GeneratePlaylistProviderInputCopyWith( + GeneratePlaylistProviderInput value, + $Res Function(GeneratePlaylistProviderInput) then) = + _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + GeneratePlaylistProviderInput>; + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + $RecommendationSeedsCopyWith<$Res>? get max; + $RecommendationSeedsCopyWith<$Res>? get min; + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + $Val extends GeneratePlaylistProviderInput> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + _$GeneratePlaylistProviderInputCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_value.copyWith( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get max { + if (_value.max == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.max!, (value) { + return _then(_value.copyWith(max: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get min { + if (_value.min == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.min!, (value) { + return _then(_value.copyWith(min: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get target { + if (_value.target == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.target!, (value) { + return _then(_value.copyWith(target: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$GeneratePlaylistProviderInputImplCopyWith<$Res> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + factory _$$GeneratePlaylistProviderInputImplCopyWith( + _$GeneratePlaylistProviderInputImpl value, + $Res Function(_$GeneratePlaylistProviderInputImpl) then) = + __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + @override + $RecommendationSeedsCopyWith<$Res>? get max; + @override + $RecommendationSeedsCopyWith<$Res>? get min; + @override + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res> + extends _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + _$GeneratePlaylistProviderInputImpl> + implements _$$GeneratePlaylistProviderInputImplCopyWith<$Res> { + __$$GeneratePlaylistProviderInputImplCopyWithImpl( + _$GeneratePlaylistProviderInputImpl _value, + $Res Function(_$GeneratePlaylistProviderInputImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_$GeneratePlaylistProviderInputImpl( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + )); + } +} + +/// @nodoc + +class _$GeneratePlaylistProviderInputImpl + implements _GeneratePlaylistProviderInput { + _$GeneratePlaylistProviderInputImpl( + {this.seedArtists, + this.seedGenres, + this.seedTracks, + required this.limit, + this.max, + this.min, + this.target}); + + @override + final Iterable? seedArtists; + @override + final Iterable? seedGenres; + @override + final Iterable? seedTracks; + @override + final int limit; + @override + final RecommendationSeeds? max; + @override + final RecommendationSeeds? min; + @override + final RecommendationSeeds? target; + + @override + String toString() { + return 'GeneratePlaylistProviderInput(seedArtists: $seedArtists, seedGenres: $seedGenres, seedTracks: $seedTracks, limit: $limit, max: $max, min: $min, target: $target)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GeneratePlaylistProviderInputImpl && + const DeepCollectionEquality() + .equals(other.seedArtists, seedArtists) && + const DeepCollectionEquality() + .equals(other.seedGenres, seedGenres) && + const DeepCollectionEquality() + .equals(other.seedTracks, seedTracks) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.max, max) || other.max == max) && + (identical(other.min, min) || other.min == min) && + (identical(other.target, target) || other.target == target)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(seedArtists), + const DeepCollectionEquality().hash(seedGenres), + const DeepCollectionEquality().hash(seedTracks), + limit, + max, + min, + target); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => __$$GeneratePlaylistProviderInputImplCopyWithImpl< + _$GeneratePlaylistProviderInputImpl>(this, _$identity); +} + +abstract class _GeneratePlaylistProviderInput + implements GeneratePlaylistProviderInput { + factory _GeneratePlaylistProviderInput( + {final Iterable? seedArtists, + final Iterable? seedGenres, + final Iterable? seedTracks, + required final int limit, + final RecommendationSeeds? max, + final RecommendationSeeds? min, + final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl; + + @override + Iterable? get seedArtists; + @override + Iterable? get seedGenres; + @override + Iterable? get seedTracks; + @override + int get limit; + @override + RecommendationSeeds? get max; + @override + RecommendationSeeds? get min; + @override + RecommendationSeeds? get target; + @override + @JsonKey(ignore: true) + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => throw _privateConstructorUsedError; +} + +RecommendationSeeds _$RecommendationSeedsFromJson(Map json) { + return _RecommendationSeeds.fromJson(json); +} + +/// @nodoc +mixin _$RecommendationSeeds { + num? get acousticness => throw _privateConstructorUsedError; + num? get danceability => throw _privateConstructorUsedError; + @JsonKey(name: "duration_ms") + num? get durationMs => throw _privateConstructorUsedError; + num? get energy => throw _privateConstructorUsedError; + num? get instrumentalness => throw _privateConstructorUsedError; + num? get key => throw _privateConstructorUsedError; + num? get liveness => throw _privateConstructorUsedError; + num? get loudness => throw _privateConstructorUsedError; + num? get mode => throw _privateConstructorUsedError; + num? get popularity => throw _privateConstructorUsedError; + num? get speechiness => throw _privateConstructorUsedError; + num? get tempo => throw _privateConstructorUsedError; + @JsonKey(name: "time_signature") + num? get timeSignature => throw _privateConstructorUsedError; + num? get valence => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $RecommendationSeedsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RecommendationSeedsCopyWith<$Res> { + factory $RecommendationSeedsCopyWith( + RecommendationSeeds value, $Res Function(RecommendationSeeds) then) = + _$RecommendationSeedsCopyWithImpl<$Res, RecommendationSeeds>; + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class _$RecommendationSeedsCopyWithImpl<$Res, $Val extends RecommendationSeeds> + implements $RecommendationSeedsCopyWith<$Res> { + _$RecommendationSeedsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_value.copyWith( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RecommendationSeedsImplCopyWith<$Res> + implements $RecommendationSeedsCopyWith<$Res> { + factory _$$RecommendationSeedsImplCopyWith(_$RecommendationSeedsImpl value, + $Res Function(_$RecommendationSeedsImpl) then) = + __$$RecommendationSeedsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class __$$RecommendationSeedsImplCopyWithImpl<$Res> + extends _$RecommendationSeedsCopyWithImpl<$Res, _$RecommendationSeedsImpl> + implements _$$RecommendationSeedsImplCopyWith<$Res> { + __$$RecommendationSeedsImplCopyWithImpl(_$RecommendationSeedsImpl _value, + $Res Function(_$RecommendationSeedsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_$RecommendationSeedsImpl( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$RecommendationSeedsImpl implements _RecommendationSeeds { + _$RecommendationSeedsImpl( + {this.acousticness, + this.danceability, + @JsonKey(name: "duration_ms") this.durationMs, + this.energy, + this.instrumentalness, + this.key, + this.liveness, + this.loudness, + this.mode, + this.popularity, + this.speechiness, + this.tempo, + @JsonKey(name: "time_signature") this.timeSignature, + this.valence}); + + factory _$RecommendationSeedsImpl.fromJson(Map json) => + _$$RecommendationSeedsImplFromJson(json); + + @override + final num? acousticness; + @override + final num? danceability; + @override + @JsonKey(name: "duration_ms") + final num? durationMs; + @override + final num? energy; + @override + final num? instrumentalness; + @override + final num? key; + @override + final num? liveness; + @override + final num? loudness; + @override + final num? mode; + @override + final num? popularity; + @override + final num? speechiness; + @override + final num? tempo; + @override + @JsonKey(name: "time_signature") + final num? timeSignature; + @override + final num? valence; + + @override + String toString() { + return 'RecommendationSeeds(acousticness: $acousticness, danceability: $danceability, durationMs: $durationMs, energy: $energy, instrumentalness: $instrumentalness, key: $key, liveness: $liveness, loudness: $loudness, mode: $mode, popularity: $popularity, speechiness: $speechiness, tempo: $tempo, timeSignature: $timeSignature, valence: $valence)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RecommendationSeedsImpl && + (identical(other.acousticness, acousticness) || + other.acousticness == acousticness) && + (identical(other.danceability, danceability) || + other.danceability == danceability) && + (identical(other.durationMs, durationMs) || + other.durationMs == durationMs) && + (identical(other.energy, energy) || other.energy == energy) && + (identical(other.instrumentalness, instrumentalness) || + other.instrumentalness == instrumentalness) && + (identical(other.key, key) || other.key == key) && + (identical(other.liveness, liveness) || + other.liveness == liveness) && + (identical(other.loudness, loudness) || + other.loudness == loudness) && + (identical(other.mode, mode) || other.mode == mode) && + (identical(other.popularity, popularity) || + other.popularity == popularity) && + (identical(other.speechiness, speechiness) || + other.speechiness == speechiness) && + (identical(other.tempo, tempo) || other.tempo == tempo) && + (identical(other.timeSignature, timeSignature) || + other.timeSignature == timeSignature) && + (identical(other.valence, valence) || other.valence == valence)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + acousticness, + danceability, + durationMs, + energy, + instrumentalness, + key, + liveness, + loudness, + mode, + popularity, + speechiness, + tempo, + timeSignature, + valence); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + __$$RecommendationSeedsImplCopyWithImpl<_$RecommendationSeedsImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$RecommendationSeedsImplToJson( + this, + ); + } +} + +abstract class _RecommendationSeeds implements RecommendationSeeds { + factory _RecommendationSeeds( + {final num? acousticness, + final num? danceability, + @JsonKey(name: "duration_ms") final num? durationMs, + final num? energy, + final num? instrumentalness, + final num? key, + final num? liveness, + final num? loudness, + final num? mode, + final num? popularity, + final num? speechiness, + final num? tempo, + @JsonKey(name: "time_signature") final num? timeSignature, + final num? valence}) = _$RecommendationSeedsImpl; + + factory _RecommendationSeeds.fromJson(Map json) = + _$RecommendationSeedsImpl.fromJson; + + @override + num? get acousticness; + @override + num? get danceability; + @override + @JsonKey(name: "duration_ms") + num? get durationMs; + @override + num? get energy; + @override + num? get instrumentalness; + @override + num? get key; + @override + num? get liveness; + @override + num? get loudness; + @override + num? get mode; + @override + num? get popularity; + @override + num? get speechiness; + @override + num? get tempo; + @override + @JsonKey(name: "time_signature") + num? get timeSignature; + @override + num? get valence; + @override + @JsonKey(ignore: true) + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart new file mode 100644 index 00000000..bdfa3a07 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( + Map json) => + _$RecommendationSeedsImpl( + acousticness: json['acousticness'] as num?, + danceability: json['danceability'] as num?, + durationMs: json['duration_ms'] as num?, + energy: json['energy'] as num?, + instrumentalness: json['instrumentalness'] as num?, + key: json['key'] as num?, + liveness: json['liveness'] as num?, + loudness: json['loudness'] as num?, + mode: json['mode'] as num?, + popularity: json['popularity'] as num?, + speechiness: json['speechiness'] as num?, + tempo: json['tempo'] as num?, + timeSignature: json['time_signature'] as num?, + valence: json['valence'] as num?, + ); + +Map _$$RecommendationSeedsImplToJson( + _$RecommendationSeedsImpl instance) => + { + 'acousticness': instance.acousticness, + 'danceability': instance.danceability, + 'duration_ms': instance.durationMs, + 'energy': instance.energy, + 'instrumentalness': instance.instrumentalness, + 'key': instance.key, + 'liveness': instance.liveness, + 'loudness': instance.loudness, + 'mode': instance.mode, + 'popularity': instance.popularity, + 'speechiness': instance.speechiness, + 'tempo': instance.tempo, + 'time_signature': instance.timeSignature, + 'valence': instance.valence, + }; diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 4578aea2..fac0a6a6 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,15 +1,10 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumPage extends HookConsumerWidget { @@ -21,26 +16,10 @@ class AlbumPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.album.tracksOf(ref, album); - - final tracks = useMemoized(() { - return tracksQuery.pages.expand((element) => element).toList(); - }, [tracksQuery.pages]); - - final client = useQueryClient(); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); + final tracks = ref.watch(albumTracksProvider(album)); + final tracksNotifier = ref.watch(albumTracksProvider(album).notifier); + final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); + final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( collectionId: album.id!, @@ -51,29 +30,33 @@ class AlbumPage extends HookConsumerWidget { title: album.name!, description: "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", - tracks: tracks, - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks(getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - - return res - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); - }); + tracks: tracks.asData?.value.items ?? [], + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: () async { + await tracksNotifier.fetchMore(); + }, + onFetchAll: () async { + return tracksNotifier.fetchAll(); + }, + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); }, ), routePath: "/album/${album.id}", shareUrl: album.externalUrls!.spotify!, - isLiked: isLiked, - onHeart: albumIsSaved.hasData - ? () async { - await toggleAlbumLike.mutate(isLiked); + isLiked: isSavedAlbum.value ?? false, + onHeart: isSavedAlbum.value == null + ? null + : () async { + if (isSavedAlbum.value!) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } return null; - } - : null, + }, child: const TrackView(), ); } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d511cb97..c153f0af 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -12,19 +12,19 @@ import 'package:spotube/pages/artist/section/footer.dart'; import 'package:spotube/pages/artist/section/header.dart'; import 'package:spotube/pages/artist/section/related_artists.dart'; import 'package:spotube/pages/artist/section/top_tracks.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { final String artistId; final logger = getLogger(ArtistPage); - ArtistPage(this.artistId, {Key? key}) : super(key: key); + ArtistPage(this.artistId, {super.key}); @override Widget build(BuildContext context, ref) { final scrollController = useScrollController(); final theme = Theme.of(context); - final artistQuery = useQueries.artist.get(ref, artistId); + final artistQuery = ref.watch(artistProvider(artistId)); return SafeArea( bottom: false, @@ -35,7 +35,7 @@ class ArtistPage extends HookConsumerWidget { ), extendBodyBehindAppBar: true, body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.data == null) { + if (artistQuery.hasError && artistQuery.value == null) { return Center(child: Text(artistQuery.error.toString())); } return Skeletonizer( @@ -66,11 +66,11 @@ class ArtistPage extends HookConsumerWidget { SliverSafeArea( sliver: ArtistPageRelatedArtists(artistId: artistId), ), - if (artistQuery.data != null) + if (artistQuery.value != null) SliverSafeArea( top: false, sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.data!), + child: ArtistPageFooter(artist: artistQuery.value!), ), ), ], diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index b01ef705..ac166252 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -5,13 +5,13 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; -class ArtistPageFooter extends HookConsumerWidget { +class ArtistPageFooter extends ConsumerWidget { final Artist artist; - const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + const ArtistPageFooter({super.key, required this.artist}); @override Widget build(BuildContext context, ref) { @@ -22,8 +22,9 @@ class ArtistPageFooter extends HookConsumerWidget { artist.images, placeholder: ImagePlaceholder.artist, ); - final summary = useQueries.artist.wikipediaSummary(artist); - if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + final summary = ref.watch(artistWikipediaSummaryProvider(artist)); + if (summary.value == null) return const SizedBox.shrink(); + return Container( margin: const EdgeInsets.all(16), padding: mediaQuery.smAndDown @@ -38,9 +39,9 @@ class ArtistPageFooter extends HookConsumerWidget { BlendMode.darken, ), image: UniversalImage.imageProvider( - summary.data!.thumbnail?.source_ ?? artistImage, - height: summary.data!.thumbnail?.height.toDouble(), - width: summary.data!.thumbnail?.width.toDouble(), + summary.value!.thumbnail?.source_ ?? artistImage, + height: summary.value!.thumbnail?.height.toDouble(), + width: summary.value!.thumbnail?.width.toDouble(), ), fit: BoxFit.cover, alignment: Alignment.center, @@ -69,7 +70,7 @@ class ArtistPageFooter extends HookConsumerWidget { ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.data!.extract, + text: summary.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', @@ -81,7 +82,7 @@ class ArtistPageFooter extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrlString( - "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + "http://en.wikipedia.org/wiki?curid=${summary.asData?.value?.pageid}", ); }, ), diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7cee7a01..7756da15 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -1,11 +1,8 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; @@ -14,20 +11,18 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistPageHeader extends HookConsumerWidget { final String artistId; - const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key); + const ArtistPageHeader({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { - final queryClient = useQueryClient(); - final artistQuery = useQueries.artist.get(ref, artistId); - final artist = artistQuery.data ?? FakeData.artist; + final artistQuery = ref.watch(artistProvider(artistId)); + final artist = artistQuery.value ?? FakeData.artist; final scaffoldMessenger = ScaffoldMessenger.of(context); final mediaQuery = MediaQuery.of(context); @@ -43,7 +38,6 @@ class ArtistPageHeader extends HookConsumerWidget { xxl: textTheme.titleMedium, ); - final spotify = ref.read(spotifyProvider); final auth = ref.watch(AuthenticationNotifier.provider); final blacklist = ref.watch(BlackListNotifier.provider); final isBlackListed = blacklist.contains( @@ -143,53 +137,41 @@ class ArtistPageHeader extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = - useQueries.artist.doIFollow(ref, artistId); + Consumer( + builder: (context, ref, _) { + final isFollowingQuery = ref + .watch(artistIsFollowingProvider(artist.id!)); + final followingArtistNotifier = + ref.watch(followedArtistsProvider.notifier); - final followUnfollow = useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], + return switch (isFollowingQuery) { + AsyncData(value: final following) => Builder( + builder: (context) { + if (following) { + return OutlinedButton( + onPressed: () async { + await followingArtistNotifier + .removeArtists([artist.id!]); + }, + child: Text(context.l10n.following), ); - await isFollowingQuery.refresh(); + } - queryClient.refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); + return FilledButton( + onPressed: () async { + await followingArtistNotifier + .saveArtists([artist.id!]); + }, + child: Text(context.l10n.follow), + ); + }, + ), + AsyncError() => const SizedBox(), + _ => const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ) + }; }, ), const SizedBox(width: 5), diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 2938c084..7fc48ded 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,49 +1,45 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; -class ArtistPageRelatedArtists extends HookConsumerWidget { +class ArtistPageRelatedArtists extends ConsumerWidget { final String artistId; const ArtistPageRelatedArtists({ - Key? key, + super.key, required this.artistId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final relatedArtists = useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); + final relatedArtists = ref.watch(relatedArtistsProvider(artistId)); - if (relatedArtists.isLoading || !relatedArtists.hasData) { - return const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator())); - } else if (relatedArtists.hasError) { - return SliverToBoxAdapter( - child: Center( - child: Text(relatedArtists.error.toString()), + return switch (relatedArtists) { + AsyncData(value: final artists) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: artists.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = artists.elementAt(index); + return ArtistCard(artist); + }, + ), ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverGrid.builder( - itemCount: relatedArtists.data!.length, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: 250, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 0.8, + AsyncError(:final error) => SliverToBoxAdapter( + child: Center( + child: Text(error.toString()), + ), ), - itemBuilder: (context, index) { - final artist = relatedArtists.data!.elementAt(index); - return ArtistCard(artist); - }, - ), - ); + _ => const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + }; } } diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 771757b9..9ad2b0db 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -7,12 +7,11 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPageTopTracks extends HookConsumerWidget { final String artistId; - const ArtistPageTopTracks({Key? key, required this.artistId}) - : super(key: key); + const ArtistPageTopTracks({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { @@ -21,13 +20,10 @@ class ArtistPageTopTracks extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); + final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], + topTracksQuery.value ?? [], ); if (topTracksQuery.hasError) { @@ -39,7 +35,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { } final topTracks = - topTracksQuery.data ?? List.generate(10, (index) => FakeData.track); + topTracksQuery.value ?? List.generate(10, (index) => FakeData.track); void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index c2cc3695..9c061091 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -9,7 +9,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class DesktopLoginPage extends HookConsumerWidget { - const DesktopLoginPage({Key? key}) : super(key: key); + const DesktopLoginPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 24373e75..e6a4cf9a 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { - const LoginTutorial({Key? key}) : super(key: key); + const LoginTutorial({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index bfb0843c..d80b4513 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -12,7 +10,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; @@ -22,23 +20,10 @@ class GenrePlaylistsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlistsQuery = useQueries.category.playlistsOf( - ref, - category.id!, - ); - - final playlists = useMemoized( - () => playlistsQuery.pages.expand( - (page) { - return page.items?.whereNotNull() ?? - const Iterable.empty(); - }, - ).toList(), - [playlistsQuery.pages], - ); - final mediaQuery = MediaQuery.of(context); - + final playlists = ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsNotifier = + ref.read(categoryPlaylistsProvider(category.id!).notifier); final scrollController = useScrollController(); return Scaffold( @@ -109,7 +94,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { padding: EdgeInsets.symmetric( horizontal: mediaQuery.mdAndDown ? 12 : 24, ), - sliver: playlists.isEmpty + sliver: playlists.asData?.value.items.isNotEmpty != true ? Skeletonizer.sliver( child: SliverToBoxAdapter( child: Wrap( @@ -129,12 +114,14 @@ class GenrePlaylistsPage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: playlists.length + 1, + itemCount: + (playlists.asData?.value.items.length ?? 0) + 1, itemBuilder: (context, index) { - final playlist = playlists.elementAtOrNull(index); + final playlist = playlists.asData?.value.items + .elementAtOrNull(index); if (playlist == null) { - if (!playlistsQuery.hasNextPage) { + if (playlists.asData?.value.hasMore == false) { return const SizedBox.shrink(); } return Skeletonizer( @@ -142,11 +129,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { child: Waypoint( controller: scrollController, isGrid: true, - onTouchEdge: () async { - if (playlistsQuery.hasNextPage) { - await playlistsQuery.fetchNext(); - } - }, + onTouchEdge: playlistsNotifier.fetchMore, child: PlaylistCard(FakeData.playlist), ), ); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 17a67beb..ed6c2835 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -5,14 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/gradients.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { const GenrePage({super.key}); @@ -21,13 +18,7 @@ class GenrePage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final ThemeData(:textTheme) = Theme.of(context); final scrollController = useScrollController(); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data ?? []; + final categories = ref.watch(categoriesProvider); final mediaQuery = MediaQuery.of(context); @@ -48,9 +39,9 @@ class GenrePage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: categories.length, + itemCount: categories.value!.length, itemBuilder: (context, index) { - final category = categories[index]; + final category = categories.value![index]; final gradient = gradients[Random().nextInt(gradients.length)]; return InkWell( borderRadius: BorderRadius.circular(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 312ca7f9..ed297065 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -11,7 +11,7 @@ import 'package:spotube/components/home/sections/new_releases.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; class HomePage extends HookConsumerWidget { - const HomePage({Key? key}) : super(key: key); + const HomePage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 4280328f..b6aeef2e 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { - const LastFMLoginPage({Key? key}) : super(key: key); + const LastFMLoginPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index b6b88656..ccdb6a35 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,7 +12,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { - const LibraryPage({Key? key}) : super(key: key); + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 802b28d3..642ceb6c 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -15,16 +15,16 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { - const PlaylistGeneratorPage({Key? key}) : super(key: key); + const PlaylistGeneratorPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -34,7 +34,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final textTheme = theme.textTheme; final preferences = ref.watch(userPreferencesProvider); - final genresCollection = useQueries.category.genreSeeds(ref); + final genresCollection = ref.watch(categoryGenresProvider); final limit = useValueNotifier(10); final market = useValueNotifier(preferences.recommendationMarket); @@ -50,22 +50,9 @@ class PlaylistGeneratorPage extends HookConsumerWidget { 5 - genres.value.length - artists.value.length - tracks.value.length; // Dial (int 0-1) attributes - final acousticness = useState(zeroValues); - final danceability = useState(zeroValues); - final energy = useState(zeroValues); - final instrumentalness = useState(zeroValues); - final key = useState(zeroValues); - final liveness = useState(zeroValues); - final loudness = useState(zeroValues); - final popularity = useState(zeroValues); - final speechiness = useState(zeroValues); - final valence = useState(zeroValues); - - // Field editable attributes - final tempo = useState(zeroValues); - final durationMs = useState(zeroValues); - final mode = useState(zeroValues); - final timeSignature = useState(zeroValues); + final min = useState(RecommendationSeeds()); + final max = useState(RecommendationSeeds()); + final target = useState(RecommendationSeeds()); final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, @@ -203,7 +190,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - options: genresCollection.data ?? [], + options: genresCollection.value ?? [], selectedOptions: genres.value, getValueForOption: (option) => option, onSelected: (value) { @@ -355,88 +342,213 @@ class PlaylistGeneratorPage extends HookConsumerWidget { const SizedBox(height: 16), RecommendationAttributeDials( title: Text(context.l10n.acousticness), - values: acousticness.value, + values: ( + target: target.value.acousticness?.toDouble() ?? 0, + min: min.value.acousticness?.toDouble() ?? 0, + max: max.value.acousticness?.toDouble() ?? 0, + ), onChanged: (value) { - acousticness.value = value; + target.value = target.value.copyWith( + acousticness: value.target, + ); + min.value = min.value.copyWith( + acousticness: value.min, + ); + max.value = max.value.copyWith( + acousticness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.danceability), - values: danceability.value, + values: ( + target: target.value.danceability?.toDouble() ?? 0, + min: min.value.danceability?.toDouble() ?? 0, + max: max.value.danceability?.toDouble() ?? 0, + ), onChanged: (value) { - danceability.value = value; + target.value = target.value.copyWith( + danceability: value.target, + ); + min.value = min.value.copyWith( + danceability: value.min, + ); + max.value = max.value.copyWith( + danceability: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.energy), - values: energy.value, + values: ( + target: target.value.energy?.toDouble() ?? 0, + min: min.value.energy?.toDouble() ?? 0, + max: max.value.energy?.toDouble() ?? 0, + ), onChanged: (value) { - energy.value = value; + target.value = target.value.copyWith( + energy: value.target, + ); + min.value = min.value.copyWith( + energy: value.min, + ); + max.value = max.value.copyWith( + energy: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.instrumentalness), - values: instrumentalness.value, + values: ( + target: + target.value.instrumentalness?.toDouble() ?? 0, + min: min.value.instrumentalness?.toDouble() ?? 0, + max: max.value.instrumentalness?.toDouble() ?? 0, + ), onChanged: (value) { - instrumentalness.value = value; + target.value = target.value.copyWith( + instrumentalness: value.target, + ); + min.value = min.value.copyWith( + instrumentalness: value.min, + ); + max.value = max.value.copyWith( + instrumentalness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.liveness), - values: liveness.value, + values: ( + target: target.value.liveness?.toDouble() ?? 0, + min: min.value.liveness?.toDouble() ?? 0, + max: max.value.liveness?.toDouble() ?? 0, + ), onChanged: (value) { - liveness.value = value; + target.value = target.value.copyWith( + liveness: value.target, + ); + min.value = min.value.copyWith( + liveness: value.min, + ); + max.value = max.value.copyWith( + liveness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.loudness), - values: loudness.value, + values: ( + target: target.value.loudness?.toDouble() ?? 0, + min: min.value.loudness?.toDouble() ?? 0, + max: max.value.loudness?.toDouble() ?? 0, + ), onChanged: (value) { - loudness.value = value; + target.value = target.value.copyWith( + loudness: value.target, + ); + min.value = min.value.copyWith( + loudness: value.min, + ); + max.value = max.value.copyWith( + loudness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.speechiness), - values: speechiness.value, + values: ( + target: target.value.speechiness?.toDouble() ?? 0, + min: min.value.speechiness?.toDouble() ?? 0, + max: max.value.speechiness?.toDouble() ?? 0, + ), onChanged: (value) { - speechiness.value = value; + target.value = target.value.copyWith( + speechiness: value.target, + ); + min.value = min.value.copyWith( + speechiness: value.min, + ); + max.value = max.value.copyWith( + speechiness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.valence), - values: valence.value, + values: ( + target: target.value.valence?.toDouble() ?? 0, + min: min.value.valence?.toDouble() ?? 0, + max: max.value.valence?.toDouble() ?? 0, + ), onChanged: (value) { - valence.value = value; + target.value = target.value.copyWith( + valence: value.target, + ); + min.value = min.value.copyWith( + valence: value.min, + ); + max.value = max.value.copyWith( + valence: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.popularity), - values: popularity.value, base: 100, + values: ( + target: target.value.popularity?.toDouble() ?? 0, + min: min.value.popularity?.toDouble() ?? 0, + max: max.value.popularity?.toDouble() ?? 0, + ), onChanged: (value) { - popularity.value = value; + target.value = target.value.copyWith( + popularity: value.target, + ); + min.value = min.value.copyWith( + popularity: value.min, + ); + max.value = max.value.copyWith( + popularity: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.key), - values: key.value, base: 11, + values: ( + target: target.value.key?.toDouble() ?? 0, + min: min.value.key?.toDouble() ?? 0, + max: max.value.key?.toDouble() ?? 0, + ), onChanged: (value) { - key.value = value; + target.value = target.value.copyWith( + key: value.target, + ); + min.value = min.value.copyWith( + key: value.min, + ); + max.value = max.value.copyWith( + key: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.duration), values: ( - max: durationMs.value.max / 1000, - target: durationMs.value.target / 1000, - min: durationMs.value.min / 1000, + max: (max.value.durationMs ?? 0) / 1000, + target: (target.value.durationMs ?? 0) / 1000, + min: (min.value.durationMs ?? 0) / 1000, ), onChanged: (value) { - durationMs.value = ( - max: value.max * 1000, - target: value.target * 1000, - min: value.min * 1000, + target.value = target.value.copyWith( + durationMs: (value.target * 1000).toInt(), + ); + min.value = min.value.copyWith( + durationMs: (value.min * 1000).toInt(), + ); + max.value = max.value.copyWith( + durationMs: (value.max * 1000).toInt(), ); }, presets: { @@ -451,23 +563,59 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), RecommendationAttributeFields( title: Text(context.l10n.tempo), - values: tempo.value, + values: ( + max: max.value.tempo?.toDouble() ?? 0, + target: target.value.tempo?.toDouble() ?? 0, + min: min.value.tempo?.toDouble() ?? 0, + ), onChanged: (value) { - tempo.value = value; + target.value = target.value.copyWith( + tempo: value.target, + ); + min.value = min.value.copyWith( + tempo: value.min, + ); + max.value = max.value.copyWith( + tempo: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.mode), - values: mode.value, + values: ( + max: max.value.mode?.toDouble() ?? 0, + target: target.value.mode?.toDouble() ?? 0, + min: min.value.mode?.toDouble() ?? 0, + ), onChanged: (value) { - mode.value = value; + target.value = target.value.copyWith( + mode: value.target, + ); + min.value = min.value.copyWith( + mode: value.min, + ); + max.value = max.value.copyWith( + mode: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.time_signature), - values: timeSignature.value, + values: ( + max: max.value.timeSignature?.toDouble() ?? 0, + target: target.value.timeSignature?.toDouble() ?? 0, + min: min.value.timeSignature?.toDouble() ?? 0, + ), onChanged: (value) { - timeSignature.value = value; + target.value = target.value.copyWith( + timeSignature: value.target, + ); + min.value = min.value.copyWith( + timeSignature: value.min, + ); + max.value = max.value.copyWith( + timeSignature: value.max, + ); }, ), const SizedBox(height: 20), @@ -479,35 +627,18 @@ class PlaylistGeneratorPage extends HookConsumerWidget { genres.value.isEmpty ? null : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: artists.value - .map((a) => a.id!) - .toList(), - tracks: tracks.value - .map((t) => t.id!) - .toList(), - genres: genres.value - ), - market: market.value, + final routeState = + GeneratePlaylistProviderInput( + seedArtists: artists.value + .map((a) => a.id!) + .toList(), + seedTracks: + tracks.value.map((t) => t.id!).toList(), + seedGenres: genres.value, limit: limit.value, - parameters: ( - acousticness: acousticness.value, - danceability: danceability.value, - energy: energy.value, - instrumentalness: instrumentalness.value, - liveness: liveness.value, - loudness: loudness.value, - speechiness: speechiness.value, - valence: valence.value, - popularity: popularity.value, - key: key.value, - duration_ms: durationMs.value, - tempo: tempo.value, - mode: mode.value, - time_signature: timeSignature.value, - ) + max: max.value, + min: min.value, + target: target.value, ); GoRouter.of(context).push( "/library/generate/result", diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index f751b65b..deb86a97 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -1,4 +1,3 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -10,249 +9,224 @@ import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistGenerateResultRouteState = ({ - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit, - Market? market, -}); +import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { - final PlaylistGenerateResultRouteState state; + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ - Key? key, + super.key, required this.state, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { final router = GoRouter.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final (:seeds, :parameters, :limit, :market) = state; - final queryClient = useQueryClient(); - final generatedPlaylist = useQueries.playlist.generate( - ref, - seeds: seeds, - parameters: parameters, - limit: limit, - market: market, - ); + final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); final selectedTracks = useState>( - generatedPlaylist.data?.map((e) => e.id!).toList() ?? [], + generatedPlaylist.asData?.value.map((e) => e.id!).toList() ?? [], ); useEffect(() { - if (generatedPlaylist.data != null) { + if (generatedPlaylist.value != null) { selectedTracks.value = - generatedPlaylist.data!.map((e) => e.id!).toList(); + generatedPlaylist.value!.map((e) => e.id!).toList(); } return null; - }, [generatedPlaylist.data]); + }, [generatedPlaylist.value]); - final isAllTrackSelected = - selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0); + final isAllTrackSelected = selectedTracks.value.length == + (generatedPlaylist.asData?.value.length ?? 0); - return WillPopScope( - onWillPop: () async { - queryClient.cache.removeQuery(generatedPlaylist); - return true; - }, - child: Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: generatedPlaylist.isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - Text(context.l10n.generating_playlist), - ], - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: ListView( - children: [ - GridView( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: 32, + return Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: generatedPlaylist.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + Text(context.l10n.generating_playlist), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + GridView( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 32, + ), + shrinkWrap: true, + children: [ + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.load( + generatedPlaylist.value!.where( + (e) => selectedTracks.value.contains(e.id!), + ), + autoPlay: true, + ); + }, ), - shrinkWrap: true, + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.queueAdd), + label: Text(context.l10n.add_to_queue), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.addTracks( + generatedPlaylist.value!.where( + (e) => selectedTracks.value.contains(e.id!), + ), + ); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_queue( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_a_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final playlist = await showDialog( + context: context, + builder: (context) => PlaylistCreateDialog( + trackIds: selectedTracks.value, + ), + ); + + if (playlist != null) { + router.go( + '/playlist/${playlist.id}', + extra: playlist, + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.playlistAdd), + label: Text(context.l10n.add_to_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final hasAdded = await showDialog( + context: context, + builder: (context) => PlaylistAddTrackDialog( + openFromPlaylist: null, + tracks: selectedTracks.value + .map( + (e) => generatedPlaylist.value! + .firstWhere( + (element) => element.id == e, + ), + ) + .toList(), + ), + ); + + if (context.mounted && hasAdded == true) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_playlist( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ) + ], + ), + const SizedBox(height: 16), + if (generatedPlaylist.value != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.load( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - autoPlay: true, - ); - }, + Text( + context.l10n.selected_count_tracks( + selectedTracks.value.length, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.add_to_queue), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.addTracks( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - ); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_queue( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, + ElevatedButton.icon( + onPressed: () { + if (isAllTrackSelected) { + selectedTracks.value = []; + } else { + selectedTracks.value = generatedPlaylist.value + ?.map((e) => e.id!) + .toList() ?? + []; + } + }, + icon: const Icon(SpotubeIcons.selectionCheck), + label: Text( + isAllTrackSelected + ? context.l10n.deselect_all + : context.l10n.select_all, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_a_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final playlist = await showDialog( - context: context, - builder: (context) => PlaylistCreateDialog( - trackIds: selectedTracks.value, - ), - ); - - if (playlist != null) { - router.go( - '/playlist/${playlist.id}', - extra: playlist, - ); - } - }, - ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.playlistAdd), - label: Text(context.l10n.add_to_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final hasAdded = await showDialog( - context: context, - builder: (context) => - PlaylistAddTrackDialog( - openFromPlaylist: null, - tracks: selectedTracks.value - .map( - (e) => generatedPlaylist.data! - .firstWhere( - (element) => element.id == e, - ), - ) - .toList(), - ), - ); - - if (context.mounted && hasAdded == true) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_playlist( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, - ) ], ), - const SizedBox(height: 16), - if (generatedPlaylist.data != null) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.all(0), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text( - context.l10n.selected_count_tracks( - selectedTracks.value.length, - ), - ), - ElevatedButton.icon( - onPressed: () { - if (isAllTrackSelected) { - selectedTracks.value = []; - } else { - selectedTracks.value = generatedPlaylist.data - ?.map((e) => e.id!) - .toList() ?? - []; - } - }, - icon: const Icon(SpotubeIcons.selectionCheck), - label: Text( - isAllTrackSelected - ? context.l10n.deselect_all - : context.l10n.select_all, - ), - ), + for (final track in generatedPlaylist.value ?? []) + CheckboxListTile( + value: selectedTracks.value.contains(track.id), + onChanged: (value) { + if (value == true) { + selectedTracks.value.add(track.id!); + } else { + selectedTracks.value.remove(track.id); + } + selectedTracks.value = + selectedTracks.value.toList(); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: SimpleTrackTile(track: track), + ) ], ), - const SizedBox(height: 8), - Card( - margin: const EdgeInsets.all(0), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final track in generatedPlaylist.data ?? []) - CheckboxListTile( - value: selectedTracks.value.contains(track.id), - onChanged: (value) { - if (value == true) { - selectedTracks.value.add(track.id!); - } else { - selectedTracks.value.remove(track.id); - } - selectedTracks.value = - selectedTracks.value.toList(); - }, - controlAffinity: - ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - dense: true, - title: SimpleTrackTile(track: track), - ) - ], - ), - ), ), - ], - ), + ), + ], ), - ), + ), ); } } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ac4b61e7..9c777660 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -22,7 +22,7 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; - const LyricsPage({Key? key, this.isModal = false}) : super(key: key); + const LyricsPage({super.key, this.isModal = false}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 2cf73728..a617909c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -21,7 +21,7 @@ import 'package:spotube/utils/platform.dart'; class MiniLyricsPage extends HookConsumerWidget { final Size prevSize; - const MiniLyricsPage({Key? key, required this.prevSize}) : super(key: key); + const MiniLyricsPage({super.key, required this.prevSize}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index bee5114d..96ad8d41 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -12,8 +12,8 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlainLyrics extends HookConsumerWidget { @@ -24,14 +24,13 @@ class PlainLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final lyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); final textTheme = Theme.of(context).textTheme; @@ -96,9 +95,9 @@ class PlainLyrics extends HookConsumerWidget { } final lyrics = - lyricsQuery.data?.lyrics.mapIndexed((i, e) { - final next = - lyricsQuery.data?.lyrics.elementAtOrNull(i + 1); + lyricsQuery.asData?.value.lyrics.mapIndexed((i, e) { + final next = lyricsQuery.asData?.value.lyrics + .elementAtOrNull(i + 1); if (next != null && e.time - next.time > const Duration(milliseconds: 700)) { diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index ddef1c65..872ad514 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -13,14 +13,12 @@ import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/components/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:stroke_text/stroke_text.dart'; -final _delay = StateProvider((ref) => 0); - class SyncedLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; @@ -30,8 +28,8 @@ class SyncedLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -40,28 +38,18 @@ class SyncedLyrics extends HookConsumerWidget { final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); - final delay = ref.watch(_delay); + final delay = ref.watch(syncedLyricsDelayProvider); final timedLyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + ref.watch(syncedLyricsProvider(playlist.activeTrack)); - final lyricValue = timedLyricsQuery.data; + final lyricValue = timedLyricsQuery.asData?.value; - final isUnSyncLyric = useMemoized( - () => lyricValue?.lyrics.every((l) => l.time == Duration.zero), - [lyricValue], + final lyricsState = ref.watch( + syncedLyricsMapProvider(playlist.activeTrack), ); - - final lyricsMap = useMemoized( - () => - lyricValue?.lyrics - .map((lyric) => {lyric.time.inSeconds: lyric.text}) - .reduce((accumulator, lyricSlice) => - {...accumulator, ...lyricSlice}) ?? - {}, - [lyricValue], - ); - final currentTime = useSyncedLyrics(ref, lyricsMap, delay); + final currentTime = + useSyncedLyrics(ref, lyricsState.asData?.value.lyricsMap ?? {}, delay); final textZoomLevel = useState(defaultTextZoom); final textTheme = Theme.of(context).textTheme; @@ -70,7 +58,7 @@ class SyncedLyrics extends HookConsumerWidget { ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), (previous, next) { controller.scrollToIndex(0); - ref.read(_delay.notifier).state = 0; + ref.read(syncedLyricsDelayProvider.notifier).state = 0; }, ); @@ -105,7 +93,7 @@ class SyncedLyrics extends HookConsumerWidget { ), if (lyricValue != null && lyricValue.lyrics.isNotEmpty && - isUnSyncLyric == false) + lyricsState.asData?.value.static != true) Expanded( child: ListView.builder( controller: controller, @@ -202,7 +190,7 @@ class SyncedLyrics extends HookConsumerWidget { ), const Gap(26), const Icon(SpotubeIcons.noLyrics, size: 60), - ] else if (isUnSyncLyric == true) + ] else if (lyricsState.asData?.value.static == true) Expanded( child: Center( child: RichText( @@ -235,7 +223,8 @@ class SyncedLyrics extends HookConsumerWidget { final actions = [ ZoomControls( value: delay, - onChanged: (value) => ref.read(_delay.notifier).state = value, + onChanged: (value) => + ref.read(syncedLyricsDelayProvider.notifier).state = value, interval: 1, unit: "s", increaseIcon: const Icon(SpotubeIcons.add), diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 8b9bce4c..d9a309ed 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -8,7 +8,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { - const WebViewLogin({Key? key}) : super(key: key); + const WebViewLogin({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 1fb2e1dc..eeea8cb1 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,19 +3,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; const LikedPlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final likedTracks = useQueries.playlist.likedTracksQuery(ref); - final tracks = likedTracks.data ?? []; + final likedTracks = ref.watch(likedTracksProvider); + final tracks = likedTracks.value ?? []; return InheritedTrackView( collectionId: playlist.id!, @@ -28,7 +28,7 @@ class LikedPlaylistPage extends HookConsumerWidget { return tracks.toList(); }, onRefresh: () async { - await likedTracks.refresh(); + ref.invalidate(likedTracksProvider); }, ), title: playlist.name!, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 89a279ab..7962c66a 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; @@ -7,46 +6,25 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; const PlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!); - - final tracks = useMemoized( - () { - return tracksQuery.pages.expand((page) => page).toList(); - }, - [tracksQuery.pages], - ); - - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - ); + final tracks = ref.watch(playlistTracksProvider(playlist.id!)); + final tracksNotifier = + ref.watch(playlistTracksProvider(playlist.id!).notifier); + final isFavoritePlaylist = + ref.watch(isFavoritePlaylistProvider(playlist.id!)); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); @@ -56,42 +34,42 @@ class PlaylistPage extends HookConsumerWidget { playlist.images, placeholder: ImagePlaceholder.collection, ), - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.playlists - .getTracksByPlaylistId(playlist.id!) - .all(); - return res.toList(); - }, - ); + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: tracksNotifier.fetchMore, + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); + }, + onFetchAll: () async { + return await tracksNotifier.fetchAll(); }, ), title: playlist.name!, description: playlist.description, - tracks: tracks, + tracks: tracks.asData?.value.items ?? [], routePath: '/playlist/${playlist.id}', - isLiked: isLikedQuery.data ?? false, + isLiked: isFavoritePlaylist.value ?? false, shareUrl: playlist.externalUrls?.spotify ?? "", - onHeart: () async { - if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) { - return false; - } - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (confirmed) { - await togglePlaylistLike.mutate(isLikedQuery.data!); - return isUserPlaylist; - } - return null; - }, + onHeart: isFavoritePlaylist.value == null + ? null + : () async { + final confirmed = isUserPlaylist + ? await showPromptDialog( + context: context, + title: context.l10n.delete_playlist, + message: context.l10n.delete_playlist_confirmation, + ) + : true; + if (!confirmed) return null; + + if (isFavoritePlaylist.value!) { + await favoritePlaylistsNotifier.removeFavorite(playlist); + } else { + await favoritePlaylistsNotifier.addFavorite(playlist); + } + return isUserPlaylist; + }, child: const TrackView(), ); } diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index aaf3e30a..b562adab 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; @@ -18,6 +17,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; const rootPaths = { @@ -31,8 +31,8 @@ class RootApp extends HookConsumerWidget { final Widget child; const RootApp({ required this.child, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -53,8 +53,9 @@ class RootApp extends HookConsumerWidget { } }); - final subscription = - QueryClient.connectivity.onConnectivityChanged.listen((status) { + final subscription = ConnectionCheckerService + .instance.onConnectivityChanged + .listen((status) { if (status) { scaffoldMessenger.showSnackBar( SnackBar( diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index f4a78d4f..e666c9aa 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -16,59 +17,33 @@ import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; import 'package:collection/collection.dart'; -final searchTermStateProvider = StateProvider((ref) => ""); - class SearchPage extends HookConsumerWidget { - const SearchPage({Key? key}) : super(key: key); + const SearchPage({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final searchTerm = ref.watch(searchTermStateProvider); + final controller = useTextEditingController(text: searchTerm); + ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); final mediaQuery = MediaQuery.of(context); - final searchTerm = ref.watch(searchTermStateProvider); - - final searchTrack = - useQueries.search.query(ref, searchTerm, SearchType.track); - final searchAlbum = - useQueries.search.query(ref, searchTerm, SearchType.album); - final searchPlaylist = - useQueries.search.query(ref, searchTerm, SearchType.playlist); - final searchArtist = - useQueries.search.query(ref, searchTerm, SearchType.artist); - - Future onSearch() async { - await Future.wait([ - searchTrack.reset(), - searchAlbum.reset(), - searchPlaylist.reset(), - searchArtist.reset(), - ]).then((_) { - return Future.wait([ - searchTrack.refreshAll(), - searchAlbum.refreshAll(), - searchPlaylist.refreshAll(), - searchArtist.refreshAll(), - ]); - }); - } + final searchTrack = ref.watch(searchProvider(SearchType.track)); + final searchAlbum = ref.watch(searchProvider(SearchType.album)); + final searchPlaylist = ref.watch(searchProvider(SearchType.playlist)); + final searchArtist = ref.watch(searchProvider(SearchType.artist)); final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; - final isFetching = queries.every( - (s) => - (!s.hasPageData && !s.hasPageError) || - s.isRefreshingPage || - !s.hasPageData, - ) && - searchTerm.isNotEmpty; + + final isFetching = queries.every((s) => s.isLoading); final resultWidget = HookBuilder( builder: (context) { @@ -78,18 +53,18 @@ class SearchPage extends HookConsumerWidget { controller: controller, child: SingleChildScrollView( controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SearchTracksSection(query: searchTrack), - SearchPlaylistsSection(query: searchPlaylist), - const SizedBox(height: 20), - SearchArtistsSection(query: searchArtist), - const SizedBox(height: 20), - SearchAlbumsSection(query: searchAlbum), + SearchTracksSection(), + SearchPlaylistsSection(), + Gap(20), + SearchArtistsSection(), + Gap(20), + SearchAlbumsSection(), ], ), ), @@ -114,21 +89,22 @@ class SearchPage extends HookConsumerWidget { ), color: theme.scaffoldBackgroundColor, child: TextField( - autofocus: queries - .none((s) => s.hasPageData && !s.hasPageError) && - !kIsMobile, + controller: controller, + autofocus: + queries.none((s) => s.value != null && !s.hasError) && + !kIsMobile, decoration: InputDecoration( prefixIcon: const Icon(SpotubeIcons.search), hintText: "${context.l10n.search}...", ), onSubmitted: (value) async { - ref.read(searchTermStateProvider.notifier).state = - value; - // Fl-Query is too fast, so we need to delay the search - // to prevent spamming the API :) - Timer(const Duration(milliseconds: 50), () { - onSearch(); - }); + Timer( + const Duration(milliseconds: 50), + () { + ref.read(searchTermStateProvider.notifier).state = + value; + }, + ); }, ), ), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 8aa33feb..6d0f1508 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -1,5 +1,3 @@ -import 'package:fl_query/fl_query.dart'; - import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,33 +5,33 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class SearchAlbumsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchAlbumsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { + final query = ref.watch(searchProvider(SearchType.album)); + final notifier = ref.watch(searchProvider(SearchType.album).notifier); final albums = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .map((e) => TypeConversionUtils.simpleAlbum_X_Album(e)) - .toList(), - [query.pages], + () => + query.asData?.value.items + .cast() + .map(TypeConversionUtils.simpleAlbum_X_Album) + .toList() ?? + [], + [query.value], ); return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: albums, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.albums), ); } diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index fe4459d6..bb8063dc 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -1,37 +1,28 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchArtistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; - const SearchArtistsSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final artists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final query = ref.watch(searchProvider(SearchType.artist)); + final notifier = ref.watch(searchProvider(SearchType.artist).notifier); + + final artists = query.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: artists, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.artists), ); } diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 47614a70..13ff483d 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,35 +1,28 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchPlaylistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchPlaylistsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final playlistsQuery = ref.watch(searchProvider(SearchType.playlist)); + final playlistsQueryNotifier = + ref.watch(searchProvider(SearchType.playlist).notifier); + final playlists = + playlistsQuery.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( - isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + isLoadingNextPage: playlistsQuery.isLoadingNextPage, + hasNextPage: playlistsQuery.asData?.value.hasMore == true, items: playlists, - onFetchMore: query.fetchNext, + onFetchMore: playlistsQueryNotifier.fetchMore, title: Text(context.l10n.playlists), ); } diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index e77cd8f2..0fdb50af 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -1,32 +1,26 @@ import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchTracksSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchTracksSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final searchTrack = query; - final tracks = useMemoized( - () => searchTrack.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType(), - [searchTrack.pages], - ); + final searchTrack = ref.watch(searchProvider(SearchType.track)); + + final searchTrackNotifier = + ref.watch(searchProvider(SearchType.track).notifier); + + final tracks = searchTrack.asData?.value.items.cast() ?? []; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); @@ -43,14 +37,10 @@ class SearchTracksSection extends HookConsumerWidget { style: theme.textTheme.titleLarge!, ), ), - if (!searchTrack.hasPageData && - !searchTrack.hasPageError && - !searchTrack.isLoadingNextPage) + if (searchTrack.isLoading) const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) + else if (searchTrack.hasError) + Text(searchTrack.error.toString()) else ...tracks.mapIndexed((i, track) { return TrackTile( @@ -81,12 +71,12 @@ class SearchTracksSection extends HookConsumerWidget { }, ); }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) + if (searchTrack.asData?.value.hasMore == true && tracks.isNotEmpty) Center( child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : () => searchTrack.fetchNext(), + : () => searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 00263680..21b8117b 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -16,7 +16,7 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { - const AboutSpotube({Key? key}) : super(key: key); + const AboutSpotube({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index b4ce5044..45ce76d9 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { - const BlackListPage({Key? key}) : super(key: key); + const BlackListPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index cfb28d18..b07ebbb1 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { - const LogsPage({Key? key}) : super(key: key); + const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { return raw diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index 9fe59662..a8d72cc0 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -11,7 +11,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:url_launcher/url_launcher_string.dart'; class SettingsAboutSection extends HookConsumerWidget { - const SettingsAboutSection({Key? key}) : super(key: key); + const SettingsAboutSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 83740866..bded71b3 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -10,7 +10,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class SettingsAccountSection extends HookConsumerWidget { - const SettingsAccountSection({Key? key}) : super(key: key); + const SettingsAccountSection({super.key}); @override Widget build(context, ref) { diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 3d941212..25bd4005 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -13,9 +13,9 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsAppearanceSection extends HookConsumerWidget { final bool isGettingStarted; const SettingsAppearanceSection({ - Key? key, + super.key, this.isGettingStarted = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index ae721fc4..2c0a1466 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -9,7 +9,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsDesktopSection extends HookConsumerWidget { - const SettingsDesktopSection({Key? key}) : super(key: key); + const SettingsDesktopSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart index 4b5f58a6..a22cf9f1 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -6,7 +6,7 @@ import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; class SettingsDevelopersSection extends HookWidget { - const SettingsDevelopersSection({Key? key}) : super(key: key); + const SettingsDevelopersSection({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index b1e360d0..1f25028e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsDownloadsSection extends HookConsumerWidget { - const SettingsDownloadsSection({Key? key}) : super(key: key); + const SettingsDownloadsSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index bd2e33b9..b3f0d897 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -14,7 +14,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/sourced_track/enums.dart'; class SettingsPlaybackSection extends HookConsumerWidget { - const SettingsPlaybackSection({Key? key}) : super(key: key); + const SettingsPlaybackSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index f773b809..d2a75057 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -16,7 +16,7 @@ import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsPage extends HookConsumerWidget { - const SettingsPage({Key? key}) : super(key: key); + const SettingsPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 14052c10..ca5dbf95 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -13,17 +13,17 @@ import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { final String trackId; const TrackPage({ - Key? key, + super.key, required this.trackId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -35,9 +35,9 @@ class TrackPage extends HookConsumerWidget { final isActive = playlist.activeTrack?.id == trackId; - final trackQuery = useQueries.tracks.track(ref, trackId); + final trackQuery = ref.watch(trackProvider(trackId)); - final track = trackQuery.data ?? FakeData.track; + final track = trackQuery.asData?.value ?? FakeData.track; void onPlay() async { if (isActive) { diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index cd77e7bb..f1cf58ec 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; @@ -52,8 +51,7 @@ class AuthenticationCredentials { ), ); } catch (e) { - if (rootNavigatorKey?.currentContext != null && - await QueryClient.connectivity.isConnected) { + if (rootNavigatorKey?.currentContext != null) { showPromptDialog( context: rootNavigatorKey!.currentContext!, title: rootNavigatorKey!.currentContext!.l10n diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart index 363d4b4c..1d4edebf 100644 --- a/lib/provider/blacklist_provider.dart +++ b/lib/provider/blacklist_provider.dart @@ -62,7 +62,7 @@ class BlackListNotifier final containsTrackArtists = track.artists?.any( (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name!), + BlacklistedElement.artist(artist.id!, artist.name ?? "Spotify"), ), ) ?? false; diff --git a/lib/provider/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart index 4857a358..7a4c5533 100644 --- a/lib/provider/custom_spotify_endpoint_provider.dart +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -1,8 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart'; final customSpotifyEndpointProvider = Provider((ref) { + ref.watch(spotifyProvider); final auth = ref.watch(AuthenticationNotifier.provider); return CustomSpotifyEndpoints(auth?.accessToken ?? ""); }); diff --git a/lib/provider/spotify/album/favorite.dart b/lib/provider/spotify/album/favorite.dart new file mode 100644 index 00000000..cf444d49 --- /dev/null +++ b/lib/provider/spotify/album/favorite.dart @@ -0,0 +1,86 @@ +part of '../spotify.dart'; + +class FavoriteAlbumState extends PaginatedState { + FavoriteAlbumState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoriteAlbumState copyWith({items, offset, limit, hasMore}) { + return FavoriteAlbumState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoriteAlbumNotifier + extends PaginatedAsyncNotifier { + @override + Future> fetch(int offset, int limit) { + return spotify.me + .savedAlbums() + .getPage(limit, offset) + .then((value) => value.items?.toList() ?? []); + } + + @override + build() async { + ref.watch(spotifyProvider); + final items = await fetch(0, 20); + return FavoriteAlbumState( + items: items, + offset: 0, + limit: 20, + hasMore: items.length == 20, + ); + } + + Future addFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.me.saveAlbums(ids); + final albums = await spotify.albums.list(ids); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...albums, + ], + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } + + Future removeFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.me.removeAlbums(ids); + + return state.value!.copyWith( + items: state.value!.items + .where((element) => !ids.contains(element.id)) + .toList(), + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } +} + +final favoriteAlbumsProvider = + AsyncNotifierProvider( + () => FavoriteAlbumNotifier(), +); diff --git a/lib/provider/spotify/album/is_saved.dart b/lib/provider/spotify/album/is_saved.dart new file mode 100644 index 00000000..987ccdf2 --- /dev/null +++ b/lib/provider/spotify/album/is_saved.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final albumsIsSavedProvider = FutureProvider.autoDispose.family( + (ref, albumId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.containsSavedAlbums([albumId]).then( + (value) => value[albumId] ?? false, + ); + }, +); diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart new file mode 100644 index 00000000..471df707 --- /dev/null +++ b/lib/provider/spotify/album/releases.dart @@ -0,0 +1,90 @@ +part of '../spotify.dart'; + +class AlbumReleasesState extends PaginatedState { + AlbumReleasesState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumReleasesState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumReleasesState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumReleasesNotifier + extends PaginatedAsyncNotifier { + AlbumReleasesNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final market = ref.read(userPreferencesProvider).recommendationMarket; + + final albums = await spotify.browse + .newReleases(country: market) + .getPage(limit, offset); + + return albums.items + ?.map(TypeConversionUtils.simpleAlbum_X_Album) + .toList() ?? + []; + } + + @override + build() async { + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + ref.watch(allFollowedArtistsProvider); + + final albums = await fetch(0, 20); + + return AlbumReleasesState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final albumReleasesProvider = + AsyncNotifierProvider( + () => AlbumReleasesNotifier(), +); + +final userArtistAlbumReleasesProvider = Provider>((ref) { + final newReleases = ref.watch(albumReleasesProvider); + final userArtistsQuery = ref.watch(allFollowedArtistsProvider); + + if (newReleases.isLoading || userArtistsQuery.isLoading) { + return const []; + } + + final userArtists = + userArtistsQuery.asData?.value.map((s) => s.id!).toList() ?? const []; + + final allReleases = newReleases.asData?.value.items; + final userArtistReleases = allReleases?.where((album) { + return album.artists?.any((artist) => userArtists.contains(artist.id!)) == + true; + }).toList(); + + if (userArtistReleases?.isEmpty == true) { + return allReleases?.toList() ?? []; + } + return userArtistReleases ?? []; +}); diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart new file mode 100644 index 00000000..9556cc52 --- /dev/null +++ b/lib/provider/spotify/album/tracks.dart @@ -0,0 +1,58 @@ +part of '../spotify.dart'; + +class AlbumTracksState extends PaginatedState { + AlbumTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier { + AlbumTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.albums.tracks(arg.id!).getPage(limit, offset); + return tracks.items + ?.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, arg)) + .toList() ?? + []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final tracks = await fetch(arg, 0, 20); + return AlbumTracksState( + items: tracks, + offset: 0, + limit: 20, + hasMore: tracks.length == 20, + ); + } +} + +final albumTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + AlbumTracksNotifier, AlbumTracksState, AlbumSimple>( + () => AlbumTracksNotifier(), +); diff --git a/lib/provider/spotify/artist/albums.dart b/lib/provider/spotify/artist/albums.dart new file mode 100644 index 00000000..16bd8768 --- /dev/null +++ b/lib/provider/spotify/artist/albums.dart @@ -0,0 +1,62 @@ +part of '../spotify.dart'; + +class ArtistAlbumsState extends PaginatedState { + ArtistAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + ArtistAlbumsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return ArtistAlbumsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Album, ArtistAlbumsState, String> { + ArtistAlbumsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final market = ref.read(userPreferencesProvider).recommendationMarket; + final albums = await spotify.artists + .albums(arg, country: market) + .getPage(limit, offset); + + return albums.items?.toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final albums = await fetch(arg, 0, 20); + return ArtistAlbumsState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final artistAlbumsProvider = AutoDisposeAsyncNotifierProviderFamily< + ArtistAlbumsNotifier, ArtistAlbumsState, String>( + () => ArtistAlbumsNotifier(), +); diff --git a/lib/provider/spotify/artist/artist.dart b/lib/provider/spotify/artist/artist.dart new file mode 100644 index 00000000..c69badd2 --- /dev/null +++ b/lib/provider/spotify/artist/artist.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistProvider = + FutureProvider.autoDispose.family((ref, String artistId) { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.artists.get(artistId); +}); diff --git a/lib/provider/spotify/artist/following.dart b/lib/provider/spotify/artist/following.dart new file mode 100644 index 00000000..4e6bcfe8 --- /dev/null +++ b/lib/provider/spotify/artist/following.dart @@ -0,0 +1,104 @@ +part of '../spotify.dart'; + +class FollowedArtistsState extends CursorPaginatedState { + FollowedArtistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FollowedArtistsState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }) { + return FollowedArtistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FollowedArtistsNotifier + extends CursorPaginatedAsyncNotifier { + FollowedArtistsNotifier() : super(); + + @override + fetch(offset, limit) async { + final artists = await spotify.me.following(FollowingType.artist).getPage( + limit, + offset ?? '', + ); + + return (artists.items?.toList() ?? [], artists.after); + } + + @override + build() async { + ref.watch(spotifyProvider); + final (artists, nextCursor) = await fetch(null, 50); + return FollowedArtistsState( + items: artists, + offset: nextCursor, + limit: 50, + hasMore: artists.length == 50, + ); + } + + Future saveArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.follow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = await spotify.artists.list(artistIds); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...artists, + ], + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } + + Future removeArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.unfollow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = state.value!.items.where((artist) { + return !artistIds.contains(artist.id); + }).toList(); + + return state.value!.copyWith( + items: artists, + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } +} + +final followedArtistsProvider = + AsyncNotifierProvider( + () => FollowedArtistsNotifier(), +); + +final allFollowedArtistsProvider = FutureProvider>( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.me.following(FollowingType.artist).all(); + return artists.toList(); + }, +); diff --git a/lib/provider/spotify/artist/is_following.dart b/lib/provider/spotify/artist/is_following.dart new file mode 100644 index 00000000..db1be184 --- /dev/null +++ b/lib/provider/spotify/artist/is_following.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistIsFollowingProvider = FutureProvider.family( + (ref, String artistId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.checkFollowing(FollowingType.artist, [artistId]).then( + (value) => value[artistId] ?? false, + ); + }, +); diff --git a/lib/provider/spotify/artist/related.dart b/lib/provider/spotify/artist/related.dart new file mode 100644 index 00000000..317feba3 --- /dev/null +++ b/lib/provider/spotify/artist/related.dart @@ -0,0 +1,11 @@ +part of '../spotify.dart'; + +final relatedArtistsProvider = FutureProvider.autoDispose + .family, String>((ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.artists.relatedArtists(artistId); + + return artists.toList(); +}); diff --git a/lib/provider/spotify/artist/top_tracks.dart b/lib/provider/spotify/artist/top_tracks.dart new file mode 100644 index 00000000..fa40d646 --- /dev/null +++ b/lib/provider/spotify/artist/top_tracks.dart @@ -0,0 +1,15 @@ +part of '../spotify.dart'; + +final artistTopTracksProvider = + FutureProvider.autoDispose.family, String>( + (ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final tracks = await spotify.artists.topTracks(artistId, market); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/artist/wikipedia.dart b/lib/provider/spotify/artist/wikipedia.dart new file mode 100644 index 00000000..b2e2e6dc --- /dev/null +++ b/lib/provider/spotify/artist/wikipedia.dart @@ -0,0 +1,12 @@ +part of '../spotify.dart'; + +final artistWikipediaSummaryProvider = FutureProvider.autoDispose + .family((ref, artist) async { + final query = artist.name!.replaceAll(" ", "_"); + final res = await wikipedia.pageContent.pageSummaryTitleGet(query); + + if (res?.type != "standard") { + return await wikipedia.pageContent.pageSummaryTitleGet("${query}_(singer)"); + } + return res; +}); diff --git a/lib/provider/spotify/category/categories.dart b/lib/provider/spotify/category/categories.dart new file mode 100644 index 00000000..7652215c --- /dev/null +++ b/lib/provider/spotify/category/categories.dart @@ -0,0 +1,20 @@ +part of '../spotify.dart'; + +final categoriesProvider = FutureProvider( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); + final categories = await spotify.categories + .list( + country: market, + locale: Intl.canonicalizedLocale( + locale.toString(), + ), + ) + .all(); + + return categories.toList()..shuffle(); + }, +); diff --git a/lib/provider/spotify/category/genres.dart b/lib/provider/spotify/category/genres.dart new file mode 100644 index 00000000..b4b75b7b --- /dev/null +++ b/lib/provider/spotify/category/genres.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final categoryGenresProvider = FutureProvider>((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + return await customSpotify.listGenreSeeds(); +}); diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart new file mode 100644 index 00000000..979b7f31 --- /dev/null +++ b/lib/provider/spotify/category/playlists.dart @@ -0,0 +1,67 @@ +part of '../spotify.dart'; + +class CategoryPlaylistsState extends PaginatedState { + CategoryPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CategoryPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return CategoryPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + PlaylistSimple, CategoryPlaylistsState, String> { + CategoryPlaylistsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final preferences = ref.read(userPreferencesProvider); + final playlists = await Pages( + spotify, + "v1/browse/categories/$arg/playlists?country=${preferences.recommendationMarket.name}&locale=${preferences.locale}", + (json) => json == null ? null : PlaylistSimple.fromJson(json), + 'playlists', + (json) => PlaylistsFeatured.fromJson(json), + ).getPage(limit, offset); + + return playlists.items?.whereNotNull().toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch(userPreferencesProvider.select((s) => s.locale)); + ref.watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + + final playlists = await fetch(arg, 0, 8); + + return CategoryPlaylistsState( + items: playlists, + offset: 0, + limit: 8, + hasMore: playlists.length == 8, + ); + } +} + +final categoryPlaylistsProvider = AutoDisposeAsyncNotifierProviderFamily< + CategoryPlaylistsNotifier, CategoryPlaylistsState, String>( + () => CategoryPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart new file mode 100644 index 00000000..d86735db --- /dev/null +++ b/lib/provider/spotify/lyrics/synced.dart @@ -0,0 +1,77 @@ +part of '../spotify.dart'; + +class SyncedLyricsNotifier extends FamilyAsyncNotifier + with Persistence { + SyncedLyricsNotifier() { + load(); + } + + @override + FutureOr build(track) async { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + final res = await http.get( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", + ), + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", + "App-platform": "WebPlayer", + "authorization": "Bearer ${token.accessToken}" + }); + + if (res.statusCode != 200) { + throw Exception("Unable to find lyrics"); + } + final linesRaw = Map.castFrom( + jsonDecode(res.body), + )["lyrics"]?["lines"] as List?; + + final lines = linesRaw?.map((line) { + return LyricSlice( + time: Duration(milliseconds: int.parse(line["startTimeMs"])), + text: line["words"] as String, + ); + }).toList() ?? + []; + + return SubtitleSimple( + lyrics: lines, + name: track.name!, + uri: res.request!.url, + rating: 100, + ); + } + + @override + FutureOr fromJson(Map json) => + SubtitleSimple.fromJson(json.castKeyDeep()); + + @override + Map toJson(SubtitleSimple data) => data.toJson(); +} + +final syncedLyricsDelayProvider = StateProvider((ref) => 0); + +final syncedLyricsProvider = + AsyncNotifierProviderFamily( + () => SyncedLyricsNotifier(), +); + +final syncedLyricsMapProvider = + FutureProvider.family((ref, Track? track) async { + final syncedLyrics = await ref.watch(syncedLyricsProvider(track).future); + + final isStaticLyrics = + syncedLyrics.lyrics.every((l) => l.time == Duration.zero); + + final lyricsMap = syncedLyrics.lyrics + .map((lyric) => {lyric.time.inSeconds: lyric.text}) + .reduce((accumulator, lyricSlice) => {...accumulator, ...lyricSlice}); + + return (static: isStaticLyrics, lyricsMap: lyricsMap); +}); diff --git a/lib/provider/spotify/playlist/favorite.dart b/lib/provider/spotify/playlist/favorite.dart new file mode 100644 index 00000000..a0e051aa --- /dev/null +++ b/lib/provider/spotify/playlist/favorite.dart @@ -0,0 +1,122 @@ +part of '../spotify.dart'; + +class FavoritePlaylistsState extends PaginatedState { + FavoritePlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoritePlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FavoritePlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoritePlaylistsNotifier + extends PaginatedAsyncNotifier { + FavoritePlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.playlists.me.getPage( + limit, + offset, + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FavoritePlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } + + Future addFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.playlists.followPlaylist(playlist.id!); + return state.copyWith( + items: [...state.items, playlist], + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future removeFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.playlists.unfollowPlaylist(playlist.id!); + return state.copyWith( + items: state.items.where((e) => e.id != playlist.id).toList(), + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future addTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.playlists.addTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } + + Future removeTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.playlists.removeTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } +} + +final favoritePlaylistsProvider = + AsyncNotifierProvider( + () => FavoritePlaylistsNotifier(), +); + +final isFavoritePlaylistProvider = FutureProvider.family( + (ref, id) async { + final spotify = ref.watch(spotifyProvider); + final me = ref.watch(meProvider); + + if (me.value == null) { + return false; + } + + final follows = + await spotify.playlists.followedByUsers(id, [me.value!.id!]); + + return follows[me.value!.id!] ?? false; + }, +); diff --git a/lib/provider/spotify/playlist/featured.dart b/lib/provider/spotify/playlist/featured.dart new file mode 100644 index 00000000..69057e5d --- /dev/null +++ b/lib/provider/spotify/playlist/featured.dart @@ -0,0 +1,58 @@ +part of '../spotify.dart'; + +class FeaturedPlaylistsState extends PaginatedState { + FeaturedPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FeaturedPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FeaturedPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FeaturedPlaylistsNotifier + extends PaginatedAsyncNotifier { + FeaturedPlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.playlists.featured.getPage( + limit, + offset, + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FeaturedPlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } +} + +final featuredPlaylistsProvider = + AsyncNotifierProvider( + () => FeaturedPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart new file mode 100644 index 00000000..15447b54 --- /dev/null +++ b/lib/provider/spotify/playlist/generate.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +final generatePlaylistProvider = FutureProvider.autoDispose + .family, GeneratePlaylistProviderInput>( + (ref, input) async { + final spotify = ref.watch(spotifyProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + + final recommendation = await spotify.recommendations + .get( + limit: input.limit, + seedArtists: input.seedArtists?.toList(), + seedGenres: input.seedGenres?.toList(), + seedTracks: input.seedTracks?.toList(), + market: market, + max: (input.max?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + min: (input.min?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + target: (input.target?.toJson() + ?..removeWhere((key, value) => value == null)) + ?.cast(), + ) + .catchError((e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + return Recommendations(); + }); + + if (recommendation.tracks?.isEmpty ?? true) { + return []; + } + + final tracks = await spotify.tracks + .list(recommendation.tracks!.map((e) => e.id!).toList()); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/playlist/liked.dart b/lib/provider/spotify/playlist/liked.dart new file mode 100644 index 00000000..52463d3d --- /dev/null +++ b/lib/provider/spotify/playlist/liked.dart @@ -0,0 +1,49 @@ +part of '../spotify.dart'; + +class LikedTracksNotifier extends AsyncNotifier> with Persistence { + LikedTracksNotifier() { + load(); + } + + @override + FutureOr> build() async { + final spotify = ref.watch(spotifyProvider); + final savedTracked = await spotify.tracks.me.saved.all(); + + return savedTracked.map((e) => e.track!).toList(); + } + + Future toggleFavorite(Track track) async { + if (state.value == null) return; + final spotify = ref.read(spotifyProvider); + + await update((tracks) async { + final isLiked = tracks.map((e) => e.id).contains(track.id); + + if (isLiked) { + await spotify.tracks.me.removeOne(track.id!); + return tracks.where((e) => e.id != track.id).toList(); + } else { + await spotify.tracks.me.saveOne(track.id!); + return [track, ...tracks]; + } + }); + } + + @override + FutureOr> fromJson(Map json) { + return (json['tracks'] as List).map((e) => Track.fromJson(e)).toList(); + } + + @override + Map toJson(List data) { + return { + 'tracks': data.map((e) => e.toJson()).toList(), + }; + } +} + +final likedTracksProvider = + AsyncNotifierProvider>( + () => LikedTracksNotifier(), +); diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart new file mode 100644 index 00000000..fd420cd9 --- /dev/null +++ b/lib/provider/spotify/playlist/playlist.dart @@ -0,0 +1,90 @@ +part of '../spotify.dart'; + +typedef PlaylistInput = ({ + String playlistName, + bool? public, + bool? collaborative, + String? description, + String? base64Image, +}); + +class PlaylistNotifier extends FamilyAsyncNotifier { + @override + FutureOr build(String arg) { + final spotify = ref.watch(spotifyProvider); + return spotify.playlists.get(arg); + } + + Future create(PlaylistInput input, [ValueChanged? onError]) async { + if (state is AsyncLoading) return; + state = const AsyncLoading(); + + final spotify = ref.read(spotifyProvider); + final me = ref.read(meProvider); + + if (me.value == null) return; + + state = await AsyncValue.guard(() async { + try { + final playlist = await spotify.playlists.createPlaylist( + me.value!.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + playlist.id!, + input.base64Image!, + ); + } + + return playlist; + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } + + Future modify(PlaylistInput input, [ValueChanged? onError]) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await update((state) async { + try { + await spotify.playlists.updatePlaylist( + state.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + state.id!, + input.base64Image!, + ); + } + + return spotify.playlists.get(state.id!); + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } +} + +final playlistProvider = + AsyncNotifierProvider.family( + () => PlaylistNotifier(), +); diff --git a/lib/provider/spotify/playlist/tracks.dart b/lib/provider/spotify/playlist/tracks.dart new file mode 100644 index 00000000..1803f6fc --- /dev/null +++ b/lib/provider/spotify/playlist/tracks.dart @@ -0,0 +1,64 @@ +part of '../spotify.dart'; + +class PlaylistTracksState extends PaginatedState { + PlaylistTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PlaylistTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return PlaylistTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Track, PlaylistTracksState, String> { + PlaylistTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.playlists + .getTracksByPlaylistId(arg) + .getPage(limit, offset); + + /// Filter out tracks with null id because some personal playlists + /// may contain local tracks that are not available in the Spotify catalog + return tracks.items + ?.where((track) => track.id != null && track.type == "track") + .toList() ?? + []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final tracks = await fetch(arg, 0, 20); + + return PlaylistTracksState( + items: tracks, + offset: 0, + limit: 20, + hasMore: tracks.length == 20, + ); + } +} + +final playlistTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + PlaylistTracksNotifier, PlaylistTracksState, String>( + () => PlaylistTracksNotifier(), +); diff --git a/lib/provider/spotify/search/search.dart b/lib/provider/spotify/search/search.dart new file mode 100644 index 00000000..bd97f08b --- /dev/null +++ b/lib/provider/spotify/search/search.dart @@ -0,0 +1,76 @@ +part of '../spotify.dart'; + +final searchTermStateProvider = StateProvider.autoDispose( + (ref) { + ref.cacheFor(const Duration(minutes: 2)); + return ""; + }, +); + +class SearchState extends PaginatedState { + SearchState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + SearchState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return SearchState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier, SearchType> { + SearchNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + if (state.value == null) return []; + final results = await spotify.search + .get( + ref.read(searchTermStateProvider), + types: [arg], + market: ref.read(userPreferencesProvider).recommendationMarket, + ) + .getPage(limit, offset); + + return results.expand((e) => e.items ?? []).toList().cast(); + } + + @override + build(arg) async { + ref.cacheFor(const Duration(minutes: 2)); + + ref.watch(searchTermStateProvider); + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((value) => value.recommendationMarket), + ); + + final results = await fetch(arg, 0, 10); + + return SearchState( + items: results, + offset: 0, + limit: 10, + hasMore: results.length == 10, + ); + } +} + +final searchProvider = AsyncNotifierProvider.autoDispose + .family( + () => SearchNotifier(), +); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart new file mode 100644 index 00000000..ea28b6d8 --- /dev/null +++ b/lib/provider/spotify/spotify.dart @@ -0,0 +1,73 @@ +library spotify; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:spotify/spotify.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +// ignore: depend_on_referenced_packages, implementation_imports +import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/map.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:http/http.dart' as http; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:wikipedia_api/wikipedia_api.dart'; + +part 'album/favorite.dart'; +part 'album/tracks.dart'; +part 'album/releases.dart'; +part 'album/is_saved.dart'; + +part 'artist/artist.dart'; +part 'artist/is_following.dart'; +part 'artist/following.dart'; +part 'artist/top_tracks.dart'; +part 'artist/albums.dart'; +part 'artist/wikipedia.dart'; +part 'artist/related.dart'; + +part 'category/genres.dart'; +part 'category/categories.dart'; +part 'category/playlists.dart'; + +part 'lyrics/synced.dart'; + +part 'playlist/favorite.dart'; +part 'playlist/playlist.dart'; +part 'playlist/liked.dart'; +part 'playlist/tracks.dart'; +part 'playlist/featured.dart'; +part 'playlist/generate.dart'; + +part 'search/search.dart'; + +part 'user/me.dart'; +part 'user/friends.dart'; + +part 'tracks/track.dart'; + +part 'views/view.dart'; + +part 'utils/mixin.dart'; +part 'utils/state.dart'; +part 'utils/provider.dart'; +part 'utils/persistence.dart'; +part 'utils/async.dart'; + +part 'utils/provider/paginated.dart'; +part 'utils/provider/cursor.dart'; +part 'utils/provider/paginated_family.dart'; +part 'utils/provider/cursor_family.dart'; diff --git a/lib/provider/spotify/tracks/track.dart b/lib/provider/spotify/tracks/track.dart new file mode 100644 index 00000000..e3913b1f --- /dev/null +++ b/lib/provider/spotify/tracks/track.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final trackProvider = + FutureProvider.autoDispose.family((ref, id) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.tracks.get(id); +}); diff --git a/lib/provider/spotify/user/friends.dart b/lib/provider/spotify/user/friends.dart new file mode 100644 index 00000000..b9cc0f46 --- /dev/null +++ b/lib/provider/spotify/user/friends.dart @@ -0,0 +1,7 @@ +part of '../spotify.dart'; + +final friendsProvider = FutureProvider((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + + return customSpotify.getFriendActivity(); +}); diff --git a/lib/provider/spotify/user/me.dart b/lib/provider/spotify/user/me.dart new file mode 100644 index 00000000..c5949e1f --- /dev/null +++ b/lib/provider/spotify/user/me.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final meProvider = FutureProvider((ref) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.get(); +}); diff --git a/lib/provider/spotify/utils/async.dart b/lib/provider/spotify/utils/async.dart new file mode 100644 index 00000000..1040d682 --- /dev/null +++ b/lib/provider/spotify/utils/async.dart @@ -0,0 +1,5 @@ +part of '../spotify.dart'; + +extension PaginationExtension on AsyncValue { + bool get isLoadingNextPage => this is AsyncData && this is AsyncLoadingNext; +} diff --git a/lib/provider/spotify/utils/mixin.dart b/lib/provider/spotify/utils/mixin.dart new file mode 100644 index 00000000..0da14c6f --- /dev/null +++ b/lib/provider/spotify/utils/mixin.dart @@ -0,0 +1,24 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin SpotifyMixin on AsyncNotifierBase { + SpotifyApi get spotify => ref.read(spotifyProvider); +} + +extension on AutoDisposeAsyncNotifierProviderRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} + +extension on AutoDisposeRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} diff --git a/lib/provider/spotify/utils/persistence.dart b/lib/provider/spotify/utils/persistence.dart new file mode 100644 index 00000000..14d3c940 --- /dev/null +++ b/lib/provider/spotify/utils/persistence.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin Persistence on BuildlessAsyncNotifier { + LazyBox get store => Hive.lazyBox("spotube_cache"); + + FutureOr fromJson(Map json); + Map toJson(T data); + + FutureOr onInit() {} + + Future load() async { + final json = await store.get(runtimeType.toString()); + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { + state = AsyncData( + await fromJson( + PersistedStateNotifier.castNestedJson(json), + ), + ); + } + + await onInit(); + } + + Future save() async { + await store.put( + runtimeType.toString(), + state.value == null ? null : toJson(state.value as T), + ); + } + + @override + set state(AsyncValue value) { + if (state == value) return; + super.state = value; + save(); + } +} diff --git a/lib/provider/spotify/utils/provider.dart b/lib/provider/spotify/utils/provider.dart new file mode 100644 index 00000000..50458c3a --- /dev/null +++ b/lib/provider/spotify/utils/provider.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +// ignore: subtype_of_sealed_class +class AsyncLoadingNext extends AsyncData { + const AsyncLoadingNext(super.value); +} diff --git a/lib/provider/spotify/utils/provider/cursor.dart b/lib/provider/spotify/utils/provider/cursor.dart new file mode 100644 index 00000000..c241827e --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor.dart @@ -0,0 +1,56 @@ +part of '../../spotify.dart'; + +mixin CursorPaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future<(List items, String nextCursor)> fetch(String? offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch(state.offset, state.limit); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class CursorPaginatedAsyncNotifier> extends AsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposeCursorPaginatedAsyncNotifier> extends AutoDisposeAsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/cursor_family.dart b/lib/provider/spotify/utils/provider/cursor_family.dart new file mode 100644 index 00000000..ea8577de --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/provider/paginated.dart b/lib/provider/spotify/utils/provider/paginated.dart new file mode 100644 index 00000000..30b66e67 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated.dart @@ -0,0 +1,63 @@ +part of '../../spotify.dart'; + +mixin PaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future> fetch(int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class PaginatedAsyncNotifier> + extends AsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposePaginatedAsyncNotifier> + extends AutoDisposeAsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/paginated_family.dart b/lib/provider/spotify/utils/provider/paginated_family.dart new file mode 100644 index 00000000..84c6ba20 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + arg, + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + arg, + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/state.dart b/lib/provider/spotify/utils/state.dart new file mode 100644 index 00000000..4b79ac7d --- /dev/null +++ b/lib/provider/spotify/utils/state.dart @@ -0,0 +1,56 @@ +part of '../spotify.dart'; + +abstract class BasePaginatedState { + final List items; + final Cursor offset; + final int limit; + final bool hasMore; + + BasePaginatedState({ + required this.items, + required this.offset, + required this.limit, + required this.hasMore, + }); + + BasePaginatedState copyWith({ + List? items, + Cursor? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class PaginatedState extends BasePaginatedState { + PaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PaginatedState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class CursorPaginatedState extends BasePaginatedState { + CursorPaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CursorPaginatedState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }); +} diff --git a/lib/provider/spotify/views/view.dart b/lib/provider/spotify/views/view.dart new file mode 100644 index 00000000..f1af998b --- /dev/null +++ b/lib/provider/spotify/views/view.dart @@ -0,0 +1,19 @@ +part of '../spotify.dart'; + +final viewProvider = FutureProvider.family, String>( + (ref, viewName) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final locale = ref.watch( + userPreferencesProvider.select((s) => s.locale), + ); + + return customSpotify.getView( + viewName, + market: market, + locale: Intl.canonicalizedLocale(locale.toString()), + ); + }, +); diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 436627e6..2dfef362 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -258,7 +258,7 @@ class _MprisMediaPlayer2Player extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus Future getLoopStatus() async { - final loopMode = switch (await audioPlayer.loopMode) { + final loopMode = switch (audioPlayer.loopMode) { PlaybackLoopMode.all => "Playlist", PlaybackLoopMode.one => "Track", PlaybackLoopMode.none => "None", diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 833df89c..d259317e 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -137,7 +137,7 @@ class MobileAudioService extends BaseAudioHandler { shuffleMode: await audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, - repeatMode: (await audioPlayer.loopMode).toAudioServiceRepeatMode(), + repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), processingState: playlist.isFetching == true ? AudioProcessingState.loading : AudioProcessingState.ready, diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index c628f2f7..1a3835ee 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -2,17 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/widgets.dart'; -class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter - with WidgetsBindingObserver { +class ConnectionCheckerService with WidgetsBindingObserver { final _connectionStreamController = StreamController.broadcast(); final Dio dio; - FlQueryInternetConnectionCheckerAdapter() - : dio = Dio(), - super() { + static final _instance = ConnectionCheckerService._(); + + static ConnectionCheckerService get instance => _instance; + + ConnectionCheckerService._() : dio = Dio() { Timer? timer; onConnectivityChanged.listen((connected) { @@ -100,15 +100,16 @@ class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter await isVpnActive(); // when VPN is active that means we are connected } - @override + bool isConnectedSync = false; + Future get isConnected async { final connected = await _isConnected(); + isConnectedSync = connected; if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(connected); } return connected; } - @override Stream get onConnectivityChanged => _connectionStreamController.stream; } diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index d7a42430..dbb96791 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -207,7 +207,7 @@ class DownloadManager { // Do nothing return _cache[downloadRequest.url]!; } else { - _queue.remove(_cache[downloadRequest.url]); + _queue.remove(_cache[downloadRequest.url]?.request); } } @@ -286,21 +286,21 @@ class DownloadManager { } Future pauseBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { pauseDownload(element); - }); + } } Future cancelBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { cancelDownload(element); - }); + } } Future resumeBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { resumeDownload(element); - }); + } } ValueNotifier getBatchDownloadProgress(List urls) { @@ -315,9 +315,9 @@ class DownloadManager { return getDownload(urls.first)?.progress ?? progress; } - var progressMap = Map(); + var progressMap = {}; - urls.forEach((url) { + for (var url in urls) { DownloadTask? task = getDownload(url); if (task != null) { @@ -328,29 +328,27 @@ class DownloadManager { progress.value = progressMap.values.sum / total; } - var progressListener; - progressListener = () { + void progressListener() { progressMap[url] = task.progress.value; progress.value = progressMap.values.sum / total; - }; + } task.progress.addListener(progressListener); - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { progressMap[url] = 1.0; progress.value = progressMap.values.sum / total; task.status.removeListener(listener); task.progress.removeListener(progressListener); } - }; + } task.status.addListener(listener); } else { total--; } - }); + } return progress; } @@ -374,8 +372,7 @@ class DownloadManager { } } - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { completed++; @@ -384,7 +381,7 @@ class DownloadManager { task.status.removeListener(listener); } } - }; + } task.status.addListener(listener); } else { diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart index 5d57a655..d65f167e 100644 --- a/lib/services/download_manager/download_task.dart +++ b/lib/services/download_manager/download_task.dart @@ -21,13 +21,14 @@ class DownloadTask { completer.complete(status.value); } - var listener; - listener = () { + void listener() { if (status.value.isCompleted) { completer.complete(status.value); status.removeListener(listener); } - }; + } + + ; status.addListener(listener); diff --git a/lib/services/mutations/album.dart b/lib/services/mutations/album.dart deleted file mode 100644 index 144b6a8f..00000000 --- a/lib/services/mutations/album.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class AlbumMutations { - const AlbumMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String albumId, { - List? refreshQueries, - List? refreshInfiniteQueries, - MutationOnDataFn? onData, - }) { - return useSpotifyMutation( - "toggle-album-like/$albumId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.me.removeAlbums([albumId]); - } else { - await spotify.me.saveAlbums([albumId]); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - onData: onData, - ); - } -} diff --git a/lib/services/mutations/mutations.dart b/lib/services/mutations/mutations.dart deleted file mode 100644 index 28670486..00000000 --- a/lib/services/mutations/mutations.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:spotube/services/mutations/album.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/mutations/track.dart'; - -class _UseMutations { - const _UseMutations._(); - final playlist = const PlaylistMutations(); - final album = const AlbumMutations(); - final track = const TrackMutations(); -} - -const useMutations = _UseMutations._(); diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart deleted file mode 100644 index f480c565..00000000 --- a/lib/services/mutations/playlist.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistCRUDVariables = ({ - String playlistName, - bool? public, - bool? collaborative, - String? description, - String? base64Image, -}); - -class PlaylistMutations { - const PlaylistMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String playlistId, { - List? refreshQueries, - List? refreshInfiniteQueries, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "toggle-playlist-like/$playlistId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.playlists.unfollowPlaylist(playlistId); - } else { - await spotify.playlists.followPlaylist(playlistId); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: [ - ...?refreshInfiniteQueries, - "current-user-playlists", - ], - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation removeTrackOf( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyMutation( - "remove-track-from-playlist/$playlistId", - (trackId, spotify) async { - await spotify.playlists.removeTracks([trackId], playlistId); - return true; - }, - ref: ref, - refreshQueries: ["playlist-tracks/$playlistId"], - ); - } - - Mutation create( - WidgetRef ref, { - List? trackIds, - ValueChanged? onError, - ValueChanged? onData, - }) { - final me = useQueries.user.me(ref); - return useSpotifyMutation( - "create-playlist", - (variable, spotify) async { - final playlist = await spotify.playlists.createPlaylist( - me.data!.id!, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlist.id!, - variable.base64Image!, - ); - } - - if (trackIds != null && trackIds.isNotEmpty) { - await spotify.playlists.addTracks( - trackIds.map((id) => "spotify:track:$id").toList(), - playlist.id!, - ); - } - - return playlist; - }, - refreshInfiniteQueries: ["current-user-playlists"], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation update( - WidgetRef ref, { - String? playlistId, - ValueChanged? onError, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "update-playlist/$playlistId", - (variable, spotify) async { - if (playlistId == null) return; - await spotify.playlists.updatePlaylist( - playlistId, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlistId, - variable.base64Image!, - ); - } - }, - refreshInfiniteQueries: [ - "playlist/$playlistId", - "current-user-playlists", - ], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } -} diff --git a/lib/services/mutations/track.dart b/lib/services/mutations/track.dart deleted file mode 100644 index f8208b5e..00000000 --- a/lib/services/mutations/track.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class TrackMutations { - const TrackMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String trackId, { - MutationOnMutationFn? onMutate, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - }) { - return useSpotifyMutation( - 'toggle-track-like/$trackId', - (isLiked, spotify) async { - if (isLiked) { - await spotify.tracks.me.removeOne(trackId); - } else { - await spotify.tracks.me.saveOne(trackId); - } - return !isLiked; - }, - ref: ref, - onData: onData, - onMutate: onMutate, - refreshQueries: ["playlist-tracks/user-liked-tracks"], - onError: onError, - ); - } -} diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart deleted file mode 100644 index 0cc10256..00000000 --- a/lib/services/queries/album.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class AlbumQueries { - const AlbumQueries(); - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-albums", - (page, spotify) { - return spotify.me.savedAlbums().getPage( - 20, - page * 20, - ); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 20 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - static final tracksOfJob = InfiniteQueryJob.withVariableKey< - List, - dynamic, - int, - ({ - SpotifyApi spotify, - AlbumSimple album, - })>( - baseQueryKey: "album-tracks", - initialPage: 0, - task: (albumId, page, args) async { - final res = - await args!.spotify.albums.tracks(albumId).getPage(20, page * 20); - return res.items - ?.map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, args.album)) - .toList() ?? - []; - }, - nextPage: (lastPage, lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - }, - ); - - InfiniteQuery, dynamic, int> tracksOf( - WidgetRef ref, - AlbumSimple album, - ) { - final spotify = ref.watch(spotifyProvider); - - return useInfiniteQueryJob( - job: tracksOfJob(album.id!), - args: (spotify: spotify, album: album), - ); - } - - Query isSavedForMe( - WidgetRef ref, - String album, - ) { - return useSpotifyQuery( - "is-saved-for-me/$album", - (spotify) { - return spotify.me - .containsSavedAlbums([album]).then((value) => value[album]); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> newReleases(WidgetRef ref) { - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - return useSpotifyInfiniteQuery, dynamic, int>( - "new-releases", - (pageParam, spotify) async { - try { - final albums = await spotify.browse - .newReleases(country: market) - .getPage(50, pageParam); - - return albums; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - ref: ref, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast) { - return null; - } - return lastPageData.nextOffset; - }, - ); - } -} diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart deleted file mode 100644 index 1b939c82..00000000 --- a/lib/services/queries/artist.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/wikipedia/wikipedia.dart'; -import 'package:wikipedia_api/wikipedia_api.dart'; - -class ArtistQueries { - const ArtistQueries(); - - Query get( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "artist-profile/$artist", - (spotify) => spotify.artists.get(artist), - ref: ref, - ); - } - - InfiniteQuery, dynamic, String> followedByMe( - WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, String>( - "user-following-artists", - (pageParam, spotify) async { - return spotify.me - .following(FollowingType.artist) - .getPage(15, pageParam); - }, - initialPage: "", - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 15) { - return null; - } - return lastPageData.after; - }, - ref: ref, - ); - } - - Query, dynamic> followedByMeAll(WidgetRef ref) { - return useSpotifyQuery( - "user-following-artists-all", - (spotify) async { - CursorPage? page = - await spotify.me.following(FollowingType.artist).getPage(50); - - final following = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - following.addAll(page.items ?? []); - while (page?.isLast != true) { - page = await spotify.me - .following(FollowingType.artist) - .getPage(50, page?.after ?? ''); - following.addAll(page.items ?? []); - } - - return following; - }, - ref: ref, - ); - } - - Query doIFollow( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "user-follows-artists-query/$artist", - (spotify) async { - final result = await spotify.me.checkFollowing( - FollowingType.artist, - [artist], - ); - return result[artist]; - }, - ref: ref, - ); - } - - Query, dynamic> topTracksOf( - WidgetRef ref, - String artist, - ) { - final preferences = ref.watch(userPreferencesProvider); - return useSpotifyQuery, dynamic>( - "artist-top-track-query/$artist", - (spotify) { - return spotify.artists - .topTracks(artist, preferences.recommendationMarket); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> albumsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "artist-albums/$artist", - (pageParam, spotify) async { - return spotify.artists.albums(artist).getPage(5, pageParam); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> relatedArtistsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery, dynamic>( - "artist-related-artist-query/$artist", - (spotify) { - return spotify.artists.relatedArtists(artist); - }, - ref: ref, - ); - } - - Query wikipediaSummary(ArtistSimple artist) { - return useQuery( - "artist-wikipedia-query/${artist.id}", - () async { - final query = artist.name!.replaceAll(" ", "_"); - final res = await wikipedia.pageContent.pageSummaryTitleGet(query); - if (res?.type != "standard") { - return await wikipedia.pageContent - .pageSummaryTitleGet("${query}_(singer)"); - } - return res; - }, - ); - } -} diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart deleted file mode 100644 index d520b909..00000000 --- a/lib/services/queries/category.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class CategoryQueries { - const CategoryQueries(); - - Query, dynamic> listAll( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - final query = useSpotifyQuery, dynamic>( - "category-playlists", - (spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .all(); - - return categories.toList()..shuffle(); - }, - ref: ref, - ); - - return query; - } - - InfiniteQuery, dynamic, int> list( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists", - (pageParam, spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .getPage(8, pageParam); - - return categories; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 8) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> playlistsOf( - WidgetRef ref, - String category, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists/$category", - (pageParam, spotify) async { - final playlists = await Pages( - spotify, - "v1/browse/categories/$category/playlists?country=${market.name}&locale=$locale", - (json) => json == null ? null : PlaylistSimple.fromJson(json), - 'playlists', - (json) => PlaylistsFeatured.fromJson(json), - ).getPage(5, pageParam); - - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> genreSeeds(WidgetRef ref) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final query = useQuery, dynamic>( - "genre-seeds", - customSpotify.listGenreSeeds, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart deleted file mode 100644 index 618f960f..00000000 --- a/lib/services/queries/lyrics.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:http/http.dart' as http; - -class LyricsQueries { - const LyricsQueries(); - - Query static( - Track? track, - String geniusAccessToken, - ) { - return useQuery( - "genius-lyrics-query/${track?.id}", - () async { - if (track == null) { - return "“Give this player a track to play”\n- S'Challa"; - } - final lyrics = await ServiceUtils.getLyrics( - track.name!, - track.artists?.map((s) => s.name).whereNotNull().toList() ?? [], - apiKey: geniusAccessToken, - optimizeQuery: true, - ); - - if (lyrics == null) throw Exception("Unable find lyrics"); - return lyrics; - }, - ); - } - - Query synced( - Track? track, - ) { - return useQuery( - "synced-lyrics/${track?.id}}", - () async { - if (track == null || track is! SourcedTrack) { - throw "No track currently"; - } - final timedLyrics = await ServiceUtils.getTimedLyrics(track); - if (timedLyrics == null) throw Exception("Unable to find lyrics"); - - return timedLyrics; - }, - ); - } - - /// The Concept behind this method was shamelessly stolen from - /// https://github.com/akashrchandran/spotify-lyrics-api - /// - /// Thanks to [akashrchandran](https://github.com/akashrchandran) for the idea - /// - /// Special thanks to [raptag](https://github.com/raptag) for discovering this - /// jem - - Query spotifySynced(WidgetRef ref, Track? track) { - return useSpotifyQuery( - "spotify-synced-lyrics/${track?.id}}", - (spotify) async { - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); - final res = await http.get( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", - ), - headers: { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", - "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" - }); - - if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); - } - final linesRaw = Map.castFrom( - jsonDecode(res.body), - )["lyrics"]?["lines"] as List?; - - final lines = linesRaw?.map((line) { - return LyricSlice( - time: Duration(milliseconds: int.parse(line["startTimeMs"])), - text: line["words"] as String, - ); - }).toList() ?? - []; - - return SubtitleSimple( - lyrics: lines, - name: track.name!, - uri: res.request!.url, - rating: 100, - ); - }, - jsonConfig: JsonConfig( - fromJson: (json) => SubtitleSimple.fromJson(json.castKeyDeep()), - toJson: (data) => data.toJson(), - ), - ref: ref, - ); - } -} diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart deleted file mode 100644 index 836f9d72..00000000 --- a/lib/services/queries/playlist.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -typedef RecommendationParameters = ({ - RecommendationAttribute acousticness, - RecommendationAttribute danceability, - RecommendationAttribute duration_ms, - RecommendationAttribute energy, - RecommendationAttribute instrumentalness, - RecommendationAttribute key, - RecommendationAttribute liveness, - RecommendationAttribute loudness, - RecommendationAttribute mode, - RecommendationAttribute popularity, - RecommendationAttribute speechiness, - RecommendationAttribute tempo, - RecommendationAttribute time_signature, - RecommendationAttribute valence, -}); - -Map recommendationAttributeToMap(RecommendationAttribute attr) => { - "min": attr.min, - "target": attr.target, - "max": attr.max, - }; - -({Map min, Map target, Map max}) - recommendationParametersToMap(RecommendationParameters params) { - final maxMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.max, - if (params.danceability != zeroValues) - "danceability": params.danceability.max, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.max, - if (params.energy != zeroValues) "energy": params.energy.max, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.max, - if (params.key != zeroValues) "key": params.key.max, - if (params.liveness != zeroValues) "liveness": params.liveness.max, - if (params.loudness != zeroValues) "loudness": params.loudness.max, - if (params.mode != zeroValues) "mode": params.mode.max, - if (params.popularity != zeroValues) "popularity": params.popularity.max, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.max, - if (params.tempo != zeroValues) "tempo": params.tempo.max, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.max, - if (params.valence != zeroValues) "valence": params.valence.max, - }; - final minMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.min, - if (params.danceability != zeroValues) - "danceability": params.danceability.min, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.min, - if (params.energy != zeroValues) "energy": params.energy.min, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.min, - if (params.key != zeroValues) "key": params.key.min, - if (params.liveness != zeroValues) "liveness": params.liveness.min, - if (params.loudness != zeroValues) "loudness": params.loudness.min, - if (params.mode != zeroValues) "mode": params.mode.min, - if (params.popularity != zeroValues) "popularity": params.popularity.min, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.min, - if (params.tempo != zeroValues) "tempo": params.tempo.min, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.min, - if (params.valence != zeroValues) "valence": params.valence.min, - }; - final targetMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.target, - if (params.danceability != zeroValues) - "danceability": params.danceability.target, - if (params.duration_ms != zeroValues) - "duration_ms": params.duration_ms.target, - if (params.energy != zeroValues) "energy": params.energy.target, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.target, - if (params.key != zeroValues) "key": params.key.target, - if (params.liveness != zeroValues) "liveness": params.liveness.target, - if (params.loudness != zeroValues) "loudness": params.loudness.target, - if (params.mode != zeroValues) "mode": params.mode.target, - if (params.popularity != zeroValues) "popularity": params.popularity.target, - if (params.speechiness != zeroValues) - "speechiness": params.speechiness.target, - if (params.tempo != zeroValues) "tempo": params.tempo.target, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.target, - if (params.valence != zeroValues) "valence": params.valence.target, - }; - - return ( - max: maxMap, - min: minMap, - target: targetMap, - ); -} - -class PlaylistQueries { - const PlaylistQueries(); - - Query doesUserFollow( - WidgetRef ref, - String playlistId, - String userId, - ) { - return useSpotifyQuery( - "playlist-is-followed/$playlistId/$userId", - (spotify) async { - final result = - await spotify.playlists.followedByUsers(playlistId, [userId]); - return result[userId] ?? false; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-playlists", - (page, spotify) async { - final playlists = await spotify.playlists.me.getPage(10, page * 10); - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 10 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - Query, dynamic> ofMineAll(WidgetRef ref) { - return useSpotifyQuery, dynamic>( - "current-user-all-playlists", - (spotify) async { - var page = await spotify.playlists.me.getPage(50); - final playlists = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - playlists.addAll(page.items ?? []); - while (!page.isLast) { - page = await spotify.playlists.me.getPage(50, page.nextOffset); - playlists.addAll(page.items ?? []); - } - - return playlists; - }, - ref: ref, - ); - } - - Future> likedTracks(SpotifyApi spotify) async { - final tracks = await spotify.tracks.me.saved.all(); - - return tracks.map((e) => e.track!).toList(); - } - - Query, dynamic> likedTracksQuery(WidgetRef ref) { - final query = useCallback((spotify) => likedTracks(spotify), []); - final context = useContext(); - - return useSpotifyQuery, dynamic>( - "user-liked-tracks", - query, - jsonConfig: JsonConfig( - toJson: (tracks) => { - 'tracks': tracks.map((e) => e.toJson()).toList(), - }, - fromJson: (json) => (json['tracks'] as List) - .map( - (e) => Track.fromJson((e as Map).castKeyDeep()), - ) - .toList(), - ), - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query byId(WidgetRef ref, String id) { - return useSpotifyQuery( - "playlist/$id", - (spotify) async { - return await spotify.playlists.get(id); - }, - ref: ref, - ); - } - - Future> tracksOf( - int pageParam, - SpotifyApi spotify, - String playlistId, - ) async { - try { - final playlists = await spotify.playlists - .getTracksByPlaylistId(playlistId) - .getPage(20, pageParam * 20); - return playlists.items?.toList() ?? []; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - } - - int? tracksOfQueryNextPage(int lastPage, List lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - } - - InfiniteQuery, dynamic, int> tracksOfQuery( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "playlist-tracks/$playlistId", - (page, spotify) => tracksOf(page, spotify, playlistId), - initialPage: 0, - nextPage: tracksOfQueryNextPage, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> featured( - WidgetRef ref, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "featured-playlists", - (pageParam, spotify) async { - try { - final playlists = - await spotify.playlists.featured.getPage(5, pageParam); - return playlists; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> generate( - WidgetRef ref, { - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit = 20, - Market? market, - }) { - final marketOfPreference = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final customSpotify = ref.watch(customSpotifyEndpointProvider); - - final parametersMap = - parameters == null ? null : recommendationParametersToMap(parameters); - - final query = useQuery, dynamic>( - "generate-playlist", - () async { - final tracks = await customSpotify.getRecommendations( - limit: limit, - market: market ?? marketOfPreference, - max: parametersMap?.max, - min: parametersMap?.min, - target: parametersMap?.target, - seedArtists: seeds?.artists, - seedGenres: seeds?.genres, - seedTracks: seeds?.tracks, - ); - return tracks; - }, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/queries.dart b/lib/services/queries/queries.dart deleted file mode 100644 index 30c23268..00000000 --- a/lib/services/queries/queries.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:spotube/services/queries/album.dart'; -import 'package:spotube/services/queries/artist.dart'; -import 'package:spotube/services/queries/category.dart'; -import 'package:spotube/services/queries/lyrics.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/search.dart'; -import 'package:spotube/services/queries/tracks.dart'; -import 'package:spotube/services/queries/user.dart'; -import 'package:spotube/services/queries/views.dart'; - -class Queries { - const Queries._(); - final album = const AlbumQueries(); - final artist = const ArtistQueries(); - final category = const CategoryQueries(); - final lyrics = const LyricsQueries(); - final playlist = const PlaylistQueries(); - final search = const SearchQueries(); - final user = const UserQueries(); - final views = const ViewsQueries(); - final tracks = const TracksQueries(); -} - -const useQueries = Queries._(); diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart deleted file mode 100644 index 3c6ee064..00000000 --- a/lib/services/queries/search.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SearchParams = ({ - SpotifyApi spotify, - SearchType searchType, - String query -}); - -class SearchQueries { - const SearchQueries(); - - static final queryJob = - InfiniteQueryJob.withVariableKey, dynamic, int, SearchParams>( - baseQueryKey: "search-query", - task: (variableKey, page, args) => args!.spotify.search.get( - args.query, - types: [args.searchType], - ).getPage(10, page), - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isEmpty) return null; - if ((lastPageData.first.isLast || - (lastPageData.first.items ?? []).length < 10)) { - return null; - } - return lastPageData.first.nextOffset; - }, - enabled: false, - ); - - InfiniteQuery, dynamic, int> query( - WidgetRef ref, - String queryStr, - SearchType searchType, - ) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQueryJob, dynamic, int, SearchParams>( - job: queryJob(searchType.name), - args: (spotify: spotify, searchType: searchType, query: queryStr), - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/tracks.dart b/lib/services/queries/tracks.dart deleted file mode 100644 index 52bab984..00000000 --- a/lib/services/queries/tracks.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; - -class TracksQueries { - const TracksQueries(); - - Query track(WidgetRef ref, String id) { - return useSpotifyQuery( - "track/$id", - (spotify) => spotify.tracks.get(id), - ref: ref, - ); - } -} diff --git a/lib/services/queries/user.dart b/lib/services/queries/user.dart deleted file mode 100644 index 82af600f..00000000 --- a/lib/services/queries/user.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class UserQueries { - const UserQueries(); - Query me(WidgetRef ref) { - final context = useContext(); - - return useSpotifyQuery( - "current-user", - (spotify) async { - final me = await spotify.me.get(); - if (ref.read(AuthenticationNotifier.provider) == null) return null; - if (me.images == null || me.images?.isEmpty == true) { - me.images = [ - Image() - ..height = 50 - ..width = 50 - ..url = TypeConversionUtils.image_X_UrlString( - me.images, - placeholder: ImagePlaceholder.artist, - ), - ]; - } - return me; - }, - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query friendActivity(WidgetRef ref) { - final customSpotify = ref.read(customSpotifyEndpointProvider); - return useSpotifyQuery( - "friend-activity", - (spotify) { - return customSpotify.getFriendActivity(); - }, - ref: ref, - ); - } -} diff --git a/lib/services/queries/views.dart b/lib/services/queries/views.dart deleted file mode 100644 index 4864ffe1..00000000 --- a/lib/services/queries/views.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class ViewsQueries { - const ViewsQueries(); - - Query?, dynamic> get( - WidgetRef ref, - String view, - ) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final auth = ref.watch(AuthenticationNotifier.provider); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - final locale = useContext().l10n.localeName; - - final query = useQuery?, dynamic>("views/$view", () { - if (auth == null) return null; - return customSpotify.getView( - view, - market: market, - country: market, - locale: locale, - ); - }); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 60f7b96e..9416a340 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -126,7 +126,7 @@ abstract class PersistedStateNotifier extends StateNotifier { } } - Map castNestedJson(Map map) { + static Map castNestedJson(Map map) { return Map.castFrom( map.map((key, value) { if (value is Map) { diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index cd594a2a..d5eb68f6 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -61,6 +61,9 @@ abstract class TypeConversionUtils { .entries .map( (artist) => Builder(builder: (context) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } return AnchorButton( (artist.key != artists.length - 1) ? "${artist.value.name}, " diff --git a/pubspec.lock b/pubspec.lock index 4485b118..bbf4faeb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -675,30 +675,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - fl_query: - dependency: "direct main" - description: - name: fl_query - sha256: daee5ab0ed8899baa201b89b5813107df5258144a9e2bcf192dbcf922c57d985 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - fl_query_devtools: - dependency: "direct main" - description: - name: fl_query_devtools - sha256: "2ae8905fd4a95f1d245a1b54057c31c8d27fc961223bcb7ce13088bcf6595059" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - fl_query_hooks: - dependency: "direct main" - description: - name: fl_query_hooks - sha256: "6c88b3bfbdc3e1330931b927903929d7351f86fc63266ac93b3acb9f133a09a9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" fluentui_system_icons: dependency: "direct main" description: @@ -1319,14 +1295,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" - json_view: - dependency: transitive - description: - name: json_view - sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374" - url: "https://pub.dev" - source: hosted - version: "0.4.2" leak_tracker: dependency: transitive description: @@ -1495,14 +1463,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - mutex: - dependency: transitive - description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" - url: "https://pub.dev" - source: hosted - version: "3.0.1" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e055c9d7..ef8401bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,9 +32,6 @@ dependencies: duration: ^3.0.12 envied: ^0.3.0 file_selector: ^1.0.1 - fl_query: ^1.0.0 - fl_query_hooks: ^1.0.0 - fl_query_devtools: ^0.1.0 fluentui_system_icons: ^1.1.189 flutter: sdk: flutter From 7545ff64159b3e5561abfe887014d8cee872597b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:18:43 +0600 Subject: [PATCH 04/17] refactor: use extension method for image to url string --- lib/components/album/album_card.dart | 4 +-- lib/components/artist/artist_card.dart | 4 +-- .../playlist_generate/simple_track_tile.dart | 4 +-- .../library/user_downloads/download_item.dart | 4 +-- lib/components/player/player.dart | 4 +-- lib/components/playlist/playlist_card.dart | 4 +-- .../playlist/playlist_create_dialog.dart | 4 +-- lib/components/root/bottom_player.dart | 4 +-- lib/components/root/sidebar.dart | 4 +-- .../dialogs/playlist_add_track_dialog.dart | 4 +-- .../shared/track_tile/track_options.dart | 5 ++-- .../shared/track_tile/track_tile.dart | 4 +-- lib/extensions/image.dart | 28 +++++++++++++++++++ .../configurators/use_get_storage_perms.dart | 4 +-- lib/pages/album/album.dart | 4 +-- lib/pages/artist/section/footer.dart | 4 +-- lib/pages/artist/section/header.dart | 4 +-- .../playlist_generate/playlist_generate.dart | 10 +++---- lib/pages/lyrics/lyrics.dart | 4 +-- lib/pages/playlist/playlist.dart | 4 +-- lib/pages/track/track.dart | 7 ++--- lib/provider/download_manager_provider.dart | 7 +++-- .../proxy_playlist_provider.dart | 4 +-- .../audio_services/audio_services.dart | 10 ++++--- .../audio_services/linux_audio_service.dart | 4 +-- .../audio_services/windows_audio_service.dart | 21 ++++++++------ lib/utils/type_conversion_utils.dart | 25 ----------------- 27 files changed, 99 insertions(+), 90 deletions(-) create mode 100644 lib/extensions/image.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 3838b7a4..97db9d72 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -49,8 +50,7 @@ class AlbumCard extends HookConsumerWidget { } return PlaybuttonCard( - imageUrl: TypeConversionUtils.image_X_UrlString( - album.images, + imageUrl: album.images.asUrlString( placeholder: ImagePlaceholder.collection, ), margin: const EdgeInsets.symmetric(horizontal: 10), diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index ac3e9bec..322ad501 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -6,6 +6,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -20,8 +21,7 @@ class ArtistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final backgroundImage = UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ); diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index e592969e..08d5060f 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -4,6 +4,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class SimpleTrackTile extends HookWidget { @@ -21,8 +22,7 @@ class SimpleTrackTile extends HookWidget { leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), height: 40, diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 1cb5e559..16bf1afe 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -51,8 +52,7 @@ class DownloadItem extends HookConsumerWidget { child: UniversalImage( height: 40, width: 40, - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 5d5a39af..6fcbbd1c 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -18,6 +18,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; @@ -59,8 +60,7 @@ class PlayerView extends HookConsumerWidget { }, [mediaQuery.lgAndUp]); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - currentTrack?.album?.images, + () => (currentTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), [currentTrack?.album?.images], diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index ffbfbae9..83e25a85 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -43,8 +44,7 @@ class PlaylistCard extends HookConsumerWidget { margin: const EdgeInsets.symmetric(horizontal: 10), title: playlist.name!, description: playlist.description, - imageUrl: TypeConversionUtils.image_X_UrlString( - playlist.images, + imageUrl: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), isPlaying: isPlaylistPlaying, diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index 669dce51..cae51444 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -14,6 +14,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -163,8 +164,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { children: [ UniversalImage( path: field.value?.path ?? - TypeConversionUtils.image_X_UrlString( - updatingPlaylist?.images, + (updatingPlaylist?.images).asUrlString( placeholder: ImagePlaceholder.collection, ), height: 200, diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 3f70490a..e6cf17dc 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; @@ -39,8 +40,7 @@ class BottomPlayer extends HookConsumerWidget { String albumArt = useMemoized( () => playlist.activeTrack?.album?.images?.isNotEmpty == true - ? TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + ? (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.albumArt, ) diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 21259a94..9049ecf1 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -11,6 +11,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -244,8 +245,7 @@ class SidebarFooter extends HookConsumerWidget { final me = ref.watch(meProvider); final data = me.asData?.value; - final avatarImg = TypeConversionUtils.image_X_UrlString( - data?.images, + final avatarImg = (data?.images).asUrlString( index: (data?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.artist, ); diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 1f1807da..28044b41 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -105,8 +106,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { return CheckboxListTile( secondary: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - playlist.images, + playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), ), diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 8522738d..590a5889 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -17,6 +17,7 @@ import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -294,8 +295,8 @@ class TrackOptions extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString(track.album!.images, - placeholder: ImagePlaceholder.albumArt), + path: track.album!.images + .asUrlString(placeholder: ImagePlaceholder.albumArt), fit: BoxFit.cover, ), ), diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index ecadc1c6..afdc19a4 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -13,6 +13,7 @@ import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -135,8 +136,7 @@ class TrackTile extends HookConsumerWidget { child: AspectRatio( aspectRatio: 1, child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), fit: BoxFit.cover, diff --git a/lib/extensions/image.dart b/lib/extensions/image.dart new file mode 100644 index 00000000..f84bd37a --- /dev/null +++ b/lib/extensions/image.dart @@ -0,0 +1,28 @@ +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:collection/collection.dart'; + +extension SpotifyImageExtensions on List? { + String asUrlString({ + int index = 1, + required ImagePlaceholder placeholder, + }) { + final String placeholderUrl = { + ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, + ImagePlaceholder.artist: Assets.userPlaceholder.path, + ImagePlaceholder.collection: Assets.placeholder.path, + ImagePlaceholder.online: + "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", + }[placeholder]!; + + final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage[ + index > sortedImage.length - 1 ? sortedImage.length - 1 : index] + .url! + : placeholderUrl; + } +} diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 3fcb369b..86b495c4 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -25,11 +25,11 @@ void useGetStoragePermissions(WidgetRef ref) { if (hasNoStoragePerm) { await Permission.storage.request(); - if (isMounted()) ref.refresh(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } if (hasNoAudioPerm) { await Permission.audio.request(); - if (isMounted()) ref.refresh(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } }, null, diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index fac0a6a6..0f36756f 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -4,6 +4,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -23,8 +24,7 @@ class AlbumPage extends HookConsumerWidget { return InheritedTrackView( collectionId: album.id!, - image: TypeConversionUtils.image_X_UrlString( - album.images, + image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), title: album.name!, diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index ac166252..c53f2c54 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -18,8 +19,7 @@ class ArtistPageFooter extends ConsumerWidget { final ThemeData(:textTheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final artistImage = TypeConversionUtils.image_X_UrlString( - artist.images, + final artistImage = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); final summary = ref.watch(artistWikipediaSummaryProvider(artist)); diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7756da15..dcf3114e 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -8,6 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -44,8 +45,7 @@ class ArtistPageHeader extends HookConsumerWidget { BlacklistedElement.artist(artistId, artist.name!), ); - final image = TypeConversionUtils.image_X_UrlString( - artist.images, + final image = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 642ceb6c..81fbbfe3 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -84,8 +85,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.images, + option.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -117,8 +117,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { selectedSeedBuilder: (artist) => Chip( avatar: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -163,8 +162,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.album?.images, + (option.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 9c777660..0482cfe9 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -11,6 +11,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; @@ -28,8 +29,7 @@ class LyricsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(ProxyPlaylistNotifier.provider); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + () => (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.albumArt, ), diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 7962c66a..3a0f9ec6 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -6,6 +6,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -30,8 +31,7 @@ class PlaylistPage extends HookConsumerWidget { return InheritedTrackView( collectionId: playlist.id!, - image: TypeConversionUtils.image_X_UrlString( - playlist.images, + image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), pagination: PaginationProps( diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index ca5dbf95..aef9a083 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -12,6 +12,7 @@ import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -60,8 +61,7 @@ class TrackPage extends HookConsumerWidget { decoration: BoxDecoration( image: DecorationImage( image: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - track.album!.images, + track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), @@ -104,8 +104,7 @@ class TrackPage extends HookConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, + path: track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 200, diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index dc538938..abf4ed6c 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -52,8 +53,10 @@ class DownloadManagerProvider extends ChangeNotifier { } final imageBytes = await downloadImage( - TypeConversionUtils.image_X_UrlString(track.album?.images, - placeholder: ImagePlaceholder.albumArt, index: 1), + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), ); final metadata = Metadata( diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 0811fe35..aea873dd 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -10,6 +10,7 @@ import 'package:http/http.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; @@ -522,8 +523,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final palette = await PaletteGenerator.fromImageProvider( UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - state.activeTrack?.album?.images, + (state.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 50, diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index a6ecac3f..068b41ba 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; @@ -50,10 +51,11 @@ class AudioServices { duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), - artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, - )), + artUri: Uri.parse( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), playable: true, )); } diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 2dfef362..11399e67 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -309,8 +310,7 @@ class _MprisMediaPlayer2Player extends DBusObject { (await audioPlayer.duration)?.inMicroseconds ?? 0, ), "mpris:artUrl": DBusString( - TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + (playlist.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index fde88145..2df0e9fe 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; @@ -80,16 +81,18 @@ class WindowsAudioService { if (!smtc.enabled) { await smtc.enableSmtc(); } - await smtc.updateMetadata(MusicMetadata( - title: track.name!, - albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), - album: track.album?.name ?? "Unknown", - thumbnail: TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, + await smtc.updateMetadata( + MusicMetadata( + title: track.name!, + albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", + artist: + TypeConversionUtils.artists_X_String(track.artists ?? []), + album: track.album?.name ?? "Unknown", + thumbnail: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), ), - )); + ); } void dispose() { diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index d5eb68f6..639299a1 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -2,14 +2,11 @@ import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart' hide Image; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; -import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; enum ImagePlaceholder { @@ -20,28 +17,6 @@ enum ImagePlaceholder { } abstract class TypeConversionUtils { - static String image_X_UrlString( - List? images, { - int index = 1, - required ImagePlaceholder placeholder, - }) { - final String placeholderUrl = { - ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, - ImagePlaceholder.artist: Assets.userPlaceholder.path, - ImagePlaceholder.collection: Assets.placeholder.path, - ImagePlaceholder.online: - "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", - }[placeholder]!; - - final sortedImage = images?.sorted((a, b) => a.width!.compareTo(b.width!)); - - return sortedImage != null && sortedImage.isNotEmpty - ? sortedImage[ - index > sortedImage.length - 1 ? sortedImage.length - 1 : index] - .url! - : placeholderUrl; - } - static String artists_X_String(List artists) { return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); } From 1cea95bbda96e706d097e05e15a9a0c9d9552567 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:30:27 +0600 Subject: [PATCH 05/17] refactor: artist name string as extension --- lib/components/album/album_card.dart | 3 ++- lib/components/library/user_local_tracks.dart | 3 ++- lib/components/player/player.dart | 6 ++---- lib/components/player/player_actions.dart | 9 +++------ lib/components/player/player_queue.dart | 4 ++-- lib/components/player/player_track_details.dart | 5 ++--- lib/components/player/sibling_tracks_sheet.dart | 3 ++- lib/components/shared/track_tile/track_tile.dart | 5 ++--- lib/extensions/artist_simple.dart | 6 ++++++ lib/pages/lyrics/plain_lyrics.dart | 4 ++-- lib/pages/lyrics/synced_lyrics.dart | 6 ++---- lib/provider/discord_provider.dart | 5 ++--- lib/provider/download_manager_provider.dart | 3 ++- lib/provider/scrobbler_provider.dart | 5 +++-- lib/services/audio_services/audio_services.dart | 2 +- lib/services/audio_services/windows_audio_service.dart | 4 ++-- lib/utils/type_conversion_utils.dart | 4 ---- 17 files changed, 37 insertions(+), 40 deletions(-) diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 97db9d72..1696fc48 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -59,7 +60,7 @@ class AlbumCard extends HookConsumerWidget { updating.value, title: album.name!, description: - "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { ServiceUtils.push(context, "/album/${album.id}", extra: album); }, diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index b8f647a5..f9b53330 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -21,6 +21,7 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -242,7 +243,7 @@ class UserLocalTracks extends HookConsumerWidget { return sortedTracks .map((e) => ( weightedRatio( - "${e.name} - ${TypeConversionUtils.artists_X_String(e.artists ?? [])}", + "${e.name} - ${e.artists?.asString() ?? ""}", searchController.text, ), e, diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 6fcbbd1c..04dd90df 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -16,6 +16,7 @@ import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; @@ -239,10 +240,7 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - TypeConversionUtils.artists_X_String< - Artist>( - currentTrack?.artists ?? [], - ), + currentTrack?.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 18168af1..4102e2ba 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -16,7 +16,6 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/sleep_timer_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerActions extends HookConsumerWidget { final MainAxisAlignment mainAxisAlignment; @@ -56,10 +55,8 @@ class PlayerActions extends HookConsumerWidget { (element) => element.name == playlist.activeTrack?.name && element.album?.name == playlist.activeTrack?.album?.name && - TypeConversionUtils.artists_X_String( - element.artists ?? []) == - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + element.artists?.asString() == + playlist.activeTrack?.artists?.asString(), ) == true; }, [localTracks, playlist.activeTrack]); diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 449b6c2e..141479a6 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -12,11 +12,11 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; @@ -55,7 +55,7 @@ class PlayerQueue extends HookConsumerWidget { return tracks .map((e) => ( weightedRatio( - '${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}', + '${e.name!} - ${e.artists?.asString() ?? ""}', searchText.value, ), e diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index fd97fd74..268f4b76 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -54,9 +55,7 @@ class PlayerTrackDetails extends HookConsumerWidget { ), ), Text( - TypeConversionUtils.artists_X_String( - playback.activeTrack?.artists ?? [], - ), + playback.activeTrack?.artists?.asString() ?? "", overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall!.copyWith(color: color), ) diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index c805cb42..a0684075 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -10,6 +10,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; @@ -67,7 +68,7 @@ class SiblingTracksSheet extends HookConsumerWidget { ).trim(); final defaultSearchTerm = - "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}"; + "$title - ${playlist.activeTrack?.artists?.asString() ?? ""}"; final searchController = useTextEditingController( text: defaultSearchTerm, ); diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index afdc19a4..77caaaef 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -11,6 +11,7 @@ import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; @@ -230,9 +231,7 @@ class TrackTile extends HookConsumerWidget { alignment: Alignment.centerLeft, child: track is LocalTrack ? Text( - TypeConversionUtils.artists_X_String( - track.artists ?? [], - ), + track.artists?.asString() ?? '', ) : ClipRect( child: ConstrainedBox( diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index caf2e510..6a80300e 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -11,3 +11,9 @@ extension ArtistJson on ArtistSimple { }; } } + +extension ArtistExtension on List { + String asString() { + return map((e) => e.name?.replaceAll(",", " ")).join(", "); + } +} diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 96ad8d41..7513ad96 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -8,6 +8,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -55,8 +56,7 @@ class PlainLyrics extends HookConsumerWidget { ), Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + playlist.activeTrack?.artists?.asString() ?? "", style: (mediaQuery.mdAndUp ? textTheme.headlineSmall : textTheme.titleLarge) diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 872ad514..5e7a24c8 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -3,10 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; @@ -16,7 +16,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:stroke_text/stroke_text.dart'; class SyncedLyrics extends HookConsumerWidget { @@ -84,8 +83,7 @@ class SyncedLyrics extends HookConsumerWidget { if (isModal != true) Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + playlist.activeTrack?.artists?.asString() ?? "", style: mediaQuery.mdAndUp ? textTheme.headlineSmall : textTheme.titleLarge, diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 3aa547a9..e07e2d3b 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -4,9 +4,9 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class Discord extends ChangeNotifier { final DiscordRPC? discordRPC; @@ -23,8 +23,7 @@ class Discord extends ChangeNotifier { void updatePresence(Track track) { clear(); - final artistNames = - TypeConversionUtils.artists_X_String(track.artists ?? []); + final artistNames = track.artists?.asString() ?? ""; discordRPC?.updatePresence( DiscordPresence( details: "Song: ${track.name} by $artistNames", diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index abf4ed6c..32c5c98c 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; @@ -137,7 +138,7 @@ class DownloadManagerProvider extends ChangeNotifier { String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; + "${track.name} - ${track.artists?.asString() ?? ""}.${downloadCodec.name}"; return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart index bf234e62..0c204664 100644 --- a/lib/provider/scrobbler_provider.dart +++ b/lib/provider/scrobbler_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -85,14 +86,14 @@ class ScrobblerNotifier extends PersistedStateNotifier { Future love(Track track) async { await state?.scrobblenaut.track.love( - artist: TypeConversionUtils.artists_X_String(track.artists!), + artist: track.artists!.asString(), track: track.name!, ); } Future unlove(Track track) async { await state?.scrobblenaut.track.unLove( - artist: TypeConversionUtils.artists_X_String(track.artists!), + artist: track.artists!.asString(), track: track.name!, ); } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 068b41ba..4fa0ae1d 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -47,7 +47,7 @@ class AudioServices { id: track.id!, album: track.album?.name ?? "", title: track.name!, - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), + artist: track.artists?.toString() ?? "", duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 2df0e9fe..8a58ecd8 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -85,8 +86,7 @@ class WindowsAudioService { MusicMetadata( title: track.name!, albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", - artist: - TypeConversionUtils.artists_X_String(track.artists ?? []), + artist: track.artists?.asString() ?? "Unknown", album: track.album?.name ?? "Unknown", thumbnail: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 639299a1..668123eb 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -17,10 +17,6 @@ enum ImagePlaceholder { } abstract class TypeConversionUtils { - static String artists_X_String(List artists) { - return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); - } - static Widget artists_X_ClickableArtists( List artists, { WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, From 1a6cea926f7267424f153be2b7d5f49dbc6b355c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:38:10 +0600 Subject: [PATCH 06/17] refactor: use widget for artist link instead of a utility function --- .../library/user_downloads/download_item.dart | 5 +- lib/components/player/player.dart | 7 +-- .../player/player_track_details.dart | 5 +- .../shared/dialogs/track_details_dialog.dart | 6 +- lib/components/shared/links/artist_link.dart | 57 +++++++++++++++++++ .../shared/track_tile/track_options.dart | 5 +- .../shared/track_tile/track_tile.dart | 5 +- lib/pages/track/track.dart | 6 +- lib/utils/type_conversion_utils.dart | 44 -------------- 9 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 lib/components/shared/links/artist_link.dart diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 16bf1afe..aed567ab 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -59,8 +60,8 @@ class DownloadItem extends HookConsumerWidget { ), ), title: Text(track.name ?? ''), - subtitle: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + subtitle: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), trailing: isQueryingSourceInfo diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 04dd90df..941bc3eb 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/player_actions.dart'; @@ -13,6 +12,7 @@ import 'package:spotube/components/player/player_queue.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; @@ -247,9 +247,8 @@ class PlayerView extends HookConsumerWidget { ), ) else - TypeConversionUtils - .artists_X_ClickableArtists( - currentTrack?.artists ?? [], + ArtistLink( + artists: currentTrack?.artists ?? [], textStyle: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 268f4b76..bbf8c995 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -4,6 +4,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -74,8 +75,8 @@ class PlayerTrackDetails extends HookConsumerWidget { overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), - TypeConversionUtils.artists_X_ClickableArtists( - playback.activeTrack?.artists ?? [], + ArtistLink( + artists: playback.activeTrack?.artists ?? [], onRouteChange: (route) { ServiceUtils.push(context, route); }, diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 4e65b8e5..da2a140b 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/duration.dart'; class TrackDetailsDialog extends HookWidget { @@ -24,8 +24,8 @@ class TrackDetailsDialog extends HookWidget { final detailsMap = { context.l10n.title: track.name!, - context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + context.l10n.artist: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, textStyle: const TextStyle(color: Colors.blue), ), diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart new file mode 100644 index 00000000..af8b186a --- /dev/null +++ b/lib/components/shared/links/artist_link.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ArtistLink extends StatelessWidget { + final List artists; + final WrapCrossAlignment crossAxisAlignment; + final WrapAlignment mainAxisAlignment; + final TextStyle textStyle; + final void Function(String route)? onRouteChange; + + const ArtistLink({ + super.key, + required this.artists, + this.crossAxisAlignment = WrapCrossAlignment.center, + this.mainAxisAlignment = WrapAlignment.center, + this.textStyle = const TextStyle(), + this.onRouteChange, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + crossAxisAlignment: crossAxisAlignment, + alignment: mainAxisAlignment, + children: artists + .asMap() + .entries + .map( + (artist) => Builder(builder: (context) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } + return AnchorButton( + (artist.key != artists.length - 1) + ? "${artist.value.name}, " + : artist.value.name!, + onTap: () { + if (onRouteChange != null) { + onRouteChange?.call("/artist/${artist.value.id}"); + } else { + ServiceUtils.push( + context, + "/artist/${artist.value.id}", + ); + } + }, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + }), + ) + .toList(), + ); + } +} diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 590a5889..1288783e 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; @@ -309,9 +310,7 @@ class TrackOptions extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, - ), + child: ArtistLink(artists: track.artists!), ), ), ], diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 77caaaef..930d922c 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -9,6 +9,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/artist_simple.dart'; @@ -236,9 +237,7 @@ class TrackTile extends HookConsumerWidget { : ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], - ), + child: ArtistLink(artists: track.artists ?? []), ), ), ), diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index aef9a083..cbb75ed8 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -8,6 +8,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; @@ -145,10 +146,7 @@ class TrackPage extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.artist), const Gap(5), - TypeConversionUtils - .artists_X_ClickableArtists( - track.artists!, - ), + ArtistLink(artists: track.artists!), ], ), const Gap(10), diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 668123eb..18d42040 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -2,12 +2,9 @@ import 'dart:io'; -import 'package:flutter/widgets.dart' hide Image; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/utils/service_utils.dart'; enum ImagePlaceholder { albumArt, @@ -17,47 +14,6 @@ enum ImagePlaceholder { } abstract class TypeConversionUtils { - static Widget artists_X_ClickableArtists( - List artists, { - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, - WrapAlignment mainAxisAlignment = WrapAlignment.center, - TextStyle textStyle = const TextStyle(), - void Function(String route)? onRouteChange, - }) { - return Wrap( - crossAxisAlignment: crossAxisAlignment, - alignment: mainAxisAlignment, - children: artists - .asMap() - .entries - .map( - (artist) => Builder(builder: (context) { - if (artist.value.name == null) { - return Text("Spotify", style: textStyle); - } - return AnchorButton( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name!, - onTap: () { - if (onRouteChange != null) { - onRouteChange("/artist/${artist.value.id}"); - } else { - ServiceUtils.push( - context, - "/artist/${artist.value.id}", - ); - } - }, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - }), - ) - .toList(), - ); - } - static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { Album album = Album(); album.albumType = albumSimple.albumType; From 9f96b5c537ef485e2ce15318bd00357b910c7928 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:48:21 +0600 Subject: [PATCH 07/17] refactor: use extension methods for simple album to album and simple track to track conversion --- lib/components/album/album_card.dart | 7 +- lib/components/artist/artist_card.dart | 1 - .../playlist_generate/simple_track_tile.dart | 1 - lib/components/library/user_albums.dart | 8 +- .../library/user_downloads/download_item.dart | 1 - lib/components/library/user_local_tracks.dart | 4 +- lib/components/player/player.dart | 2 +- .../player/player_track_details.dart | 1 - .../player/sibling_tracks_sheet.dart | 1 - lib/components/playlist/playlist_card.dart | 1 - .../playlist/playlist_create_dialog.dart | 1 - lib/components/root/bottom_player.dart | 1 - lib/components/root/sidebar.dart | 1 - .../dialogs/playlist_add_track_dialog.dart | 1 - .../shared/track_tile/track_options.dart | 2 +- .../shared/track_tile/track_tile.dart | 1 - lib/extensions/album_simple.dart | 20 ++++- lib/extensions/image.dart | 8 +- lib/extensions/track.dart | 64 ++++++++++++- lib/models/local_track.dart | 2 +- lib/pages/album/album.dart | 1 - lib/pages/artist/section/footer.dart | 2 +- lib/pages/artist/section/header.dart | 1 - .../playlist_generate/playlist_generate.dart | 1 - lib/pages/lyrics/lyrics.dart | 1 - lib/pages/lyrics/plain_lyrics.dart | 2 - lib/pages/playlist/playlist.dart | 1 - lib/pages/search/sections/albums.dart | 4 +- lib/pages/track/track.dart | 2 +- lib/provider/download_manager_provider.dart | 1 - .../proxy_playlist_provider.dart | 1 - lib/provider/scrobbler_provider.dart | 1 - lib/provider/spotify/album/releases.dart | 5 +- lib/provider/spotify/album/tracks.dart | 5 +- lib/provider/spotify/spotify.dart | 3 +- .../audio_services/audio_services.dart | 1 - .../audio_services/linux_audio_service.dart | 1 - .../audio_services/windows_audio_service.dart | 1 - lib/utils/type_conversion_utils.dart | 89 ------------------- 39 files changed, 105 insertions(+), 146 deletions(-) delete mode 100644 lib/utils/type_conversion_utils.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 1696fc48..083c1949 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -6,11 +6,11 @@ import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; extension FormattedAlbumType on AlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); @@ -41,10 +41,7 @@ class AlbumCard extends HookConsumerWidget { Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { - return album.tracks! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); + return album.tracks!.map((track) => track.asTrack(album)).toList(); } await ref.read(albumTracksProvider(album).future); return ref.read(albumTracksProvider(album).notifier).fetchAll(); diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 322ad501..ebe18e72 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -11,7 +11,6 @@ import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistCard extends HookConsumerWidget { final Artist artist; diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index 08d5060f..cf4ddb1a 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -5,7 +5,6 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class SimpleTrackTile extends HookWidget { final Track track; diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 07ba7a40..be421a40 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -12,12 +12,11 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class UserAlbums extends HookConsumerWidget { const UserAlbums({super.key}); @@ -99,10 +98,7 @@ class UserAlbums extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [NotFound()], ), - for (final album in albums) - AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), + for (final album in albums) AlbumCard(album.toAlbum()), if (albums.isNotEmpty && albumsQuery.asData?.value.hasMore == true) Skeletonizer( diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index aed567ab..a145fdad 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -10,7 +10,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index f9b53330..5450bc34 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -23,11 +23,11 @@ import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; const supportedAudioTypes = [ @@ -112,7 +112,7 @@ final localTracksProvider = FutureProvider>((ref) async { final tracks = filesWithMetadata .map( (fileWithMetadata) => LocalTrack.fromTrack( - track: TypeConversionUtils.localTrack_X_Track( + track: Track().fromFile( fileWithMetadata["file"], metadata: fileWithMetadata["metadata"], art: fileWithMetadata["art"], diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 941bc3eb..5559be73 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -27,7 +27,7 @@ import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; class PlayerView extends HookConsumerWidget { diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index bbf8c995..95fecdc2 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -10,7 +10,6 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { final String? albumArt; diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index a0684075..99ab223f 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -25,7 +25,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; final sourceInfoToIconMap = { YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 83e25a85..8915e97a 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -8,7 +8,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index cae51444..bac98b64 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -17,7 +17,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { /// Track ids to add to the playlist diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index e6cf17dc..16633f7c 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -23,7 +23,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class BottomPlayer extends HookConsumerWidget { BottomPlayer({super.key}); diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 9049ecf1..903e812e 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -21,7 +21,6 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class Sidebar extends HookConsumerWidget { final int? selectedIndex; diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 28044b41..5d493a68 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -9,7 +9,6 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { /// The id of the playlist this dialog was opened from diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 1288783e..76c91003 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -26,7 +26,7 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; enum TrackOptionValue { diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 930d922c..897abdae 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -19,7 +19,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 00db4dca..7c8ae09e 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,6 +1,6 @@ import 'package:spotify/spotify.dart'; -extension AlbumJson on AlbumSimple { +extension AlbumExtensions on AlbumSimple { Map toJson() { return { "albumType": albumType?.name, @@ -15,4 +15,22 @@ extension AlbumJson on AlbumSimple { .toList(), }; } + + Album toAlbum() { + Album album = Album(); + album.albumType = albumType; + album.artists = artists; + album.availableMarkets = availableMarkets; + album.externalUrls = externalUrls; + album.href = href; + album.id = id; + album.images = images; + album.name = name; + album.releaseDate = releaseDate; + album.releaseDatePrecision = releaseDatePrecision; + album.tracks = tracks; + album.type = type; + album.uri = uri; + return album; + } } diff --git a/lib/extensions/image.dart b/lib/extensions/image.dart index f84bd37a..ee78653a 100644 --- a/lib/extensions/image.dart +++ b/lib/extensions/image.dart @@ -1,9 +1,15 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:collection/collection.dart'; +enum ImagePlaceholder { + albumArt, + artist, + collection, + online, +} + extension SpotifyImageExtensions on List? { String asUrlString({ int index = 1, diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 51498b33..d8258a6d 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -1,10 +1,46 @@ +import 'dart:io'; + +import 'package:metadata_god/metadata_god.dart'; +import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; -extension TrackJson on Track { +extension TrackExtensions on Track { + Track fromFile( + File file, { + Metadata? metadata, + String? art, + }) { + album = Album() + ..name = metadata?.album ?? "Unknown" + ..images = [if (art != null) Image()..url = art] + ..genres = [if (metadata?.genre != null) metadata!.genre!] + ..artists = [ + Artist() + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" + ..type = "artist", + ] + ..id = metadata?.album + ..releaseDate = metadata?.year?.toString(); + artists = [ + Artist() + ..name = metadata?.artist ?? "Unknown" + ..id = metadata?.artist ?? "Unknown" + ]; + + id = metadata?.title ?? basenameWithoutExtension(file.path); + name = metadata?.title ?? basenameWithoutExtension(file.path); + type = "track"; + uri = file.path; + durationMs = (metadata?.durationMs?.toInt() ?? 0); + + return this; + } + Map toJson() { - return TrackJson.trackToJson(this); + return TrackExtensions.trackToJson(this); } static Map trackToJson(Track track) { @@ -30,3 +66,27 @@ extension TrackJson on Track { }; } } + +extension TrackSimpleExtensions on TrackSimple { + Track asTrack(AlbumSimple album) { + Track track = Track(); + track.name = name; + track.album = album; + track.artists = artists; + track.availableMarkets = availableMarkets; + track.discNumber = discNumber; + track.durationMs = durationMs; + track.explicit = explicit; + track.externalUrls = externalUrls; + track.href = href; + track.id = id; + track.isPlayable = isPlayable; + track.linkedFrom = linkedFrom; + track.name = name; + track.previewUrl = previewUrl; + track.trackNumber = trackNumber; + track.type = type; + track.uri = uri; + return track; + } +} diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index 134cd327..923f5f26 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -37,7 +37,7 @@ class LocalTrack extends Track { Map toJson() { return { - ...TrackJson.trackToJson(this), + ...TrackExtensions.trackToJson(this), 'path': path, }; } diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 0f36756f..7c03b6dd 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -6,7 +6,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumPage extends HookConsumerWidget { final AlbumSimple album; diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index c53f2c54..835dbdd3 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -7,7 +7,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; class ArtistPageFooter extends ConsumerWidget { diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index dcf3114e..1f1d028d 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -14,7 +14,6 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistPageHeader extends HookConsumerWidget { final String artistId; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 81fbbfe3..49a33164 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -20,7 +20,6 @@ import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 0482cfe9..6d406e33 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -19,7 +19,6 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 7513ad96..f1c6ec2e 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -15,8 +15,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class PlainLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 3a0f9ec6..ce070b06 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -8,7 +8,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 6d0f1508..dee27041 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class SearchAlbumsSection extends HookConsumerWidget { const SearchAlbumsSection({ @@ -21,7 +21,7 @@ class SearchAlbumsSection extends HookConsumerWidget { () => query.asData?.value.items .cast() - .map(TypeConversionUtils.simpleAlbum_X_Album) + .map((e) => e.toAlbum()) .toList() ?? [], [query.value], diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index cbb75ed8..829256d4 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -17,7 +17,7 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 32c5c98c..c964f982 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -16,7 +16,6 @@ import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadManagerProvider extends ChangeNotifier { DownloadManagerProvider({required this.ref}) diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index aea873dd..aa63e3f3 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -33,7 +33,6 @@ import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; /// Things implemented: /// * [x] Sponsor-Block skip diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart index 0c204664..9ad2a58b 100644 --- a/lib/provider/scrobbler_provider.dart +++ b/lib/provider/scrobbler_provider.dart @@ -8,7 +8,6 @@ import 'package:spotube/collections/env.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ScrobblerState { final String username; diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart index 471df707..cacddbdf 100644 --- a/lib/provider/spotify/album/releases.dart +++ b/lib/provider/spotify/album/releases.dart @@ -36,10 +36,7 @@ class AlbumReleasesNotifier .newReleases(country: market) .getPage(limit, offset); - return albums.items - ?.map(TypeConversionUtils.simpleAlbum_X_Album) - .toList() ?? - []; + return albums.items?.map((album) => album.toAlbum()).toList() ?? []; } @override diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart index 9556cc52..e9f712e7 100644 --- a/lib/provider/spotify/album/tracks.dart +++ b/lib/provider/spotify/album/tracks.dart @@ -31,10 +31,7 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier TypeConversionUtils.simpleTrack_X_Track(e, arg)) - .toList() ?? - []; + return tracks.items?.map((e) => e.asTrack(arg)).toList() ?? []; } @override diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index ea28b6d8..b152db65 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -12,6 +12,7 @@ import 'package:spotify/spotify.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/lyrics.dart'; @@ -23,7 +24,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/wikipedia/wikipedia.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:http/http.dart' as http; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:wikipedia_api/wikipedia_api.dart'; part 'album/favorite.dart'; diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 4fa0ae1d..facbcc4c 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -7,7 +7,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class AudioServices { final MobileAudioService? mobile; diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 11399e67..84a6f7b8 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -9,7 +9,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; final dbus = DBusClient.session(); diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 8a58ecd8..a3ee31e1 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -8,7 +8,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class WindowsAudioService { final SMTCWindows smtc; diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart deleted file mode 100644 index 18d42040..00000000 --- a/lib/utils/type_conversion_utils.dart +++ /dev/null @@ -1,89 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'dart:io'; - -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; -import 'package:spotify/spotify.dart'; - -enum ImagePlaceholder { - albumArt, - artist, - collection, - online, -} - -abstract class TypeConversionUtils { - static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { - Album album = Album(); - album.albumType = albumSimple.albumType; - album.artists = albumSimple.artists; - album.availableMarkets = albumSimple.availableMarkets; - album.externalUrls = albumSimple.externalUrls; - album.href = albumSimple.href; - album.id = albumSimple.id; - album.images = albumSimple.images; - album.name = albumSimple.name; - album.releaseDate = albumSimple.releaseDate; - album.releaseDatePrecision = albumSimple.releaseDatePrecision; - album.tracks = albumSimple.tracks; - album.type = albumSimple.type; - album.uri = albumSimple.uri; - return album; - } - - static Track simpleTrack_X_Track(TrackSimple trackSmp, AlbumSimple album) { - Track track = Track(); - track.name = trackSmp.name; - track.album = album; - track.artists = trackSmp.artists; - track.availableMarkets = trackSmp.availableMarkets; - track.discNumber = trackSmp.discNumber; - track.durationMs = trackSmp.durationMs; - track.explicit = trackSmp.explicit; - track.externalUrls = trackSmp.externalUrls; - track.href = trackSmp.href; - track.id = trackSmp.id; - track.isPlayable = trackSmp.isPlayable; - track.linkedFrom = trackSmp.linkedFrom; - track.name = trackSmp.name; - track.previewUrl = trackSmp.previewUrl; - track.trackNumber = trackSmp.trackNumber; - track.type = trackSmp.type; - track.uri = trackSmp.uri; - return track; - } - - static Track localTrack_X_Track( - File file, { - Metadata? metadata, - String? art, - }) { - final track = Track(); - track.album = Album() - ..name = metadata?.album ?? "Unknown" - ..images = [if (art != null) Image()..url = art] - ..genres = [if (metadata?.genre != null) metadata!.genre!] - ..artists = [ - Artist() - ..name = metadata?.albumArtist ?? "Unknown" - ..id = metadata?.albumArtist ?? "Unknown" - ..type = "artist", - ] - ..id = metadata?.album - ..releaseDate = metadata?.year?.toString(); - track.artists = [ - Artist() - ..name = metadata?.artist ?? "Unknown" - ..id = metadata?.artist ?? "Unknown" - ]; - - track.id = metadata?.title ?? basenameWithoutExtension(file.path); - track.name = metadata?.title ?? basenameWithoutExtension(file.path); - track.type = "track"; - track.uri = file.path; - track.durationMs = (metadata?.durationMs?.toInt() ?? 0); - - return track; - } -} From e99f32b6101c24d8177c57dfa5543e5763ba3ec7 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 23 Mar 2024 19:00:37 +0600 Subject: [PATCH 08/17] chore: set yt as jiosaavn fallback --- lib/services/sourced_track/sourced_track.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c73f3078..c06efd87 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -127,7 +127,7 @@ abstract class SourcedTrack extends Track { weakMatch: true, ), AudioSource.jiosaavn => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); From 82b1cfa0d775e3958c666280943a893c9113d468 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 23 Mar 2024 20:19:34 +0600 Subject: [PATCH 09/17] feat: search history support #1236 --- lib/collections/spotube_icons.dart | 1 + lib/pages/search/search.dart | 96 +++++++++++++++++++++++++---- lib/services/kv_store/kv_store.dart | 6 ++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6cf92085..98c8ad45 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -115,4 +115,5 @@ abstract class SpotubeIcons { static const github = SimpleIcons.github; static const openCollective = SimpleIcons.opencollective; static const anonymous = FeatherIcons.user; + static const history = FeatherIcons.clock; } diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index e666c9aa..ca66e02a 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,15 +14,16 @@ import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:collection/collection.dart'; class SearchPage extends HookConsumerWidget { const SearchPage({super.key}); @@ -29,7 +32,7 @@ class SearchPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final searchTerm = ref.watch(searchTermStateProvider); - final controller = useTextEditingController(text: searchTerm); + final controller = useSearchController(); ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = @@ -45,6 +48,12 @@ class SearchPage extends HookConsumerWidget { final isFetching = queries.every((s) => s.isLoading); + useEffect(() { + controller.text = searchTerm; + + return null; + }, []); + final resultWidget = HookBuilder( builder: (context) { final controller = useScrollController(); @@ -88,24 +97,87 @@ class SearchPage extends HookConsumerWidget { vertical: 10, ), color: theme.scaffoldBackgroundColor, - child: TextField( - controller: controller, - autofocus: - queries.none((s) => s.value != null && !s.hasError) && - !kIsMobile, - decoration: InputDecoration( - prefixIcon: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - ), - onSubmitted: (value) async { + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text.toLowerCase(), + ) > + 50, + ) + .toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read(searchTermStateProvider.notifier) + .state = suggestion; + }, + ); + }, + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); Timer( const Duration(milliseconds: 50), () { ref.read(searchTermStateProvider.notifier).state = value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); }, ); }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none( + (s) => s.value != null && !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, ), ), Expanded( diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index 5845b120..f94ec4ee 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -17,4 +17,10 @@ abstract class KVStoreService { sharedPreferences.getBool('askedForBatteryOptimization') ?? false; static Future setAskedForBatteryOptimization(bool value) async => await sharedPreferences.setBool('askedForBatteryOptimization', value); + + static List get recentSearches => + sharedPreferences.getStringList('recentSearches') ?? []; + + static Future setRecentSearches(List value) async => + await sharedPreferences.setStringList('recentSearches', value); } From ee97aedcfc64d972e70dc51aa25232913ecb4073 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 23 Mar 2024 20:37:52 +0600 Subject: [PATCH 10/17] chore: remove direct access to .value without calling asData.value --- .vscode/settings.json | 2 ++ lib/components/home/sections/genres.dart | 6 ++--- .../home/sections/made_for_user.dart | 4 ++-- lib/components/library/user_albums.dart | 6 ++--- lib/components/library/user_local_tracks.dart | 6 ++--- lib/components/shared/heart_button.dart | 4 ++-- .../shared/track_tile/track_options.dart | 2 +- .../sections/body/use_is_user_playlist.dart | 4 ++-- lib/pages/album/album.dart | 6 ++--- lib/pages/artist/artist.dart | 7 +++--- lib/pages/artist/section/footer.dart | 10 ++++----- lib/pages/artist/section/header.dart | 2 +- lib/pages/artist/section/top_tracks.dart | 6 ++--- lib/pages/home/genres/genres.dart | 4 ++-- .../playlist_generate/playlist_generate.dart | 2 +- .../playlist_generate_result.dart | 22 ++++++++++--------- lib/pages/playlist/liked_playlist.dart | 2 +- lib/pages/playlist/playlist.dart | 6 ++--- lib/pages/search/search.dart | 4 ++-- lib/pages/search/sections/albums.dart | 2 +- 20 files changed, 56 insertions(+), 51 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 472520ab..0fedc544 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,10 +4,12 @@ "acousticness", "Buildless", "danceability", + "fuzzywuzzy", "instrumentalness", "Mpris", "riverpod", "Scrobblenaut", + "skeletonizer", "speechiness", "Spotube", "winget" diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 87f28821..ac2644f0 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -26,12 +26,12 @@ class HomeGenresSection extends HookConsumerWidget { final categoriesQuery = ref.watch(categoriesProvider); final categories = useMemoized( () => - categoriesQuery.value - ?.where((c) => (c.icons?.length ?? 0) > 0) + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) .take(mediaQuery.mdAndDown ? 6 : 10) .toList() ?? [], - [mediaQuery.mdAndDown, categoriesQuery.value], + [mediaQuery.mdAndDown, categoriesQuery.asData?.value], ); return SliverMainAxisGroup( diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart index 439d9c38..d1d269f6 100644 --- a/lib/components/home/sections/made_for_user.dart +++ b/lib/components/home/sections/made_for_user.dart @@ -12,9 +12,9 @@ class HomeMadeForUserSection extends HookConsumerWidget { final madeForUser = ref.watch(viewProvider("made-for-x-hub")); return SliverList.builder( - itemCount: madeForUser.value?["content"]?["items"]?.length ?? 0, + itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0, itemBuilder: (context, index) { - final item = madeForUser.value?["content"]?["items"]?[index]; + final item = madeForUser.asData?.value["content"]?["items"]?[index]; final playlists = item["content"]?["items"] ?.where((itemL2) => itemL2["type"] == "playlist") .map((itemL2) => PlaylistSimple.fromJson(itemL2)) diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index be421a40..f58d6693 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -44,7 +44,7 @@ class UserAlbums extends HookConsumerWidget { .map((e) => e.$2) .toList() ?? []; - }, [albumsQuery.value, searchText.value]); + }, [albumsQuery.asData?.value, searchText.value]); if (auth == null) { return const AnonymousFallback(); @@ -87,8 +87,8 @@ class UserAlbums extends HookConsumerWidget { runAlignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (albumsQuery.value == null || - albumsQuery.value!.items.isEmpty) + if (albumsQuery.asData?.value == null || + albumsQuery.asData!.value.items.isEmpty) ...List.generate( 10, (index) => AlbumCard(FakeData.album), diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 5450bc34..e2098570 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -160,7 +160,7 @@ class UserLocalTracks extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.value ?? []); + playlist.containsTracks(trackSnapshot.asData?.value ?? []); final searchController = useTextEditingController(); useValueListenable(searchController); @@ -177,13 +177,13 @@ class UserLocalTracks extends HookConsumerWidget { children: [ const SizedBox(width: 10), FilledButton( - onPressed: trackSnapshot.value != null + onPressed: trackSnapshot.asData?.value != null ? () async { if (trackSnapshot.asData?.value.isNotEmpty == true) { if (!isPlaylistPlaying) { await playLocalTracks( ref, - trackSnapshot.value!, + trackSnapshot.asData!.value, ); } else { // TODO: Remove stop capability diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index a733c36c..9475f9e3 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -68,7 +68,7 @@ UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { () => savedTracks.asData?.value.any((element) => element.id == track.id) ?? false, - [savedTracks.value, track.id], + [savedTracks.asData?.value, track.id], ); final scrobblerNotifier = ref.read(scrobblerProvider.notifier); @@ -109,7 +109,7 @@ class TrackHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, isLiked: isLiked, - onPressed: savedTracks.value != null + onPressed: savedTracks.asData?.value != null ? () { toggleTrackLike(track); } diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 76c91003..29349602 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -348,7 +348,7 @@ class TrackOptions extends HookConsumerWidget { leading: const Icon(SpotubeIcons.queueRemove), title: Text(context.l10n.remove_from_queue), ), - if (me.value != null) + if (me.asData?.value != null) PopSheetEntry( value: TrackOptionValue.favorite, leading: favorites.isLiked diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart index d32efed2..2f87ccc8 100644 --- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart +++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart @@ -10,9 +10,9 @@ bool useIsUserPlaylist(WidgetRef ref, String playlistId) { () => userPlaylistsQuery.asData?.value.items.any((e) => e.id == playlistId && - me.value != null && + me.asData?.value != null && e.owner?.id == me.asData?.value.id) ?? false, - [userPlaylistsQuery.value, playlistId, me.value], + [userPlaylistsQuery.asData?.value, playlistId, me.asData?.value], ); } diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 7c03b6dd..b24b69f4 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -45,11 +45,11 @@ class AlbumPage extends HookConsumerWidget { ), routePath: "/album/${album.id}", shareUrl: album.externalUrls!.spotify!, - isLiked: isSavedAlbum.value ?? false, - onHeart: isSavedAlbum.value == null + isLiked: isSavedAlbum.asData?.value ?? false, + onHeart: isSavedAlbum.asData?.value == null ? null : () async { - if (isSavedAlbum.value!) { + if (isSavedAlbum.asData!.value) { await favoriteAlbumsNotifier.removeFavorites([album.id!]); } else { await favoriteAlbumsNotifier.addFavorites([album.id!]); diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index c153f0af..c3b04691 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -35,7 +35,7 @@ class ArtistPage extends HookConsumerWidget { ), extendBodyBehindAppBar: true, body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.value == null) { + if (artistQuery.hasError && artistQuery.asData?.value == null) { return Center(child: Text(artistQuery.error.toString())); } return Skeletonizer( @@ -66,11 +66,12 @@ class ArtistPage extends HookConsumerWidget { SliverSafeArea( sliver: ArtistPageRelatedArtists(artistId: artistId), ), - if (artistQuery.value != null) + if (artistQuery.asData?.value != null) SliverSafeArea( top: false, sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.value!), + child: + ArtistPageFooter(artist: artistQuery.asData!.value), ), ), ], diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index 835dbdd3..4707b939 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -23,7 +23,7 @@ class ArtistPageFooter extends ConsumerWidget { placeholder: ImagePlaceholder.artist, ); final summary = ref.watch(artistWikipediaSummaryProvider(artist)); - if (summary.value == null) return const SizedBox.shrink(); + if (summary.asData?.value == null) return const SizedBox.shrink(); return Container( margin: const EdgeInsets.all(16), @@ -39,9 +39,9 @@ class ArtistPageFooter extends ConsumerWidget { BlendMode.darken, ), image: UniversalImage.imageProvider( - summary.value!.thumbnail?.source_ ?? artistImage, - height: summary.value!.thumbnail?.height.toDouble(), - width: summary.value!.thumbnail?.width.toDouble(), + summary.asData?.value!.thumbnail?.source_ ?? artistImage, + height: summary.asData?.value!.thumbnail?.height.toDouble(), + width: summary.asData?.value!.thumbnail?.width.toDouble(), ), fit: BoxFit.cover, alignment: Alignment.center, @@ -70,7 +70,7 @@ class ArtistPageFooter extends ConsumerWidget { ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.value!.extract, + text: summary.asData?.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 1f1d028d..e5cb8900 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -22,7 +22,7 @@ class ArtistPageHeader extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final artistQuery = ref.watch(artistProvider(artistId)); - final artist = artistQuery.value ?? FakeData.artist; + final artist = artistQuery.asData?.value ?? FakeData.artist; final scaffoldMessenger = ScaffoldMessenger.of(context); final mediaQuery = MediaQuery.of(context); diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9ad2b0db..173ace54 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -23,7 +23,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.value ?? [], + topTracksQuery.asData?.value ?? [], ); if (topTracksQuery.hasError) { @@ -34,8 +34,8 @@ class ArtistPageTopTracks extends HookConsumerWidget { ); } - final topTracks = - topTracksQuery.value ?? List.generate(10, (index) => FakeData.track); + final topTracks = topTracksQuery.asData?.value ?? + List.generate(10, (index) => FakeData.track); void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index ed6c2835..a981cbe7 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -39,9 +39,9 @@ class GenrePage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: categories.value!.length, + itemCount: categories.asData!.value.length, itemBuilder: (context, index) { - final category = categories.value![index]; + final category = categories.asData!.value[index]; final gradient = gradients[Random().nextInt(gradients.length)]; return InkWell( borderRadius: BorderRadius.circular(8), diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 49a33164..5044090d 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -187,7 +187,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - options: genresCollection.value ?? [], + options: genresCollection.asData?.value ?? [], selectedOptions: genres.value, getValueForOption: (option) => option, onSelected: (value) { diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index deb86a97..5390c337 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -34,12 +34,12 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); useEffect(() { - if (generatedPlaylist.value != null) { + if (generatedPlaylist.asData?.value != null) { selectedTracks.value = - generatedPlaylist.value!.map((e) => e.id!).toList(); + generatedPlaylist.asData!.value.map((e) => e.id!).toList(); } return null; - }, [generatedPlaylist.value]); + }, [generatedPlaylist.asData?.value]); final isAllTrackSelected = selectedTracks.value.length == (generatedPlaylist.asData?.value.length ?? 0); @@ -78,7 +78,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ? null : () async { await playlistNotifier.load( - generatedPlaylist.value!.where( + generatedPlaylist.asData!.value.where( (e) => selectedTracks.value.contains(e.id!), ), autoPlay: true, @@ -92,7 +92,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ? null : () async { await playlistNotifier.addTracks( - generatedPlaylist.value!.where( + generatedPlaylist.asData!.value.where( (e) => selectedTracks.value.contains(e.id!), ), ); @@ -142,7 +142,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { openFromPlaylist: null, tracks: selectedTracks.value .map( - (e) => generatedPlaylist.value! + (e) => generatedPlaylist.asData!.value .firstWhere( (element) => element.id == e, ), @@ -167,7 +167,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ], ), const SizedBox(height: 16), - if (generatedPlaylist.value != null) + if (generatedPlaylist.asData?.value != null) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -181,8 +181,9 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { if (isAllTrackSelected) { selectedTracks.value = []; } else { - selectedTracks.value = generatedPlaylist.value - ?.map((e) => e.id!) + selectedTracks.value = generatedPlaylist + .asData?.value + .map((e) => e.id!) .toList() ?? []; } @@ -203,7 +204,8 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - for (final track in generatedPlaylist.value ?? []) + for (final track + in generatedPlaylist.asData?.value ?? []) CheckboxListTile( value: selectedTracks.value.contains(track.id), onChanged: (value) { diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index eeea8cb1..72983518 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -15,7 +15,7 @@ class LikedPlaylistPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final likedTracks = ref.watch(likedTracksProvider); - final tracks = likedTracks.value ?? []; + final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( collectionId: playlist.id!, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index ce070b06..d9d224e0 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -48,9 +48,9 @@ class PlaylistPage extends HookConsumerWidget { description: playlist.description, tracks: tracks.asData?.value.items ?? [], routePath: '/playlist/${playlist.id}', - isLiked: isFavoritePlaylist.value ?? false, + isLiked: isFavoritePlaylist.asData?.value ?? false, shareUrl: playlist.externalUrls?.spotify ?? "", - onHeart: isFavoritePlaylist.value == null + onHeart: isFavoritePlaylist.asData?.value == null ? null : () async { final confirmed = isUserPlaylist @@ -62,7 +62,7 @@ class PlaylistPage extends HookConsumerWidget { : true; if (!confirmed) return null; - if (isFavoritePlaylist.value!) { + if (isFavoritePlaylist.asData!.value) { await favoritePlaylistsNotifier.removeFavorite(playlist); } else { await favoritePlaylistsNotifier.addFavorite(playlist); diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index ca66e02a..c58b8df3 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -168,8 +168,8 @@ class SearchPage extends HookConsumerWidget { }, builder: (context, controller) { return SearchBar( - autoFocus: queries.none( - (s) => s.value != null && !s.hasError) && + autoFocus: queries.none((s) => + s.asData?.value != null && !s.hasError) && !kIsMobile, controller: controller, leading: const Icon(SpotubeIcons.search), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index dee27041..d15c34ff 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -24,7 +24,7 @@ class SearchAlbumsSection extends HookConsumerWidget { .map((e) => e.toAlbum()) .toList() ?? [], - [query.value], + [query.asData?.value], ); return HorizontalPlaybuttonCardView( From 044d3b4820437cdab01a905f6569dcdbeee0b8f8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 28 Mar 2024 22:49:40 +0600 Subject: [PATCH 11/17] refactor: use CustomScrollView in player queue --- lib/components/player/player_queue.dart | 315 ++++++++++++------------ 1 file changed, 158 insertions(+), 157 deletions(-) diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 141479a6..7641fad5 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -3,11 +3,15 @@ import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; @@ -109,171 +113,168 @@ class PlayerQueue extends HookConsumerWidget { searchText.value = ''; } }, - child: Column( - children: [ - if (!floating) - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - const Spacer(), - ], - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + if (!floating) + SliverToBoxAdapter( + child: Center( + child: Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), ), ), - ) - else - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, ), - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: theme.textTheme.headlineSmall?.color, - ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], - ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, + ), + SliverAppBar( + floating: true, + pinned: false, + snap: false, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: !isSearching.value, + title: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, ), - const SizedBox(width: 10), - ], - ], - ), - const SizedBox(height: 10), - if (!isSearching.value && searchText.value.isEmpty) - Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), + child: SizedBox( + height: kToolbarHeight, + child: mediaQuery.mdAndUp || !isSearching.value + ? Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, + ), ), - ], - ), - ), - ); - }, - ), - ) - else - Flexible( - child: InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: filteredTracks.length, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - ), - ); - }, + ) + : null, ), ), + actions: [ + if (mediaQuery.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: mediaQuery.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: + theme.scaffoldBackgroundColor.withOpacity(0.5), + foregroundColor: + theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], ), - ], + const SliverGap(10), + SliverReorderableList( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + itemCount: filteredTracks.length, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Material( + color: Colors.transparent, + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableDragStartListener( + index: i, + child: const Icon( + SpotubeIcons.dragHandle, + ), + ), + ), + ], + ), + ), + ); + }, + ), + const SliverGap(100), + ], + ), ), ), ), From 68374efd3ec556f31b937e5b96920787b54eec78 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 4 Apr 2024 22:22:00 +0600 Subject: [PATCH 12/17] feat: LAN connect a.k.a control remote Spotube playback and local output device selection (#1355) * feat: add connect server support * feat: add ability discover and connect to same network Spotube(s) and sync queue * feat(connect): add player controls, shuffle, loop, progress bar and queue support * feat: make control page adaptive * feat: add volume control support * cd: upgrade macos runner version * chore: upgrade inappwebview version to 6 * feat: customized devices button * feat: add user icon next to devices button * feat: add play in remote device support * feat: show alert when new client connects * fix: ignore the device itself from broadcast list * fix: volume control not working * feat: add ability to select current device's output speaker --- .github/workflows/spotube-release-binary.yml | 4 +- CONTRIBUTION.md | 8 +- ios/Podfile | 2 +- ios/Podfile.lock | 31 +- ios/Runner/Info.plist | 6 + lib/collections/routes.dart | 17 + lib/collections/spotube_icons.dart | 5 + lib/components/album/album_card.dart | 18 +- lib/components/connect/connect_device.dart | 85 ++++ lib/components/connect/local_devices.dart | 60 +++ lib/components/library/user_local_tracks.dart | 14 +- lib/components/player/player.dart | 50 ++- lib/components/player/player_controls.dart | 24 +- lib/components/player/player_overlay.dart | 2 +- lib/components/player/player_queue.dart | 399 ++++++++++-------- .../player/player_track_details.dart | 8 +- lib/components/player/volume_slider.dart | 34 +- lib/components/playlist/playlist_card.dart | 18 +- lib/components/root/bottom_player.dart | 21 +- .../shared/dialogs/select_device_dialog.dart | 70 +++ .../shared/page_window_title_bar.dart | 98 ++++- .../shared/track_tile/track_tile.dart | 9 +- .../sections/body/track_view_body.dart | 50 ++- .../sections/header/header_buttons.dart | 44 +- lib/l10n/app_en.arb | 9 +- lib/main.dart | 6 + lib/models/connect/connect.dart | 16 + lib/models/connect/connect.freezed.dart | 216 ++++++++++ lib/models/connect/connect.g.dart | 25 ++ lib/models/connect/load.dart | 27 ++ lib/models/connect/ws_event.dart | 374 ++++++++++++++++ lib/pages/artist/section/top_tracks.dart | 49 ++- lib/pages/connect/connect.dart | 93 ++++ lib/pages/connect/control/control.dart | 317 ++++++++++++++ lib/pages/home/home.dart | 20 +- lib/pages/lyrics/mini_lyrics.dart | 13 +- lib/pages/mobile_login/mobile_login.dart | 18 +- lib/pages/root/root_app.dart | 103 +++-- lib/pages/search/sections/tracks.dart | 73 +++- lib/pages/settings/sections/playback.dart | 7 + lib/provider/connect/clients.dart | 111 +++++ lib/provider/connect/connect.dart | 184 ++++++++ lib/provider/connect/server.dart | 261 ++++++++++++ .../proxy_playlist/proxy_playlist.dart | 14 +- .../user_preferences_provider.dart | 4 + .../user_preferences_state.dart | 1 + .../user_preferences_state.freezed.dart | 37 +- .../user_preferences_state.g.dart | 2 + lib/services/audio_player/audio_player.dart | 11 +- .../audio_player/audio_player_impl.dart | 4 + .../audio_players_streams_mixin.dart | 6 + lib/services/device_info/device_info.dart | 34 ++ linux/packaging/deb/make_config.yaml | 5 + linux/packaging/rpm/make_config.yaml | 3 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile | 2 +- macos/Podfile.lock | 19 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- pubspec.lock | 134 +++++- pubspec.yaml | 9 +- untranslated_messages.json | 199 ++++++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 63 files changed, 3090 insertions(+), 407 deletions(-) create mode 100644 lib/components/connect/connect_device.dart create mode 100644 lib/components/connect/local_devices.dart create mode 100644 lib/components/shared/dialogs/select_device_dialog.dart create mode 100644 lib/models/connect/connect.dart create mode 100644 lib/models/connect/connect.freezed.dart create mode 100644 lib/models/connect/connect.g.dart create mode 100644 lib/models/connect/load.dart create mode 100644 lib/models/connect/ws_event.dart create mode 100644 lib/pages/connect/connect.dart create mode 100644 lib/pages/connect/control/control.dart create mode 100644 lib/provider/connect/clients.dart create mode 100644 lib/provider/connect/connect.dart create mode 100644 lib/provider/connect/server.dart create mode 100644 lib/services/device_info/device_info.dart diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 68ea2d67..e05bf75d 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -284,7 +284,7 @@ jobs: macos: - runs-on: macos-12 + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.12.0 @@ -349,7 +349,7 @@ jobs: limit-access-to-actor: true iOS: - runs-on: macos-latest + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 13996cea..e859f9e6 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents - [Before Submitting an Enhancement](#before-submitting-an-enhancement) - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) - [Your First Code Contribution](#your-first-code-contribution) - - [Submit translations](#submit-translations) + - [Submit Translations](#submit-translations) ## Code of Conduct @@ -123,16 +123,16 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan ``` - Fedora ```bash - dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel + dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns ``` - Clone the Repo - Create a `.env` in root of the project following the `.env.example` template diff --git a/ios/Podfile b/ios/Podfile index bc3dcaa6..7235f482 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0b75217f..1d048cc9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -5,6 +5,9 @@ PODS: - Flutter - audio_session (0.0.1): - Flutter + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.4): @@ -44,11 +47,13 @@ PODS: - file_selector_ios (0.0.1): - Flutter - Flutter (1.0.0) - - flutter_inappwebview (0.0.1): + - flutter_broadcasts (0.0.1): - Flutter - - flutter_inappwebview/Core (= 0.0.1) + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) - OrderedSet (~> 5.0) - - flutter_inappwebview/Core (0.0.1): + - flutter_inappwebview_ios/Core (0.0.1): - Flutter - OrderedSet (~> 5.0) - flutter_keyboard_visibility (0.0.1): @@ -102,11 +107,13 @@ DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) + - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) + - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -142,6 +149,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audio_service/ios" audio_session: :path: ".symlinks/plugins/audio_session/ios" + bonsoir_darwin: + :path: ".symlinks/plugins/bonsoir_darwin/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -150,8 +159,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter - flutter_inappwebview: - :path: ".symlinks/plugins/flutter_inappwebview/ios" + flutter_broadcasts: + :path: ".symlinks/plugins/flutter_broadcasts/ios" + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_mailer: @@ -191,13 +202,15 @@ SPEC CHECKSUMS: app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 - device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 + flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 + flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef @@ -221,6 +234,6 @@ SPEC CHECKSUMS: Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 -PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd +PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e COCOAPODS: 1.15.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e103cfa..ffd511a4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,11 @@ UIViewControllerBasedStatusBarAppearance + NSLocalNetworkUsageDescription + To allow other devices on the network control playback of Spotube securely. + NSBonjourServices + + _spotube._tcp + \ No newline at end of file diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 8428aaf3..80067405 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/connect/connect.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; @@ -173,6 +175,21 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + path: "/connect", + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + routes: [ + GoRoute( + path: "control", + pageBuilder: (context, state) { + return const SpotubePage( + child: ConnectControlPage(), + ); + }, + ) + ]) ], ), GoRoute( diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 98c8ad45..6de21284 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -116,4 +116,9 @@ abstract class SpotubeIcons { static const openCollective = SimpleIcons.opencollective; static const anonymous = FeatherIcons.user; static const history = FeatherIcons.clock; + static const connect = FeatherIcons.link; + static const speaker = FeatherIcons.speaker; + static const monitor = FeatherIcons.monitor; + static const power = FeatherIcons.power; + static const bluetooth = FeatherIcons.bluetooth; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 083c1949..678bfd06 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: album.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(album.id!); + } } finally { updating.value = false; } diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart new file mode 100644 index 00000000..8ece074f --- /dev/null +++ b/lib/components/connect/connect_device.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectDeviceButton extends HookConsumerWidget { + const ConnectDeviceButton({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final connectClients = ref.watch(connectClientsProvider); + + return SizedBox( + height: 40 * pixelRatio, + child: Stack( + alignment: Alignment.centerRight, + fit: StackFit.loose, + children: [ + Center( + child: InkWell( + onTap: () { + ServiceUtils.push(context, "/connect"); + }, + borderRadius: BorderRadius.circular(50), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: colorScheme.primaryContainer, + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectClients.asData?.value.resolvedService != + null) ...[ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: Colors.greenAccent, + borderRadius: BorderRadius.circular(50), + ), + ), + const Gap(5), + ], + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == + true) + Text( + " (${connectClients.asData?.value.services.length})", + style: TextStyle( + color: + colorScheme.onPrimaryContainer.withOpacity(0.5), + ), + ), + const Gap(35), + ], + ), + ), + ), + ), + Positioned( + right: 0, + child: IconButton.filled( + icon: const Icon(SpotubeIcons.speaker), + style: IconButton.styleFrom( + visualDensity: VisualDensity.standard, + foregroundColor: colorScheme.onPrimary, + ), + onPressed: () { + ServiceUtils.push(context, "/connect"); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/connect/local_devices.dart b/lib/components/connect/local_devices.dart new file mode 100644 index 00000000..dd7db971 --- /dev/null +++ b/lib/components/connect/local_devices.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class ConnectPageLocalDevices extends HookWidget { + const ConnectPageLocalDevices({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme) = Theme.of(context); + final devicesFuture = useFuture(audioPlayer.devices); + final devicesStream = useStream(audioPlayer.devicesStream); + final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); + final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream); + + final devices = devicesStream.data ?? devicesFuture.data; + final selectedDevice = + selectedDeviceStream.data ?? selectedDeviceFuture.data; + + if (devices == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return SliverMainAxisGroup( + slivers: [ + const SliverGap(10), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.this_device, + style: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: devices.length, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = devices[index]; + + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.speaker), + title: Text(device.description), + subtitle: Text(device.name), + selected: selectedDevice == device, + onTap: () => audioPlayer.setAudioDevice(device), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index e2098570..778558f6 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -283,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget { trackSnapshot.isLoading ? 5 : filteredTracks.length, itemBuilder: (context, index) { if (trackSnapshot.isLoading) { - return TrackTile(track: FakeData.track, index: index); + return TrackTile( + playlist: playlist, + track: FakeData.track, + index: index, + ); } final track = filteredTracks[index]; return TrackTile( index: index, + playlist: playlist, track: track, userPlaylist: false, onTap: () async { @@ -311,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget { enabled: true, child: ListView.builder( itemCount: 5, - itemBuilder: (context, index) => - TrackTile(track: FakeData.track, index: index), + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 5559be73..6dbd9b11 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -46,9 +47,7 @@ class PlayerView extends HookConsumerWidget { final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( (value) => value.activeTrack, )); - final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack is LocalTrack, - )); + final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); useEffect(() { @@ -240,7 +239,7 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - currentTrack?.artists?.asString() ?? "", + currentTrack.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, @@ -304,10 +303,25 @@ class PlayerView extends HookConsumerWidget { .height * .7, ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, + builder: (context) => Consumer( + builder: (context, ref, _) { + final playlist = ref.watch( + ProxyPlaylistNotifier + .provider, + ); + final playlistNotifier = + ref.read( + ProxyPlaylistNotifier + .notifier, + ); + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ); } : null), @@ -365,11 +379,21 @@ class PlayerView extends HookConsumerWidget { enabledThumbRadius: 8, ), ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: VolumeSlider( - fullWidth: true, - ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref + .read(volumeProvider.notifier) + .setVolume(value); + }, + ); + }), ), ), ], diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 02cbfff5..0190e2e6 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget { onPressed: playlist.isFetching == true ? null : () async { - switch (audioPlayer.loopMode) { - case PlaybackLoopMode.all: - audioPlayer - .setLoopMode(PlaybackLoopMode.one); - break; - case PlaybackLoopMode.one: - audioPlayer - .setLoopMode(PlaybackLoopMode.none); - break; - case PlaybackLoopMode.none: - audioPlayer - .setLoopMode(PlaybackLoopMode.all); - break; - } + audioPlayer.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); }, ); }), diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 1ad91a52..e2ca9674 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget { width: double.infinity, color: Colors.transparent, child: PlayerTrackDetails( - albumArt: albumArt, + track: playlist.activeTrack, color: textColor, ), ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 7641fad5..0bf61da4 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -3,15 +3,13 @@ import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/collections/fake.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; @@ -20,19 +18,43 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; + final ProxyPlaylist playlist; + + final Future Function(Track track) onJump; + final Future Function(String trackId) onRemove; + final Future Function(int oldIndex, int newIndex) onReorder; + final Future Function() onStop; + const PlayerQueue({ this.floating = true, + required this.playlist, + required this.onJump, + required this.onRemove, + required this.onReorder, + required this.onStop, super.key, }); + PlayerQueue.fromProxyPlaylistNotifier({ + this.floating = true, + required this.playlist, + required ProxyPlaylistNotifier notifier, + super.key, + }) : onJump = notifier.jumpToTrack, + onRemove = notifier.removeTrack, + onReorder = notifier.moveTrack, + onStop = notifier.stop; + @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final mediaQuery = MediaQuery.of(context); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); final controller = useAutoScrollController(); final searchText = useState(''); @@ -48,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget { topRight: Radius.circular(10), ); final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); final headlineColor = theme.textTheme.headlineSmall?.color; final filteredTracks = useMemoized( @@ -87,198 +108,204 @@ class PlayerQueue extends HookConsumerWidget { return const NotFound(vertical: true); } - return ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.hardEdge, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 15, - sigmaY: 15, - ), - child: Container( - padding: const EdgeInsets.only( - top: 5.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); - } - isSearching.value = false; - searchText.value = ''; - } - }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - if (!floating) - SliverToBoxAdapter( - child: Center( - child: Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - ), - ), - SliverAppBar( - floating: true, - pinned: false, - snap: false, - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: !isSearching.value, - title: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: SizedBox( - height: kToolbarHeight, - child: mediaQuery.mdAndUp || !isSearching.value - ? Align( - alignment: Alignment.centerLeft, - child: Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ) - : null, - ), - ), - actions: [ - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, + return LayoutBuilder( + builder: (context, constrains) { + return ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 15, + sigmaY: 15, + ), + child: Container( + padding: const EdgeInsets.only( + top: 5.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: borderRadius, + ), + child: CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + if (!isSearching.value) { + Navigator.of(context).pop(); + } + isSearching.value = false; + searchText.value = ''; + } + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + if (!floating) + SliverToBoxAdapter( + child: Center( + child: Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), + ), ), ), - ) - else - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, ), - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: - theme.textTheme.headlineSmall?.color, + SliverAppBar( + floating: true, + pinned: false, + snap: false, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: !isSearching.value, + title: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], + child: SizedBox( + height: kToolbarHeight, + child: mediaQuery.mdAndUp || !isSearching.value + ? Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n + .tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ) + : null, ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, ), - const SizedBox(width: 10), - ], + actions: [ + if (mediaQuery.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: mediaQuery.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: theme.scaffoldBackgroundColor + .withOpacity(0.5), + foregroundColor: + theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], + ), + const SliverGap(10), + SliverReorderableList( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + itemCount: filteredTracks.length, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Material( + color: Colors.transparent, + child: TrackTile( + playlist: playlist, + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableDragStartListener( + index: i, + child: const Icon( + SpotubeIcons.dragHandle, + ), + ), + ), + ], + ), + ), + ); + }, + ), + const SliverGap(100), ], ), - const SliverGap(10), - SliverReorderableList( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - itemCount: filteredTracks.length, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Material( - color: Colors.transparent, - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - if (!isSearching.value && - searchText.value.isEmpty) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: ReorderableDragStartListener( - index: i, - child: const Icon( - SpotubeIcons.dragHandle, - ), - ), - ), - ], - ), - ), - ); - }, - ), - const SliverGap(100), - ], + ), ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 95fecdc2..65e40fe6 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { - final String? albumArt; final Color? color; - const PlayerTrackDetails({super.key, this.albumArt, this.color}); + final Track? track; + const PlayerTrackDetails({super.key, this.color, this.track}); @override Widget build(BuildContext context, ref) { @@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( - path: albumArt ?? "", + path: (track?.album?.images) + .asUrlString(placeholder: ImagePlaceholder.albumArt), placeholder: Assets.albumPlaceholder.path, ), ), diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 7596a347..102bbef6 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -3,37 +3,39 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/provider/volume_provider.dart'; class VolumeSlider extends HookConsumerWidget { final bool fullWidth; + + final double value; + final ValueChanged onChanged; + const VolumeSlider({ super.key, this.fullWidth = false, + required this.value, + required this.onChanged, }); @override Widget build(BuildContext context, ref) { - final volume = ref.watch(volumeProvider); - final volumeNotifier = ref.watch(volumeProvider.notifier); - var slider = Listener( onPointerSignal: (event) async { if (event is PointerScrollEvent) { if (event.scrollDelta.dy > 0) { - final value = volume - .2; - volumeNotifier.setVolume(value < 0 ? 0 : value); + final newValue = value - .2; + onChanged(newValue < 0 ? 0 : newValue); } else { - final value = volume + .2; - volumeNotifier.setVolume(value > 1 ? 1 : value); + final newValue = value + .2; + onChanged(newValue > 1 ? 1 : newValue); } } }, child: Slider( min: 0, max: 1, - value: volume, - onChanged: volumeNotifier.setVolume, + value: value, + onChanged: onChanged, ), ); return Row( @@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget { children: [ IconButton( icon: Icon( - volume == 0 + value == 0 ? SpotubeIcons.volumeMute - : volume <= 0.2 + : value <= 0.2 ? SpotubeIcons.volumeLow - : volume <= 0.6 + : value <= 0.6 ? SpotubeIcons.volumeMedium : SpotubeIcons.volumeHigh, size: 16, ), onPressed: () { - if (volume == 0) { - volumeNotifier.setVolume(1); + if (value == 0) { + onChanged(1); } else { - volumeNotifier.setVolume(0); + onChanged(0); } }, ), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 8915e97a..e5b87d6d 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: playlist.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(playlist.id!); + } } finally { if (context.mounted) { updating.value = false; diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 16633f7c..19fa7c93 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -19,9 +19,11 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/connect/connect.dart' hide volumeProvider; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; class BottomPlayer extends HookConsumerWidget { @@ -34,6 +36,7 @@ class BottomPlayer extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final remoteControl = ref.watch(connectProvider); final mediaQuery = MediaQuery.of(context); @@ -73,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: PlayerTrackDetails(albumArt: albumArt)), + Expanded( + child: PlayerTrackDetails(track: playlist.activeTrack), + ), // controls Flexible( flex: 3, @@ -121,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget { Container( height: 40, constraints: const BoxConstraints(maxWidth: 250), - child: const VolumeSlider(), + padding: const EdgeInsets.only(right: 10), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).setVolume(value); + }, + ); + }), ) ], - ) + ), ], ), ), diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/shared/dialogs/select_device_dialog.dart new file mode 100644 index 00000000..cd8dedb7 --- /dev/null +++ b/lib/components/shared/dialogs/select_device_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; + +class SelectDeviceDialog extends HookConsumerWidget { + const SelectDeviceDialog({super.key}); + + @override + Widget build(BuildContext context, ref) { + final isRemoteService = useState(false); + + final connectClients = ref.watch(connectClientsProvider); + final remoteService = connectClients.asData!.value.resolvedService!; + + return AlertDialog( + title: const Text("Choose the device:"), + insetPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "There are multiple device connected.\n" + "Choose the device you want this action to take place", + ), + RadioListTile.adaptive( + title: Text(remoteService.name), + value: true, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = value!; + }, + ), + RadioListTile.adaptive( + title: const Text("This Device"), + value: false, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = !value!; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(isRemoteService.value); + }, + child: Text(context.l10n.select), + ), + ], + ); + } +} + +Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { + final connectClients = ref.read(connectClientsProvider); + + if (connectClients.asData?.value.resolvedService == null) { + return false; + } + + final isRemote = await showDialog( + context: context, + builder: (context) => const SelectDeviceDialog(), + ); + + return isRemote ?? false; +} diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index ff40bac7..f956fa28 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -26,6 +26,8 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget final double? titleWidth; final Widget? title; + final bool _sliver; + const PageWindowTitleBar({ super.key, this.actions, @@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget this.titleTextStyle, this.titleWidth, this.toolbarTextStyle, - }); + }) : _sliver = false, + pinned = false, + floating = false, + snap = false, + stretch = false; + + final bool pinned; + final bool floating; + final bool snap; + final bool stretch; + + const PageWindowTitleBar.sliver({ + super.key, + this.actions, + this.title, + this.backgroundColor, + this.actionsIconTheme, + this.automaticallyImplyLeading = false, + this.centerTitle, + this.foregroundColor, + this.leading, + this.leadingWidth, + this.titleSpacing, + this.titleTextStyle, + this.titleWidth, + this.toolbarTextStyle, + this.pinned = false, + this.floating = false, + this.snap = false, + this.stretch = false, + }) : _sliver = true, + toolbarOpacity = 1; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState { Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); + if (widget._sliver) { + return SliverLayoutBuilder( + builder: (context, constraints) { + final hasFullscreen = + mediaQuery.size.width == constraints.crossAxisExtent; + final hasLeadingOrCanPop = + widget.leading != null || Navigator.canPop(context); + + return SliverPadding( + padding: EdgeInsets.only( + left: DesktopTools.platform.isMacOS && + hasFullscreen && + hasLeadingOrCanPop + ? 65 + : 0, + ), + sliver: SliverAppBar( + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + actions: [ + ...?widget.actions, + WindowTitleBarButtons(foregroundColor: widget.foregroundColor), + ], + backgroundColor: widget.backgroundColor, + foregroundColor: widget.foregroundColor, + actionsIconTheme: widget.actionsIconTheme, + centerTitle: widget.centerTitle, + titleSpacing: widget.titleSpacing, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + title: widget.title, + pinned: widget.pinned, + floating: widget.floating, + snap: widget.snap, + stretch: widget.stretch, + ), + ); + }, + ); + } + return LayoutBuilder(builder: (context, constrains) { final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; final hasLeadingOrCanPop = @@ -349,10 +424,7 @@ class WindowButton extends StatelessWidget { class MinimizeWindowButton extends WindowButton { MinimizeWindowButton( - {super.key, - super.colors, - super.onPressed, - bool? animate}) + {super.key, super.colors, super.onPressed, bool? animate}) : super( animate: animate ?? false, iconBuilder: (buttonContext) => @@ -362,10 +434,7 @@ class MinimizeWindowButton extends WindowButton { class MaximizeWindowButton extends WindowButton { MaximizeWindowButton( - {super.key, - super.colors, - super.onPressed, - bool? animate}) + {super.key, super.colors, super.onPressed, bool? animate}) : super( animate: animate ?? false, iconBuilder: (buttonContext) => @@ -374,11 +443,7 @@ class MaximizeWindowButton extends WindowButton { } class RestoreWindowButton extends WindowButton { - RestoreWindowButton( - {super.key, - super.colors, - super.onPressed, - bool? animate}) + RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) : super( animate: animate ?? false, iconBuilder: (buttonContext) => @@ -394,10 +459,7 @@ final _defaultCloseButtonColors = WindowButtonColors( class CloseWindowButton extends WindowButton { CloseWindowButton( - {super.key, - WindowButtonColors? colors, - super.onPressed, - bool? animate}) + {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) : super( colors: colors ?? _defaultCloseButtonColors, animate: animate ?? false, diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 897abdae..61061d24 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null @@ -30,6 +30,7 @@ class TrackTile extends HookConsumerWidget { final VoidCallback? onLongPress; final bool userPlaylist; final String? playlistId; + final ProxyPlaylist playlist; final List? leadingActions; @@ -38,6 +39,7 @@ class TrackTile extends HookConsumerWidget { this.index, required this.track, this.selected = false, + required this.playlist, this.onTap, this.onLongPress, this.onChanged, @@ -48,7 +50,6 @@ class TrackTile extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); final blacklist = ref.watch(BlackListNotifier.provider); @@ -65,10 +66,10 @@ class TrackTile extends HookConsumerWidget { final showOptionCbRef = useRef?>(null); - final isPlaying = track.id == playlist.activeTrack?.id; - final isLoading = useState(false); + final isPlaying = playlist.activeTrack?.id == track.id; + final isSelected = isPlaying || isLoading.value; return LayoutBuilder(builder: (context, constrains) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index 661e5af4..80368445 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget { loadingBuilder: (context) => Skeletonizer( enabled: true, child: TrackTile( + playlist: playlist, track: FakeData.track, index: 0, ), @@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget { child: Column( children: List.generate( 10, - (index) => TrackTile(track: FakeData.track, index: index), + (index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), itemBuilder: (context, index) { final track = tracks[index]; return TrackTile( + playlist: playlist, track: track, index: index, selected: trackViewState.selectedTrackIds.contains(track.id!), @@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget { return; } - if (isActive || playlist.tracks.contains(track)) { - await playlistNotifier.jumpToTrack(track); + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(props.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + collectionId: props.collectionId, + initialIndex: index, + ), + ); + } } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + } } }, ); diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 513f7aaa..f505f765 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -6,8 +6,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - allTracks, - autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + initialIndex: Random().nextInt(allTracks.length)), + ); + await remotePlayback.setShuffle(true); + } else { + await playlistNotifier.load( + allTracks, + autoPlay: true, + initialIndex: Random().nextInt(allTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } @@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load(allTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + ), + ); + } else { + await playlistNotifier.load(allTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8257eac9..832862c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", "contribute_on_github": "Contribute on GitHub", "donate_on_open_collective": "Donate on Open Collective", - "browse_anonymously": "Browse Anonymously" + "browse_anonymously": "Browse Anonymously", + "enable_connect": "Enable Connect", + "enable_connect_description": "Control Spotube from other devices", + "devices": "Devices", + "select": "Select", + "connect_client_alert": "You're being controlled by {client}", + "this_device": "This Device", + "remote": "Remote" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5c100fd3..2a2d8d18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,9 @@ import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -180,6 +183,9 @@ class SpotubeState extends ConsumerState { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + ref.listen(connectServerProvider, (_, __) {}); + ref.listen(connectClientsProvider, (_, __) {}); + useDisableBatteryOptimizations(); useInitSysTray(ref); useDeepLinking(ref); diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart new file mode 100644 index 00000000..efb37315 --- /dev/null +++ b/lib/models/connect/connect.dart @@ -0,0 +1,16 @@ +library connect; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; + +part 'connect.freezed.dart'; +part 'connect.g.dart'; + +part 'ws_event.dart'; +part 'load.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart new file mode 100644 index 00000000..dcbd783d --- /dev/null +++ b/lib/models/connect/connect.freezed.dart @@ -0,0 +1,216 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connect.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( + Map json) { + return _WebSocketLoadEventData.fromJson(json); +} + +/// @nodoc +mixin _$WebSocketLoadEventData { + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks => throw _privateConstructorUsedError; + String? get collectionId => throw _privateConstructorUsedError; + int? get initialIndex => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $WebSocketLoadEventDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $WebSocketLoadEventDataCopyWith<$Res> { + factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value, + $Res Function(WebSocketLoadEventData) then) = + _$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>; + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, + int? initialIndex}); +} + +/// @nodoc +class _$WebSocketLoadEventDataCopyWithImpl<$Res, + $Val extends WebSocketLoadEventData> + implements $WebSocketLoadEventDataCopyWith<$Res> { + _$WebSocketLoadEventDataCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collectionId = freezed, + Object? initialIndex = freezed, + }) { + return _then(_value.copyWith( + tracks: null == tracks + ? _value.tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> + implements $WebSocketLoadEventDataCopyWith<$Res> { + factory _$$WebSocketLoadEventDataImplCopyWith( + _$WebSocketLoadEventDataImpl value, + $Res Function(_$WebSocketLoadEventDataImpl) then) = + __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, + int? initialIndex}); +} + +/// @nodoc +class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> + extends _$WebSocketLoadEventDataCopyWithImpl<$Res, + _$WebSocketLoadEventDataImpl> + implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { + __$$WebSocketLoadEventDataImplCopyWithImpl( + _$WebSocketLoadEventDataImpl _value, + $Res Function(_$WebSocketLoadEventDataImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collectionId = freezed, + Object? initialIndex = freezed, + }) { + return _then(_$WebSocketLoadEventDataImpl( + tracks: null == tracks + ? _value._tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { + _$WebSocketLoadEventDataImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + this.collectionId, + this.initialIndex}) + : _tracks = tracks; + + factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => + _$$WebSocketLoadEventDataImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final String? collectionId; + @override + final int? initialIndex; + + @override + String toString() { + return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketLoadEventDataImpl && + const DeepCollectionEquality().equals(other._tracks, _tracks) && + (identical(other.collectionId, collectionId) || + other.collectionId == collectionId) && + (identical(other.initialIndex, initialIndex) || + other.initialIndex == initialIndex)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< + _$WebSocketLoadEventDataImpl>(this, _$identity); + + @override + Map toJson() { + return _$$WebSocketLoadEventDataImplToJson( + this, + ); + } +} + +abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { + factory _WebSocketLoadEventData( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final String? collectionId, + final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + + factory _WebSocketLoadEventData.fromJson(Map json) = + _$WebSocketLoadEventDataImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + String? get collectionId; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart new file mode 100644 index 00000000..f636e035 --- /dev/null +++ b/lib/models/connect/connect.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connect.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( + Map json) => + _$WebSocketLoadEventDataImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(e as Map)) + .toList(), + collectionId: json['collectionId'] as String?, + initialIndex: json['initialIndex'] as int?, + ); + +Map _$$WebSocketLoadEventDataImplToJson( + _$WebSocketLoadEventDataImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collectionId': instance.collectionId, + 'initialIndex': instance.initialIndex, + }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart new file mode 100644 index 00000000..d750cddd --- /dev/null +++ b/lib/models/connect/load.dart @@ -0,0 +1,27 @@ +part of 'connect.dart'; + +List> _tracksJson(List tracks) { + return tracks.map((e) => e.toJson()).toList(); +} + +@freezed +class WebSocketLoadEventData with _$WebSocketLoadEventData { + factory WebSocketLoadEventData({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + String? collectionId, + int? initialIndex, + }) = _WebSocketLoadEventData; + + factory WebSocketLoadEventData.fromJson(Map json) => + _$WebSocketLoadEventDataFromJson(json); +} + +class WebSocketLoadEvent extends WebSocketEvent { + WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data); + + factory WebSocketLoadEvent.fromJson(Map json) { + return WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(json['data'] as Map), + ); + } +} diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart new file mode 100644 index 00000000..2d7213b1 --- /dev/null +++ b/lib/models/connect/ws_event.dart @@ -0,0 +1,374 @@ +part of 'connect.dart'; + +enum WsEvent { + error, + volume, + removeTrack, + addTrack, + reorder, + shuffle, + loop, + seek, + duration, + queue, + position, + playing, + resume, + pause, + load, + next, + previous, + jump, + stop; + + static WsEvent fromString(String value) { + return WsEvent.values.firstWhere((e) => e.name == value); + } +} + +typedef EventCallback = FutureOr Function(T event); + +class WebSocketEvent { + final WsEvent type; + final T data; + + WebSocketEvent(this.type, this.data); + + factory WebSocketEvent.fromJson( + Map json, + T Function(dynamic) fromJson, + ) { + return WebSocketEvent( + WsEvent.fromString(json["type"]), + fromJson(json["data"]), + ); + } + + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data, + }); + } + + Future onPosition( + EventCallback callback, + ) async { + if (type == WsEvent.position) { + await callback(WebSocketPositionEvent.fromJson({"data": data})); + } + } + + Future onPlaying( + EventCallback callback, + ) async { + if (type == WsEvent.playing) { + await callback(WebSocketPlayingEvent(data as bool)); + } + } + + Future onResume( + EventCallback callback, + ) async { + if (type == WsEvent.resume) { + await callback(WebSocketResumeEvent()); + } + } + + Future onPause( + EventCallback callback, + ) async { + if (type == WsEvent.pause) { + await callback(WebSocketPauseEvent()); + } + } + + Future onStop( + EventCallback callback, + ) async { + if (type == WsEvent.stop) { + await callback(WebSocketStopEvent()); + } + } + + Future onLoad( + EventCallback callback, + ) async { + if (type == WsEvent.load) { + await callback( + WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(data as Map), + ), + ); + } + } + + Future onNext( + EventCallback callback, + ) async { + if (type == WsEvent.next) { + await callback(WebSocketNextEvent()); + } + } + + Future onPrevious( + EventCallback callback, + ) async { + if (type == WsEvent.previous) { + await callback(WebSocketPreviousEvent()); + } + } + + Future onJump( + EventCallback callback, + ) async { + if (type == WsEvent.jump) { + await callback(WebSocketJumpEvent(data as int)); + } + } + + Future onError( + EventCallback callback, + ) async { + if (type == WsEvent.error) { + await callback(WebSocketErrorEvent(data as String)); + } + } + + Future onQueue( + EventCallback callback, + ) async { + if (type == WsEvent.queue) { + await callback( + WebSocketQueueEvent.fromJson(data as Map), + ); + } + } + + Future onDuration( + EventCallback callback, + ) async { + if (type == WsEvent.duration) { + await callback( + WebSocketDurationEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onSeek( + EventCallback callback, + ) async { + if (type == WsEvent.seek) { + await callback( + WebSocketSeekEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onShuffle( + EventCallback callback, + ) async { + if (type == WsEvent.shuffle) { + await callback(WebSocketShuffleEvent(data as bool)); + } + } + + Future onLoop( + EventCallback callback, + ) async { + if (type == WsEvent.loop) { + await callback( + WebSocketLoopEvent( + PlaybackLoopMode.fromString(data as String), + ), + ); + } + } + + Future onRemoveTrack( + EventCallback callback, + ) async { + if (type == WsEvent.removeTrack) { + await callback(WebSocketRemoveTrackEvent(data as String)); + } + } + + Future onAddTrack( + EventCallback callback, + ) async { + if (type == WsEvent.addTrack) { + await callback( + WebSocketAddTrackEvent.fromJson(data as Map)); + } + } + + Future onReorder( + EventCallback callback, + ) async { + if (type == WsEvent.reorder) { + await callback( + WebSocketReorderEvent.fromJson(data as Map)); + } + } + + Future onVolume( + EventCallback callback, + ) async { + if (type == WsEvent.volume) { + await callback(WebSocketVolumeEvent(data as double)); + } + } +} + +class WebSocketLoopEvent extends WebSocketEvent { + WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data); + + WebSocketLoopEvent.fromJson(Map json) + : super( + WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.name, + }); + } +} + +class WebSocketPositionEvent extends WebSocketEvent { + WebSocketPositionEvent(Duration data) : super(WsEvent.position, data); + + WebSocketPositionEvent.fromJson(Map json) + : super(WsEvent.position, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketDurationEvent extends WebSocketEvent { + WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data); + + WebSocketDurationEvent.fromJson(Map json) + : super(WsEvent.duration, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketSeekEvent extends WebSocketEvent { + WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data); + + WebSocketSeekEvent.fromJson(Map json) + : super(WsEvent.seek, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketShuffleEvent extends WebSocketEvent { + WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data); +} + +class WebSocketPlayingEvent extends WebSocketEvent { + WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); +} + +class WebSocketResumeEvent extends WebSocketEvent { + WebSocketResumeEvent() : super(WsEvent.resume, null); +} + +class WebSocketPauseEvent extends WebSocketEvent { + WebSocketPauseEvent() : super(WsEvent.pause, null); +} + +class WebSocketStopEvent extends WebSocketEvent { + WebSocketStopEvent() : super(WsEvent.stop, null); +} + +class WebSocketNextEvent extends WebSocketEvent { + WebSocketNextEvent() : super(WsEvent.next, null); +} + +class WebSocketPreviousEvent extends WebSocketEvent { + WebSocketPreviousEvent() : super(WsEvent.previous, null); +} + +class WebSocketJumpEvent extends WebSocketEvent { + WebSocketJumpEvent(int data) : super(WsEvent.jump, data); +} + +class WebSocketErrorEvent extends WebSocketEvent { + WebSocketErrorEvent(String data) : super(WsEvent.error, data); +} + +class WebSocketQueueEvent extends WebSocketEvent { + WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data); + + factory WebSocketQueueEvent.fromJson(Map json) => + WebSocketQueueEvent( + ProxyPlaylist.fromJsonRaw(json), + ); +} + +class WebSocketRemoveTrackEvent extends WebSocketEvent { + WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data); +} + +class WebSocketAddTrackEvent extends WebSocketEvent { + WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data); + + WebSocketAddTrackEvent.fromJson(Map json) + : super( + WsEvent.addTrack, + Track.fromJson(json["data"] as Map), + ); +} + +typedef ReorderData = ({int oldIndex, int newIndex}); + +class WebSocketReorderEvent extends WebSocketEvent { + WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data); + + factory WebSocketReorderEvent.fromJson(Map json) => + WebSocketReorderEvent( + ( + oldIndex: json["oldIndex"] as int, + newIndex: json["newIndex"] as int, + ), + ); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": { + "oldIndex": data.oldIndex, + "newIndex": data.newIndex, + }, + }); + } +} + +class WebSocketVolumeEvent extends WebSocketEvent { + WebSocketVolumeEvent(double data) : super(WsEvent.volume, data); +} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 173ace54..9dec5f7c 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,8 +4,11 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -39,16 +42,41 @@ class ArtistPageTopTracks extends HookConsumerWidget { void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); + + if (!isPlaylistPlaying) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + ), + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != remotePlaylist.activeTrack?.id) { + final index = playlist.tracks + .toList() + .indexWhere((s) => s.id == currentTrack!.id); + await remotePlayback.jumpTo(index); + } + } else { + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } } } @@ -107,6 +135,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { final track = topTracks.elementAt(index); return TrackTile( index: index, + playlist: playlist, track: track, onTap: () async { playPlaylist( diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 00000000..170a0c72 --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/local_devices.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectPage extends HookConsumerWidget { + const ConnectPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme, :textTheme) = Theme.of(context); + + final connectClients = ref.watch(connectClientsProvider); + final connectClientsNotifier = ref.read(connectClientsProvider.notifier); + final discoveredDevices = connectClients.asData?.value.services; + + return Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + title: Text(context.l10n.devices), + ), + body: ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + selectedTileColor: colorScheme.secondary.withOpacity(0.1), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.remote, + style: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: discoveredDevices?.length ?? 0, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = discoveredDevices![index]; + final selected = + connectClients.asData?.value.resolvedService?.name == + device.name; + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + selected: selected, + onTap: () { + if (selected) { + ServiceUtils.push( + context, + "/connect/control", + ); + } else { + connectClientsNotifier.resolveService(device); + } + }, + trailing: selected + ? IconButton( + icon: const Icon(SpotubeIcons.power), + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, + ), + ); + }, + ), + const ConnectPageLocalDevices(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart new file mode 100644 index 00000000..16256568 --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/player/player_queue.dart'; +import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectControlPage extends HookConsumerWidget { + const ConnectControlPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + + final resolvedService = + ref.watch(connectClientsProvider).asData?.value.resolvedService; + final connectNotifier = ref.read(connectProvider.notifier); + final playlist = ref.watch(queueProvider); + final playing = ref.watch(playingProvider); + final shuffled = ref.watch(shuffleProvider); + final loopMode = ref.watch(loopModeProvider); + + final resumePauseStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.all(12), + iconSize: 24, + ); + final buttonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.surface.withOpacity(0.4), + minimumSize: const Size(28, 28), + ); + + final activeButtonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + minimumSize: const Size(28, 28), + ); + + final playerQueue = Consumer(builder: (context, ref, _) { + final playlist = ref.watch(queueProvider); + return PlayerQueue( + playlist: playlist, + floating: true, + onJump: (track) async { + final index = playlist.tracks.toList().indexOf(track); + connectNotifier.jumpTo(index); + }, + onRemove: (track) async { + await connectNotifier.removeTrack(track); + }, + onStop: () async => connectNotifier.stop(), + onReorder: (oldIndex, newIndex) async { + await connectNotifier.reorder( + (oldIndex: oldIndex, newIndex: newIndex), + ); + }, + ); + }); + + ref.listen(connectClientsProvider, (prev, next) { + if (next.asData?.value.resolvedService == null) { + context.pop(); + } + }); + + return SafeArea( + child: Scaffold( + appBar: PageWindowTitleBar( + title: Text(resolvedService!.name), + automaticallyImplyLeading: true, + ), + body: LayoutBuilder(builder: (context, constrains) { + return Row( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ).copyWith(top: 0), + constraints: + const BoxConstraints(maxHeight: 400, maxWidth: 400), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: (playlist.activeTrack?.album?.images) + .asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: AnchorButton( + playlist.activeTrack?.name ?? "", + style: textTheme.titleLarge!, + onTap: () { + ServiceUtils.push( + context, + "/track/${playlist.activeTrack?.id}", + ); + }, + ), + ), + SliverToBoxAdapter( + child: ArtistLink( + artists: playlist.activeTrack?.artists ?? [], + textStyle: textTheme.bodyMedium!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + ), + const SliverGap(30), + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final position = ref.watch(positionProvider); + final duration = ref.watch(durationProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + children: [ + Slider( + value: position > duration + ? 0 + : position.inSeconds.toDouble(), + min: 0, + max: duration.inSeconds.toDouble(), + onChanged: (value) { + connectNotifier + .seek(Duration(seconds: value.toInt())); + }, + ), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), + ], + ), + ], + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + IconButton( + tooltip: context.l10n.previous_track, + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + IconButton( + tooltip: playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + icon: playlist.activeTrack == null + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: colorScheme.onPrimary, + ), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + style: resumePauseStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + IconButton( + tooltip: context.l10n.next_track, + icon: const Icon(SpotubeIcons.skipForward), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.next, + ), + IconButton( + tooltip: loopMode == PlaybackLoopMode.one + ? context.l10n.loop_track + : loopMode == PlaybackLoopMode.all + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaybackLoopMode.one + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaybackLoopMode.one || + loopMode == PlaybackLoopMode.all + ? activeButtonStyle + : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () async { + connectNotifier.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); + }, + ) + ], + ), + ), + const SliverGap(30), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).state = value; + connectNotifier.setVolume(value); + }, + ); + }), + ), + ), + const SliverGap(30), + if (constrains.mdAndDown) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queue), + label: Text(context.l10n.queue), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return playerQueue; + }, + ); + }, + ), + ), + ) + ], + ), + ), + if (constrains.lgAndUp) ...[ + const VerticalDivider(thickness: 1), + Expanded( + child: playerQueue, + ), + ] + ], + ); + }), + ), + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index ed297065..487ceb4c 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; @@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: - DesktopTools.platform.isLinux || DesktopTools.platform.isWindows - ? const PageWindowTitleBar() - : null, body: CustomScrollView( controller: controller, slivers: [ - if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) - const SliverGap(20), + PageWindowTitleBar.sliver( + pinned: DesktopTools.platform.isDesktop, + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.user), + onPressed: () {}, + ), + const Gap(10), + ], + ), const HomeGenresSection(), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index a617909c..310df75c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget { MediaQuery.of(context).size.height * .7, ), builder: (context) { - return const PlayerQueue(floating: true); + return Consumer(builder: (context, ref, _) { + final playlist = ref + .watch(ProxyPlaylistNotifier.provider); + + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: ref + .read(ProxyPlaylistNotifier.notifier), + ); + }); }, ); } diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index d9a309ed..6260e284 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget { return Scaffold( body: SafeArea( child: InAppWebView( - initialOptions: InAppWebViewGroupOptions( - crossPlatform: InAppWebViewOptions( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", - ), + initialSettings: InAppWebViewSettings( + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", ), initialUrlRequest: URLRequest( - url: Uri.parse("https://accounts.spotify.com/"), + url: WebUri("https://accounts.spotify.com/"), ), - androidOnPermissionRequest: (controller, origin, resources) async { - return PermissionRequestResponse( - resources: resources, - action: PermissionRequestResponseAction.GRANT, + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, ); }, onLoadStop: (controller, action) async { diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index b562adab..2e079200 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -16,7 +16,9 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; +import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -53,50 +55,75 @@ class RootApp extends HookConsumerWidget { } }); - final subscription = ConnectionCheckerService - .instance.onConnectivityChanged - .listen((status) { - if (status) { + final subscriptions = [ + ConnectionCheckerService.instance.onConnectivityChanged + .listen((status) { + if (status) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.wifi, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Text(context.l10n.connection_restored), + ], + ), + backgroundColor: theme.colorScheme.primary, + showCloseIcon: true, + width: 350, + ), + ); + } else { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.noWifi, + color: theme.colorScheme.onError, + ), + const SizedBox(width: 10), + Text(context.l10n.you_are_offline), + ], + ), + backgroundColor: theme.colorScheme.error, + showCloseIcon: true, + width: 300, + ), + ); + } + }), + connectClientStream.listen((clientOrigin) { scaffoldMessenger.showSnackBar( SnackBar( + backgroundColor: Colors.yellow[600], + behavior: SnackBarBehavior.floating, content: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.onPrimary, + const Icon( + SpotubeIcons.error, + color: Colors.black, ), const SizedBox(width: 10), - Text(context.l10n.connection_restored), - ], - ), - backgroundColor: theme.colorScheme.primary, - showCloseIcon: true, - width: 350, - ), - ); - } else { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.onError, + Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), ], ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, ), ); - } - }); + }) + ]; return () { - subscription.cancel(); + for (final subscription in subscriptions) { + subscription.cancel(); + } }; }, []); @@ -191,7 +218,19 @@ class RootApp extends HookConsumerWidget { top: 40, bottom: 100, ), - child: const PlayerQueue(floating: true), + child: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = + ref.read(ProxyPlaylistNotifier.notifier); + + return PlayerQueue.fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ) : null, bottomNavigationBar: Column( diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 0fdb50af..2152cc45 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -46,26 +49,60 @@ class SearchTracksSection extends HookConsumerWidget { return TrackTile( index: i, track: track, + playlist: playlist, onTap: () async { - final isTrackPlaying = playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isTrackPlaying = + remotePlaylist.activeTrack?.id == track.id; + + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: [track], + ), + ); + } + } + } else { + final isTrackPlaying = playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } } } }, diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index b3f0d897..e023cc60 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.endlessPlayback, onChanged: preferencesNotifier.setEndlessPlayback, ), + SwitchListTile( + title: Text(context.l10n.enable_connect), + subtitle: Text(context.l10n.enable_connect_description), + secondary: const Icon(SpotubeIcons.connect), + value: preferences.enableConnect, + onChanged: preferencesNotifier.setEnableConnect, + ), ], ); } diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart new file mode 100644 index 00000000..282c96aa --- /dev/null +++ b/lib/provider/connect/clients.dart @@ -0,0 +1,111 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/services/device_info/device_info.dart'; + +class ConnectClientsState { + final List services; + final ResolvedBonsoirService? resolvedService; + final BonsoirDiscovery discovery; + + ConnectClientsState({ + required this.services, + required this.discovery, + this.resolvedService, + }); + + ConnectClientsState copyWith({ + List? services, + BonsoirDiscovery? discovery, + ResolvedBonsoirService? resolvedService, + }) { + return ConnectClientsState( + services: services ?? this.services, + discovery: discovery ?? this.discovery, + resolvedService: resolvedService ?? this.resolvedService, + ); + } +} + +class ConnectClientsNotifier extends AsyncNotifier { + ConnectClientsNotifier(); + + @override + build() async { + final discovery = BonsoirDiscovery(type: '_spotube._tcp'); + final deviceId = await DeviceInfoService.instance.deviceId(); + await discovery.ready; + + final subscription = discovery.eventStream?.listen((event) { + // ignore device itself + if (event.service?.attributes["deviceId"] == deviceId) { + return; + } + + switch (event.type) { + case BonsoirDiscoveryEventType.discoveryServiceFound: + state = AsyncData(state.value!.copyWith( + services: [ + ...?state.value?.services, + event.service!, + ], + )); + break; + case BonsoirDiscoveryEventType.discoveryServiceResolved: + state = AsyncData( + state.value!.copyWith( + resolvedService: event.service as ResolvedBonsoirService, + ), + ); + break; + case BonsoirDiscoveryEventType.discoveryServiceLost: + state = AsyncData( + ConnectClientsState( + services: state.value!.services + .where((s) => s.name != event.service!.name) + .toList(), + discovery: state.value!.discovery, + resolvedService: + event.service?.name == state.value!.resolvedService!.name + ? null + : state.value!.resolvedService, + ), + ); + break; + default: + break; + } + }); + + ref.onDispose(() { + subscription?.cancel(); + discovery.stop(); + }); + + await discovery.start(); + + return ConnectClientsState( + services: [], + discovery: discovery, + ); + } + + Future resolveService(BonsoirService service) async { + if (state.value == null) return; + await service.resolve(state.value!.discovery.serviceResolver); + } + + Future clearResolvedService() async { + if (state.value == null) return; + state = AsyncData( + ConnectClientsState( + services: state.value!.services, + discovery: state.value!.discovery, + ), + ); + } +} + +final connectClientsProvider = + AsyncNotifierProvider( + () => ConnectClientsNotifier(), +); diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart new file mode 100644 index 00000000..65daaf55 --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +final playingProvider = StateProvider( + (ref) => false, +); + +final positionProvider = StateProvider( + (ref) => Duration.zero, +); + +final durationProvider = StateProvider( + (ref) => Duration.zero, +); + +final shuffleProvider = StateProvider( + (ref) => false, +); + +final loopModeProvider = StateProvider( + (ref) => PlaybackLoopMode.none, +); + +final queueProvider = StateProvider( + (ref) => ProxyPlaylist({}), +); + +final volumeProvider = StateProvider( + (ref) => 1.0, +); + +class ConnectNotifier extends AsyncNotifier { + @override + build() async { + try { + final connectClients = ref.watch(connectClientsProvider); + print('Building ConnectNotifier'); + + if (connectClients.asData?.value.resolvedService == null) return null; + + final service = connectClients.asData!.value.resolvedService!; + + print( + 'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final channel = WebSocketChannel.connect( + Uri.parse('ws://${service.host}:${service.port}/ws'), + ); + + await channel.ready; + + print( + 'Connected to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final subscription = channel.stream.listen( + (message) { + final event = + WebSocketEvent.fromJson(jsonDecode(message), (data) => data); + + event.onQueue((event) { + ref.read(queueProvider.notifier).state = event.data; + }); + + event.onPlaying((event) { + ref.read(playingProvider.notifier).state = event.data; + }); + + event.onPosition((event) { + ref.read(positionProvider.notifier).state = event.data; + }); + + event.onDuration((event) { + ref.read(durationProvider.notifier).state = event.data; + }); + + event.onShuffle((event) { + ref.read(shuffleProvider.notifier).state = event.data; + }); + + event.onLoop((event) { + ref.read(loopModeProvider.notifier).state = event.data; + }); + + event.onVolume((event) { + ref.read(volumeProvider.notifier).state = event.data; + }); + }, + onError: (error) { + Catcher2.reportCheckedError( + error, + StackTrace.current, + ); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + channel.sink.close(status.goingAway); + }); + + return channel; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + rethrow; + } + } + + Future emit(Object message) async { + if (state.value == null) return; + state.value?.sink.add( + message is String ? message : (message as dynamic).toJson(), + ); + } + + Future resume() async { + emit(WebSocketResumeEvent()); + } + + Future pause() async { + emit(WebSocketPauseEvent()); + } + + Future stop() async { + emit(WebSocketStopEvent()); + } + + Future jumpTo(int position) async { + emit(WebSocketJumpEvent(position)); + } + + Future load(WebSocketLoadEventData data) async { + emit(WebSocketLoadEvent(data)); + } + + Future next() async { + emit(WebSocketNextEvent()); + } + + Future previous() async { + emit(WebSocketPreviousEvent()); + } + + Future seek(Duration position) async { + emit(WebSocketSeekEvent(position)); + } + + Future setShuffle(bool value) async { + emit(WebSocketShuffleEvent(value)); + } + + Future setLoopMode(PlaybackLoopMode value) async { + emit(WebSocketLoopEvent(value)); + } + + Future addTrack(Track data) async { + emit(WebSocketAddTrackEvent(data)); + } + + Future removeTrack(String data) async { + emit(WebSocketRemoveTrackEvent(data)); + } + + Future reorder(ReorderData data) async { + emit(WebSocketReorderEvent(data)); + } + + Future setVolume(double value) async { + emit(WebSocketVolumeEvent(value)); + } +} + +final connectProvider = + AsyncNotifierProvider( + () => ConnectNotifier(), +); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart new file mode 100644 index 00000000..0469e3f5 --- /dev/null +++ b/lib/provider/connect/server.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:bonsoir/bonsoir.dart'; +import 'package:spotube/services/device_info/device_info.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:spotube/provider/volume_provider.dart'; + +final logger = getLogger('ConnectServer'); +final _connectClientStreamController = StreamController.broadcast(); + +Stream get connectClientStream => _connectClientStreamController.stream; + +final connectServerProvider = FutureProvider((ref) async { + final enabled = + ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); + final resolvedService = await ref + .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); + final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + + if (!enabled || resolvedService != null) { + return null; + } + + final app = Router(); + + app.get( + "/ping", + (Request req) { + return Response.ok("pong"); + }, + ); + + final subscriptions = []; + + FutureOr websocket(Request req) => webSocketHandler( + (WebSocketChannel channel, String? protocol) async { + final context = + (req.context["shelf.io.connection_info"] as HttpConnectionInfo?); + final origin = + "${context?.remoteAddress.host}:${context?.remotePort}"; + _connectClientStreamController.add(origin); + + ref.listen( + ProxyPlaylistNotifier.provider, + (previous, next) { + channel.sink.add( + WebSocketQueueEvent(next).toJson(), + ); + }, + fireImmediately: true, + ); + + // because audioPlayer events doesn't fireImmediately + channel.sink.add( + WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(), + ); + channel.sink.add( + WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + ); + channel.sink.add( + WebSocketLoopEvent(audioPlayer.loopMode).toJson(), + ); + channel.sink.add( + WebSocketVolumeEvent(audioPlayer.volume).toJson(), + ); + + subscriptions.addAll([ + audioPlayer.positionStream.listen( + (position) { + channel.sink.add( + WebSocketPositionEvent(position).toJson(), + ); + }, + ), + audioPlayer.playingStream.listen( + (playing) { + channel.sink.add( + WebSocketPlayingEvent(playing).toJson(), + ); + }, + ), + audioPlayer.durationStream.listen( + (duration) { + channel.sink.add( + WebSocketDurationEvent(duration).toJson(), + ); + }, + ), + audioPlayer.shuffledStream.listen( + (shuffled) { + channel.sink.add( + WebSocketShuffleEvent(shuffled).toJson(), + ); + }, + ), + audioPlayer.loopModeStream.listen( + (loopMode) { + channel.sink.add( + WebSocketLoopEvent(loopMode).toJson(), + ); + }, + ), + audioPlayer.volumeStream.listen( + (volume) { + channel.sink.add( + WebSocketVolumeEvent(volume).toJson(), + ); + }, + ), + channel.stream.listen( + (message) { + try { + final event = WebSocketEvent.fromJson( + jsonDecode(message), + (data) => data, + ); + + event.onLoad((event) async { + await playbackNotifier.load( + event.data.tracks, + autoPlay: true, + initialIndex: event.data.initialIndex ?? 0, + ); + + if (event.data.collectionId != null) { + playbackNotifier.addCollection(event.data.collectionId!); + } + }); + + event.onPause((event) async { + await audioPlayer.pause(); + }); + + event.onResume((event) async { + await audioPlayer.resume(); + }); + + event.onStop((event) async { + await audioPlayer.stop(); + }); + + event.onNext((event) async { + await playbackNotifier.next(); + }); + + event.onPrevious((event) async { + await playbackNotifier.previous(); + }); + + event.onJump((event) async { + await playbackNotifier.jumpTo(event.data); + }); + + event.onSeek((event) async { + await audioPlayer.seek(event.data); + }); + + event.onShuffle((event) async { + await audioPlayer.setShuffle(event.data); + }); + + event.onLoop((event) async { + await audioPlayer.setLoopMode(event.data); + }); + + event.onAddTrack((event) async { + await playbackNotifier.addTrack(event.data); + }); + + event.onRemoveTrack((event) async { + await playbackNotifier.removeTrack(event.data); + }); + + event.onReorder((event) async { + await playbackNotifier.moveTrack( + event.data.oldIndex, + event.data.newIndex, + ); + }); + + event.onVolume((event) async { + ref.read(volumeProvider.notifier).setVolume(event.data); + }); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + channel.sink.add(WebSocketErrorEvent(e.toString()).toJson()); + } + }, + onDone: () { + logger.i('Connection closed'); + }, + ), + ]); + }, + )(req); + + final port = Random().nextInt(17000) + 3000; + + final server = await serve( + (request) { + if (request.url.path.startsWith('ws')) { + return websocket(request); + } + return app(request); + }, + InternetAddress.anyIPv4, + port, + ); + + logger.i('Server running on http://${server.address.host}:${server.port}'); + + final service = BonsoirService( + name: await DeviceInfoService.instance.computerName(), + type: '_spotube._tcp', + port: port, + attributes: { + "id": PrimitiveUtils.uuid.v4(), + "deviceId": await DeviceInfoService.instance.deviceId(), + }, + ); + + final broadcast = BonsoirBroadcast(service: service); + + await broadcast.ready; + await broadcast.start(); + + ref.onDispose(() async { + logger.i('Stopping server'); + for (final subscription in subscriptions) { + await subscription.cancel(); + } + await broadcast.stop(); + await server.close(); + }); + + return app; +}); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index 026b3403..efc818ed 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -27,6 +27,16 @@ class ProxyPlaylist { ); } + factory ProxyPlaylist.fromJsonRaw(Map json) => ProxyPlaylist( + json['tracks'] == null + ? {} + : (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(), + json['active'] as int?, + json['collections'] == null + ? {} + : (json['collections'] as List).toSet().cast(), + ); + Track? get activeTrack => active == null || active == -1 ? null : tracks.elementAtOrNull(active!); @@ -62,8 +72,8 @@ class ProxyPlaylist { /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { return switch (track.runtimeType) { - LocalTrack => track.toJson(), - SourcedTrack => track.toJson(), + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 875f36cc..42b38746 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(endlessPlayback: endless); } + void setEnableConnect(bool enable) { + state = state.copyWith(enableConnect: enable); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index cf6c0597..e35c73b5 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences { @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, @Default(true) bool discordPresence, @Default(true) bool endlessPlayback, + @Default(false) bool enableConnect, }) = _UserPreferences; factory UserPreferences.fromJson(Map json) => _$UserPreferencesFromJson(json); diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 4d08d1a9..a5b076bb 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -50,6 +50,7 @@ mixin _$UserPreferences { SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; bool get discordPresence => throw _privateConstructorUsedError; bool get endlessPlayback => throw _privateConstructorUsedError; + bool get enableConnect => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> { SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_value.copyWith( audioQuality: null == audioQuality @@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_$UserPreferencesImpl( audioQuality: null == audioQuality @@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.streamMusicCodec = SourceCodecs.weba, this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, - this.endlessPlayback = true}); + this.endlessPlayback = true, + this.enableConnect = false}); factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final bool endlessPlayback; + @override + @JsonKey() + final bool enableConnect; @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences { (identical(other.discordPresence, discordPresence) || other.discordPresence == discordPresence) && (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback)); + other.endlessPlayback == endlessPlayback) && + (identical(other.enableConnect, enableConnect) || + other.enableConnect == enableConnect)); } @JsonKey(ignore: true) @@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences { streamMusicCodec, downloadMusicCodec, discordPresence, - endlessPlayback + endlessPlayback, + enableConnect ]); @JsonKey(ignore: true) @@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences { final SourceCodecs streamMusicCodec, final SourceCodecs downloadMusicCodec, final bool discordPresence, - final bool endlessPlayback}) = _$UserPreferencesImpl; + final bool endlessPlayback, + final bool enableConnect}) = _$UserPreferencesImpl; factory _UserPreferences.fromJson(Map json) = _$UserPreferencesImpl.fromJson; @@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences { @override bool get endlessPlayback; @override + bool get enableConnect; + @override @JsonKey(ignore: true) _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index ce488247..8bdd12cc 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( SourceCodecs.m4a, discordPresence: json['discordPresence'] as bool? ?? true, endlessPlayback: json['endlessPlayback'] as bool? ?? true, + enableConnect: json['enableConnect'] as bool? ?? false, ); Map _$$UserPreferencesImplToJson( @@ -87,6 +88,7 @@ Map _$$UserPreferencesImplToJson( 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, 'discordPresence': instance.discordPresence, 'endlessPlayback': instance.endlessPlayback, + 'enableConnect': instance.enableConnect, }; const _$SourceQualitiesEnumMap = { diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index b3957964..0a22bec1 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,4 +1,5 @@ import 'package:catcher_2/catcher_2.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:spotube/services/audio_player/mk_state_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -14,7 +15,7 @@ part 'audio_player_impl.dart'; abstract class AudioPlayerInterface { final MkPlayerWithState _mkPlayer; - // final ja.AudioPlayer? _justAudio; + // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() : _mkPlayer = MkPlayerWithState( @@ -60,6 +61,14 @@ abstract class AudioPlayerInterface { } } + Future get selectedDevice async { + return _mkPlayer.state.audioDevice; + } + + Future> get devices async { + return _mkPlayer.state.audioDevices; + } + bool get hasSource { return _mkPlayer.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 2af94dd7..bfa13220 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio?.setSpeed(speed); } + Future setAudioDevice(AudioDevice device) async { + await _mkPlayer.setAudioDevice(device); + } + Future dispose() async { await _mkPlayer.dispose(); // await _justAudio?.dispose(); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index a736dc1c..f05ba5ef 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -140,4 +140,10 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // .cast(); // } } + + Stream> get devicesStream => + _mkPlayer.stream.audioDevices.asBroadcastStream(); + + Stream get selectedDeviceStream => + _mkPlayer.stream.audioDevice.asBroadcastStream(); } diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart new file mode 100644 index 00000000..87ddd6eb --- /dev/null +++ b/lib/services/device_info/device_info.dart @@ -0,0 +1,34 @@ +import 'package:device_info_plus/device_info_plus.dart'; + +class DeviceInfoService { + final DeviceInfoPlugin deviceInfo; + DeviceInfoService._() : deviceInfo = DeviceInfoPlugin(); + + static final instance = DeviceInfoService._(); + + Future deviceId() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.id, + IosDeviceInfo() => info.identifierForVendor ?? info.model, + MacOsDeviceInfo() => info.systemGUID ?? info.model, + WindowsDeviceInfo() => info.deviceId, + LinuxDeviceInfo() => info.machineId ?? info.id, + _ => 'Unknown', + }; + } + + Future computerName() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.model, + IosDeviceInfo() => info.localizedModel, + MacOsDeviceInfo() => info.computerName, + WindowsDeviceInfo() => info.computerName, + LinuxDeviceInfo() => info.name, + _ => 'Unknown', + }; + } +} diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index f4c279b4..95777f56 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -18,6 +18,11 @@ dependencies: - libjsoncpp25 - libmpv1 | libmpv2 - xdg-user-dirs + - avahi-daemon + - avahi-discover + - avahi-utils + - libnss-mdns + - mdns-scan essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 1f952d0e..12b4473e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -13,6 +13,9 @@ requires: - libsecret - libnotify - xdg-user-dirs + - avahi + - mdns-scan + - nss-mdns display_name: Spotube diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a7965e14..a9f6650f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,8 +8,10 @@ import Foundation import app_links import audio_service import audio_session +import bonsoir_darwin import device_info_plus import file_selector_macos +import flutter_inappwebview_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -28,8 +30,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) diff --git a/macos/Podfile b/macos/Podfile index 049abe29..9ec46f8c 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 566e8196..317de385 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,10 +5,16 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 5.0) - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -22,6 +28,7 @@ PODS: - media_kit_native_event_loop (1.0.0): - FlutterMacOS - metadata_god (0.0.1) + - OrderedSet (5.0.0) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -50,8 +57,10 @@ DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) @@ -72,6 +81,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - OrderedSet EXTERNAL SOURCES: app_links: @@ -80,10 +90,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + bonsoir_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_inappwebview_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_secure_storage_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: @@ -121,8 +135,10 @@ SPEC CHECKSUMS: app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a @@ -130,6 +146,7 @@ SPEC CHECKSUMS: media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 @@ -141,6 +158,6 @@ SPEC CHECKSUMS: window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index a2dd74c4..bf5d70cf 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -436,7 +436,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -567,7 +567,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -592,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/pubspec.lock b/pubspec.lock index bbf4faeb..47c1aba3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + bonsoir: + dependency: "direct main" + description: + name: bonsoir + sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106" + url: "https://pub.dev" + source: hosted + version: "5.1.9" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618" + url: "https://pub.dev" + source: hosted + version: "5.1.4" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1 + url: "https://pub.dev" + source: hosted + version: "5.1.4" boolean_selector: dependency: transitive description: @@ -478,10 +526,10 @@ packages: dependency: "direct main" description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_frame: dependency: transitive description: @@ -494,10 +542,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: @@ -786,10 +834,58 @@ packages: dependency: "direct main" description: name: flutter_inappwebview - sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf + sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" url: "https://pub.dev" source: hosted - version: "5.7.2+3" + version: "6.0.0" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + url: "https://pub.dev" + source: hosted + version: "1.0.8" flutter_keyboard_visibility: dependency: transitive description: @@ -1146,6 +1242,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -1882,13 +1986,21 @@ packages: source: hosted version: "2.3.2" shelf: - dependency: transitive + dependency: "direct main" description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" shelf_static: dependency: transitive description: @@ -1898,7 +2010,7 @@ packages: source: hosted version: "1.1.2" shelf_web_socket: - dependency: transitive + dependency: "direct main" description: name: shelf_web_socket sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" @@ -2311,13 +2423,13 @@ packages: source: hosted version: "0.5.0" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" webdriver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ef8401bc..9f323a6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.0.3 + device_info_plus: ^9.1.2 device_preview: ^1.1.0 dio: ^5.4.1 disable_battery_optimization: ^1.1.0+1 @@ -43,7 +43,7 @@ dependencies: flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.0 - flutter_inappwebview: ^5.7.2+3 + flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter flutter_native_splash: ^2.3.10 @@ -123,6 +123,11 @@ dependencies: flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 spotify: ^0.13.3 + bonsoir: ^5.1.9 + shelf: ^1.4.1 + shelf_router: ^1.1.4 + shelf_web_socket: ^1.0.4 + web_socket_channel: ^2.4.4 dev_dependencies: build_runner: ^2.3.2 diff --git a/untranslated_messages.json b/untranslated_messages.json index 4275f461..be7d38f1 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,6 +1,203 @@ { + "ar": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "bn": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ca": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "de": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "es": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "fa": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "fr": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "hi": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "it": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ja": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ko": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ne": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "nl": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "pl": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "pt": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ru": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "tr": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "uk": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + "vi": [ "friends", - "no_lyrics_available" + "no_lyrics_available", + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "zh": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ] } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index fcf9927e..d8a9db29 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -23,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + BonsoirWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); DartDiscordRpcPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0fe6e076..90292744 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + bonsoir_windows dart_discord_rpc file_selector_windows flutter_secure_storage_windows From c8dd8025ec96bd78ed77cae35f1429aa48c16fde Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 4 Apr 2024 22:33:01 +0600 Subject: [PATCH 13/17] fix: instance of Artist bug #1362 --- lib/provider/proxy_playlist/proxy_playlist_provider.dart | 2 +- lib/services/audio_services/audio_services.dart | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index aa63e3f3..b5bcdefe 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -242,7 +242,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack => track as SourcedTrack, + SourcedTrack() => track as SourcedTrack, _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), }; diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index facbcc4c..338427aa 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; @@ -46,7 +47,7 @@ class AudioServices { id: track.id!, album: track.album?.name ?? "", title: track.name!, - artist: track.artists?.toString() ?? "", + artist: (track.artists)?.asString() ?? "", duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), From 5afe823abdb198340b55d138d8173d886a811632 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Apr 2024 00:48:08 +0600 Subject: [PATCH 14/17] feat(lyrics): add LRCLIB lyrics provider as fallback --- lib/models/lyrics.dart | 14 ++ lib/pages/lyrics/lyrics.dart | 32 +++- lib/pages/lyrics/plain_lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 199 ++++++++++++------------ lib/provider/spotify/lyrics/synced.dart | 117 ++++++++++++-- lib/provider/spotify/spotify.dart | 2 + lib/utils/service_utils.dart | 5 +- pubspec.lock | 8 + pubspec.yaml | 1 + 9 files changed, 270 insertions(+), 110 deletions(-) diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart index c800b040..f6457287 100644 --- a/lib/models/lyrics.dart +++ b/lib/models/lyrics.dart @@ -1,13 +1,18 @@ +import 'package:lrc/lrc.dart'; + class SubtitleSimple { Uri uri; String name; List lyrics; int rating; + String provider; + SubtitleSimple({ required this.uri, required this.name, required this.lyrics, required this.rating, + required this.provider, }); factory SubtitleSimple.fromJson(Map json) { @@ -18,6 +23,7 @@ class SubtitleSimple { .map((e) => LyricSlice.fromJson(e as Map)) .toList(), rating: json["rating"] as int, + provider: json["provider"] as String? ?? "unknown", ); } @@ -27,6 +33,7 @@ class SubtitleSimple { "name": name, "lyrics": lyrics.map((e) => e.toJson()).toList(), "rating": rating, + "provider": provider, }; } } @@ -37,6 +44,13 @@ class LyricSlice { LyricSlice({required this.time, required this.text}); + factory LyricSlice.fromLrcLine(LrcLine line) { + return LyricSlice( + time: line.timestamp, + text: line.lyrics.trim(), + ); + } + factory LyricSlice.fromJson(Map json) { return LyricSlice( time: Duration(milliseconds: json["time"]), diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 6d406e33..a0db7178 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -19,6 +20,7 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; @@ -43,13 +45,41 @@ class LyricsPage extends HookConsumerWidget { noSetBGColor: true, ); - final tabbar = ThemedButtonsTabBar( + PreferredSizeWidget tabbar = ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.synced} "), Tab(text: " ${context.l10n.plain} "), ], ); + tabbar = PreferredSize( + preferredSize: tabbar.preferredSize, + child: Row( + children: [ + tabbar, + const Spacer(), + Consumer( + builder: (context, ref, child) { + final playback = ref.watch(ProxyPlaylistNotifier.provider); + final lyric = + ref.watch(syncedLyricsProvider(playback.activeTrack)); + final providerName = lyric.asData?.value.provider; + + if (providerName == null) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.bottomRight, + child: Text("Powered by $providerName"), + ); + }, + ), + const Gap(5), + ], + ), + ); + final auth = ref.watch(AuthenticationNotifier.provider); if (auth == null) { diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index f1c6ec2e..2c0df0aa 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; @@ -120,6 +119,7 @@ class PlainLyrics extends HookConsumerWidget { lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", + textAlign: TextAlign.center, ), ); }, diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 5e7a24c8..52824f5e 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,4 +1,8 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -71,125 +75,128 @@ class SyncedLyrics extends HookConsumerWidget { ); return Stack( children: [ - Column( - children: [ + CustomScrollView( + controller: controller, + slivers: [ if (isModal != true) - Center( - child: Text( + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + centerTitle: true, + title: Text( playlist.activeTrack?.name ?? "Not Playing", style: headlineTextStyle, ), - ), - if (isModal != true) - Center( - child: Text( - playlist.activeTrack?.artists?.asString() ?? "", - style: mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40), + child: Text( + playlist.activeTrack?.artists?.asString() ?? "", + style: mediaQuery.mdAndUp + ? textTheme.headlineSmall + : textTheme.titleLarge, + ), ), ), if (lyricValue != null && lyricValue.lyrics.isNotEmpty && lyricsState.asData?.value.static != true) - Expanded( - child: ListView.builder( - controller: controller, - itemCount: lyricValue.lyrics.length, - itemBuilder: (context, index) { - final lyricSlice = lyricValue.lyrics[index]; - final isActive = lyricSlice.time.inSeconds == currentTime; + SliverList.builder( + itemCount: lyricValue.lyrics.length, + itemBuilder: (context, index) { + final lyricSlice = lyricValue.lyrics[index]; + final isActive = lyricSlice.time.inSeconds == currentTime; - if (isActive) { - controller.scrollToIndex( - index, - preferPosition: AutoScrollPosition.middle, - ); - } - return AutoScrollTag( - key: ValueKey(index), - index: index, - controller: controller, - child: lyricSlice.text.isEmpty - ? Container( + if (isActive) { + controller.scrollToIndex( + index, + preferPosition: AutoScrollPosition.middle, + ); + } + return AutoScrollTag( + key: ValueKey(index), + index: index, + controller: controller, + child: lyricSlice.text.isEmpty + ? Container( + padding: index == lyricValue.lyrics.length - 1 + ? EdgeInsets.only( + bottom: mediaQuery.size.height / 2, + ) + : null, + ) + : Center( + child: Padding( padding: index == lyricValue.lyrics.length - 1 - ? EdgeInsets.only( - bottom: mediaQuery.size.height / 2, + ? const EdgeInsets.all(8.0).copyWith( + bottom: 100, ) - : null, - ) - : Center( - child: Padding( - padding: index == lyricValue.lyrics.length - 1 - ? const EdgeInsets.all(8.0).copyWith( - bottom: 100, - ) - : const EdgeInsets.all(8.0), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 250), - style: TextStyle( - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, - fontSize: (isActive ? 28 : 26) * - (textZoomLevel.value / 100), - ), - textAlign: TextAlign.center, - child: InkWell( - onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > duration || time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Builder(builder: (context) { - return StrokeText( - text: lyricSlice.text, - textStyle: - DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, - strokeColor: isActive - ? Colors.black - : Colors.transparent, - ); - }), - ), + : const EdgeInsets.all(8.0), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: TextStyle( + fontWeight: isActive + ? FontWeight.w500 + : FontWeight.normal, + fontSize: (isActive ? 28 : 26) * + (textZoomLevel.value / 100), + ), + textAlign: TextAlign.center, + child: InkWell( + onTap: () async { + final duration = + await audioPlayer.duration ?? + Duration.zero; + final time = Duration( + seconds: + lyricSlice.time.inSeconds - delay, + ); + if (time > duration || time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return StrokeText( + text: lyricSlice.text, + textStyle: + DefaultTextStyle.of(context).style, + textColor: isActive + ? Colors.white + : palette.bodyTextColor, + strokeColor: isActive + ? Colors.black + : Colors.transparent, + ); + }), ), ), ), - ); - }, - ), + ), + ); + }, ), if (playlist.activeTrack != null && (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) - const Expanded( - child: ShimmerLyrics(), - ) + const SliverToBoxAdapter(child: ShimmerLyrics()) else if (playlist.activeTrack != null && (timedLyricsQuery.hasError)) ...[ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.no_lyrics_available, - style: bodyTextTheme, - textAlign: TextAlign.center, + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.no_lyrics_available, + style: bodyTextTheme, + textAlign: TextAlign.center, + ), ), ), - const Gap(26), - const Icon(SpotubeIcons.noLyrics, size: 60), + const SliverGap(26), + const SliverToBoxAdapter( + child: Icon(SpotubeIcons.noLyrics, size: 60), + ), ] else if (lyricsState.asData?.value.static == true) - Expanded( + SliverFillRemaining( child: Center( child: RichText( textAlign: TextAlign.center, diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index d86735db..6ce74ae7 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -6,26 +6,28 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier load(); } - @override - FutureOr build(track) async { - final spotify = ref.watch(spotifyProvider); - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); + Track get _track => arg!; + + Future getSpotifyLyrics(String? token) async { final res = await http.get( Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", ), headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" + "authorization": "Bearer $token" }); if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "Spotify", + ); } final linesRaw = Map.castFrom( jsonDecode(res.body), @@ -41,12 +43,105 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: lines, - name: track.name!, + name: _track.name!, uri: res.request!.url, rating: 100, + provider: "Spotify", ); } + /// Lyrics credits: [lrclib.net](https://lrclib.net) and their contributors + /// Thanks for their generous public API + Future getLRCLibLyrics() async { + final packageInfo = await PackageInfo.fromPlatform(); + + final res = await http.get( + Uri( + scheme: "https", + host: "lrclib.net", + path: "/api/get", + queryParameters: { + "artist_name": _track.artists?.first.name, + "track_name": _track.name, + "album_name": _track.album?.name, + "duration": _track.duration?.inSeconds.toString(), + }, + ), + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + final json = jsonDecode(res.body) as Map; + + final syncedLyricsRaw = json["syncedLyrics"] as String?; + final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true + ? Lrc.parse(syncedLyricsRaw!) + .lyrics + .map(LyricSlice.fromLrcLine) + .toList() + : null; + + if (syncedLyrics?.isNotEmpty == true) { + return SubtitleSimple( + lyrics: syncedLyrics!, + name: _track.name!, + uri: res.request!.url, + rating: 100, + provider: "LRCLib", + ); + } + + final plainLyrics = (json["plainLyrics"] as String) + .split("\n") + .map((line) => LyricSlice(text: line, time: Duration.zero)) + .toList(); + + return SubtitleSimple( + lyrics: plainLyrics, + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + @override + FutureOr build(track) async { + try { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); + + if (lyrics.lyrics.isEmpty) { + lyrics = await getLRCLibLyrics(); + } + + if (lyrics.lyrics.isEmpty) { + throw Exception("Unable to find lyrics"); + } + + return lyrics; + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + rethrow; + } + } + @override FutureOr fromJson(Map json) => SubtitleSimple.fromJson(json.castKeyDeep()); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index b152db65..816420f6 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -8,6 +8,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; +import 'package:lrc/lrc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:spotify/spotify.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 60c77e59..88c52896 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -251,6 +251,7 @@ abstract class ServiceUtils { uri: subtitleUri, lyrics: lrcList, rating: rateSortedResults.first["points"] as int, + provider: "Rent An Adviser", ); return subtitle; @@ -307,7 +308,9 @@ abstract class ServiceUtils { case SortBy.duration: return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0; case SortBy.artist: - return a.artists?.first.name?.compareTo(b.artists?.first.name ?? "") ?? 0; + return a.artists?.first.name + ?.compareTo(b.artists?.first.name ?? "") ?? + 0; case SortBy.album: return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; default: diff --git a/pubspec.lock b/pubspec.lock index 47c1aba3..588aca13 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,6 +1455,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + lrc: + dependency: "direct main" + description: + name: lrc + sha256: "5100362b5c8e97f4d3f03ff87efeb40e73a6dd780eca2cbde9312e0d44b8e5ba" + url: "https://pub.dev" + source: hosted + version: "1.0.2" mailer: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9f323a6f..298631d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -128,6 +128,7 @@ dependencies: shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 web_socket_channel: ^2.4.4 + lrc: ^1.0.2 dev_dependencies: build_runner: ^2.3.2 From f26503990cf4c7c3f3083e58f056c65dafe5f008 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Apr 2024 12:39:54 +0600 Subject: [PATCH 15/17] cd: use brew to install setuptools --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index e05bf75d..5d918a03 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -327,7 +327,7 @@ jobs: - name: Package Macos App run: | - python3 -m pip install setuptools + brew install python-setuptools npm install -g appdmg mkdir -p build/${{ env.BUILD_VERSION }} appdmg appdmg.json build/Spotube-macos-universal.dmg From 0d080b77b72529c0be5ebc27ace1c52307511f73 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 7 Apr 2024 13:05:40 +0600 Subject: [PATCH 16/17] fix(playback): sponsor block skips and stutters in same position --- .vscode/settings.json | 1 + lib/collections/fake.dart | 2 +- lib/main.dart | 1 - .../proxy_playlist/player_listeners.dart | 132 +++++++++ .../proxy_playlist_provider.dart | 274 ++---------------- .../proxy_playlist/skip_segments.dart | 110 +++++++ .../audio_players_streams_mixin.dart | 2 + lib/services/sourced_track/sourced_track.dart | 2 + 8 files changed, 270 insertions(+), 254 deletions(-) create mode 100644 lib/provider/proxy_playlist/player_listeners.dart create mode 100644 lib/provider/proxy_playlist/skip_segments.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 0fedc544..462d33ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Amoled", "Buildless", "danceability", "fuzzywuzzy", diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 8f5f9e8b..c5379ec6 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -6,7 +6,7 @@ abstract class FakeData { static final Image image = Image() ..height = 1 ..width = 1 - ..url = "url"; + ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg"; static final Followers followers = Followers() ..href = "text" diff --git a/lib/main.dart b/lib/main.dart index 2a2d8d18..8de524c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,6 @@ import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart new file mode 100644 index 00000000..9069f3e1 --- /dev/null +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -0,0 +1,132 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:async'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; + +extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + StreamSubscription subscribeToSourceChanges() => + audioPlayer.activeSourceChangedStream.listen((event) { + try { + final newActiveTrack = mapSourcesToTracks([event]).firstOrNull; + + if (newActiveTrack == null || + newActiveTrack.id == state.activeTrack?.id) { + return; + } + + notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); + state = state.copyWith( + active: state.tracks + .toList() + .indexWhere((element) => element.id == newActiveTrack.id), + ); + + updatePalette(); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + + StreamSubscription subscribeToPercentCompletion() { + final isPreSearching = ObjectRef(false); + + return audioPlayer.percentCompletedStream(2).listen((event) async { + if (isPreSearching.value || + audioPlayer.currentSource == null || + audioPlayer.nextSource == null || + isPlayable(audioPlayer.nextSource!)) return; + + try { + isPreSearching.value = true; + + final track = await ensureSourcePlayable(audioPlayer.nextSource!); + + if (track != null) { + state = state.copyWith(tracks: mergeTracks([track], state.tracks)); + } + } catch (e, stackTrace) { + // Removing tracks that were not found to avoid queue interruption + if (e is TrackNotFoundError) { + final oldTrack = + mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; + await removeTrack(oldTrack!.id!); + } + Catcher2.reportCheckedError(e, stackTrace); + } finally { + isPreSearching.value = false; + } + }); + } + + StreamSubscription subscribeToShuffleChanges() { + return audioPlayer.shuffledStream.listen((event) { + try { + final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); + + final newActiveIndex = newlyOrderedTracks.indexWhere( + (element) => element.id == state.activeTrack?.id, + ); + + if (newActiveIndex == -1) return; + + state = state.copyWith( + tracks: newlyOrderedTracks.toSet(), + active: newActiveIndex, + ); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + } + + StreamSubscription subscribeToSkipSponsor() { + return audioPlayer.positionStream.listen((position) async { + final currentSegments = await ref.read(segmentProvider.future); + + if (currentSegments?.segments.isNotEmpty != true || + position < const Duration(seconds: 3)) return; + + for (final segment in currentSegments!.segments) { + final seconds = position.inSeconds; + + if (seconds < segment.start || seconds >= segment.end) continue; + + await audioPlayer.seek(Duration(seconds: segment.end + 1)); + } + }); + } + + StreamSubscription subscribeToScrobbleChanged() { + String? lastScrobbled; + return audioPlayer.positionStream.listen((position) { + try { + final uid = state.activeTrack is LocalTrack + ? (state.activeTrack as LocalTrack).path + : state.activeTrack?.id; + + if (state.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + scrobbler.scrobble(state.activeTrack!); + lastScrobbled = uid; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + }); + } + + StreamSubscription subscribeToPlayerError() { + return audioPlayer.errorStream.listen((event) {}); + } +} diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index b5bcdefe..438088de 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,24 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; -import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; - -import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; +import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -26,34 +20,11 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/provider/discord_provider.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/services/sourced_track/sources/piped.dart'; -import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -/// Things implemented: -/// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] -/// * [x] Modification of the Queue -/// * [x] Add track at the end -/// * [x] Add track at the beginning -/// * [x] Remove track -/// * [x] Reorder track -/// * [x] Caching and loading of cache of tracks -/// * [x] Shuffling -/// * [x] loop => playlist, track, none -/// * [x] Alternative Track Source -/// * [x] Blacklisting of tracks and artist -/// -/// Don'ts: -/// * It'll not have any proxy method for [SpotubeAudioPlayer] -/// * It'll not store any sort of player state e.g playing, paused, shuffled etc -/// * For that, use [SpotubeAudioPlayer] - class ProxyPlaylistNotifier extends PersistedStateNotifier with NextFetcher { final Ref ref; @@ -74,162 +45,21 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier static AlwaysAliveRefreshable get notifier => provider.notifier; + List _subscriptions = []; + ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { - () async { - notificationService = await AudioServices.create(ref, this); + AudioServices.create(ref, this).then( + (value) => notificationService = value, + ); - // listeners state - final currentSegments = - // using source as unique id because alternative track source support - ObjectRef<({String source, List segments})?>(null); - final isPreSearching = ObjectRef(false); - final isFetchingSegments = ObjectRef(false); - - audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { - try { - final newActiveTrack = - mapSourcesToTracks([newActiveSource]).firstOrNull; - - if (newActiveTrack == null || - newActiveTrack.id == state.activeTrack?.id) { - return; - } - - notificationService.addTrack(newActiveTrack); - discord.updatePresence(newActiveTrack); - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == newActiveTrack.id), - ); - - updatePalette(); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - audioPlayer.shuffledStream.listen((event) { - try { - final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); - - final newActiveIndex = newlyOrderedTracks.indexWhere( - (element) => element.id == state.activeTrack?.id, - ); - - if (newActiveIndex == -1) return; - - state = state.copyWith( - tracks: newlyOrderedTracks.toSet(), - active: newActiveIndex, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - listenTo2Percent(int percent) async { - if (isPreSearching.value || - audioPlayer.currentSource == null || - audioPlayer.nextSource == null || - isPlayable(audioPlayer.nextSource!)) return; - - try { - isPreSearching.value = true; - - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith(tracks: mergeTracks([track], state.tracks)); - } - } catch (e, stackTrace) { - // Removing tracks that were not found to avoid queue interruption - if (e is TrackNotFoundError) { - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - await removeTrack(oldTrack!.id!); - } - Catcher2.reportCheckedError(e, stackTrace); - } finally { - isPreSearching.value = false; - } - } - - audioPlayer.percentCompletedStream(2).listen(listenTo2Percent); - - audioPlayer.positionStream.listen((position) async { - if (state.activeTrack == null || state.activeTrack is LocalTrack) { - isFetchingSegments.value = false; - return; - } - try { - final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && - (state.activeTrack is PipedSourcedTrack && - preferences.searchMode == SearchMode.youtubeMusic); - - if (isNotYTMode || !preferences.skipNonMusic) return; - - final isNotSameSegmentId = - currentSegments.value?.source != audioPlayer.currentSource; - - if (currentSegments.value == null || - (isNotSameSegmentId && !isFetchingSegments.value)) { - isFetchingSegments.value = true; - try { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: await getAndCacheSkipSegments( - (state.activeTrack as SourcedTrack).sourceInfo.id, - ), - ); - } catch (e) { - if (audioPlayer.currentSource != null) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); - } - } finally { - isFetchingSegments.value = false; - } - } - - // skipping in first 2 second breaks stream - if (currentSegments.value == null || - currentSegments.value!.segments.isEmpty || - position < const Duration(seconds: 3)) return; - - for (final segment in currentSegments.value!.segments) { - if (position.inSeconds >= segment.start && - position.inSeconds < segment.end) { - await audioPlayer.seek(Duration(seconds: segment.end)); - } - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - String? lastScrobbled; - audioPlayer.positionStream.listen((position) { - try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; - - if (state.activeTrack == null || - lastScrobbled == uid || - position.inSeconds < 30) { - return; - } - - scrobbler.scrobble(state.activeTrack!); - lastScrobbled = uid; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - } - }); - }(); + _subscriptions = [ + // These are subscription methods from player_listeners.dart + subscribeToSourceChanges(), + subscribeToPercentCompletion(), + subscribeToShuffleChanges(), + subscribeToSkipSponsor(), + subscribeToScrobbleChanged(), + ]; } Future ensureSourcePlayable(String source) async { @@ -283,8 +113,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - // TODO: Safely Remove playing tracks - Future removeTrack(String trackId) async { final track = state.tracks.firstWhereOrNull((element) => element.id == trackId); @@ -533,72 +361,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - Future> getAndCacheSkipSegments(String id) async { - if (!preferences.skipNonMusic || - (preferences.audioSource == AudioSource.piped && - preferences.searchMode == SearchMode.youtubeMusic)) return []; - - try { - final cached = await SkipSegment.box.get(id); - if (cached != null && cached.isNotEmpty) { - return List.castFrom( - (cached as List) - .map( - (json) => SkipSegment.fromJson( - Map.castFrom(json), - ), - ) - .toList(), - ); - } - - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); - - if (res.body == "Not Found") { - return List.castFrom([]); - } - - final data = jsonDecode(res.body) as List; - final segments = data.map((obj) { - final start = obj["segment"].first.toInt(); - final end = obj["segment"].last.toInt(); - return SkipSegment( - start, - end, - ); - }).toList(); - getLogger('getSkipSegments').t( - "[SponsorBlock] successfully fetched skip segments for $id", - ); - - await SkipSegment.box.put( - id, - segments.map((e) => e.toJson()).toList(), - ); - return List.castFrom(segments); - } catch (e, stack) { - await SkipSegment.box.put(id, []); - Catcher2.reportCheckedError(e, stack); - return List.castFrom([]); - } - } - @override set state(state) { super.state = state; @@ -631,4 +393,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final json = state.toJson(); return json; } + + @override + void dispose() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + super.dispose(); + } } diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart new file mode 100644 index 00000000..94a63324 --- /dev/null +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/skip_segment.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class SourcedSegments { + final String source; + final List segments; + + SourcedSegments({required this.source, required this.segments}); +} + +Future> getAndCacheSkipSegments(String id) async { + try { + final cached = await SkipSegment.box.get(id) as List?; + if (cached != null && cached.isNotEmpty) { + return List.castFrom( + cached + .map( + (json) => SkipSegment.fromJson( + Map.castFrom(json), + ), + ) + .toList(), + ); + } + + final res = await get(Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + )); + + if (res.body == "Not Found") { + return List.castFrom([]); + } + + final data = jsonDecode(res.body) as List; + final segments = data.map((obj) { + final start = obj["segment"].first.toInt(); + final end = obj["segment"].last.toInt(); + return SkipSegment(start, end); + }).toList(); + + await SkipSegment.box.put( + id, + segments.map((e) => e.toJson()).toList(), + ); + return List.castFrom(segments); + } catch (e, stack) { + await SkipSegment.box.put(id, []); + Catcher2.reportCheckedError(e, stack); + return List.castFrom([]); + } +} + +final segmentProvider = FutureProvider( + (ref) async { + final track = ref.watch( + ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), + ); + if (track == null) return null; + + if (track is LocalTrack || track is! SourcedTrack) return null; + + final skipNonMusic = ref.watch( + userPreferencesProvider.select( + (s) { + final isPipedYTMusicMode = s.audioSource == AudioSource.piped && + s.searchMode == SearchMode.youtubeMusic; + + return s.skipNonMusic && !isPipedYTMusicMode; + }, + ), + ); + + if (!skipNonMusic) { + return SourcedSegments( + segments: [], + source: track.sourceInfo.id, + ); + } + + final segments = await getAndCacheSkipSegments(track.sourceInfo.id); + + return SourcedSegments( + source: track.sourceInfo.id, + segments: segments, + ); + }, +); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index f05ba5ef..54e36c6b 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -146,4 +146,6 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get selectedDeviceStream => _mkPlayer.stream.audioDevice.asBroadcastStream(); + + Stream get errorStream => _mkPlayer.stream.error; } diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c06efd87..a5e094ed 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -131,6 +131,8 @@ abstract class SourcedTrack extends Track { }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); + } on VideoUnplayableException catch (_) { + return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { if (preferences.audioSource == AudioSource.jiosaavn) { From de68fe3a6b0ade67a6e91a68de896ad634c491b6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Apr 2024 01:15:39 +0600 Subject: [PATCH 17/17] chore: make dropdown buttons more attractive --- .../shared/adaptive/adaptive_select_tile.dart | 22 ++++++++++++++----- lib/pages/settings/sections/desktop.dart | 2 ++ .../settings/sections/language_region.dart | 2 ++ lib/pages/settings/sections/playback.dart | 11 +++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart index 58666e46..3f6d2700 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/shared/adaptive/adaptive_select_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/constrains.dart'; class AdaptiveSelectTile extends HookWidget { @@ -38,11 +39,22 @@ class AdaptiveSelectTile extends HookWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final rawControl = DropdownButton( - items: options, - value: value, - onChanged: onChanged, - menuMaxHeight: mediaQuery.size.height * 0.6, + final rawControl = DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: DropdownButton( + items: options, + value: value, + onChanged: onChanged, + menuMaxHeight: mediaQuery.size.height * 0.6, + underline: const SizedBox.shrink(), + padding: const EdgeInsets.symmetric(horizontal: 10), + borderRadius: BorderRadius.circular(10), + icon: const Icon(SpotubeIcons.angleDown), + dropdownColor: theme.colorScheme.secondaryContainer, + ), ); final controlPlaceholder = useMemoized( () => options diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 2c0a1466..4e4408d9 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; @@ -19,6 +20,7 @@ class SettingsDesktopSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.desktop, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.close), title: Text(context.l10n.close_behavior), diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index fbfe1030..76670c77 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; @@ -23,6 +24,7 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.language_region, children: [ + const Gap(10), AdaptiveSelectTile( value: preferences.locale, onChanged: (locale) { diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index e023cc60..eeae98cb 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,6 +26,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.playback, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.audioQuality), title: Text(context.l10n.audio_quality), @@ -49,6 +51,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { } }, ), + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.api), title: Text(context.l10n.audio_source), @@ -181,7 +184,8 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.normalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio, ), - if (preferences.audioSource != AudioSource.jiosaavn) + if (preferences.audioSource != AudioSource.jiosaavn) ...[ + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.stream), title: Text(context.l10n.streaming_music_codec), @@ -201,7 +205,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setStreamMusicCodec(value); }, ), - if (preferences.audioSource != AudioSource.jiosaavn) + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.file), title: Text(context.l10n.download_music_codec), @@ -220,7 +224,8 @@ class SettingsPlaybackSection extends HookConsumerWidget { if (value == null) return; preferencesNotifier.setDownloadMusicCodec(value); }, - ), + ) + ], SwitchListTile( secondary: const Icon(SpotubeIcons.repeat), title: Text(context.l10n.endless_playback),