From 22a49e56a2397791da735356a1173e741966c376 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 11 Apr 2024 17:56:41 +0600 Subject: [PATCH] refactor: use tcp server based track matcher (#1386) * refactor: remove SourcedTrack based audio player and utilize mediakit playback system * feat: implement local (loopback) server to resolve stream source and leverage the media_kit playback API * feat: add source change support and re-add prefetching tracks * fix: assign lastId when track fetch completes regardless of error * chore: remove print statements * fix: remote queue not working * fix: increase mpv network timeout to reduce auto-skipping * fix: do not pre-fetch local tracks * fix(proxy-playlist): reset collections on load * chore: fix lint warnings * fix(mobile): player overlay should not be visible when the player is not playing * chore: fix typo in turkish translation * cd: checkout PR branch * cd: upgrade flutter version * chore: fix lint errors --- .github/workflows/pr-lint.yml | 4 +- .vscode/settings.json | 4 + analysis_options.yaml | 8 +- bin/translated_messages.dart | 2 + bin/untranslated_messages.dart | 3 +- lib/components/album/album_card.dart | 2 +- lib/components/library/user_local_tracks.dart | 4 +- lib/components/player/player.dart | 1 + lib/components/player/player_overlay.dart | 5 +- lib/components/player/player_queue.dart | 13 +- .../player/sibling_tracks_sheet.dart | 44 +- lib/components/playlist/playlist_card.dart | 2 +- lib/components/root/bottom_player.dart | 2 - lib/components/shared/bordered_text.dart | 2 +- .../shared/page_window_title_bar.dart | 1 + lib/components/shared/panels/controller.dart | 2 +- lib/components/shared/panels/helpers.dart | 2 +- .../shared/track_tile/track_tile.dart | 2 +- .../sections/header/header_buttons.dart | 4 + lib/extensions/track.dart | 7 + .../configurators/use_close_behavior.dart | 1 + .../utils/use_custom_status_bar_color.dart | 3 + lib/hooks/utils/use_force_update.dart | 1 + lib/l10n/app_th.arb | 2 +- lib/l10n/app_tr.arb | 2 +- lib/l10n/l10n.dart | 4 +- lib/main.dart | 2 + lib/pages/connect/control/control.dart | 54 +- lib/pages/lyrics/synced_lyrics.dart | 4 - lib/pages/root/root_app.dart | 1 + lib/provider/authentication_provider.dart | 2 +- lib/provider/connect/clients.dart | 8 +- lib/provider/connect/connect.dart | 14 +- .../proxy_playlist/next_fetcher_mixin.dart | 108 --- .../proxy_playlist/player_listeners.dart | 106 +-- .../proxy_playlist/proxy_playlist.dart | 15 +- .../proxy_playlist_provider.dart | 212 +---- .../proxy_playlist/skip_segments.dart | 10 +- lib/provider/server/active_sourced_track.dart | 47 ++ lib/provider/server/server.dart | 119 +++ lib/provider/server/sourced_track.dart | 28 + lib/services/audio_player/audio_player.dart | 49 +- .../audio_player/audio_player_impl.dart | 233 +----- .../audio_players_streams_mixin.dart | 10 +- lib/services/audio_player/custom_player.dart | 143 ++++ .../audio_player/mk_state_player.dart | 382 --------- .../audio_services/linux_audio_service.dart | 736 ------------------ .../audio_services/mobile_audio_service.dart | 1 + .../audio_services/smtc_windows_web.dart | 2 + lib/services/cli/cli.dart | 2 + .../download_manager/download_task.dart | 2 - lib/utils/duration.dart | 2 - pubspec.lock | 4 +- pubspec.yaml | 4 +- untranslated_messages.json | 3 +- 55 files changed, 591 insertions(+), 1839 deletions(-) delete mode 100644 lib/provider/proxy_playlist/next_fetcher_mixin.dart create mode 100644 lib/provider/server/active_sourced_track.dart create mode 100644 lib/provider/server/server.dart create mode 100644 lib/provider/server/sourced_track.dart create mode 100644 lib/services/audio_player/custom_player.dart delete mode 100644 lib/services/audio_player/mk_state_player.dart delete mode 100644 lib/services/audio_services/linux_audio_service.dart diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index e4fb55c5b..156d1a076 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,13 +4,15 @@ on: pull_request: env: - FLUTTER_VERSION: '3.16.0' + FLUTTER_VERSION: '3.19.5' jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} diff --git a/.vscode/settings.json b/.vscode/settings.json index 462d33ef4..29c5ba4e0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,15 +2,19 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "ambiguate", "Amoled", "Buildless", "danceability", "fuzzywuzzy", + "gapless", "instrumentalness", "Mpris", + "RGBO", "riverpod", "Scrobblenaut", "skeletonizer", + "songlink", "speechiness", "Spotube", "winget" diff --git a/analysis_options.yaml b/analysis_options.yaml index 4ba476e00..d5b904ccc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -30,10 +30,12 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options analyzer: - enable-experiment: - - records - - patterns errors: invalid_annotation_target: ignore plugins: - custom_lint + exclude: + - "**.freezed.dart" + - "**.g.dart" + - "**.gr.dart" + - "**/generated_plugin_registrant.dart" diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart index 0de398df2..1ac8f148f 100644 --- a/bin/translated_messages.dart +++ b/bin/translated_messages.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart index e19f9a076..0b3485a7b 100644 --- a/bin/untranslated_messages.dart +++ b/bin/untranslated_messages.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; @@ -40,7 +42,6 @@ void main(List args) { "Translate following to their appropriate locale for flutter arb translations files." " Put the respective new translations in a map of their corresponding locale.", ); - // ignore: avoid_print print( const JsonEncoder.withIndent(' ').convert( args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 678bfd06a..ef831d27f 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -73,7 +73,7 @@ class AlbumCard extends HookConsumerWidget { final fetchedTracks = await fetchAllTrack(); - if (fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 778558f60..6a953385f 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -28,6 +28,7 @@ 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'; +// ignore: depend_on_referenced_packages import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; const supportedAudioTypes = [ @@ -185,9 +186,6 @@ class UserLocalTracks extends HookConsumerWidget { ref, trackSnapshot.asData!.value, ); - } else { - // TODO: Remove stop capability - // playlistNotifier.stop(); } } } diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 6dbd9b11f..054e67069 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -96,6 +96,7 @@ class PlayerView extends HookConsumerWidget { final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; + // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { await panelController.close(); diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index e2ca96749..37ae49cf1 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -24,11 +24,10 @@ class PlayerOverlay extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final canShow = ref.watch( - ProxyPlaylistNotifier.provider.select((s) => s.active != null), - ); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final canShow = playlist.activeTrack != null; + final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 0bf61da46..914d7bc97 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -53,8 +53,7 @@ class PlayerQueue extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final controller = useAutoScrollController(); final searchText = useState(''); @@ -161,7 +160,7 @@ class PlayerQueue extends HookConsumerWidget { snap: false, backgroundColor: Colors.transparent, elevation: 0, - automaticallyImplyLeading: !isSearching.value, + automaticallyImplyLeading: false, title: BackdropFilter( filter: ImageFilter.blur( sigmaX: 10, @@ -241,7 +240,7 @@ class PlayerQueue extends HookConsumerWidget { ], ), onPressed: () { - playlistNotifier.stop(); + onStop(); Navigator.of(context).pop(); }, ), @@ -251,9 +250,7 @@ class PlayerQueue extends HookConsumerWidget { ), const SliverGap(10), SliverReorderableList( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, + onReorder: onReorder, itemCount: filteredTracks.length, onReorderStart: (index) { HapticFeedback.selectionClick(); @@ -277,7 +274,7 @@ class PlayerQueue extends HookConsumerWidget { if (playlist.activeTrack?.id == track.id) { return; } - await playlistNotifier.jumpToTrack(track); + await onJump(track); }, leadingActions: [ if (!isSearching.value && diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 99ab223f4..eef34be6c 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.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' hide Offset; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -16,6 +15,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.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/models/source_info.dart'; @@ -53,21 +53,22 @@ class SiblingTracksSheet extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final preferences = ref.watch(userPreferencesProvider); final isSearching = useState(false); final searchMode = useState(preferences.searchMode); + final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier); + final activeTrack = + ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack; final title = ServiceUtils.getTitle( - playlist.activeTrack?.name ?? "", - artists: - playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [], + activeTrack?.name ?? "", + artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [], onlyCleanArtist: true, ).trim(); final defaultSearchTerm = - "$title - ${playlist.activeTrack?.artists?.asString() ?? ""}"; + "$title - ${activeTrack?.artists?.asString() ?? ""}"; final searchController = useTextEditingController( text: defaultSearchTerm, ); @@ -91,8 +92,7 @@ class SiblingTracksSheet extends HookConsumerWidget { return siblingType.info; })); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; return results ..removeWhere((element) => element.id == activeSourceInfo.id) @@ -112,8 +112,7 @@ class SiblingTracksSheet extends HookConsumerWidget { return siblingType.info; }), ); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; return searchResults ..removeWhere((element) => element.id == activeSourceInfo.id) ..insert( @@ -124,18 +123,18 @@ class SiblingTracksSheet extends HookConsumerWidget { }, [ searchTerm, searchMode.value, - playlist.activeTrack, + activeTrack, preferences.audioSource, ]); final siblings = useMemoized( () => playlist.isFetching == false ? [ - (playlist.activeTrack as SourcedTrack).sourceInfo, - ...(playlist.activeTrack as SourcedTrack).siblings, + (activeTrack as SourcedTrack).sourceInfo, + ...activeTrack.siblings, ] : [], - [playlist.isFetching, playlist.activeTrack], + [playlist.isFetching, activeTrack], ); final borderRadius = floating @@ -146,12 +145,11 @@ class SiblingTracksSheet extends HookConsumerWidget { ); useEffect(() { - if (playlist.activeTrack is SourcedTrack && - (playlist.activeTrack as SourcedTrack).siblings.isEmpty) { - playlistNotifier.populateSibling(); + if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { + activeTrackNotifier.populateSibling(); } return null; - }, [playlist.activeTrack]); + }, [activeTrack]); final itemBuilder = useCallback( (SourceInfo sourceInfo) { @@ -178,20 +176,18 @@ class SiblingTracksSheet extends HookConsumerWidget { ), enabled: playlist.isFetching != true, selected: playlist.isFetching != true && - sourceInfo.id == - (playlist.activeTrack as SourcedTrack).sourceInfo.id, + sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, selectedTileColor: theme.popupMenuTheme.color, onTap: () { if (playlist.isFetching == false && - sourceInfo.id != - (playlist.activeTrack as SourcedTrack).sourceInfo.id) { - playlistNotifier.swapSibling(sourceInfo); + sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { + activeTrackNotifier.swapSibling(sourceInfo); Navigator.of(context).pop(); } }, ); }, - [playlist.isFetching, playlist.activeTrack, siblings], + [playlist.isFetching, activeTrack, siblings], ); final mediaQuery = MediaQuery.of(context); diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index e5b87d6d7..3777a1cb8 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -72,7 +72,7 @@ class PlaylistCard extends HookConsumerWidget { List fetchedTracks = await fetchAllTracks(); - if (fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 19fa7c93c..1cdf72b53 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -19,7 +19,6 @@ 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'; @@ -36,7 +35,6 @@ 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); diff --git a/lib/components/shared/bordered_text.dart b/lib/components/shared/bordered_text.dart index 627b2a3c5..f25f2208b 100644 --- a/lib/components/shared/bordered_text.dart +++ b/lib/components/shared/bordered_text.dart @@ -79,7 +79,7 @@ class BorderedText extends StatelessWidget { strutStyle: child.strutStyle, textAlign: child.textAlign, textDirection: child.textDirection, - textScaleFactor: child.textScaleFactor, + textScaler: child.textScaler, ), child, ], diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index f956fa284..37daefa95 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -599,6 +599,7 @@ class MouseStateBuilder extends StatefulWidget { final VoidCallback? onPressed; const MouseStateBuilder({super.key, required this.builder, this.onPressed}); @override + // ignore: library_private_types_in_public_api _MouseStateBuilderState createState() => _MouseStateBuilderState(); } diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart index a573c06c2..65c2444e7 100644 --- a/lib/components/shared/panels/controller.dart +++ b/lib/components/shared/panels/controller.dart @@ -1,4 +1,4 @@ -part of panels; +part of './sliding_up_panel.dart'; class PanelController extends ChangeNotifier { SlidingUpPanelState? _panelState; diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart index 7dad96d57..6d0dde310 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/shared/panels/helpers.dart @@ -1,4 +1,4 @@ -part of panels; +part of "./sliding_up_panel.dart"; /// if you want to prevent the panel from being dragged using the widget, /// wrap the widget with this diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 61061d241..5a075502d 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -208,7 +208,7 @@ class TrackTile extends HookConsumerWidget { Expanded( flex: 4, child: switch (track.runtimeType) { - LocalTrack => Text( + LocalTrack() => Text( track.album!.name!, maxLines: 1, overflow: TextOverflow.ellipsis, 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 f505f7652..71e6c9f5e 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -46,6 +46,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); + if (!context.mounted) return; + final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); @@ -76,6 +78,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); + if (!context.mounted) return; + final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index d8258a6d5..9755179db 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { Track fromFile( @@ -90,3 +91,9 @@ extension TrackSimpleExtensions on TrackSimple { return track; } } + +extension TracksToMediaExtension on Iterable { + List asMediaList() { + return map((track) => SpotubeMedia(track)).toList(); + } +} diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart index 05c03fff6..79b14fa96 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/hooks/configurators/use_window_listener.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +// ignore: depend_on_referenced_packages import 'package:local_notifier/local_notifier.dart'; final closeNotification = DesktopTools.createNotification( diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart index d1266fe26..7c5c7b278 100644 --- a/lib/hooks/utils/use_custom_status_bar_color.dart +++ b/lib/hooks/utils/use_custom_status_bar_color.dart @@ -19,11 +19,13 @@ void useCustomStatusBarColor( ), ); + // ignore: invalid_use_of_visible_for_testing_member final statusBarColor = SystemChrome.latestStyle?.statusBarColor; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { if (automaticSystemUiAdjustment != null) { + // ignore: deprecated_member_use WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = automaticSystemUiAdjustment; } @@ -43,6 +45,7 @@ void useCustomStatusBarColor( }); return () { if (automaticSystemUiAdjustment != null) { + // ignore: deprecated_member_use WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; } }; diff --git a/lib/hooks/utils/use_force_update.dart b/lib/hooks/utils/use_force_update.dart index 74151a65f..268f0f04a 100644 --- a/lib/hooks/utils/use_force_update.dart +++ b/lib/hooks/utils/use_force_update.dart @@ -2,5 +2,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; void Function() useForceUpdate() { final state = useState(null); + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member return () => state.notifyListeners(); } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 5df6bc20d..cd58a20da 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -12,7 +12,7 @@ "new_releases": "เพิ่งปล่อยใหม่", "songs": "เพลง", "playing_track": "กำลังเล่น {track}", - "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", + "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track_length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", "load_more": "โหลดเพิ่มเติม", "playlists": "เพลย์ลิสต์", "artists": "ศิลปิน", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index ee7562efb..a4050853d 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -313,7 +313,7 @@ "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", "contribute_on_github": "GitHub'a katkıda bulunun", "donate_on_open_collective": "Open Collective'e bağış yap", - "browse_anonymously": "Anonim Olarak Göz at" + "browse_anonymously": "Anonim Olarak Göz at", "enable_connect": "Bağlantıyı Etkinleştir", "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", "devices": "Cihazlar", diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 180d2ec66..e584d2be6 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -13,6 +13,8 @@ /// sappho192@github => Korean /// watchakorn-18k@github => Thai +library l10n; + import 'package:flutter/material.dart'; class L10n { @@ -40,4 +42,4 @@ class L10n { const Locale('zh', 'CN'), const Locale('vi', 'VN'), ]; -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 8de524c72..d6df20ead 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/server/server.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'; @@ -182,6 +183,7 @@ class SpotubeState extends ConsumerState { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + ref.listen(playbackServerProvider, (_, __) {}); ref.listen(connectServerProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index 162565681..b78f0ed32 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -18,6 +18,33 @@ import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/utils/service_utils.dart'; +class RemotePlayerQueue extends ConsumerWidget { + const RemotePlayerQueue({super.key}); + + @override + Widget build(BuildContext context, ref) { + final connectNotifier = ref.watch(connectProvider.notifier); + 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), + ); + }, + ); + } +} + class ConnectControlPage extends HookConsumerWidget { const ConnectControlPage({super.key}); @@ -50,27 +77,6 @@ class ConnectControlPage extends HookConsumerWidget { 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(); @@ -292,7 +298,7 @@ class ConnectControlPage extends HookConsumerWidget { showModalBottomSheet( context: context, builder: (context) { - return playerQueue; + return const RemotePlayerQueue(); }, ); }, @@ -304,8 +310,8 @@ class ConnectControlPage extends HookConsumerWidget { ), if (constrains.lgAndUp) ...[ const VerticalDivider(thickness: 1), - Expanded( - child: playerQueue, + const Expanded( + child: RemotePlayerQueue(), ), ] ], diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 52824f5e2..3b158d474 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,8 +1,4 @@ -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'; diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 2e079200d..6ce74e53f 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -190,6 +190,7 @@ class RootApp extends HookConsumerWidget { } } + // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { if (rootPaths[location] != 0) { diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index f1cf58ec9..0258058bb 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -131,7 +131,7 @@ class AuthenticationNotifier Future logout() async { state = null; if (kIsMobile) { - WebStorageManager.instance().android.deleteAllData(); + WebStorageManager.instance().deleteAllData(); CookieManager.instance().deleteAllCookies(); } } diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart index 282c96aa6..d92ff8d3c 100644 --- a/lib/provider/connect/clients.dart +++ b/lib/provider/connect/clients.dart @@ -64,10 +64,10 @@ class ConnectClientsNotifier extends AsyncNotifier { .where((s) => s.name != event.service!.name) .toList(), discovery: state.value!.discovery, - resolvedService: - event.service?.name == state.value!.resolvedService!.name - ? null - : state.value!.resolvedService, + resolvedService: state.value?.resolvedService != null && + event.service?.name == state.value?.resolvedService?.name + ? null + : state.value!.resolvedService, ), ); break; diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index 65daaf553..6360c750b 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -4,6 +4,7 @@ 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/models/logger.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'; @@ -38,19 +39,21 @@ final volumeProvider = StateProvider( (ref) => 1.0, ); +final logger = getLogger('ConnectNotifier'); + 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'); + logger.t( + '♾️ Connecting to ${service.name}: ws://${service.host}:${service.port}/ws', + ); final channel = WebSocketChannel.connect( Uri.parse('ws://${service.host}:${service.port}/ws'), @@ -58,8 +61,9 @@ class ConnectNotifier extends AsyncNotifier { await channel.ready; - print( - 'Connected to ${service.name}: ws://${service.host}:${service.port}/ws'); + logger.t( + '✅ Connected to ${service.name}: ws://${service.host}:${service.port}/ws', + ); final subscription = channel.stream.listen( (message) { diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart deleted file mode 100644 index 1d2cfde8c..000000000 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -final logger = getLogger("NextFetcherMixin"); - -mixin NextFetcher on StateNotifier { - Future> fetchTracks( - Ref ref, { - int count = 3, - int offset = 0, - }) async { - /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack] - - final bareTracks = state.tracks - .skip(offset) - .where((element) => element is! SourcedTrack && element is! LocalTrack) - .take(count); - - /// fetch [bareTracks] one by one with 100ms delay - final fetchedTracks = await Future.wait( - bareTracks.mapIndexed((i, track) async { - final future = SourcedTrack.fetchFromTrack( - ref: ref, - track: track, - ); - if (i == 0) { - return await future; - } - return await Future.delayed( - const Duration(milliseconds: 100), - () => future, - ); - }), - ); - - return fetchedTracks; - } - - /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List - Set mergeTracks( - Iterable fetchTracks, - Iterable tracks, - ) { - return tracks.map((track) { - final fetchedTrack = fetchTracks.firstWhereOrNull( - (fetchTrack) => fetchTrack.id == track.id, - ); - if (fetchedTrack != null) { - return fetchedTrack; - } - return track; - }).toSet(); - } - - /// Checks if [Track] is playable - bool isUnPlayable(String source) { - return source.startsWith('https://youtube.com/unplayable.m4a?id='); - } - - bool isPlayable(String source) => !isUnPlayable(source); - - /// Returns [Track.id] from [isUnPlayable] source that is not playable - String getIdFromUnPlayable(String source) { - return source - .split('&') - .first - .replaceFirst('https://youtube.com/unplayable.m4a?id=', ''); - } - - /// Returns appropriate Media source for [Track] - /// - /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri] - /// * If [Track] is [LocalTrack] then return [LocalTrack.path] - /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source - String makeAppropriateSource(Track track) { - if (track is SourcedTrack) { - return track.url; - } else if (track is LocalTrack) { - return track.path; - } else { - return trackToUnplayableSource(track); - } - } - - String trackToUnplayableSource(Track track) { - return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${Uri.encodeComponent(track.name!)}"; - } - - List mapSourcesToTracks(List sources) { - return sources - .map((source) { - final track = state.tracks.firstWhereOrNull( - (track) => - trackToUnplayableSource(track) == source || - (track is SourcedTrack && track.url == source) || - (track is LocalTrack && track.path == source), - ); - return track; - }) - .whereNotNull() - .toList(); - } -} diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index 9069f3e16..f86ad3d47 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -3,87 +3,25 @@ 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/provider/server/sourced_track.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 subscribeToPlaylist() { + return audioPlayer.playlistStream.listen((playlist) { + state = state.copyWith( + tracks: playlist.medias + .map((media) => SpotubeMedia.fromMedia(media).track) + .toSet(), + active: playlist.index, + ); + + notificationService.addTrack(state.activeTrack!); + discord.updatePresence(state.activeTrack!); + updatePalette(); }); } @@ -126,6 +64,24 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { }); } + StreamSubscription subscribeToPosition() { + String lastTrack = ""; // used to prevent multiple calls to the same track + return audioPlayer.positionStream.listen((event) async { + if (event < const Duration(seconds: 3) || + state.active == null || + state.active == state.tracks.length - 1) return; + final nextTrack = state.tracks.elementAt(state.active! + 1); + + if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; + + try { + await ref.read(sourcedTrackProvider(nextTrack).future); + } finally { + lastTrack = nextTrack.id!; + } + }); + } + StreamSubscription subscribeToPlayerError() { return audioPlayer.errorStream.listen((event) {}); } diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index efc818ed2..f70301ff4 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; @@ -14,12 +13,11 @@ class ProxyPlaylist { factory ProxyPlaylist.fromJson( Map json, - Ref ref, ) { return ProxyPlaylist( List.castFrom>( json['tracks'] ?? >[], - ).map((t) => _makeAppropriateTrack(t, ref)).toSet(), + ).map((t) => _makeAppropriateTrack(t)).toSet(), json['active'] as int?, json['collections'] == null ? {} @@ -40,10 +38,7 @@ class ProxyPlaylist { Track? get activeTrack => active == null || active == -1 ? null : tracks.elementAtOrNull(active!); - bool get isFetching => - activeTrack != null && - activeTrack is! SourcedTrack && - activeTrack is! LocalTrack; + bool get isFetching => activeTrack == null && tracks.isNotEmpty; bool containsCollection(String collection) { return collections.contains(collection); @@ -58,10 +53,8 @@ class ProxyPlaylist { return tracks.every(containsTrack); } - static Track _makeAppropriateTrack(Map track, Ref ref) { - if (track.containsKey("ytUri")) { - return SourcedTrack.fromJson(track, ref: ref); - } else if (track.containsKey("path")) { + static Track _makeAppropriateTrack(Map track) { + if (track.containsKey("path")) { return LocalTrack.fromJson(track); } else { return Track.fromJson(track); diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 438088de7..bf039395d 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/extensions/track.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'; @@ -20,13 +18,10 @@ 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/models/source_info.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -class ProxyPlaylistNotifier extends PersistedStateNotifier - with NextFetcher { +class ProxyPlaylistNotifier extends PersistedStateNotifier { final Ref ref; late final AudioServices notificationService; @@ -54,49 +49,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier _subscriptions = [ // These are subscription methods from player_listeners.dart - subscribeToSourceChanges(), - subscribeToPercentCompletion(), - subscribeToShuffleChanges(), + subscribeToPlaylist(), subscribeToSkipSponsor(), + subscribeToPosition(), subscribeToScrobbleChanged(), ]; } - - Future ensureSourcePlayable(String source) async { - if (isPlayable(source)) return null; - - final track = mapSourcesToTracks([source]).firstOrNull; - - if (track == null || track is LocalTrack) { - return null; - } - - final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack() => track as SourcedTrack, - _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), - }; - - await audioPlayer.replaceSource( - source, - nthFetchedTrack.url, - ); - - return nthFetchedTrack; - } - // Basic methods for adding or removing tracks to playlist Future addTrack(Track track) async { if (blacklist.contains(track)) return; - state = state.copyWith(tracks: {...state.tracks, track}); - await audioPlayer.addTrack(makeAppropriateSource(track)); + await audioPlayer.addTrack(SpotubeMedia(track)); } Future addTracks(Iterable tracks) async { tracks = blacklist.filter(tracks).toList() as List; - state = state.copyWith(tracks: {...state.tracks, ...tracks}); for (final track in tracks) { - await audioPlayer.addTrack(makeAppropriateSource(track)); + await audioPlayer.addTrack(SpotubeMedia(track)); } } @@ -114,25 +83,17 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } Future removeTrack(String trackId) async { - final track = - state.tracks.firstWhereOrNull((element) => element.id == trackId); - if (track == null) return; - state = state.copyWith(tracks: {...state.tracks..remove(track)}); - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); - if (index == -1) return; - await audioPlayer.removeTrack(index); + final trackIndex = + state.tracks.toList().indexWhere((element) => element.id == trackId); + if (trackIndex == -1) return; + await audioPlayer.removeTrack(trackIndex); } Future removeTracks(Iterable tracksIds) async { - final tracks = - state.tracks.where((element) => tracksIds.contains(element.id)); - - state = state.copyWith(tracks: { - ...state.tracks..removeWhere((element) => tracksIds.contains(element.id)) - }); + final tracks = state.tracks.map((t) => t.id!).toList(); for (final track in tracks) { - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); + final index = tracks.indexOf(track); if (index == -1) continue; await audioPlayer.removeTrack(index); } @@ -144,64 +105,18 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier bool autoPlay = false, }) async { tracks = blacklist.filter(tracks).toList() as List; - final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first; - if (indexTrack is LocalTrack) { - state = state.copyWith( - tracks: tracks.toSet(), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(indexTrack); - discord.updatePresence(indexTrack); - } else { - final addableTrack = await SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, - ).catchError((e, stackTrace) { - return SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, - ); - }); - - state = state.copyWith( - tracks: mergeTracks([addableTrack], tracks), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(addableTrack); - discord.updatePresence(addableTrack); - } + state = state.copyWith(collections: {}); await audioPlayer.openPlaylist( - state.tracks.map(makeAppropriateSource).toList(), + tracks.asMediaList(), initialIndex: initialIndex, autoPlay: autoPlay, ); } Future jumpTo(int index) async { - final oldTrack = - mapSourcesToTracks([audioPlayer.currentSource!]).firstOrNull; - - state = state.copyWith(active: index); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.sources[index]); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: index, - ); - } - await audioPlayer.jumpTo(index); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future jumpToTrack(Track track) async { @@ -211,7 +126,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier await jumpTo(index); } - // TODO: add safe guards for active/playing track that needs to be moved Future moveTrack(int oldIndex, int newIndex) async { if (oldIndex == newIndex || newIndex < 0 || @@ -219,11 +133,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier newIndex > state.tracks.length - 1 || oldIndex > state.tracks.length - 1) return; - final tracks = state.tracks.toList(); - final track = tracks.removeAt(oldIndex); - tracks.insert(newIndex, track); - state = state.copyWith(tracks: {...tracks}); - await audioPlayer.moveTrack(oldIndex, newIndex); } @@ -233,104 +142,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } tracks = blacklist.filter(tracks).toList() as List; - final destIndex = state.active != null ? state.active! + 1 : 0; - final newTracks = state.tracks.toList()..insertAll(destIndex, tracks); - state = state.copyWith(tracks: newTracks.toSet()); - - tracks.forEachIndexed((index, track) async { - audioPlayer.addTrackAt( - makeAppropriateSource(track), - destIndex + index, - ); - }); - } - Future populateSibling() async { - if (state.activeTrack is SourcedTrack) { - final activeTrackWithSiblingsForSure = - await (state.activeTrack as SourcedTrack).copyWithSibling(); + for (int i = 0; i < tracks.length; i++) { + final track = tracks.elementAt(i); - state = state.copyWith( - tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), - active: state.tracks.toList().indexWhere( - (element) => element.id == activeTrackWithSiblingsForSure.id), - ); - } - } - - Future swapSibling(SourceInfo sibling) async { - if (state.activeTrack is SourcedTrack) { - await populateSibling(); - final newTrack = - await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); - if (newTrack == null) return; - state = state.copyWith( - tracks: mergeTracks([newTrack], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == newTrack.id), - ); - await audioPlayer.pause(); - await audioPlayer.replaceSource( - audioPlayer.currentSource!, - makeAppropriateSource(newTrack), + await audioPlayer.addTrackAt( + SpotubeMedia(track), + (state.active ?? 0) + i + 1, ); } } Future next() async { - if (audioPlayer.nextSource == null) return; - final oldTrack = mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } await audioPlayer.skipToNext(); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future previous() async { - if (audioPlayer.previousSource == null) return; - final oldTrack = - mapSourcesToTracks([audioPlayer.previousSource!]).firstOrNull; - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.previousSource!); - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } await audioPlayer.skipToPrevious(); - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future stop() async { @@ -385,7 +213,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier @override FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json, ref); + return ProxyPlaylist.fromJson(json); } @override diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 94a633245..2d90eea63 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -3,12 +3,10 @@ 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/server/active_sourced_track.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; @@ -75,13 +73,9 @@ Future> getAndCacheSkipSegments(String id) async { final segmentProvider = FutureProvider( (ref) async { - final track = ref.watch( - ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), - ); + final track = ref.watch(activeSourcedTrackProvider); if (track == null) return null; - if (track is LocalTrack || track is! SourcedTrack) return null; - final skipNonMusic = ref.watch( userPreferencesProvider.select( (s) { diff --git a/lib/provider/server/active_sourced_track.dart b/lib/provider/server/active_sourced_track.dart new file mode 100644 index 000000000..6ecd67b47 --- /dev/null +++ b/lib/provider/server/active_sourced_track.dart @@ -0,0 +1,47 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class ActiveSourcedTrackNotifier extends Notifier { + @override + build() { + return null; + } + + void update(SourcedTrack? sourcedTrack) { + state = sourcedTrack; + } + + Future populateSibling() async { + if (state == null) return; + state = await state!.copyWithSibling(); + } + + Future swapSibling(SourceInfo sibling) async { + if (state == null) return; + await populateSibling(); + final newTrack = await state!.swapWithSibling(sibling); + if (newTrack == null) return; + + state = newTrack; + await audioPlayer.pause(); + + final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + final oldActiveIndex = audioPlayer.currentIndex; + + await playbackNotifier.addTracksAtFirst([newTrack]); + await Future.delayed(const Duration(milliseconds: 50)); + await playbackNotifier.jumpToTrack(newTrack); + + await audioPlayer.removeTrack(oldActiveIndex); + + await audioPlayer.resume(); + } +} + +final activeSourcedTrackProvider = + NotifierProvider( + () => ActiveSourcedTrackNotifier(), +); diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart new file mode 100644 index 000000000..48f32a3cf --- /dev/null +++ b/lib/provider/server/server.dart @@ -0,0 +1,119 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:dio/dio.dart' hide Response; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +class PlaybackServer { + final Ref ref; + UserPreferences get userPreferences => ref.read(userPreferencesProvider); + ProxyPlaylist get playlist => ref.read(ProxyPlaylistNotifier.provider); + final Logger logger; + final Dio dio; + + final Router router; + + static final port = Random().nextInt(17000) + 1500; + + PlaybackServer(this.ref) + : logger = getLogger('PlaybackServer'), + dio = Dio(), + router = Router() { + router.get('/stream/', getStreamTrackId); + + const pipeline = Pipeline(); + + if (kDebugMode) { + pipeline.addMiddleware(logRequests()); + } + + serve(pipeline.addHandler(router.call), InternetAddress.loopbackIPv4, port) + .then((server) { + logger + .t('Playback server at http://${server.address.host}:${server.port}'); + + ref.onDispose(() { + dio.close(force: true); + server.close(); + }); + }); + } + + /// @get('/stream/') + Future getStreamTrackId(Request request, String trackId) async { + try { + final track = + playlist.tracks.firstWhere((element) => element.id == trackId); + final activeSourcedTrack = ref.read(activeSourcedTrackProvider); + final sourcedTrack = activeSourcedTrack?.id == track.id + ? activeSourcedTrack + : await ref.read(sourcedTrackProvider(track).future); + + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + + final res = await dio.get( + sourcedTrack!.url, + options: Options( + headers: { + ...request.headers, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "host": Uri.parse(sourcedTrack.url).host, + "Cache-Control": "max-age=0", + "Connection": "keep-alive", + }, + responseType: ResponseType.stream, + validateStatus: (status) => status! < 500, + ), + ); + + final audioStream = + (res.data?.stream as Stream?)?.asBroadcastStream(); + + // if (res.statusCode! > 300) { + // debugPrint( + // "[[Request]]\n" + // "URI: ${res.requestOptions.uri}\n" + // "Status: ${res.statusCode}\n" + // "Request Headers: ${res.requestOptions.headers}\n" + // "Response Body: ${res.data}\n" + // "Response Headers: ${res.headers.map}", + // ); + // } + + audioStream!.listen( + (event) {}, + cancelOnError: true, + ); + + return Response( + res.statusCode!, + body: audioStream, + context: { + "shelf.io.buffer_output": false, + }, + headers: res.headers.map, + ); + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return Response.internalServerError(); + } + } +} + +final playbackServerProvider = Provider((ref) { + return PlaybackServer(ref); +}); diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart new file mode 100644 index 000000000..ffa622137 --- /dev/null +++ b/lib/provider/server/sourced_track.dart @@ -0,0 +1,28 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +final sourcedTrackProvider = + FutureProvider.family((ref, track) async { + if (track == null || track is LocalTrack) { + return null; + } + + ref.listen( + ProxyPlaylistNotifier.provider, + (old, next) { + if (next.tracks.isEmpty || + next.tracks.none((element) => element.id == track.id)) { + ref.invalidateSelf(); + } + }, + ); + + final sourcedTrack = + await SourcedTrack.fetchFromTrack(track: track, ref: ref); + + return sourcedTrack; +}); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 0a22bec1a..d5ebddb48 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,6 +1,12 @@ +import 'dart:io'; + 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:flutter/foundation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/server/server.dart'; +import 'package:spotube/services/audio_player/custom_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -8,19 +14,42 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; +class SpotubeMedia extends mk.Media { + final Track track; + + SpotubeMedia( + this.track, { + Map? extras, + super.httpHeaders, + }) : super( + track is LocalTrack + ? track.path + : "http://${InternetAddress.loopbackIPv4.address}:${PlaybackServer.port}/stream/${track.id}", + extras: { + ...?extras, + "track": track.toJson(), + }, + ); + + factory SpotubeMedia.fromMedia(mk.Media media) { + final track = Track.fromJson(media.extras?["track"]); + return SpotubeMedia(track); + } +} + abstract class AudioPlayerInterface { - final MkPlayerWithState _mkPlayer; + final CustomPlayer _mkPlayer; // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() - : _mkPlayer = MkPlayerWithState( + : _mkPlayer = CustomPlayer( configuration: const mk.PlayerConfiguration( title: "Spotube", + logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, ), ) // _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null @@ -61,18 +90,18 @@ abstract class AudioPlayerInterface { } } - Future get selectedDevice async { + Future get selectedDevice async { return _mkPlayer.state.audioDevice; } - Future> get devices async { + Future> get devices async { return _mkPlayer.state.audioDevices; } bool get hasSource { - return _mkPlayer.playlist.medias.isNotEmpty; + return _mkPlayer.state.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { - // return _mkPlayer.playlist.medias.isNotEmpty; + // return _mkPlayer.state.playlist.medias.isNotEmpty; // } else { // return _justAudio!.audioSource != null; // } @@ -125,7 +154,7 @@ abstract class AudioPlayerInterface { } PlaybackLoopMode get loopMode { - return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); + return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.state.playlistMode); // if (mkSupportedPlatform) { // return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); // } else { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index bfa132207..58868aed7 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -4,320 +4,129 @@ final audioPlayer = SpotubeAudioPlayer(); class SpotubeAudioPlayer extends AudioPlayerInterface with SpotubeAudioPlayersStreams { - Object _resolveUrlType(String url) { - // if (mkSupportedPlatform) { - return mk.Media(url); - // } else { - // if (url.startsWith("https")) { - // return ja.AudioSource.uri(Uri.parse(url)); - // } else { - // return ja.AudioSource.file(url); - // } - // } - } - - Future preload(String url) async { - throw UnimplementedError(); - // final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is ap.Source) { - // // audioplayers doesn't have the capability to preload - // return; - // } else { - // return; - // } - } - - Future play(String url) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.open(urlType as mk.Media, play: true); - // } else { - // if (_justAudio?.audioSource is ja.ProgressiveAudioSource && - // (_justAudio?.audioSource as ja.ProgressiveAudioSource) - // .uri - // .toString() == - // url) { - // await _justAudio?.play(); - // } else { - // await _justAudio?.stop(); - // await _justAudio?.setAudioSource( - // urlType as ja.AudioSource, - // preload: true, - // ); - // await _justAudio?.play(); - // } - // } - } - Future pause() async { await _mkPlayer.pause(); - // await _justAudio?.pause(); } Future resume() async { await _mkPlayer.play(); - // await _justAudio?.play(); } Future stop() async { await _mkPlayer.stop(); - // await _justAudio?.stop(); - // await _justAudio?.setShuffleModeEnabled(false); - // await _justAudio?.setLoopMode(ja.LoopMode.off); } Future seek(Duration position) async { await _mkPlayer.seek(position); - // await _justAudio?.seek(position); } /// Volume is between 0 and 1 Future setVolume(double volume) async { assert(volume >= 0 && volume <= 1); await _mkPlayer.setVolume(volume * 100); - // await _justAudio?.setVolume(volume); } Future setSpeed(double speed) async { await _mkPlayer.setRate(speed); - // await _justAudio?.setSpeed(speed); } - Future setAudioDevice(AudioDevice device) async { + Future setAudioDevice(mk.AudioDevice device) async { await _mkPlayer.setAudioDevice(device); } Future dispose() async { await _mkPlayer.dispose(); - // await _justAudio?.dispose(); } // Playlist related Future openPlaylist( - List tracks, { + List tracks, { bool autoPlay = true, int initialIndex = 0, }) async { assert(tracks.isNotEmpty); assert(initialIndex <= tracks.length - 1); - // if (mkSupportedPlatform) { await _mkPlayer.open( - mk.Playlist( - tracks.map(mk.Media.new).toList(), - index: initialIndex, - ), + mk.Playlist(tracks, index: initialIndex), play: autoPlay, ); - // } else { - // await _justAudio!.setAudioSource( - // ja.ConcatenatingAudioSource( - // useLazyPreparation: true, - // children: - // tracks.map((e) => ja.AudioSource.uri(Uri.parse(e))).toList(), - // ), - // preload: true, - // initialIndex: initialIndex, - // ); - // if (autoPlay) { - // await _justAudio!.play(); - // } - // } - } - - // TODO: Make sure audio player soruces are also - // TODO: changed when preferences sources are changed - List resolveTracksForSource(List tracks) { - return tracks.where((e) => sources.contains(e.url)).toList(); - } - - bool tracksExistsInPlaylist(List tracks) { - return resolveTracksForSource(tracks).length == tracks.length; } List get sources { - // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias.map((e) => e.uri).toList(); - // } else { - // return _justAudio!.sequenceState?.effectiveSequence - // .map((e) => (e as ja.UriAudioSource).uri.toString()) - // .toList() ?? - // []; - // } + return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList(); } String? get currentSource { - // if (mkSupportedPlatform) { - if (_mkPlayer.playlist.index == -1) return null; - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index) + if (_mkPlayer.state.playlist.index == -1) return null; + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } String? get nextSource { - // if (mkSupportedPlatform) { - if (loopMode == PlaybackLoopMode.all && - _mkPlayer.playlist.index == _mkPlayer.playlist.medias.length - 1) { + _mkPlayer.state.playlist.index == + _mkPlayer.state.playlist.medias.length - 1) { return sources.first; } - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index + 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index + 1) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex + 1) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } String? get previousSource { - if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) { + if (loopMode == PlaybackLoopMode.all && + _mkPlayer.state.playlist.index == 0) { return sources.last; } - // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index - 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index - 1) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex - 1) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } + int get currentIndex => _mkPlayer.state.playlist.index; + Future skipToNext() async { - // if (mkSupportedPlatform) { await _mkPlayer.next(); - // } else { - // await _justAudio!.seekToNext(); - // } } Future skipToPrevious() async { - // if (mkSupportedPlatform) { await _mkPlayer.previous(); - // } else { - // await _justAudio!.seekToPrevious(); - // } } Future jumpTo(int index) async { - // if (mkSupportedPlatform) { await _mkPlayer.jump(index); - // } else { - // await _justAudio!.seek(Duration.zero, index: index); - // } } - Future addTrack(String url) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.add(urlType as mk.Media); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .add(urlType as ja.AudioSource); - // } + Future addTrack(mk.Media media) async { + await _mkPlayer.add(media); } - Future addTrackAt(String url, int index) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.insert(index, urlType as mk.Media); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .insert(index, urlType as ja.AudioSource); - // } + Future addTrackAt(mk.Media media, int index) async { + await _mkPlayer.insert(index, media); } Future removeTrack(int index) async { - // if (mkSupportedPlatform) { await _mkPlayer.remove(index); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .removeAt(index); - // } } Future moveTrack(int from, int to) async { - // if (mkSupportedPlatform) { await _mkPlayer.move(from, to); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .move(from, to); - // } - } - - Future replaceSource( - String oldSource, - String newSource, { - bool exclusive = false, - }) async { - final oldSourceIndex = sources.indexOf(oldSource); - if (oldSourceIndex == -1) return; - - // if (mkSupportedPlatform) { - _mkPlayer.replace(oldSource, newSource); - // } else { - // final playlist = _justAudio!.audioSource as ja.ConcatenatingAudioSource; - - // print('oldSource: $oldSource'); - // print('newSource: $newSource'); - // final oldSourceIndexInPlaylist = - // _justAudio?.sequenceState?.effectiveSequence.indexWhere( - // (e) => (e as ja.UriAudioSource).uri.toString() == oldSource, - // ); - - // print('oldSourceIndexInPlaylist: $oldSourceIndexInPlaylist'); - - // // ignores non existing source - // if (oldSourceIndexInPlaylist == null || oldSourceIndexInPlaylist == -1) { - // return; - // } - - // await playlist.removeAt(oldSourceIndexInPlaylist); - // await playlist.insert( - // oldSourceIndexInPlaylist, - // ja.AudioSource.uri(Uri.parse(newSource)), - // ); - // } } Future clearPlaylist() async { - // if (mkSupportedPlatform) { _mkPlayer.stop(); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource).clear(); - // } } Future setShuffle(bool shuffle) async { - // if (mkSupportedPlatform) { await _mkPlayer.setShuffle(shuffle); - // } else { - // await _justAudio!.setShuffleModeEnabled(shuffle); - // } } Future setLoopMode(PlaybackLoopMode loop) async { - // if (mkSupportedPlatform) { await _mkPlayer.setPlaylistMode(loop.toPlaylistMode()); - // } else { - // await _justAudio!.setLoopMode(loop.toLoopMode()); - // } } Future setAudioNormalization(bool normalize) async { diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index 54e36c6b4..f6fe06302 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -73,7 +73,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get loopModeStream { // if (mkSupportedPlatform) { - return _mkPlayer.loopModeStream.map(PlaybackLoopMode.fromPlaylistMode); + return _mkPlayer.stream.playlistMode.map(PlaybackLoopMode.fromPlaylistMode); // } else { // return _justAudio!.loopModeStream // .map(PlaybackLoopMode.fromLoopMode) @@ -127,7 +127,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // if (mkSupportedPlatform) { return _mkPlayer.indexChangeStream .map((event) { - return _mkPlayer.playlist.medias.elementAtOrNull(event)?.uri; + return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri; }) .where((event) => event != null) .cast(); @@ -141,11 +141,13 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // } } - Stream> get devicesStream => + Stream> get devicesStream => _mkPlayer.stream.audioDevices.asBroadcastStream(); - Stream get selectedDeviceStream => + Stream get selectedDeviceStream => _mkPlayer.stream.audioDevice.asBroadcastStream(); Stream get errorStream => _mkPlayer.stream.error; + + Stream get playlistStream => _mkPlayer.stream.playlist; } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart new file mode 100644 index 000000000..d273519ea --- /dev/null +++ b/lib/services/audio_player/custom_player.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:catcher_2/catcher_2.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:flutter_broadcasts/flutter_broadcasts.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:audio_session/audio_session.dart'; +// ignore: implementation_imports +import 'package:spotube/services/audio_player/playback_state.dart'; + +/// MediaKit [Player] by default doesn't have a state stream. +/// This class adds a state stream to the [Player] class. +class CustomPlayer extends Player { + final StreamController _playerStateStream; + final StreamController _shuffleStream; + + late final List _subscriptions; + + bool _shuffled; + int _androidAudioSessionId = 0; + String _packageName = ""; + AndroidAudioManager? _androidAudioManager; + + CustomPlayer({super.configuration}) + : _playerStateStream = StreamController.broadcast(), + _shuffleStream = StreamController.broadcast(), + _shuffled = false { + nativePlayer.setProperty("network-timeout", "120"); + + _subscriptions = [ + stream.buffering.listen((event) { + _playerStateStream.add(AudioPlaybackState.buffering); + }), + stream.playing.listen((playing) { + if (playing) { + _playerStateStream.add(AudioPlaybackState.playing); + } else { + _playerStateStream.add(AudioPlaybackState.paused); + } + }), + stream.completed.listen((isCompleted) async { + if (!isCompleted) return; + _playerStateStream.add(AudioPlaybackState.completed); + }), + stream.playlist.listen((event) { + if (event.medias.isEmpty) { + _playerStateStream.add(AudioPlaybackState.stopped); + } + }), + stream.error.listen((event) { + Catcher2.reportCheckedError('[MediaKitError] \n$event', null); + }), + ]; + PackageInfo.fromPlatform().then((packageInfo) { + _packageName = packageInfo.packageName; + }); + if (DesktopTools.platform.isAndroid) { + _androidAudioManager = AndroidAudioManager(); + AudioSession.instance.then((s) async { + _androidAudioSessionId = + await _androidAudioManager!.generateAudioSessionId(); + notifyAudioSessionUpdate(true); + + await nativePlayer.setProperty( + "audiotrack-session-id", + _androidAudioSessionId.toString(), + ); + await nativePlayer.setProperty("ao", "audiotrack,opensles,"); + }); + } + } + + Future notifyAudioSessionUpdate(bool active) async { + if (DesktopTools.platform.isAndroid) { + sendBroadcast( + BroadcastMessage( + name: active + ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" + : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", + data: { + "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, + "android.media.extra.PACKAGE_NAME": _packageName + }, + ), + ); + } + } + + bool get shuffled => _shuffled; + + Stream get playerStateStream => _playerStateStream.stream; + Stream get shuffleStream => _shuffleStream.stream; + Stream get indexChangeStream { + int oldIndex = state.playlist.index; + return stream.playlist.map((event) => event.index).where((newIndex) { + if (newIndex != oldIndex) { + oldIndex = newIndex; + return true; + } + return false; + }); + } + + @override + Future setShuffle(bool shuffle) async { + _shuffled = shuffle; + await super.setShuffle(shuffle); + _shuffleStream.add(shuffle); + } + + @override + Future stop() async { + await super.stop(); + + _shuffled = false; + _playerStateStream.add(AudioPlaybackState.stopped); + _shuffleStream.add(false); + } + + @override + Future dispose() async { + for (var element in _subscriptions) { + element.cancel(); + } + await notifyAudioSessionUpdate(false); + return super.dispose(); + } + + NativePlayer get nativePlayer => platform as NativePlayer; + + Future insert(int index, Media media) async { + await add(media); + await move(state.playlist.medias.length, index); + } + + Future setAudioNormalization(bool normalize) async { + if (normalize) { + await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); + } else { + await nativePlayer.setProperty('af', ''); + } + } +} diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart deleted file mode 100644 index 8b796d669..000000000 --- a/lib/services/audio_player/mk_state_player.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:async'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:catcher_2/catcher_2.dart'; -import 'package:collection/collection.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:flutter_broadcasts/flutter_broadcasts.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:audio_session/audio_session.dart'; -// ignore: implementation_imports -import 'package:spotube/services/audio_player/playback_state.dart'; - -/// MediaKit [Player] by default doesn't have a state stream. -/// This class adds a state stream to the [Player] class. -class MkPlayerWithState extends Player { - final StreamController _playerStateStream; - final StreamController _playlistStream; - final StreamController _shuffleStream; - final StreamController _loopModeStream; - - late final List _subscriptions; - - bool _shuffled; - PlaylistMode _loopMode; - - Playlist? _playlist; - List? _tempMedias; - int _androidAudioSessionId = 0; - String _packageName = ""; - AndroidAudioManager? _androidAudioManager; - - MkPlayerWithState({super.configuration}) - : _playerStateStream = StreamController.broadcast(), - _shuffleStream = StreamController.broadcast(), - _loopModeStream = StreamController.broadcast(), - _playlistStream = StreamController.broadcast(), - _shuffled = false, - _loopMode = PlaylistMode.none { - _subscriptions = [ - stream.buffering.listen((event) { - _playerStateStream.add(AudioPlaybackState.buffering); - }), - stream.playing.listen((playing) { - if (playing) { - _playerStateStream.add(AudioPlaybackState.playing); - } else { - _playerStateStream.add(AudioPlaybackState.paused); - } - }), - stream.completed.listen((isCompleted) async { - try { - if (!isCompleted) return; - - _playerStateStream.add(AudioPlaybackState.completed); - if (loopMode == PlaylistMode.single) { - await super.open(_playlist!.medias[_playlist!.index], play: true); - } else { - await next(); - await Future.delayed(const Duration(milliseconds: 250), play); - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }), - stream.playlist.listen((event) { - if (event.medias.isEmpty) { - _playerStateStream.add(AudioPlaybackState.stopped); - } - }), - stream.error.listen((event) { - Catcher2.reportCheckedError('[MediaKitError] \n$event', null); - }), - ]; - PackageInfo.fromPlatform().then((packageInfo) { - _packageName = packageInfo.packageName; - }); - if (DesktopTools.platform.isAndroid) { - _androidAudioManager = AndroidAudioManager(); - AudioSession.instance.then((s) async { - _androidAudioSessionId = - await _androidAudioManager!.generateAudioSessionId(); - notifyAudioSessionUpdate(true); - - await nativePlayer.setProperty( - "audiotrack-session-id", - _androidAudioSessionId.toString(), - ); - await nativePlayer.setProperty("ao", "audiotrack,opensles,"); - }); - } - } - - Future notifyAudioSessionUpdate(bool active) async { - if (DesktopTools.platform.isAndroid) { - sendBroadcast( - BroadcastMessage( - name: active - ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" - : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", - data: { - "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, - "android.media.extra.PACKAGE_NAME": _packageName - }, - ), - ); - } - } - - bool get shuffled => _shuffled; - PlaylistMode get loopMode => _loopMode; - Playlist get playlist => _playlist ?? const Playlist([], index: -1); - - Stream get playerStateStream => _playerStateStream.stream; - Stream get shuffleStream => _shuffleStream.stream; - Stream get loopModeStream => _loopModeStream.stream; - Stream get playlistStream => _playlistStream.stream; - Stream get indexChangeStream { - int oldIndex = playlist.index; - return playlistStream.map((event) => event.index).where((newIndex) { - if (newIndex != oldIndex) { - oldIndex = newIndex; - return true; - } - return false; - }); - } - - set playlist(Playlist playlist) { - _playlist = playlist; - _playlistStream.add(playlist); - } - - @override - Future setShuffle(bool shuffle) async { - _shuffled = shuffle; - if (shuffle) { - _tempMedias = _playlist!.medias; - final active = _playlist!.medias[_playlist!.index]; - final newMedias = _playlist!.medias.toList() - ..shuffle() - ..remove(active) - ..insert(0, active); - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(active), - ); - } else { - if (_tempMedias == null) return; - playlist = _playlist!.copyWith( - medias: _tempMedias!, - index: _tempMedias?.indexOf( - _playlist!.medias[_playlist!.index], - ), - ); - _tempMedias = null; - } - await super.setShuffle(shuffle); - _shuffleStream.add(shuffle); - } - - @override - Future setPlaylistMode(PlaylistMode playlistMode) async { - _loopMode = playlistMode; - await super.setPlaylistMode(playlistMode); - _loopModeStream.add(playlistMode); - } - - @override - Future stop() async { - await super.stop(); - await pause(); - await seek(Duration.zero); - - _loopMode = PlaylistMode.none; - _shuffled = false; - _playlist = null; - _tempMedias = null; - _playerStateStream.add(AudioPlaybackState.stopped); - _shuffleStream.add(false); - } - - @override - Future dispose() async { - for (var element in _subscriptions) { - element.cancel(); - } - await notifyAudioSessionUpdate(false); - return super.dispose(); - } - - @override - Future open( - Playable playable, { - bool play = true, - }) async { - await stop(); - if (playable is Playlist) { - playlist = playable; - super.open(playable.medias[playable.index], play: play); - } - await super.open(playable, play: play); - } - - @override - Future next() async { - if (_playlist == null) { - return; - } - - final isLast = _playlist!.index == _playlist!.medias.length - 1; - - if (isLast) { - switch (loopMode) { - case PlaylistMode.loop: - playlist = _playlist!.copyWith(index: 0); - super.open(_playlist!.medias[_playlist!.index], play: true); - break; - case PlaylistMode.none: - // Fixes auto-repeating the last track - await super.stop(); - break; - default: - } - } else { - playlist = _playlist!.copyWith(index: _playlist!.index + 1); - - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future previous() async { - if (_playlist == null || _playlist!.index - 1 < 0) return; - - if (loopMode == PlaylistMode.loop && _playlist!.index == 0) { - playlist = _playlist!.copyWith(index: _playlist!.medias.length - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } else if (_playlist!.index != 0) { - playlist = _playlist!.copyWith(index: _playlist!.index - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future jump(int index) async { - if (_playlist == null || index < 0 || index >= _playlist!.medias.length) { - return; - } - - playlist = _playlist!.copyWith(index: index); - return super.open(_playlist!.medias[index], play: true); - } - - @override - Future move(int from, int to) async { - if (_playlist == null || - from >= _playlist!.medias.length || - to >= _playlist!.medias.length) return; - - final active = _playlist!.medias[_playlist!.index]; - final newPlaylist = _playlist!.copyWith( - medias: _playlist!.medias.mapIndexed((index, element) { - if (index == from) { - return _playlist!.medias[to]; - } else if (index == to) { - return _playlist!.medias[from]; - } - return element; - }).toList(), - ); - playlist = _playlist!.copyWith( - index: newPlaylist.medias.indexOf(active), - medias: newPlaylist.medias, - ); - } - - /// This replaces the old source with a new one - /// - /// If the old source is playing, the new one will play - /// from the beginning - /// - /// This doesn't work when [playlist] is null - void replace(String oldUrl, String newUrl) { - if (_playlist == null) { - return; - } - - final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl; - - // ends the loop where match is found - // tends to be a bit more efficient than forEach - _playlist!.medias.firstWhereIndexedOrNull((i, media) { - if (media.uri != oldUrl) return false; - if (isOldUrlPlaying) { - pause(); - } - final copyMedias = [..._playlist!.medias]; - copyMedias[i] = Media(newUrl, extras: media.extras); - playlist = _playlist!.copyWith(medias: copyMedias); - if (isOldUrlPlaying) { - super.open( - copyMedias[i], - play: true, - ); - } - - // replace in the _tempMedias if it's not null - if (shuffled && _tempMedias != null) { - final tempIndex = _tempMedias!.indexOf(media); - _tempMedias![tempIndex] = Media(newUrl, extras: media.extras); - } - return true; - }); - } - - @override - Future add(Media media) async { - if (_playlist == null) return; - - playlist = _playlist!.copyWith( - medias: [..._playlist!.medias, media], - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.add(media); - } - } - - FutureOr insert(int index, Media media) { - if (_playlist == null || - index < 0 || - (_playlist!.medias.length > 1 && - index > _playlist!.medias.length - 1)) { - return null; - } - - final newMedias = _playlist!.medias.toList()..insert(index, media); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.insert(index, media); - } - } - - /// Doesn't work when active media is the one to be removed - @override - Future remove(int index) async { - if (_playlist == null || - index < 0 || - index > _playlist!.medias.length - 1 || - _playlist!.index == index) { - return; - } - - final targetItem = _playlist!.medias.elementAtOrNull(index); - if (targetItem == null) return; - - if (shuffled && _tempMedias != null) { - _tempMedias!.remove(targetItem); - } - - final newMedias = _playlist!.medias.toList()..removeAt(index); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - } - - NativePlayer get nativePlayer => platform as NativePlayer; - - Future setAudioNormalization(bool normalize) async { - if (normalize) { - await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); - } else { - await nativePlayer.setProperty('af', ''); - } - } -} diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart deleted file mode 100644 index 84a6f7b88..000000000 --- a/lib/services/audio_services/linux_audio_service.dart +++ /dev/null @@ -1,736 +0,0 @@ -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'; -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'; - -final dbus = DBusClient.session(); - -class _MprisMediaPlayer2 extends DBusObject { - /// Creates a new object to expose on [path]. - _MprisMediaPlayer2() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { - dbus.registerObject(this); - } - - void dispose() { - dbus.unregisterObject(this); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanQuit - Future getCanQuit() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Fullscreen - Future getFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Sets property org.mpris.MediaPlayer2.Fullscreen - Future setFullscreen(bool value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen - Future getCanSetFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanRaise - Future getCanRaise() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.HasTrackList - Future getHasTrackList() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Identity - Future getIdentity() async { - return DBusMethodSuccessResponse([const DBusString("Spotube")]); - } - - /// Gets value of property org.mpris.MediaPlayer2.DesktopEntry - Future getDesktopEntry() async { - return DBusMethodSuccessResponse( - [const DBusString("/usr/share/application/spotube")], - ); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes - Future getSupportedUriSchemes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["http"]) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes - Future getSupportedMimeTypes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["audio/mpeg"]) - ]); - } - - /// Implementation of org.mpris.MediaPlayer2.Raise() - Future doRaise() async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Quit() - Future doQuit() async { - exit(0); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2', methods: [ - DBusIntrospectMethod('Raise'), - DBusIntrospectMethod('Quit') - ], properties: [ - DBusIntrospectProperty('CanQuit', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Fullscreen', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('CanSetFullscreen', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanRaise', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('HasTrackList', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Identity', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('DesktopEntry', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedUriSchemes', DBusSignature('as'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedMimeTypes', DBusSignature('as'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2') { - if (methodCall.name == 'Raise') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doRaise(); - } else if (methodCall.name == 'Quit') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doQuit(); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return getCanQuit(); - } else if (name == 'Fullscreen') { - return getFullscreen(); - } else if (name == 'CanSetFullscreen') { - return getCanSetFullscreen(); - } else if (name == 'CanRaise') { - return getCanRaise(); - } else if (name == 'HasTrackList') { - return getHasTrackList(); - } else if (name == 'Identity') { - return getIdentity(); - } else if (name == 'DesktopEntry') { - return getDesktopEntry(); - } else if (name == 'SupportedUriSchemes') { - return getSupportedUriSchemes(); - } else if (name == 'SupportedMimeTypes') { - return getSupportedMimeTypes(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Fullscreen') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setFullscreen((value as DBusBoolean).value); - } else if (name == 'CanSetFullscreen') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanRaise') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'HasTrackList') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Identity') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'DesktopEntry') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedUriSchemes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedMimeTypes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2') { - properties['CanQuit'] = (await getCanQuit()).returnValues[0]; - properties['Fullscreen'] = (await getFullscreen()).returnValues[0]; - properties['CanSetFullscreen'] = - (await getCanSetFullscreen()).returnValues[0]; - properties['CanRaise'] = (await getCanRaise()).returnValues[0]; - properties['HasTrackList'] = (await getHasTrackList()).returnValues[0]; - properties['Identity'] = (await getIdentity()).returnValues[0]; - properties['DesktopEntry'] = (await getDesktopEntry()).returnValues[0]; - properties['SupportedUriSchemes'] = - (await getSupportedUriSchemes()).returnValues[0]; - properties['SupportedMimeTypes'] = - (await getSupportedMimeTypes()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} - -class _MprisMediaPlayer2Player extends DBusObject { - final Ref ref; - final ProxyPlaylistNotifier playlistNotifier; - - /// Creates a new object to expose on [path]. - _MprisMediaPlayer2Player(this.ref, this.playlistNotifier) - : super(DBusObjectPath("/org/mpris/MediaPlayer2")) { - (() async { - final nameStatus = - await dbus.requestName("org.mpris.MediaPlayer2.spotube"); - if (nameStatus == DBusRequestNameReply.exists) { - await dbus.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); - } - await dbus.registerObject(this); - }()); - } - - ProxyPlaylist get playlist => playlistNotifier.playlist; - - void dispose() { - dbus.unregisterObject(this); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus - Future getPlaybackStatus() async { - final status = audioPlayer.isPlaying - ? "Playing" - : playlist.active == null - ? "Stopped" - : "Paused"; - return DBusMethodSuccessResponse([DBusString(status)]); - } - - // TODO: Implement Track Loop - - /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus - Future getLoopStatus() async { - final loopMode = switch (audioPlayer.loopMode) { - PlaybackLoopMode.all => "Playlist", - PlaybackLoopMode.one => "Track", - PlaybackLoopMode.none => "None", - }; - - return DBusMethodSuccessResponse([DBusString(loopMode)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.LoopStatus - Future setLoopStatus(String value) async { - // playlistNotifier.setIsLoop(value == "Track"); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Rate - Future getRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Rate - Future setRate(double value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle - Future getShuffle() async { - return DBusMethodSuccessResponse( - [DBusBoolean(await audioPlayer.isShuffled)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Shuffle - Future setShuffle(bool value) async { - audioPlayer.setShuffle(value); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata - Future getMetadata() async { - if (playlist.activeTrack == null || playlist.isFetching) { - return DBusMethodSuccessResponse([DBusDict.stringVariant({})]); - } - final id = playlist.activeTrack!.id; - - return DBusMethodSuccessResponse([ - DBusDict.stringVariant({ - "mpris:trackid": DBusString("${path.value}/Track/$id"), - "mpris:length": DBusInt32( - (await audioPlayer.duration)?.inMicroseconds ?? 0, - ), - "mpris:artUrl": DBusString( - (playlist.activeTrack?.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), - "xesam:album": DBusString(playlist.activeTrack!.album!.name!), - "xesam:artist": DBusArray.string( - playlist.activeTrack!.artists!.map((artist) => artist.name!), - ), - "xesam:title": DBusString(playlist.activeTrack!.name!), - "xesam:url": DBusString( - playlist.activeTrack is SourcedTrack - ? (playlist.activeTrack as SourcedTrack).url - : playlist.activeTrack!.previewUrl ?? "", - ), - "xesam:genre": const DBusString("Unknown"), - }), - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Volume - Future getVolume() async { - return DBusMethodSuccessResponse([DBusDouble(audioPlayer.volume)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Volume - Future setVolume(double value) async { - await audioPlayer.setVolume(value); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Position - Future getPosition() async { - return DBusMethodSuccessResponse([ - DBusInt64((await audioPlayer.position)?.inMicroseconds ?? 0), - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate - Future getMinimumRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate - Future getMaximumRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext - Future getCanGoNext() async { - return DBusMethodSuccessResponse([ - DBusBoolean( - (playlist.tracks.length) > 1, - ) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious - Future getCanGoPrevious() async { - return DBusMethodSuccessResponse([ - DBusBoolean( - (playlist.tracks.length) > 1, - ) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay - Future getCanPlay() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanPause - Future getCanPause() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek - Future getCanSeek() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanControl - Future getCanControl() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Next() - Future doNext() async { - await playlistNotifier.next(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Previous() - Future doPrevious() async { - await playlistNotifier.previous(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Pause() - Future doPause() async { - await audioPlayer.pause(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() - Future doPlayPause() async { - audioPlayer.isPlaying - ? await audioPlayer.pause() - : await audioPlayer.resume(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Stop() - Future doStop() async { - playlistNotifier.stop(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Play() - Future doPlay() async { - await audioPlayer.resume(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Seek() - Future doSeek(int offset) async { - await audioPlayer.seek(Duration(microseconds: offset)); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.SetPosition() - Future doSetPosition(String TrackId, int Position) async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.OpenUri() - Future doOpenUri(String Uri) async { - return DBusMethodSuccessResponse(); - } - - /// Emits signal org.mpris.MediaPlayer2.Player.Seeked - Future emitSeeked(int position) async { - await emitSignal( - 'org.mpris.MediaPlayer2.Player', - 'Seeked', - [DBusInt64(position)], - ); - } - - Future updateProperties() async { - return emitPropertiesChanged( - "org.mpris.MediaPlayer2.Player", - changedProperties: { - "PlaybackStatus": (await getPlaybackStatus()).returnValues.first, - "LoopStatus": (await getLoopStatus()).returnValues.first, - "Rate": (await getRate()).returnValues.first, - "Shuffle": (await getShuffle()).returnValues.first, - "Metadata": (await getMetadata()).returnValues.first, - "Volume": (await getVolume()).returnValues.first, - "Position": (await getPosition()).returnValues.first, - "MinimumRate": (await getMinimumRate()).returnValues.first, - "MaximumRate": (await getMaximumRate()).returnValues.first, - "CanGoNext": (await getCanGoNext()).returnValues.first, - "CanGoPrevious": (await getCanGoPrevious()).returnValues.first, - "CanPlay": (await getCanPlay()).returnValues.first, - "CanPause": (await getCanPause()).returnValues.first, - "CanSeek": (await getCanSeek()).returnValues.first, - "CanControl": (await getCanControl()).returnValues.first, - }, - ); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2.Player', methods: [ - DBusIntrospectMethod('Next'), - DBusIntrospectMethod('Previous'), - DBusIntrospectMethod('Pause'), - DBusIntrospectMethod('PlayPause'), - DBusIntrospectMethod('Stop'), - DBusIntrospectMethod('Play'), - DBusIntrospectMethod('Seek', args: [ - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, - name: 'Offset') - ]), - DBusIntrospectMethod('SetPosition', args: [ - DBusIntrospectArgument(DBusSignature('o'), DBusArgumentDirection.in_, - name: 'TrackId'), - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, - name: 'Position') - ]), - DBusIntrospectMethod('OpenUri', args: [ - DBusIntrospectArgument(DBusSignature('s'), DBusArgumentDirection.in_, - name: 'Uri') - ]) - ], signals: [ - DBusIntrospectSignal('Seeked', args: [ - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.out, - name: 'Position') - ]) - ], properties: [ - DBusIntrospectProperty('PlaybackStatus', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('LoopStatus', DBusSignature('s'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Rate', DBusSignature('d'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Shuffle', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Metadata', DBusSignature('a{sv}'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Volume', DBusSignature('d'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Position', DBusSignature('x'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('MinimumRate', DBusSignature('d'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('MaximumRate', DBusSignature('d'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanGoNext', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanGoPrevious', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanPlay', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanPause', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanSeek', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanControl', DBusSignature('b'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2.Player') { - if (methodCall.name == 'Next') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doNext(); - } else if (methodCall.name == 'Previous') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPrevious(); - } else if (methodCall.name == 'Pause') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPause(); - } else if (methodCall.name == 'PlayPause') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPlayPause(); - } else if (methodCall.name == 'Stop') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doStop(); - } else if (methodCall.name == 'Play') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPlay(); - } else if (methodCall.name == 'Seek') { - if (methodCall.signature != DBusSignature('x')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doSeek((methodCall.values[0] as DBusInt64).value); - } else if (methodCall.name == 'SetPosition') { - if (methodCall.signature != DBusSignature('ox')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doSetPosition((methodCall.values[0] as DBusObjectPath).value, - (methodCall.values[1] as DBusInt64).value); - } else if (methodCall.name == 'OpenUri') { - if (methodCall.signature != DBusSignature('s')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doOpenUri((methodCall.values[0] as DBusString).value); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2.Player') { - if (name == 'PlaybackStatus') { - return getPlaybackStatus(); - } else if (name == 'LoopStatus') { - return getLoopStatus(); - } else if (name == 'Rate') { - return getRate(); - } else if (name == 'Shuffle') { - return getShuffle(); - } else if (name == 'Metadata') { - return getMetadata(); - } else if (name == 'Volume') { - return getVolume(); - } else if (name == 'Position') { - return getPosition(); - } else if (name == 'MinimumRate') { - return getMinimumRate(); - } else if (name == 'MaximumRate') { - return getMaximumRate(); - } else if (name == 'CanGoNext') { - return getCanGoNext(); - } else if (name == 'CanGoPrevious') { - return getCanGoPrevious(); - } else if (name == 'CanPlay') { - return getCanPlay(); - } else if (name == 'CanPause') { - return getCanPause(); - } else if (name == 'CanSeek') { - return getCanSeek(); - } else if (name == 'CanControl') { - return getCanControl(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2.Player') { - if (name == 'PlaybackStatus') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'LoopStatus') { - if (value.signature != DBusSignature('s')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setLoopStatus((value as DBusString).value); - } else if (name == 'Rate') { - if (value.signature != DBusSignature('d')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setRate((value as DBusDouble).value); - } else if (name == 'Shuffle') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setShuffle((value as DBusBoolean).value); - } else if (name == 'Metadata') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Volume') { - if (value.signature != DBusSignature('d')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setVolume((value as DBusDouble).value); - } else if (name == 'Position') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'MinimumRate') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'MaximumRate') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanGoNext') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanGoPrevious') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanPlay') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanPause') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanSeek') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanControl') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2.Player') { - properties['PlaybackStatus'] = - (await getPlaybackStatus()).returnValues[0]; - properties['LoopStatus'] = (await getLoopStatus()).returnValues[0]; - properties['Rate'] = (await getRate()).returnValues[0]; - properties['Shuffle'] = (await getShuffle()).returnValues[0]; - properties['Metadata'] = (await getMetadata()).returnValues[0]; - properties['Volume'] = (await getVolume()).returnValues[0]; - properties['Position'] = (await getPosition()).returnValues[0]; - properties['MinimumRate'] = (await getMinimumRate()).returnValues[0]; - properties['MaximumRate'] = (await getMaximumRate()).returnValues[0]; - properties['CanGoNext'] = (await getCanGoNext()).returnValues[0]; - properties['CanGoPrevious'] = (await getCanGoPrevious()).returnValues[0]; - properties['CanPlay'] = (await getCanPlay()).returnValues[0]; - properties['CanPause'] = (await getCanPause()).returnValues[0]; - properties['CanSeek'] = (await getCanSeek()).returnValues[0]; - properties['CanControl'] = (await getCanControl()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} - -class LinuxAudioService { - _MprisMediaPlayer2 mp2; - _MprisMediaPlayer2Player player; - - LinuxAudioService(Ref ref, ProxyPlaylistNotifier playlistNotifier) - : mp2 = _MprisMediaPlayer2(), - player = _MprisMediaPlayer2Player(ref, playlistNotifier); - - void dispose() { - mp2.dispose(); - player.dispose(); - } -} diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index d259317ec..3bb884475 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -11,6 +11,7 @@ class MobileAudioService extends BaseAudioHandler { AudioSession? session; final ProxyPlaylistNotifier playlistNotifier; + // ignore: invalid_use_of_protected_member ProxyPlaylist get playlist => playlistNotifier.state; MobileAudioService(this.playlistNotifier) { diff --git a/lib/services/audio_services/smtc_windows_web.dart b/lib/services/audio_services/smtc_windows_web.dart index 177f3ac5b..055d43be1 100644 --- a/lib/services/audio_services/smtc_windows_web.dart +++ b/lib/services/audio_services/smtc_windows_web.dart @@ -1,3 +1,5 @@ +// ignore_for_file: constant_identifier_names + class MusicMetadata { final String? title; final String? artist; diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart index 61af710ed..720216c70 100644 --- a/lib/services/cli/cli.dart +++ b/lib/services/cli/cli.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:io'; import 'package:args/args.dart'; diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart index d65f167e4..d79cf95bb 100644 --- a/lib/services/download_manager/download_task.dart +++ b/lib/services/download_manager/download_task.dart @@ -28,8 +28,6 @@ class DownloadTask { } } - ; - status.addListener(listener); return completer.future.timeout(timeout); diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart index 35678a96f..a2bb4d165 100644 --- a/lib/utils/duration.dart +++ b/lib/utils/duration.dart @@ -37,8 +37,6 @@ Duration parseDuration(String input) { days = p ~/ 24; } - // TODO verify that there are no negative parts - return Duration( days: days, hours: hours, diff --git a/pubspec.lock b/pubspec.lock index b4e38b7fe..411dc0563 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1817,7 +1817,7 @@ packages: source: hosted version: "6.0.5" pub_api_client: - dependency: "direct dev" + dependency: "direct main" description: name: pub_api_client sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 @@ -1841,7 +1841,7 @@ packages: source: hosted version: "2.3.0" pubspec_parse: - dependency: "direct dev" + dependency: "direct main" description: name: pubspec_parse sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 diff --git a/pubspec.yaml b/pubspec.yaml index a9b4ed62d..dfd77387d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -129,6 +129,8 @@ dependencies: shelf_web_socket: ^1.0.4 web_socket_channel: ^2.4.4 lrc: ^1.0.2 + pub_api_client: ^2.4.0 + pubspec_parse: ^1.2.2 dev_dependencies: build_runner: ^2.4.9 @@ -143,8 +145,6 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.6.2 - 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 diff --git a/untranslated_messages.json b/untranslated_messages.json index be7d38f18..3696d52e7 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -159,7 +159,8 @@ "remote" ], - "tr": [ + "th": [ + "choose_your_language", "enable_connect", "enable_connect_description", "devices",