mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
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
This commit is contained in:
parent
b948872258
commit
22a49e56a2
4
.github/workflows/pr-lint.yml
vendored
4
.github/workflows/pr-lint.yml
vendored
@ -4,13 +4,15 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: '3.16.0'
|
FLUTTER_VERSION: '3.19.5'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -2,15 +2,19 @@
|
|||||||
"cmake.configureOnOpen": false,
|
"cmake.configureOnOpen": false,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"acousticness",
|
"acousticness",
|
||||||
|
"ambiguate",
|
||||||
"Amoled",
|
"Amoled",
|
||||||
"Buildless",
|
"Buildless",
|
||||||
"danceability",
|
"danceability",
|
||||||
"fuzzywuzzy",
|
"fuzzywuzzy",
|
||||||
|
"gapless",
|
||||||
"instrumentalness",
|
"instrumentalness",
|
||||||
"Mpris",
|
"Mpris",
|
||||||
|
"RGBO",
|
||||||
"riverpod",
|
"riverpod",
|
||||||
"Scrobblenaut",
|
"Scrobblenaut",
|
||||||
"skeletonizer",
|
"skeletonizer",
|
||||||
|
"songlink",
|
||||||
"speechiness",
|
"speechiness",
|
||||||
"Spotube",
|
"Spotube",
|
||||||
"winget"
|
"winget"
|
||||||
|
@ -30,10 +30,12 @@ linter:
|
|||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
analyzer:
|
analyzer:
|
||||||
enable-experiment:
|
|
||||||
- records
|
|
||||||
- patterns
|
|
||||||
errors:
|
errors:
|
||||||
invalid_annotation_target: ignore
|
invalid_annotation_target: ignore
|
||||||
plugins:
|
plugins:
|
||||||
- custom_lint
|
- custom_lint
|
||||||
|
exclude:
|
||||||
|
- "**.freezed.dart"
|
||||||
|
- "**.g.dart"
|
||||||
|
- "**.gr.dart"
|
||||||
|
- "**/generated_plugin_registrant.dart"
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// ignore_for_file: avoid_print
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// ignore_for_file: avoid_print
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@ -40,7 +42,6 @@ void main(List<String> args) {
|
|||||||
"Translate following to their appropriate locale for flutter arb translations files."
|
"Translate following to their appropriate locale for flutter arb translations files."
|
||||||
" Put the respective new translations in a map of their corresponding locale.",
|
" Put the respective new translations in a map of their corresponding locale.",
|
||||||
);
|
);
|
||||||
// ignore: avoid_print
|
|
||||||
print(
|
print(
|
||||||
const JsonEncoder.withIndent(' ').convert(
|
const JsonEncoder.withIndent(' ').convert(
|
||||||
args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues,
|
args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues,
|
||||||
|
@ -73,7 +73,7 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
final fetchedTracks = await fetchAllTrack();
|
final fetchedTracks = await fetchAllTrack();
|
||||||
|
|
||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty || !context.mounted) return;
|
||||||
|
|
||||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
if (isRemoteDevice) {
|
if (isRemoteDevice) {
|
||||||
|
@ -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/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
|
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
|
||||||
|
|
||||||
const supportedAudioTypes = [
|
const supportedAudioTypes = [
|
||||||
@ -185,9 +186,6 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
ref,
|
ref,
|
||||||
trackSnapshot.asData!.value,
|
trackSnapshot.asData!.value,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// TODO: Remove stop capability
|
|
||||||
// playlistNotifier.stop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -96,6 +96,7 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
|
|
||||||
final topPadding = MediaQueryData.fromView(View.of(context)).padding.top;
|
final topPadding = MediaQueryData.fromView(View.of(context)).padding.top;
|
||||||
|
|
||||||
|
// ignore: deprecated_member_use
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
await panelController.close();
|
await panelController.close();
|
||||||
|
@ -24,11 +24,10 @@ class PlayerOverlay extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final canShow = ref.watch(
|
|
||||||
ProxyPlaylistNotifier.provider.select((s) => s.active != null),
|
|
||||||
);
|
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||||
|
final canShow = playlist.activeTrack != null;
|
||||||
|
|
||||||
final playing =
|
final playing =
|
||||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||||
|
|
||||||
|
@ -53,8 +53,7 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
|
||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
|
||||||
final controller = useAutoScrollController();
|
final controller = useAutoScrollController();
|
||||||
final searchText = useState('');
|
final searchText = useState('');
|
||||||
|
|
||||||
@ -161,7 +160,7 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
snap: false,
|
snap: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
automaticallyImplyLeading: !isSearching.value,
|
automaticallyImplyLeading: false,
|
||||||
title: BackdropFilter(
|
title: BackdropFilter(
|
||||||
filter: ImageFilter.blur(
|
filter: ImageFilter.blur(
|
||||||
sigmaX: 10,
|
sigmaX: 10,
|
||||||
@ -241,7 +240,7 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
playlistNotifier.stop();
|
onStop();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -251,9 +250,7 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SliverGap(10),
|
const SliverGap(10),
|
||||||
SliverReorderableList(
|
SliverReorderableList(
|
||||||
onReorder: (oldIndex, newIndex) {
|
onReorder: onReorder,
|
||||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
|
||||||
},
|
|
||||||
itemCount: filteredTracks.length,
|
itemCount: filteredTracks.length,
|
||||||
onReorderStart: (index) {
|
onReorderStart: (index) {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
@ -277,7 +274,7 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
if (playlist.activeTrack?.id == track.id) {
|
if (playlist.activeTrack?.id == track.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await playlistNotifier.jumpToTrack(track);
|
await onJump(track);
|
||||||
},
|
},
|
||||||
leadingActions: [
|
leadingActions: [
|
||||||
if (!isSearching.value &&
|
if (!isSearching.value &&
|
||||||
|
@ -4,7 +4,6 @@ import 'package:collection/collection.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/assets.gen.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.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/extensions/duration.dart';
|
||||||
import 'package:spotube/hooks/utils/use_debounce.dart';
|
import 'package:spotube/hooks/utils/use_debounce.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||||
import 'package:spotube/services/sourced_track/models/source_info.dart';
|
import 'package:spotube/services/sourced_track/models/source_info.dart';
|
||||||
@ -53,21 +53,22 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
|
||||||
final preferences = ref.watch(userPreferencesProvider);
|
final preferences = ref.watch(userPreferencesProvider);
|
||||||
|
|
||||||
final isSearching = useState(false);
|
final isSearching = useState(false);
|
||||||
final searchMode = useState(preferences.searchMode);
|
final searchMode = useState(preferences.searchMode);
|
||||||
|
final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier);
|
||||||
|
final activeTrack =
|
||||||
|
ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack;
|
||||||
|
|
||||||
final title = ServiceUtils.getTitle(
|
final title = ServiceUtils.getTitle(
|
||||||
playlist.activeTrack?.name ?? "",
|
activeTrack?.name ?? "",
|
||||||
artists:
|
artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
|
||||||
playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
|
|
||||||
onlyCleanArtist: true,
|
onlyCleanArtist: true,
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
final defaultSearchTerm =
|
final defaultSearchTerm =
|
||||||
"$title - ${playlist.activeTrack?.artists?.asString() ?? ""}";
|
"$title - ${activeTrack?.artists?.asString() ?? ""}";
|
||||||
final searchController = useTextEditingController(
|
final searchController = useTextEditingController(
|
||||||
text: defaultSearchTerm,
|
text: defaultSearchTerm,
|
||||||
);
|
);
|
||||||
@ -91,8 +92,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
return siblingType.info;
|
return siblingType.info;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
final activeSourceInfo =
|
final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo;
|
||||||
(playlist.activeTrack! as SourcedTrack).sourceInfo;
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||||
@ -112,8 +112,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
return siblingType.info;
|
return siblingType.info;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
final activeSourceInfo =
|
final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo;
|
||||||
(playlist.activeTrack! as SourcedTrack).sourceInfo;
|
|
||||||
return searchResults
|
return searchResults
|
||||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||||
..insert(
|
..insert(
|
||||||
@ -124,18 +123,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
}, [
|
}, [
|
||||||
searchTerm,
|
searchTerm,
|
||||||
searchMode.value,
|
searchMode.value,
|
||||||
playlist.activeTrack,
|
activeTrack,
|
||||||
preferences.audioSource,
|
preferences.audioSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final siblings = useMemoized(
|
final siblings = useMemoized(
|
||||||
() => playlist.isFetching == false
|
() => playlist.isFetching == false
|
||||||
? [
|
? [
|
||||||
(playlist.activeTrack as SourcedTrack).sourceInfo,
|
(activeTrack as SourcedTrack).sourceInfo,
|
||||||
...(playlist.activeTrack as SourcedTrack).siblings,
|
...activeTrack.siblings,
|
||||||
]
|
]
|
||||||
: <SourceInfo>[],
|
: <SourceInfo>[],
|
||||||
[playlist.isFetching, playlist.activeTrack],
|
[playlist.isFetching, activeTrack],
|
||||||
);
|
);
|
||||||
|
|
||||||
final borderRadius = floating
|
final borderRadius = floating
|
||||||
@ -146,12 +145,11 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (playlist.activeTrack is SourcedTrack &&
|
if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) {
|
||||||
(playlist.activeTrack as SourcedTrack).siblings.isEmpty) {
|
activeTrackNotifier.populateSibling();
|
||||||
playlistNotifier.populateSibling();
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [playlist.activeTrack]);
|
}, [activeTrack]);
|
||||||
|
|
||||||
final itemBuilder = useCallback(
|
final itemBuilder = useCallback(
|
||||||
(SourceInfo sourceInfo) {
|
(SourceInfo sourceInfo) {
|
||||||
@ -178,20 +176,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
enabled: playlist.isFetching != true,
|
enabled: playlist.isFetching != true,
|
||||||
selected: playlist.isFetching != true &&
|
selected: playlist.isFetching != true &&
|
||||||
sourceInfo.id ==
|
sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id,
|
||||||
(playlist.activeTrack as SourcedTrack).sourceInfo.id,
|
|
||||||
selectedTileColor: theme.popupMenuTheme.color,
|
selectedTileColor: theme.popupMenuTheme.color,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (playlist.isFetching == false &&
|
if (playlist.isFetching == false &&
|
||||||
sourceInfo.id !=
|
sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) {
|
||||||
(playlist.activeTrack as SourcedTrack).sourceInfo.id) {
|
activeTrackNotifier.swapSibling(sourceInfo);
|
||||||
playlistNotifier.swapSibling(sourceInfo);
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[playlist.isFetching, playlist.activeTrack, siblings],
|
[playlist.isFetching, activeTrack, siblings],
|
||||||
);
|
);
|
||||||
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
@ -72,7 +72,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
List<Track> fetchedTracks = await fetchAllTracks();
|
List<Track> fetchedTracks = await fetchAllTracks();
|
||||||
|
|
||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty || !context.mounted) return;
|
||||||
|
|
||||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
if (isRemoteDevice) {
|
if (isRemoteDevice) {
|
||||||
|
@ -19,7 +19,6 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
|||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.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/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||||
@ -36,7 +35,6 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||||
final layoutMode =
|
final layoutMode =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||||
final remoteControl = ref.watch(connectProvider);
|
|
||||||
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ class BorderedText extends StatelessWidget {
|
|||||||
strutStyle: child.strutStyle,
|
strutStyle: child.strutStyle,
|
||||||
textAlign: child.textAlign,
|
textAlign: child.textAlign,
|
||||||
textDirection: child.textDirection,
|
textDirection: child.textDirection,
|
||||||
textScaleFactor: child.textScaleFactor,
|
textScaler: child.textScaler,
|
||||||
),
|
),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
|
@ -599,6 +599,7 @@ class MouseStateBuilder extends StatefulWidget {
|
|||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
|
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
|
||||||
@override
|
@override
|
||||||
|
// ignore: library_private_types_in_public_api
|
||||||
_MouseStateBuilderState createState() => _MouseStateBuilderState();
|
_MouseStateBuilderState createState() => _MouseStateBuilderState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
part of panels;
|
part of './sliding_up_panel.dart';
|
||||||
|
|
||||||
class PanelController extends ChangeNotifier {
|
class PanelController extends ChangeNotifier {
|
||||||
SlidingUpPanelState? _panelState;
|
SlidingUpPanelState? _panelState;
|
||||||
|
@ -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,
|
/// if you want to prevent the panel from being dragged using the widget,
|
||||||
/// wrap the widget with this
|
/// wrap the widget with this
|
||||||
|
@ -208,7 +208,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: switch (track.runtimeType) {
|
child: switch (track.runtimeType) {
|
||||||
LocalTrack => Text(
|
LocalTrack() => Text(
|
||||||
track.album!.name!,
|
track.album!.name!,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
@ -46,6 +46,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
|||||||
|
|
||||||
final allTracks = await props.pagination.onFetchAll();
|
final allTracks = await props.pagination.onFetchAll();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
if (isRemoteDevice) {
|
if (isRemoteDevice) {
|
||||||
final remotePlayback = ref.read(connectProvider.notifier);
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
@ -76,6 +78,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
|||||||
|
|
||||||
final allTracks = await props.pagination.onFetchAll();
|
final allTracks = await props.pagination.onFetchAll();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
if (isRemoteDevice) {
|
if (isRemoteDevice) {
|
||||||
final remotePlayback = ref.read(connectProvider.notifier);
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
|
@ -5,6 +5,7 @@ import 'package:path/path.dart';
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/album_simple.dart';
|
import 'package:spotube/extensions/album_simple.dart';
|
||||||
import 'package:spotube/extensions/artist_simple.dart';
|
import 'package:spotube/extensions/artist_simple.dart';
|
||||||
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
extension TrackExtensions on Track {
|
extension TrackExtensions on Track {
|
||||||
Track fromFile(
|
Track fromFile(
|
||||||
@ -90,3 +91,9 @@ extension TrackSimpleExtensions on TrackSimple {
|
|||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TracksToMediaExtension on Iterable<Track> {
|
||||||
|
List<SpotubeMedia> asMediaList() {
|
||||||
|
return map((track) => SpotubeMedia(track)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:spotube/hooks/configurators/use_window_listener.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_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||||
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:local_notifier/local_notifier.dart';
|
import 'package:local_notifier/local_notifier.dart';
|
||||||
|
|
||||||
final closeNotification = DesktopTools.createNotification(
|
final closeNotification = DesktopTools.createNotification(
|
||||||
|
@ -19,11 +19,13 @@ void useCustomStatusBarColor(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ignore: invalid_use_of_visible_for_testing_member
|
||||||
final statusBarColor = SystemChrome.latestStyle?.statusBarColor;
|
final statusBarColor = SystemChrome.latestStyle?.statusBarColor;
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (automaticSystemUiAdjustment != null) {
|
if (automaticSystemUiAdjustment != null) {
|
||||||
|
// ignore: deprecated_member_use
|
||||||
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment =
|
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment =
|
||||||
automaticSystemUiAdjustment;
|
automaticSystemUiAdjustment;
|
||||||
}
|
}
|
||||||
@ -43,6 +45,7 @@ void useCustomStatusBarColor(
|
|||||||
});
|
});
|
||||||
return () {
|
return () {
|
||||||
if (automaticSystemUiAdjustment != null) {
|
if (automaticSystemUiAdjustment != null) {
|
||||||
|
// ignore: deprecated_member_use
|
||||||
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false;
|
WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -2,5 +2,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
|
|
||||||
void Function() useForceUpdate() {
|
void Function() useForceUpdate() {
|
||||||
final state = useState(null);
|
final state = useState(null);
|
||||||
|
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
||||||
return () => state.notifyListeners();
|
return () => state.notifyListeners();
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
"new_releases": "เพิ่งปล่อยใหม่",
|
"new_releases": "เพิ่งปล่อยใหม่",
|
||||||
"songs": "เพลง",
|
"songs": "เพลง",
|
||||||
"playing_track": "กำลังเล่น {track}",
|
"playing_track": "กำลังเล่น {track}",
|
||||||
"queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?",
|
"queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track_length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?",
|
||||||
"load_more": "โหลดเพิ่มเติม",
|
"load_more": "โหลดเพิ่มเติม",
|
||||||
"playlists": "เพลย์ลิสต์",
|
"playlists": "เพลย์ลิสต์",
|
||||||
"artists": "ศิลปิน",
|
"artists": "ศิลปิน",
|
||||||
|
@ -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.",
|
"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",
|
"contribute_on_github": "GitHub'a katkıda bulunun",
|
||||||
"donate_on_open_collective": "Open Collective'e bağış yap",
|
"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": "Bağlantıyı Etkinleştir",
|
||||||
"enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin",
|
"enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin",
|
||||||
"devices": "Cihazlar",
|
"devices": "Cihazlar",
|
||||||
|
@ -13,6 +13,8 @@
|
|||||||
/// sappho192@github => Korean
|
/// sappho192@github => Korean
|
||||||
/// watchakorn-18k@github => Thai
|
/// watchakorn-18k@github => Thai
|
||||||
|
|
||||||
|
library l10n;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class L10n {
|
class L10n {
|
||||||
|
@ -26,6 +26,7 @@ import 'package:spotube/models/source_match.dart';
|
|||||||
import 'package:spotube/provider/connect/clients.dart';
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
import 'package:spotube/provider/connect/server.dart';
|
import 'package:spotube/provider/connect/server.dart';
|
||||||
import 'package:spotube/provider/palette_provider.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/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/services/cli/cli.dart';
|
import 'package:spotube/services/cli/cli.dart';
|
||||||
@ -182,6 +183,7 @@ class SpotubeState extends ConsumerState<Spotube> {
|
|||||||
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
|
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
ref.listen(playbackServerProvider, (_, __) {});
|
||||||
ref.listen(connectServerProvider, (_, __) {});
|
ref.listen(connectServerProvider, (_, __) {});
|
||||||
ref.listen(connectClientsProvider, (_, __) {});
|
ref.listen(connectClientsProvider, (_, __) {});
|
||||||
|
|
||||||
|
@ -18,6 +18,33 @@ import 'package:spotube/provider/connect/connect.dart';
|
|||||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||||
import 'package:spotube/utils/service_utils.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 {
|
class ConnectControlPage extends HookConsumerWidget {
|
||||||
const ConnectControlPage({super.key});
|
const ConnectControlPage({super.key});
|
||||||
|
|
||||||
@ -50,27 +77,6 @@ class ConnectControlPage extends HookConsumerWidget {
|
|||||||
minimumSize: const Size(28, 28),
|
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) {
|
ref.listen(connectClientsProvider, (prev, next) {
|
||||||
if (next.asData?.value.resolvedService == null) {
|
if (next.asData?.value.resolvedService == null) {
|
||||||
context.pop();
|
context.pop();
|
||||||
@ -292,7 +298,7 @@ class ConnectControlPage extends HookConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return playerQueue;
|
return const RemotePlayerQueue();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -304,8 +310,8 @@ class ConnectControlPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (constrains.lgAndUp) ...[
|
if (constrains.lgAndUp) ...[
|
||||||
const VerticalDivider(thickness: 1),
|
const VerticalDivider(thickness: 1),
|
||||||
Expanded(
|
const Expanded(
|
||||||
child: playerQueue,
|
child: RemotePlayerQueue(),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
@ -1,8 +1,4 @@
|
|||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
@ -190,6 +190,7 @@ class RootApp extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: deprecated_member_use
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
if (rootPaths[location] != 0) {
|
if (rootPaths[location] != 0) {
|
||||||
|
@ -131,7 +131,7 @@ class AuthenticationNotifier
|
|||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
state = null;
|
state = null;
|
||||||
if (kIsMobile) {
|
if (kIsMobile) {
|
||||||
WebStorageManager.instance().android.deleteAllData();
|
WebStorageManager.instance().deleteAllData();
|
||||||
CookieManager.instance().deleteAllCookies();
|
CookieManager.instance().deleteAllCookies();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,10 +64,10 @@ class ConnectClientsNotifier extends AsyncNotifier<ConnectClientsState> {
|
|||||||
.where((s) => s.name != event.service!.name)
|
.where((s) => s.name != event.service!.name)
|
||||||
.toList(),
|
.toList(),
|
||||||
discovery: state.value!.discovery,
|
discovery: state.value!.discovery,
|
||||||
resolvedService:
|
resolvedService: state.value?.resolvedService != null &&
|
||||||
event.service?.name == state.value!.resolvedService!.name
|
event.service?.name == state.value?.resolvedService?.name
|
||||||
? null
|
? null
|
||||||
: state.value!.resolvedService,
|
: state.value!.resolvedService,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
@ -4,6 +4,7 @@ import 'package:catcher_2/catcher_2.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/models/connect/connect.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/connect/clients.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||||
import 'package:spotube/services/audio_player/loop_mode.dart';
|
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||||
@ -38,19 +39,21 @@ final volumeProvider = StateProvider<double>(
|
|||||||
(ref) => 1.0,
|
(ref) => 1.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final logger = getLogger('ConnectNotifier');
|
||||||
|
|
||||||
class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
||||||
@override
|
@override
|
||||||
build() async {
|
build() async {
|
||||||
try {
|
try {
|
||||||
final connectClients = ref.watch(connectClientsProvider);
|
final connectClients = ref.watch(connectClientsProvider);
|
||||||
print('Building ConnectNotifier');
|
|
||||||
|
|
||||||
if (connectClients.asData?.value.resolvedService == null) return null;
|
if (connectClients.asData?.value.resolvedService == null) return null;
|
||||||
|
|
||||||
final service = connectClients.asData!.value.resolvedService!;
|
final service = connectClients.asData!.value.resolvedService!;
|
||||||
|
|
||||||
print(
|
logger.t(
|
||||||
'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws');
|
'♾️ Connecting to ${service.name}: ws://${service.host}:${service.port}/ws',
|
||||||
|
);
|
||||||
|
|
||||||
final channel = WebSocketChannel.connect(
|
final channel = WebSocketChannel.connect(
|
||||||
Uri.parse('ws://${service.host}:${service.port}/ws'),
|
Uri.parse('ws://${service.host}:${service.port}/ws'),
|
||||||
@ -58,8 +61,9 @@ class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
|||||||
|
|
||||||
await channel.ready;
|
await channel.ready;
|
||||||
|
|
||||||
print(
|
logger.t(
|
||||||
'Connected to ${service.name}: ws://${service.host}:${service.port}/ws');
|
'✅ Connected to ${service.name}: ws://${service.host}:${service.port}/ws',
|
||||||
|
);
|
||||||
|
|
||||||
final subscription = channel.stream.listen(
|
final subscription = channel.stream.listen(
|
||||||
(message) {
|
(message) {
|
||||||
|
@ -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<ProxyPlaylist> {
|
|
||||||
Future<List<SourcedTrack>> 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<Track> mergeTracks(
|
|
||||||
Iterable<SourcedTrack> fetchTracks,
|
|
||||||
Iterable<Track> 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<Track> mapSourcesToTracks(List<String> 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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,87 +3,25 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/skip_segments.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/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/services/sourced_track/exceptions.dart';
|
|
||||||
|
|
||||||
extension ProxyPlaylistListeners on ProxyPlaylistNotifier {
|
extension ProxyPlaylistListeners on ProxyPlaylistNotifier {
|
||||||
StreamSubscription<String> subscribeToSourceChanges() =>
|
StreamSubscription subscribeToPlaylist() {
|
||||||
audioPlayer.activeSourceChangedStream.listen((event) {
|
return audioPlayer.playlistStream.listen((playlist) {
|
||||||
try {
|
state = state.copyWith(
|
||||||
final newActiveTrack = mapSourcesToTracks([event]).firstOrNull;
|
tracks: playlist.medias
|
||||||
|
.map((media) => SpotubeMedia.fromMedia(media).track)
|
||||||
|
.toSet(),
|
||||||
|
active: playlist.index,
|
||||||
|
);
|
||||||
|
|
||||||
if (newActiveTrack == null ||
|
notificationService.addTrack(state.activeTrack!);
|
||||||
newActiveTrack.id == state.activeTrack?.id) {
|
discord.updatePresence(state.activeTrack!);
|
||||||
return;
|
updatePalette();
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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() {
|
StreamSubscription subscribeToPlayerError() {
|
||||||
return audioPlayer.errorStream.listen((event) {});
|
return audioPlayer.errorStream.listen((event) {});
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/track.dart';
|
import 'package:spotube/extensions/track.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
@ -14,12 +13,11 @@ class ProxyPlaylist {
|
|||||||
|
|
||||||
factory ProxyPlaylist.fromJson(
|
factory ProxyPlaylist.fromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
Ref ref,
|
|
||||||
) {
|
) {
|
||||||
return ProxyPlaylist(
|
return ProxyPlaylist(
|
||||||
List.castFrom<dynamic, Map<String, dynamic>>(
|
List.castFrom<dynamic, Map<String, dynamic>>(
|
||||||
json['tracks'] ?? <Map<String, dynamic>>[],
|
json['tracks'] ?? <Map<String, dynamic>>[],
|
||||||
).map((t) => _makeAppropriateTrack(t, ref)).toSet(),
|
).map((t) => _makeAppropriateTrack(t)).toSet(),
|
||||||
json['active'] as int?,
|
json['active'] as int?,
|
||||||
json['collections'] == null
|
json['collections'] == null
|
||||||
? {}
|
? {}
|
||||||
@ -40,10 +38,7 @@ class ProxyPlaylist {
|
|||||||
Track? get activeTrack =>
|
Track? get activeTrack =>
|
||||||
active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
|
active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
|
||||||
|
|
||||||
bool get isFetching =>
|
bool get isFetching => activeTrack == null && tracks.isNotEmpty;
|
||||||
activeTrack != null &&
|
|
||||||
activeTrack is! SourcedTrack &&
|
|
||||||
activeTrack is! LocalTrack;
|
|
||||||
|
|
||||||
bool containsCollection(String collection) {
|
bool containsCollection(String collection) {
|
||||||
return collections.contains(collection);
|
return collections.contains(collection);
|
||||||
@ -58,10 +53,8 @@ class ProxyPlaylist {
|
|||||||
return tracks.every(containsTrack);
|
return tracks.every(containsTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Track _makeAppropriateTrack(Map<String, dynamic> track, Ref ref) {
|
static Track _makeAppropriateTrack(Map<String, dynamic> track) {
|
||||||
if (track.containsKey("ytUri")) {
|
if (track.containsKey("path")) {
|
||||||
return SourcedTrack.fromJson(track, ref: ref);
|
|
||||||
} else if (track.containsKey("path")) {
|
|
||||||
return LocalTrack.fromJson(track);
|
return LocalTrack.fromJson(track);
|
||||||
} else {
|
} else {
|
||||||
return Track.fromJson(track);
|
return Track.fromJson(track);
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/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/blacklist_provider.dart';
|
||||||
import 'package:spotube/provider/palette_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/player_listeners.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||||
import 'package:spotube/provider/scrobbler_provider.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_player/audio_player.dart';
|
||||||
import 'package:spotube/services/audio_services/audio_services.dart';
|
import 'package:spotube/services/audio_services/audio_services.dart';
|
||||||
import 'package:spotube/provider/discord_provider.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';
|
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||||
|
|
||||||
class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist> {
|
||||||
with NextFetcher {
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
late final AudioServices notificationService;
|
late final AudioServices notificationService;
|
||||||
|
|
||||||
@ -54,49 +49,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
|
|
||||||
_subscriptions = [
|
_subscriptions = [
|
||||||
// These are subscription methods from player_listeners.dart
|
// These are subscription methods from player_listeners.dart
|
||||||
subscribeToSourceChanges(),
|
subscribeToPlaylist(),
|
||||||
subscribeToPercentCompletion(),
|
|
||||||
subscribeToShuffleChanges(),
|
|
||||||
subscribeToSkipSponsor(),
|
subscribeToSkipSponsor(),
|
||||||
|
subscribeToPosition(),
|
||||||
subscribeToScrobbleChanged(),
|
subscribeToScrobbleChanged(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SourcedTrack?> 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
|
// Basic methods for adding or removing tracks to playlist
|
||||||
|
|
||||||
Future<void> addTrack(Track track) async {
|
Future<void> addTrack(Track track) async {
|
||||||
if (blacklist.contains(track)) return;
|
if (blacklist.contains(track)) return;
|
||||||
state = state.copyWith(tracks: {...state.tracks, track});
|
await audioPlayer.addTrack(SpotubeMedia(track));
|
||||||
await audioPlayer.addTrack(makeAppropriateSource(track));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addTracks(Iterable<Track> tracks) async {
|
Future<void> addTracks(Iterable<Track> tracks) async {
|
||||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||||
state = state.copyWith(tracks: {...state.tracks, ...tracks});
|
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
await audioPlayer.addTrack(makeAppropriateSource(track));
|
await audioPlayer.addTrack(SpotubeMedia(track));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,25 +83,17 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeTrack(String trackId) async {
|
Future<void> removeTrack(String trackId) async {
|
||||||
final track =
|
final trackIndex =
|
||||||
state.tracks.firstWhereOrNull((element) => element.id == trackId);
|
state.tracks.toList().indexWhere((element) => element.id == trackId);
|
||||||
if (track == null) return;
|
if (trackIndex == -1) return;
|
||||||
state = state.copyWith(tracks: {...state.tracks..remove(track)});
|
await audioPlayer.removeTrack(trackIndex);
|
||||||
final index = audioPlayer.sources.indexOf(makeAppropriateSource(track));
|
|
||||||
if (index == -1) return;
|
|
||||||
await audioPlayer.removeTrack(index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeTracks(Iterable<String> tracksIds) async {
|
Future<void> removeTracks(Iterable<String> tracksIds) async {
|
||||||
final tracks =
|
final tracks = state.tracks.map((t) => t.id!).toList();
|
||||||
state.tracks.where((element) => tracksIds.contains(element.id));
|
|
||||||
|
|
||||||
state = state.copyWith(tracks: {
|
|
||||||
...state.tracks..removeWhere((element) => tracksIds.contains(element.id))
|
|
||||||
});
|
|
||||||
|
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
final index = audioPlayer.sources.indexOf(makeAppropriateSource(track));
|
final index = tracks.indexOf(track);
|
||||||
if (index == -1) continue;
|
if (index == -1) continue;
|
||||||
await audioPlayer.removeTrack(index);
|
await audioPlayer.removeTrack(index);
|
||||||
}
|
}
|
||||||
@ -144,64 +105,18 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
bool autoPlay = false,
|
bool autoPlay = false,
|
||||||
}) async {
|
}) async {
|
||||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||||
final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first;
|
|
||||||
|
|
||||||
if (indexTrack is LocalTrack) {
|
state = state.copyWith(collections: {});
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
await audioPlayer.openPlaylist(
|
await audioPlayer.openPlaylist(
|
||||||
state.tracks.map(makeAppropriateSource).toList(),
|
tracks.asMediaList(),
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
autoPlay: autoPlay,
|
autoPlay: autoPlay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> jumpTo(int index) async {
|
Future<void> 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);
|
await audioPlayer.jumpTo(index);
|
||||||
|
|
||||||
if (oldTrack != null || track != null) {
|
|
||||||
await notificationService.addTrack(track ?? oldTrack!);
|
|
||||||
discord.updatePresence(track ?? oldTrack!);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> jumpToTrack(Track track) async {
|
Future<void> jumpToTrack(Track track) async {
|
||||||
@ -211,7 +126,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
await jumpTo(index);
|
await jumpTo(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: add safe guards for active/playing track that needs to be moved
|
|
||||||
Future<void> moveTrack(int oldIndex, int newIndex) async {
|
Future<void> moveTrack(int oldIndex, int newIndex) async {
|
||||||
if (oldIndex == newIndex ||
|
if (oldIndex == newIndex ||
|
||||||
newIndex < 0 ||
|
newIndex < 0 ||
|
||||||
@ -219,11 +133,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
newIndex > state.tracks.length - 1 ||
|
newIndex > state.tracks.length - 1 ||
|
||||||
oldIndex > state.tracks.length - 1) return;
|
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);
|
await audioPlayer.moveTrack(oldIndex, newIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,104 +142,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||||
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 {
|
for (int i = 0; i < tracks.length; i++) {
|
||||||
audioPlayer.addTrackAt(
|
final track = tracks.elementAt(i);
|
||||||
makeAppropriateSource(track),
|
|
||||||
destIndex + index,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> populateSibling() async {
|
await audioPlayer.addTrackAt(
|
||||||
if (state.activeTrack is SourcedTrack) {
|
SpotubeMedia(track),
|
||||||
final activeTrackWithSiblingsForSure =
|
(state.active ?? 0) + i + 1,
|
||||||
await (state.activeTrack as SourcedTrack).copyWithSibling();
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks),
|
|
||||||
active: state.tracks.toList().indexWhere(
|
|
||||||
(element) => element.id == activeTrackWithSiblingsForSure.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> next() async {
|
Future<void> 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();
|
await audioPlayer.skipToNext();
|
||||||
|
|
||||||
if (oldTrack != null || track != null) {
|
|
||||||
await notificationService.addTrack(track ?? oldTrack!);
|
|
||||||
discord.updatePresence(track ?? oldTrack!);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> previous() async {
|
Future<void> 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();
|
await audioPlayer.skipToPrevious();
|
||||||
if (oldTrack != null || track != null) {
|
|
||||||
await notificationService.addTrack(track ?? oldTrack!);
|
|
||||||
discord.updatePresence(track ?? oldTrack!);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
@ -385,7 +213,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<ProxyPlaylist> fromJson(Map<String, dynamic> json) {
|
FutureOr<ProxyPlaylist> fromJson(Map<String, dynamic> json) {
|
||||||
return ProxyPlaylist.fromJson(json, ref);
|
return ProxyPlaylist.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -3,12 +3,10 @@ import 'dart:convert';
|
|||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
|
||||||
import 'package:spotube/models/skip_segment.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_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
|
||||||
|
|
||||||
class SourcedSegments {
|
class SourcedSegments {
|
||||||
final String source;
|
final String source;
|
||||||
@ -75,13 +73,9 @@ Future<List<SkipSegment>> getAndCacheSkipSegments(String id) async {
|
|||||||
|
|
||||||
final segmentProvider = FutureProvider<SourcedSegments?>(
|
final segmentProvider = FutureProvider<SourcedSegments?>(
|
||||||
(ref) async {
|
(ref) async {
|
||||||
final track = ref.watch(
|
final track = ref.watch(activeSourcedTrackProvider);
|
||||||
ProxyPlaylistNotifier.provider.select((s) => s.activeTrack),
|
|
||||||
);
|
|
||||||
if (track == null) return null;
|
if (track == null) return null;
|
||||||
|
|
||||||
if (track is LocalTrack || track is! SourcedTrack) return null;
|
|
||||||
|
|
||||||
final skipNonMusic = ref.watch(
|
final skipNonMusic = ref.watch(
|
||||||
userPreferencesProvider.select(
|
userPreferencesProvider.select(
|
||||||
(s) {
|
(s) {
|
||||||
|
47
lib/provider/server/active_sourced_track.dart
Normal file
47
lib/provider/server/active_sourced_track.dart
Normal file
@ -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<SourcedTrack?> {
|
||||||
|
@override
|
||||||
|
build() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void update(SourcedTrack? sourcedTrack) {
|
||||||
|
state = sourcedTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> populateSibling() async {
|
||||||
|
if (state == null) return;
|
||||||
|
state = await state!.copyWithSibling();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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, SourcedTrack?>(
|
||||||
|
() => ActiveSourcedTrackNotifier(),
|
||||||
|
);
|
119
lib/provider/server/server.dart
Normal file
119
lib/provider/server/server.dart
Normal file
@ -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/<trackId>', 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/<trackId>')
|
||||||
|
Future<Response> 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<Uint8List>?)?.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<PlaybackServer>((ref) {
|
||||||
|
return PlaybackServer(ref);
|
||||||
|
});
|
28
lib/provider/server/sourced_track.dart
Normal file
28
lib/provider/server/sourced_track.dart
Normal file
@ -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<SourcedTrack?, Track?>((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;
|
||||||
|
});
|
@ -1,6 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:spotube/services/audio_player/mk_state_player.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 'package:just_audio/just_audio.dart' as ja;
|
||||||
import 'dart:async';
|
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/loop_mode.dart';
|
||||||
import 'package:spotube/services/audio_player/playback_state.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_players_streams_mixin.dart';
|
||||||
part 'audio_player_impl.dart';
|
part 'audio_player_impl.dart';
|
||||||
|
|
||||||
|
class SpotubeMedia extends mk.Media {
|
||||||
|
final Track track;
|
||||||
|
|
||||||
|
SpotubeMedia(
|
||||||
|
this.track, {
|
||||||
|
Map<String, String>? 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 {
|
abstract class AudioPlayerInterface {
|
||||||
final MkPlayerWithState _mkPlayer;
|
final CustomPlayer _mkPlayer;
|
||||||
// final ja.AudioPlayer? _justAudxio;
|
// final ja.AudioPlayer? _justAudxio;
|
||||||
|
|
||||||
AudioPlayerInterface()
|
AudioPlayerInterface()
|
||||||
: _mkPlayer = MkPlayerWithState(
|
: _mkPlayer = CustomPlayer(
|
||||||
configuration: const mk.PlayerConfiguration(
|
configuration: const mk.PlayerConfiguration(
|
||||||
title: "Spotube",
|
title: "Spotube",
|
||||||
|
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null
|
// _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null
|
||||||
@ -61,18 +90,18 @@ abstract class AudioPlayerInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AudioDevice> get selectedDevice async {
|
Future<mk.AudioDevice> get selectedDevice async {
|
||||||
return _mkPlayer.state.audioDevice;
|
return _mkPlayer.state.audioDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<AudioDevice>> get devices async {
|
Future<List<mk.AudioDevice>> get devices async {
|
||||||
return _mkPlayer.state.audioDevices;
|
return _mkPlayer.state.audioDevices;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get hasSource {
|
bool get hasSource {
|
||||||
return _mkPlayer.playlist.medias.isNotEmpty;
|
return _mkPlayer.state.playlist.medias.isNotEmpty;
|
||||||
// if (mkSupportedPlatform) {
|
// if (mkSupportedPlatform) {
|
||||||
// return _mkPlayer.playlist.medias.isNotEmpty;
|
// return _mkPlayer.state.playlist.medias.isNotEmpty;
|
||||||
// } else {
|
// } else {
|
||||||
// return _justAudio!.audioSource != null;
|
// return _justAudio!.audioSource != null;
|
||||||
// }
|
// }
|
||||||
@ -125,7 +154,7 @@ abstract class AudioPlayerInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PlaybackLoopMode get loopMode {
|
PlaybackLoopMode get loopMode {
|
||||||
return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode);
|
return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.state.playlistMode);
|
||||||
// if (mkSupportedPlatform) {
|
// if (mkSupportedPlatform) {
|
||||||
// return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode);
|
// return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode);
|
||||||
// } else {
|
// } else {
|
||||||
|
@ -4,320 +4,129 @@ final audioPlayer = SpotubeAudioPlayer();
|
|||||||
|
|
||||||
class SpotubeAudioPlayer extends AudioPlayerInterface
|
class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||||
with SpotubeAudioPlayersStreams {
|
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<void> 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<void> 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<void> pause() async {
|
Future<void> pause() async {
|
||||||
await _mkPlayer.pause();
|
await _mkPlayer.pause();
|
||||||
// await _justAudio?.pause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume() async {
|
Future<void> resume() async {
|
||||||
await _mkPlayer.play();
|
await _mkPlayer.play();
|
||||||
// await _justAudio?.play();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _mkPlayer.stop();
|
await _mkPlayer.stop();
|
||||||
// await _justAudio?.stop();
|
|
||||||
// await _justAudio?.setShuffleModeEnabled(false);
|
|
||||||
// await _justAudio?.setLoopMode(ja.LoopMode.off);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
await _mkPlayer.seek(position);
|
await _mkPlayer.seek(position);
|
||||||
// await _justAudio?.seek(position);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volume is between 0 and 1
|
/// Volume is between 0 and 1
|
||||||
Future<void> setVolume(double volume) async {
|
Future<void> setVolume(double volume) async {
|
||||||
assert(volume >= 0 && volume <= 1);
|
assert(volume >= 0 && volume <= 1);
|
||||||
await _mkPlayer.setVolume(volume * 100);
|
await _mkPlayer.setVolume(volume * 100);
|
||||||
// await _justAudio?.setVolume(volume);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setSpeed(double speed) async {
|
Future<void> setSpeed(double speed) async {
|
||||||
await _mkPlayer.setRate(speed);
|
await _mkPlayer.setRate(speed);
|
||||||
// await _justAudio?.setSpeed(speed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setAudioDevice(AudioDevice device) async {
|
Future<void> setAudioDevice(mk.AudioDevice device) async {
|
||||||
await _mkPlayer.setAudioDevice(device);
|
await _mkPlayer.setAudioDevice(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _mkPlayer.dispose();
|
await _mkPlayer.dispose();
|
||||||
// await _justAudio?.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist related
|
// Playlist related
|
||||||
|
|
||||||
Future<void> openPlaylist(
|
Future<void> openPlaylist(
|
||||||
List<String> tracks, {
|
List<mk.Media> tracks, {
|
||||||
bool autoPlay = true,
|
bool autoPlay = true,
|
||||||
int initialIndex = 0,
|
int initialIndex = 0,
|
||||||
}) async {
|
}) async {
|
||||||
assert(tracks.isNotEmpty);
|
assert(tracks.isNotEmpty);
|
||||||
assert(initialIndex <= tracks.length - 1);
|
assert(initialIndex <= tracks.length - 1);
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.open(
|
await _mkPlayer.open(
|
||||||
mk.Playlist(
|
mk.Playlist(tracks, index: initialIndex),
|
||||||
tracks.map(mk.Media.new).toList(),
|
|
||||||
index: initialIndex,
|
|
||||||
),
|
|
||||||
play: autoPlay,
|
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<SourcedTrack> resolveTracksForSource(List<SourcedTrack> tracks) {
|
|
||||||
return tracks.where((e) => sources.contains(e.url)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tracksExistsInPlaylist(List<SourcedTrack> tracks) {
|
|
||||||
return resolveTracksForSource(tracks).length == tracks.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> get sources {
|
List<String> get sources {
|
||||||
// if (mkSupportedPlatform) {
|
return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList();
|
||||||
return _mkPlayer.playlist.medias.map((e) => e.uri).toList();
|
|
||||||
// } else {
|
|
||||||
// return _justAudio!.sequenceState?.effectiveSequence
|
|
||||||
// .map((e) => (e as ja.UriAudioSource).uri.toString())
|
|
||||||
// .toList() ??
|
|
||||||
// <String>[];
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get currentSource {
|
String? get currentSource {
|
||||||
// if (mkSupportedPlatform) {
|
if (_mkPlayer.state.playlist.index == -1) return null;
|
||||||
if (_mkPlayer.playlist.index == -1) return null;
|
return _mkPlayer.state.playlist.medias
|
||||||
return _mkPlayer.playlist.medias
|
.elementAtOrNull(_mkPlayer.state.playlist.index)
|
||||||
.elementAtOrNull(_mkPlayer.playlist.index)
|
|
||||||
?.uri;
|
?.uri;
|
||||||
// } else {
|
|
||||||
// return (_justAudio?.sequenceState?.effectiveSequence
|
|
||||||
// .elementAtOrNull(_justAudio!.sequenceState!.currentIndex)
|
|
||||||
// as ja.UriAudioSource?)
|
|
||||||
// ?.uri
|
|
||||||
// .toString();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get nextSource {
|
String? get nextSource {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
|
|
||||||
if (loopMode == PlaybackLoopMode.all &&
|
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 sources.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _mkPlayer.playlist.medias
|
return _mkPlayer.state.playlist.medias
|
||||||
.elementAtOrNull(_mkPlayer.playlist.index + 1)
|
.elementAtOrNull(_mkPlayer.state.playlist.index + 1)
|
||||||
?.uri;
|
?.uri;
|
||||||
// } else {
|
|
||||||
// return (_justAudio?.sequenceState?.effectiveSequence
|
|
||||||
// .elementAtOrNull(_justAudio!.sequenceState!.currentIndex + 1)
|
|
||||||
// as ja.UriAudioSource?)
|
|
||||||
// ?.uri
|
|
||||||
// .toString();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get previousSource {
|
String? get previousSource {
|
||||||
if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) {
|
if (loopMode == PlaybackLoopMode.all &&
|
||||||
|
_mkPlayer.state.playlist.index == 0) {
|
||||||
return sources.last;
|
return sources.last;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (mkSupportedPlatform) {
|
return _mkPlayer.state.playlist.medias
|
||||||
return _mkPlayer.playlist.medias
|
.elementAtOrNull(_mkPlayer.state.playlist.index - 1)
|
||||||
.elementAtOrNull(_mkPlayer.playlist.index - 1)
|
|
||||||
?.uri;
|
?.uri;
|
||||||
// } else {
|
|
||||||
// return (_justAudio?.sequenceState?.effectiveSequence
|
|
||||||
// .elementAtOrNull(_justAudio!.sequenceState!.currentIndex - 1)
|
|
||||||
// as ja.UriAudioSource?)
|
|
||||||
// ?.uri
|
|
||||||
// .toString();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int get currentIndex => _mkPlayer.state.playlist.index;
|
||||||
|
|
||||||
Future<void> skipToNext() async {
|
Future<void> skipToNext() async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.next();
|
await _mkPlayer.next();
|
||||||
// } else {
|
|
||||||
// await _justAudio!.seekToNext();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipToPrevious() async {
|
Future<void> skipToPrevious() async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.previous();
|
await _mkPlayer.previous();
|
||||||
// } else {
|
|
||||||
// await _justAudio!.seekToPrevious();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> jumpTo(int index) async {
|
Future<void> jumpTo(int index) async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.jump(index);
|
await _mkPlayer.jump(index);
|
||||||
// } else {
|
|
||||||
// await _justAudio!.seek(Duration.zero, index: index);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addTrack(String url) async {
|
Future<void> addTrack(mk.Media media) async {
|
||||||
final urlType = _resolveUrlType(url);
|
await _mkPlayer.add(media);
|
||||||
// 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<void> addTrackAt(String url, int index) async {
|
Future<void> addTrackAt(mk.Media media, int index) async {
|
||||||
final urlType = _resolveUrlType(url);
|
await _mkPlayer.insert(index, media);
|
||||||
// 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<void> removeTrack(int index) async {
|
Future<void> removeTrack(int index) async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.remove(index);
|
await _mkPlayer.remove(index);
|
||||||
// } else {
|
|
||||||
// await (_justAudio!.audioSource as ja.ConcatenatingAudioSource)
|
|
||||||
// .removeAt(index);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> moveTrack(int from, int to) async {
|
Future<void> moveTrack(int from, int to) async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.move(from, to);
|
await _mkPlayer.move(from, to);
|
||||||
// } else {
|
|
||||||
// await (_justAudio!.audioSource as ja.ConcatenatingAudioSource)
|
|
||||||
// .move(from, to);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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<void> clearPlaylist() async {
|
Future<void> clearPlaylist() async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
_mkPlayer.stop();
|
_mkPlayer.stop();
|
||||||
// } else {
|
|
||||||
// await (_justAudio!.audioSource as ja.ConcatenatingAudioSource).clear();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setShuffle(bool shuffle) async {
|
Future<void> setShuffle(bool shuffle) async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.setShuffle(shuffle);
|
await _mkPlayer.setShuffle(shuffle);
|
||||||
// } else {
|
|
||||||
// await _justAudio!.setShuffleModeEnabled(shuffle);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setLoopMode(PlaybackLoopMode loop) async {
|
Future<void> setLoopMode(PlaybackLoopMode loop) async {
|
||||||
// if (mkSupportedPlatform) {
|
|
||||||
await _mkPlayer.setPlaylistMode(loop.toPlaylistMode());
|
await _mkPlayer.setPlaylistMode(loop.toPlaylistMode());
|
||||||
// } else {
|
|
||||||
// await _justAudio!.setLoopMode(loop.toLoopMode());
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setAudioNormalization(bool normalize) async {
|
Future<void> setAudioNormalization(bool normalize) async {
|
||||||
|
@ -73,7 +73,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
|
|||||||
|
|
||||||
Stream<PlaybackLoopMode> get loopModeStream {
|
Stream<PlaybackLoopMode> get loopModeStream {
|
||||||
// if (mkSupportedPlatform) {
|
// if (mkSupportedPlatform) {
|
||||||
return _mkPlayer.loopModeStream.map(PlaybackLoopMode.fromPlaylistMode);
|
return _mkPlayer.stream.playlistMode.map(PlaybackLoopMode.fromPlaylistMode);
|
||||||
// } else {
|
// } else {
|
||||||
// return _justAudio!.loopModeStream
|
// return _justAudio!.loopModeStream
|
||||||
// .map(PlaybackLoopMode.fromLoopMode)
|
// .map(PlaybackLoopMode.fromLoopMode)
|
||||||
@ -127,7 +127,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
|
|||||||
// if (mkSupportedPlatform) {
|
// if (mkSupportedPlatform) {
|
||||||
return _mkPlayer.indexChangeStream
|
return _mkPlayer.indexChangeStream
|
||||||
.map((event) {
|
.map((event) {
|
||||||
return _mkPlayer.playlist.medias.elementAtOrNull(event)?.uri;
|
return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri;
|
||||||
})
|
})
|
||||||
.where((event) => event != null)
|
.where((event) => event != null)
|
||||||
.cast<String>();
|
.cast<String>();
|
||||||
@ -141,11 +141,13 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<AudioDevice>> get devicesStream =>
|
Stream<List<mk.AudioDevice>> get devicesStream =>
|
||||||
_mkPlayer.stream.audioDevices.asBroadcastStream();
|
_mkPlayer.stream.audioDevices.asBroadcastStream();
|
||||||
|
|
||||||
Stream<AudioDevice> get selectedDeviceStream =>
|
Stream<mk.AudioDevice> get selectedDeviceStream =>
|
||||||
_mkPlayer.stream.audioDevice.asBroadcastStream();
|
_mkPlayer.stream.audioDevice.asBroadcastStream();
|
||||||
|
|
||||||
Stream<String> get errorStream => _mkPlayer.stream.error;
|
Stream<String> get errorStream => _mkPlayer.stream.error;
|
||||||
|
|
||||||
|
Stream<mk.Playlist> get playlistStream => _mkPlayer.stream.playlist;
|
||||||
}
|
}
|
||||||
|
143
lib/services/audio_player/custom_player.dart
Normal file
143
lib/services/audio_player/custom_player.dart
Normal file
@ -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<AudioPlaybackState> _playerStateStream;
|
||||||
|
final StreamController<bool> _shuffleStream;
|
||||||
|
|
||||||
|
late final List<StreamSubscription> _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<void> 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<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
|
||||||
|
Stream<bool> get shuffleStream => _shuffleStream.stream;
|
||||||
|
Stream<int> 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<void> setShuffle(bool shuffle) async {
|
||||||
|
_shuffled = shuffle;
|
||||||
|
await super.setShuffle(shuffle);
|
||||||
|
_shuffleStream.add(shuffle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
await super.stop();
|
||||||
|
|
||||||
|
_shuffled = false;
|
||||||
|
_playerStateStream.add(AudioPlaybackState.stopped);
|
||||||
|
_shuffleStream.add(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
for (var element in _subscriptions) {
|
||||||
|
element.cancel();
|
||||||
|
}
|
||||||
|
await notifyAudioSessionUpdate(false);
|
||||||
|
return super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
NativePlayer get nativePlayer => platform as NativePlayer;
|
||||||
|
|
||||||
|
Future<void> insert(int index, Media media) async {
|
||||||
|
await add(media);
|
||||||
|
await move(state.playlist.medias.length, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<AudioPlaybackState> _playerStateStream;
|
|
||||||
final StreamController<Playlist> _playlistStream;
|
|
||||||
final StreamController<bool> _shuffleStream;
|
|
||||||
final StreamController<PlaylistMode> _loopModeStream;
|
|
||||||
|
|
||||||
late final List<StreamSubscription> _subscriptions;
|
|
||||||
|
|
||||||
bool _shuffled;
|
|
||||||
PlaylistMode _loopMode;
|
|
||||||
|
|
||||||
Playlist? _playlist;
|
|
||||||
List<Media>? _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<void> 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<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
|
|
||||||
Stream<bool> get shuffleStream => _shuffleStream.stream;
|
|
||||||
Stream<PlaylistMode> get loopModeStream => _loopModeStream.stream;
|
|
||||||
Stream<Playlist> get playlistStream => _playlistStream.stream;
|
|
||||||
Stream<int> 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<void> 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<void> setPlaylistMode(PlaylistMode playlistMode) async {
|
|
||||||
_loopMode = playlistMode;
|
|
||||||
await super.setPlaylistMode(playlistMode);
|
|
||||||
_loopModeStream.add(playlistMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> 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<void> dispose() async {
|
|
||||||
for (var element in _subscriptions) {
|
|
||||||
element.cancel();
|
|
||||||
}
|
|
||||||
await notifyAudioSessionUpdate(false);
|
|
||||||
return super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> add(Media media) async {
|
|
||||||
if (_playlist == null) return;
|
|
||||||
|
|
||||||
playlist = _playlist!.copyWith(
|
|
||||||
medias: [..._playlist!.medias, media],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shuffled && _tempMedias != null) {
|
|
||||||
_tempMedias!.add(media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> 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<void> 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<void> 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', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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<DBusMethodResponse> getCanQuit() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Fullscreen
|
|
||||||
Future<DBusMethodResponse> getFullscreen() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Fullscreen
|
|
||||||
Future<DBusMethodResponse> setFullscreen(bool value) async {
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen
|
|
||||||
Future<DBusMethodResponse> getCanSetFullscreen() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.CanRaise
|
|
||||||
Future<DBusMethodResponse> getCanRaise() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.HasTrackList
|
|
||||||
Future<DBusMethodResponse> getHasTrackList() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(false)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Identity
|
|
||||||
Future<DBusMethodResponse> getIdentity() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusString("Spotube")]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.DesktopEntry
|
|
||||||
Future<DBusMethodResponse> getDesktopEntry() async {
|
|
||||||
return DBusMethodSuccessResponse(
|
|
||||||
[const DBusString("/usr/share/application/spotube")],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes
|
|
||||||
Future<DBusMethodResponse> getSupportedUriSchemes() async {
|
|
||||||
return DBusMethodSuccessResponse([
|
|
||||||
DBusArray.string(["http"])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes
|
|
||||||
Future<DBusMethodResponse> getSupportedMimeTypes() async {
|
|
||||||
return DBusMethodSuccessResponse([
|
|
||||||
DBusArray.string(["audio/mpeg"])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Raise()
|
|
||||||
Future<DBusMethodResponse> doRaise() async {
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Quit()
|
|
||||||
Future<DBusMethodResponse> doQuit() async {
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<DBusIntrospectInterface> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> getAllProperties(String interface) async {
|
|
||||||
var properties = <String, DBusValue>{};
|
|
||||||
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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> setLoopStatus(String value) async {
|
|
||||||
// playlistNotifier.setIsLoop(value == "Track");
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Rate
|
|
||||||
Future<DBusMethodResponse> getRate() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusDouble(1)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Player.Rate
|
|
||||||
Future<DBusMethodResponse> setRate(double value) async {
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle
|
|
||||||
Future<DBusMethodResponse> getShuffle() async {
|
|
||||||
return DBusMethodSuccessResponse(
|
|
||||||
[DBusBoolean(await audioPlayer.isShuffled)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Player.Shuffle
|
|
||||||
Future<DBusMethodResponse> setShuffle(bool value) async {
|
|
||||||
audioPlayer.setShuffle(value);
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Metadata
|
|
||||||
Future<DBusMethodResponse> 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<DBusMethodResponse> getVolume() async {
|
|
||||||
return DBusMethodSuccessResponse([DBusDouble(audioPlayer.volume)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Player.Volume
|
|
||||||
Future<DBusMethodResponse> setVolume(double value) async {
|
|
||||||
await audioPlayer.setVolume(value);
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Position
|
|
||||||
Future<DBusMethodResponse> getPosition() async {
|
|
||||||
return DBusMethodSuccessResponse([
|
|
||||||
DBusInt64((await audioPlayer.position)?.inMicroseconds ?? 0),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate
|
|
||||||
Future<DBusMethodResponse> getMinimumRate() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusDouble(1)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate
|
|
||||||
Future<DBusMethodResponse> getMaximumRate() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusDouble(1)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext
|
|
||||||
Future<DBusMethodResponse> getCanGoNext() async {
|
|
||||||
return DBusMethodSuccessResponse([
|
|
||||||
DBusBoolean(
|
|
||||||
(playlist.tracks.length) > 1,
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious
|
|
||||||
Future<DBusMethodResponse> getCanGoPrevious() async {
|
|
||||||
return DBusMethodSuccessResponse([
|
|
||||||
DBusBoolean(
|
|
||||||
(playlist.tracks.length) > 1,
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay
|
|
||||||
Future<DBusMethodResponse> getCanPlay() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.CanPause
|
|
||||||
Future<DBusMethodResponse> getCanPause() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek
|
|
||||||
Future<DBusMethodResponse> getCanSeek() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.CanControl
|
|
||||||
Future<DBusMethodResponse> getCanControl() async {
|
|
||||||
return DBusMethodSuccessResponse([const DBusBoolean(true)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Next()
|
|
||||||
Future<DBusMethodResponse> doNext() async {
|
|
||||||
await playlistNotifier.next();
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Previous()
|
|
||||||
Future<DBusMethodResponse> doPrevious() async {
|
|
||||||
await playlistNotifier.previous();
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Pause()
|
|
||||||
Future<DBusMethodResponse> doPause() async {
|
|
||||||
await audioPlayer.pause();
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.PlayPause()
|
|
||||||
Future<DBusMethodResponse> doPlayPause() async {
|
|
||||||
audioPlayer.isPlaying
|
|
||||||
? await audioPlayer.pause()
|
|
||||||
: await audioPlayer.resume();
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Stop()
|
|
||||||
Future<DBusMethodResponse> doStop() async {
|
|
||||||
playlistNotifier.stop();
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Play()
|
|
||||||
Future<DBusMethodResponse> doPlay() async {
|
|
||||||
await audioPlayer.resume();
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Seek()
|
|
||||||
Future<DBusMethodResponse> doSeek(int offset) async {
|
|
||||||
await audioPlayer.seek(Duration(microseconds: offset));
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.SetPosition()
|
|
||||||
Future<DBusMethodResponse> doSetPosition(String TrackId, int Position) async {
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.OpenUri()
|
|
||||||
Future<DBusMethodResponse> doOpenUri(String Uri) async {
|
|
||||||
return DBusMethodSuccessResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emits signal org.mpris.MediaPlayer2.Player.Seeked
|
|
||||||
Future<void> emitSeeked(int position) async {
|
|
||||||
await emitSignal(
|
|
||||||
'org.mpris.MediaPlayer2.Player',
|
|
||||||
'Seeked',
|
|
||||||
[DBusInt64(position)],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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<DBusIntrospectInterface> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> 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<DBusMethodResponse> getAllProperties(String interface) async {
|
|
||||||
var properties = <String, DBusValue>{};
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,6 +11,7 @@ class MobileAudioService extends BaseAudioHandler {
|
|||||||
AudioSession? session;
|
AudioSession? session;
|
||||||
final ProxyPlaylistNotifier playlistNotifier;
|
final ProxyPlaylistNotifier playlistNotifier;
|
||||||
|
|
||||||
|
// ignore: invalid_use_of_protected_member
|
||||||
ProxyPlaylist get playlist => playlistNotifier.state;
|
ProxyPlaylist get playlist => playlistNotifier.state;
|
||||||
|
|
||||||
MobileAudioService(this.playlistNotifier) {
|
MobileAudioService(this.playlistNotifier) {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
|
||||||
class MusicMetadata {
|
class MusicMetadata {
|
||||||
final String? title;
|
final String? title;
|
||||||
final String? artist;
|
final String? artist;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// ignore_for_file: avoid_print
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:args/args.dart';
|
import 'package:args/args.dart';
|
||||||
|
@ -28,8 +28,6 @@ class DownloadTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
;
|
|
||||||
|
|
||||||
status.addListener(listener);
|
status.addListener(listener);
|
||||||
|
|
||||||
return completer.future.timeout(timeout);
|
return completer.future.timeout(timeout);
|
||||||
|
@ -37,8 +37,6 @@ Duration parseDuration(String input) {
|
|||||||
days = p ~/ 24;
|
days = p ~/ 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO verify that there are no negative parts
|
|
||||||
|
|
||||||
return Duration(
|
return Duration(
|
||||||
days: days,
|
days: days,
|
||||||
hours: hours,
|
hours: hours,
|
||||||
|
@ -1817,7 +1817,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.5"
|
version: "6.0.5"
|
||||||
pub_api_client:
|
pub_api_client:
|
||||||
dependency: "direct dev"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pub_api_client
|
name: pub_api_client
|
||||||
sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71
|
sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71
|
||||||
@ -1841,7 +1841,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
pubspec_parse:
|
pubspec_parse:
|
||||||
dependency: "direct dev"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pubspec_parse
|
name: pubspec_parse
|
||||||
sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367
|
sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367
|
||||||
|
@ -129,6 +129,8 @@ dependencies:
|
|||||||
shelf_web_socket: ^1.0.4
|
shelf_web_socket: ^1.0.4
|
||||||
web_socket_channel: ^2.4.4
|
web_socket_channel: ^2.4.4
|
||||||
lrc: ^1.0.2
|
lrc: ^1.0.2
|
||||||
|
pub_api_client: ^2.4.0
|
||||||
|
pubspec_parse: ^1.2.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.9
|
build_runner: ^2.4.9
|
||||||
@ -143,8 +145,6 @@ dev_dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
hive_generator: ^2.0.0
|
hive_generator: ^2.0.0
|
||||||
json_serializable: ^6.6.2
|
json_serializable: ^6.6.2
|
||||||
pub_api_client: ^2.4.0
|
|
||||||
pubspec_parse: ^1.2.2
|
|
||||||
freezed: ^2.4.6
|
freezed: ^2.4.6
|
||||||
custom_lint: ^0.5.11
|
custom_lint: ^0.5.11
|
||||||
riverpod_lint: ^2.1.1
|
riverpod_lint: ^2.1.1
|
||||||
|
@ -159,7 +159,8 @@
|
|||||||
"remote"
|
"remote"
|
||||||
],
|
],
|
||||||
|
|
||||||
"tr": [
|
"th": [
|
||||||
|
"choose_your_language",
|
||||||
"enable_connect",
|
"enable_connect",
|
||||||
"enable_connect_description",
|
"enable_connect_description",
|
||||||
"devices",
|
"devices",
|
||||||
|
Loading…
Reference in New Issue
Block a user