diff --git a/README.md b/README.md index 72fe188..f61bdb3 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Their original app is good enough. But I just want to redesign the user interfac ## Roadmap - [x] Playing music - - [ ] Add netease music as source + - [x] Add netease music as source - [x] Re-design user interface - [x] Simplified UI and UX - - [ ] Support for large screen device + - [x] Support for large screen device ## License diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fdde745..1b28b3a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/lib/providers/error_notifier.dart b/lib/providers/error_notifier.dart index 68aa594..83ddc6e 100644 --- a/lib/providers/error_notifier.dart +++ b/lib/providers/error_notifier.dart @@ -7,7 +7,7 @@ class ErrorNotifier extends GetxController { Rx showing = Rx(null); void logError(String msg, {StackTrace? trace}) { - log('$msg${trace != null ? '\nTrace:\ntrace' : ''}'); + log('$msg${trace != null ? '\nTrace:\n$trace' : ''}'); showError(msg); } diff --git a/lib/providers/user_preferences.dart b/lib/providers/user_preferences.dart index 268e501..4cd9c57 100644 --- a/lib/providers/user_preferences.dart +++ b/lib/providers/user_preferences.dart @@ -141,6 +141,10 @@ class UserPreferencesProvider extends GetxController { setData(PreferencesTableCompanion(locale: Value(locale))); } + void setNeteaseApiInstance(String instance) { + setData(PreferencesTableCompanion(neteaseApiInstance: Value(instance))); + } + void setPipedInstance(String instance) { setData(PreferencesTableCompanion(pipedInstance: Value(instance))); } @@ -161,10 +165,6 @@ class UserPreferencesProvider extends GetxController { setData(PreferencesTableCompanion(systemTitleBar: Value(isSystemTitleBar))); } - void setDiscordPresence(bool discordPresence) { - setData(PreferencesTableCompanion(discordPresence: Value(discordPresence))); - } - void setNormalizeAudio(bool normalize) { setData(PreferencesTableCompanion(normalizeAudio: Value(normalize))); audioPlayer.setAudioNormalization(normalize); diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 83865b6..36b596f 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -71,14 +71,16 @@ class _ExploreScreenState extends State { return; } - final customEndpoint = - CustomSpotifyEndpoints(_auth.auth.value?.accessToken.value ?? ''); - final forYouView = await customEndpoint.getView( - 'made-for-x-hub', - market: market, - locale: Intl.canonicalizedLocale(locale.toString()), - ); - _forYouView = forYouView['content']?['items']; + if (_auth.auth.value != null) { + final customEndpoint = + CustomSpotifyEndpoints(_auth.auth.value?.accessToken.value ?? ''); + final forYouView = await customEndpoint.getView( + 'made-for-x-hub', + market: market, + locale: Intl.canonicalizedLocale(locale.toString()), + ); + _forYouView = forYouView['content']?['items']; + } if (mounted) { setState(() => _isLoading['forYou'] = false); } else { diff --git a/lib/screens/player/view.dart b/lib/screens/player/view.dart index f3ad594..41fee5e 100644 --- a/lib/screens/player/view.dart +++ b/lib/screens/player/view.dart @@ -69,301 +69,310 @@ class _PlayerScreenState extends State { Navigator.of(context).pop(); }, direction: DismissiblePageDismissDirection.down, - child: Material( - color: Colors.transparent, - child: SafeArea( - child: Row( - children: [ - Expanded( - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 24), - children: [ - Obx( - () => LimitedBox( - maxHeight: maxAlbumSize, - maxWidth: maxAlbumSize, - child: Hero( - tag: const Key('current-active-track-album-art'), - child: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: - const BorderRadius.all(Radius.circular(16)), - child: _albumArt != null - ? AutoCacheImage( - _albumArt!, - width: albumSize, - height: albumSize, - ) - : Container( - color: Theme.of(context) - .colorScheme - .surfaceContainerHigh, - width: 64, - height: 64, - child: const Center( - child: Icon(Icons.image)), - ), - ), - ).marginSymmetric(horizontal: 24), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Center( + child: Row( + children: [ + Expanded( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 24), + children: [ + Obx( + () => LimitedBox( + maxHeight: maxAlbumSize, + maxWidth: maxAlbumSize, + child: Hero( + tag: const Key('current-active-track-album-art'), + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(16)), + child: _albumArt != null + ? AutoCacheImage( + _albumArt!, + width: albumSize, + height: albumSize, + ) + : Container( + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, + width: 64, + height: 64, + child: const Center( + child: Icon(Icons.image), + ), + ), + ), + ).marginSymmetric(horizontal: 24), + ), ), ), - ), - const Gap(24), - Obx( - () => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const Gap(24), + Obx( + () => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _playback.state.value.activeTrack?.name ?? + 'Not playing', + style: + Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.left, + ), + Text( + _playback.state.value.activeTrack?.artists + ?.asString() ?? + 'No author', + style: + Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + ), + ], + ), + ), + if (_playback.state.value.activeTrack != null && + _auth.auth.value != null) + TrackHeartButton( + key: ValueKey( + _playback.state.value.activeTrack!.id!, + ), + trackId: _playback.state.value.activeTrack!.id!, + ), + ], + ).paddingSymmetric(horizontal: 32), + ), + const Gap(24), + Obx( + () => Column( + children: [ + SliderTheme( + data: SliderThemeData( + trackHeight: 2, + trackShape: _PlayerProgressTrackShape(), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + overlayShape: SliderComponentShape.noOverlay, + ), + child: Slider( + secondaryTrackValue: _playback + .durationBuffered.value.inMilliseconds + .abs() + .toDouble(), + value: _draggingValue?.abs() ?? + _playback + .durationCurrent.value.inMilliseconds + .toDouble() + .abs(), + min: 0, + max: max( + _playback.durationCurrent.value.inMilliseconds + .abs(), + _playback.durationTotal.value.inMilliseconds + .abs(), + ).toDouble(), + onChanged: (value) { + setState(() => _draggingValue = value); + }, + onChangeEnd: (value) { + audioPlayer.seek( + Duration(milliseconds: value.toInt()), + ); + setState(() => _draggingValue = null); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _playback.state.value.activeTrack?.name ?? - 'Not playing', - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.left, + _playback.durationCurrent.value + .toHumanReadableString(), + style: GoogleFonts.robotoMono(fontSize: 12), ), Text( - _playback.state.value.activeTrack?.artists - ?.asString() ?? - 'No author', - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, + _playback.durationTotal.value + .toHumanReadableString(), + style: GoogleFonts.robotoMono(fontSize: 12), ), ], - ), - ), - if (_playback.state.value.activeTrack != null && - _auth.auth.value != null) - TrackHeartButton( - key: ValueKey( - _playback.state.value.activeTrack!.id!, - ), - trackId: _playback.state.value.activeTrack!.id!, - ), - ], - ).paddingSymmetric(horizontal: 32), - ), - const Gap(24), - Obx( - () => Column( + ).paddingSymmetric(horizontal: 8, vertical: 4), + ], + ).paddingSymmetric(horizontal: 24), + ), + const Gap(24), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - SliderTheme( - data: SliderThemeData( - trackHeight: 2, - trackShape: _PlayerProgressTrackShape(), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8, - ), - overlayShape: SliderComponentShape.noOverlay, - ), - child: Slider( - secondaryTrackValue: _playback - .durationBuffered.value.inMilliseconds - .abs() - .toDouble(), - value: _draggingValue?.abs() ?? - _playback.durationCurrent.value.inMilliseconds - .toDouble() - .abs(), - min: 0, - max: max( - _playback.durationCurrent.value.inMilliseconds - .abs(), - _playback.durationTotal.value.inMilliseconds - .abs(), - ).toDouble(), - onChanged: (value) { - setState(() => _draggingValue = value); - }, - onChangeEnd: (value) { - audioPlayer.seek( - Duration(milliseconds: value.toInt()), - ); - setState(() => _draggingValue = null); - }, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _playback.durationCurrent.value - .toHumanReadableString(), - style: GoogleFonts.robotoMono(fontSize: 12), - ), - Text( - _playback.durationTotal.value - .toHumanReadableString(), - style: GoogleFonts.robotoMono(fontSize: 12), - ), - ], - ).paddingSymmetric(horizontal: 8, vertical: 4), - ], - ).paddingSymmetric(horizontal: 24), - ), - const Gap(24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamBuilder( - stream: audioPlayer.shuffledStream, - builder: (context, snapshot) { - final shuffled = snapshot.data ?? false; - return Obx( - () => IconButton( - icon: Icon( - shuffled - ? Icons.shuffle_on_outlined - : Icons.shuffle, + StreamBuilder( + stream: audioPlayer.shuffledStream, + builder: (context, snapshot) { + final shuffled = snapshot.data ?? false; + return Obx( + () => IconButton( + icon: Icon( + shuffled + ? Icons.shuffle_on_outlined + : Icons.shuffle, + ), + onPressed: _isFetchingActiveTrack + ? null + : () { + if (shuffled) { + audioPlayer.setShuffle(false); + } else { + audioPlayer.setShuffle(true); + } + }, ), - onPressed: _isFetchingActiveTrack - ? null - : () { - if (shuffled) { - audioPlayer.setShuffle(false); - } else { - audioPlayer.setShuffle(true); - } - }, - ), - ); - }, - ), - Obx( - () => IconButton( - icon: const Icon(Icons.skip_previous), - onPressed: _isFetchingActiveTrack - ? null - : audioPlayer.skipToPrevious, - ), - ), - const Gap(8), - Obx( - () => SizedBox( - width: 56, - height: 56, - child: IconButton.filled( - icon: _isFetchingActiveTrack - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) - : Icon( - !_isPlaying - ? Icons.play_arrow - : Icons.pause, - size: 28, - ), - onPressed: _togglePlayState, - ), - ), - ), - const Gap(8), - Obx( - () => IconButton( - icon: const Icon(Icons.skip_next), - onPressed: _isFetchingActiveTrack - ? null - : audioPlayer.skipToNext, - ), - ), - Obx( - () => IconButton( - icon: Icon( - _loopMode == PlaylistMode.none - ? Icons.repeat - : _loopMode == PlaylistMode.loop - ? Icons.repeat_on_outlined - : Icons.repeat_one_on_outlined, - ), - onPressed: _isFetchingActiveTrack - ? null - : () async { - await audioPlayer.setLoopMode( - switch (_loopMode) { - PlaylistMode.loop => - PlaylistMode.single, - PlaylistMode.single => - PlaylistMode.none, - PlaylistMode.none => PlaylistMode.loop, - }, - ); - }, - ), - ), - ], - ), - const Gap(20), - Row( - children: [ - Expanded( - child: TextButton.icon( - icon: const Icon(Icons.queue_music), - label: const Text('Queue'), - onPressed: () { - showModalBottomSheet( - useRootNavigator: true, - isScrollControlled: true, - context: context, - builder: (context) => const PlayerQueuePopup(), - ).then((_) { - if (mounted) { - setState(() {}); - } - }); + ); }, ), - ), - if (!isLargeScreen) const Gap(4), - if (!isLargeScreen) + Obx( + () => IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: _isFetchingActiveTrack + ? null + : audioPlayer.skipToPrevious, + ), + ), + const Gap(8), + Obx( + () => SizedBox( + width: 56, + height: 56, + child: IconButton.filled( + icon: _isFetchingActiveTrack + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : Icon( + !_isPlaying + ? Icons.play_arrow + : Icons.pause, + size: 28, + ), + onPressed: _togglePlayState, + ), + ), + ), + const Gap(8), + Obx( + () => IconButton( + icon: const Icon(Icons.skip_next), + onPressed: _isFetchingActiveTrack + ? null + : audioPlayer.skipToNext, + ), + ), + Obx( + () => IconButton( + icon: Icon( + _loopMode == PlaylistMode.none + ? Icons.repeat + : _loopMode == PlaylistMode.loop + ? Icons.repeat_on_outlined + : Icons.repeat_one_on_outlined, + ), + onPressed: _isFetchingActiveTrack + ? null + : () async { + await audioPlayer.setLoopMode( + switch (_loopMode) { + PlaylistMode.loop => + PlaylistMode.single, + PlaylistMode.single => + PlaylistMode.none, + PlaylistMode.none => + PlaylistMode.loop, + }, + ); + }, + ), + ), + ], + ), + const Gap(20), + Row( + children: [ Expanded( child: TextButton.icon( - icon: const Icon(Icons.lyrics), - label: const Text('Lyrics'), + icon: const Icon(Icons.queue_music), + label: const Text('Queue'), onPressed: () { - GoRouter.of(context).pushNamed('playerLyrics'); + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + context: context, + builder: (context) => + const PlayerQueuePopup(), + ).then((_) { + if (mounted) { + setState(() {}); + } + }); }, ), ), - const Gap(4), - Expanded( - child: TextButton.icon( - icon: const Icon(Icons.merge), - label: const Text('Sources'), - onPressed: () { - showModalBottomSheet( - useRootNavigator: true, - isScrollControlled: true, - context: context, - builder: (context) => - const SiblingTracksPopup(), - ).then((_) { - if (mounted) { - setState(() {}); - } - }); - }, + if (!isLargeScreen) const Gap(4), + if (!isLargeScreen) + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.lyrics), + label: const Text('Lyrics'), + onPressed: () { + GoRouter.of(context) + .pushNamed('playerLyrics'); + }, + ), + ), + const Gap(4), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.merge), + label: const Text('Sources'), + onPressed: () { + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + context: context, + builder: (context) => + const SiblingTracksPopup(), + ).then((_) { + if (mounted) { + setState(() {}); + } + }); + }, + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), - if (isLargeScreen) const Gap(24), - if (isLargeScreen) - const Expanded( - child: SyncedLyrics(defaultTextZoom: 67), - ) - ], + if (isLargeScreen) const Gap(24), + if (isLargeScreen) + const Expanded( + child: SyncedLyrics(defaultTextZoom: 67), + ) + ], + ), ), ).marginSymmetric(horizontal: 24), ), diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 65918d3..4024523 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,3 +1,4 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; @@ -5,6 +6,7 @@ import 'package:rhythm_box/providers/auth.dart'; import 'package:rhythm_box/providers/spotify.dart'; import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/screens/auth/login.dart'; +import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/widgets/auto_cache_image.dart'; import 'package:rhythm_box/widgets/sized_container.dart'; @@ -101,6 +103,53 @@ class _SettingsScreenState extends State { ); }), const Divider(thickness: 0.3, height: 1), + Obx( + () => ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.audio_file), + title: const Text('Audio Source'), + subtitle: + const Text('Choose who to provide the songs you played.'), + trailing: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: Text( + 'Select Item', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ), + items: AudioSource.values + .map((AudioSource item) => + DropdownMenuItem( + value: item, + child: Text( + item.label, + style: const TextStyle( + fontSize: 14, + ), + ), + )) + .toList(), + value: _preferences.state.value.audioSource, + onChanged: (AudioSource? value) { + _preferences + .setAudioSource(value ?? AudioSource.youtube); + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.symmetric(horizontal: 16), + height: 40, + width: 140, + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + ), + ), + ), + ), + const Divider(thickness: 0.3, height: 1), Obx( () => SwitchListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24), diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index e8db415..8b0bf30 100755 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -94,7 +94,7 @@ abstract class AudioPlayerInterface { ), ) { _mkPlayer.stream.error.listen((event) { - Get.find().logError('[Playback] Error: $event'); + Get.find().logError('[Playback][Player] Error: $event'); }); } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index c7bf4c1..445583c 100755 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -50,7 +50,8 @@ class CustomPlayer extends Player { } }), stream.error.listen((event) { - Get.find().logError('[Playback] Error: $event'); + Get.find() + .logError('[Playback][CustomLayer] Error: $event'); }), ]; PackageInfo.fromPlatform().then((packageInfo) { diff --git a/lib/services/database/database.g.dart b/lib/services/database/database.g.dart index aafaa71..4146ad3 100644 --- a/lib/services/database/database.g.dart +++ b/lib/services/database/database.g.dart @@ -453,6 +453,15 @@ class $PreferencesTableTable extends PreferencesTable type: DriftSqlType.string, requiredDuringInsert: false, defaultValue: const Constant('https://pipedapi.kavin.rocks')); + static const VerificationMeta _neteaseApiInstanceMeta = + const VerificationMeta('neteaseApiInstance'); + @override + late final GeneratedColumn neteaseApiInstance = + GeneratedColumn('netease_api_instance', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: + const Constant('https://rhythmbox-netease-music-api.vercel.app')); static const VerificationMeta _themeModeMeta = const VerificationMeta('themeMode'); @override @@ -494,16 +503,6 @@ class $PreferencesTableTable extends PreferencesTable defaultValue: Constant(SourceCodecs.m4a.name)) .withConverter( $PreferencesTableTable.$converterdownloadMusicCodec); - static const VerificationMeta _discordPresenceMeta = - const VerificationMeta('discordPresence'); - @override - late final GeneratedColumn discordPresence = GeneratedColumn( - 'discord_presence', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("discord_presence" IN (0, 1))'), - defaultValue: const Constant(true)); static const VerificationMeta _endlessPlaybackMeta = const VerificationMeta('endlessPlayback'); @override @@ -543,11 +542,11 @@ class $PreferencesTableTable extends PreferencesTable downloadLocation, localLibraryLocation, pipedInstance, + neteaseApiInstance, themeMode, audioSource, streamMusicCodec, downloadMusicCodec, - discordPresence, endlessPlayback, playerWakelock ]; @@ -622,16 +621,16 @@ class $PreferencesTableTable extends PreferencesTable pipedInstance.isAcceptableOrUnknown( data['piped_instance']!, _pipedInstanceMeta)); } + if (data.containsKey('netease_api_instance')) { + context.handle( + _neteaseApiInstanceMeta, + neteaseApiInstance.isAcceptableOrUnknown( + data['netease_api_instance']!, _neteaseApiInstanceMeta)); + } context.handle(_themeModeMeta, const VerificationResult.success()); context.handle(_audioSourceMeta, const VerificationResult.success()); context.handle(_streamMusicCodecMeta, const VerificationResult.success()); context.handle(_downloadMusicCodecMeta, const VerificationResult.success()); - if (data.containsKey('discord_presence')) { - context.handle( - _discordPresenceMeta, - discordPresence.isAcceptableOrUnknown( - data['discord_presence']!, _discordPresenceMeta)); - } if (data.containsKey('endless_playback')) { context.handle( _endlessPlaybackMeta, @@ -696,6 +695,8 @@ class $PreferencesTableTable extends PreferencesTable data['${effectivePrefix}local_library_location'])!), pipedInstance: attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}piped_instance'])!, + neteaseApiInstance: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}netease_api_instance'])!, themeMode: $PreferencesTableTable.$converterthemeMode.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}theme_mode'])!), @@ -708,8 +709,6 @@ class $PreferencesTableTable extends PreferencesTable downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}download_music_codec'])!), - discordPresence: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}discord_presence'])!, endlessPlayback: attachedDatabase.typeMapping .read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!, playerWakelock: attachedDatabase.typeMapping @@ -771,11 +770,11 @@ class PreferencesTableData extends DataClass final String downloadLocation; final List localLibraryLocation; final String pipedInstance; + final String neteaseApiInstance; final ThemeMode themeMode; final AudioSource audioSource; final SourceCodecs streamMusicCodec; final SourceCodecs downloadMusicCodec; - final bool discordPresence; final bool endlessPlayback; final bool playerWakelock; const PreferencesTableData( @@ -796,11 +795,11 @@ class PreferencesTableData extends DataClass required this.downloadLocation, required this.localLibraryLocation, required this.pipedInstance, + required this.neteaseApiInstance, required this.themeMode, required this.audioSource, required this.streamMusicCodec, required this.downloadMusicCodec, - required this.discordPresence, required this.endlessPlayback, required this.playerWakelock}); @override @@ -849,6 +848,7 @@ class PreferencesTableData extends DataClass .toSql(localLibraryLocation)); } map['piped_instance'] = Variable(pipedInstance); + map['netease_api_instance'] = Variable(neteaseApiInstance); { map['theme_mode'] = Variable( $PreferencesTableTable.$converterthemeMode.toSql(themeMode)); @@ -867,7 +867,6 @@ class PreferencesTableData extends DataClass .$converterdownloadMusicCodec .toSql(downloadMusicCodec)); } - map['discord_presence'] = Variable(discordPresence); map['endless_playback'] = Variable(endlessPlayback); map['player_wakelock'] = Variable(playerWakelock); return map; @@ -892,11 +891,11 @@ class PreferencesTableData extends DataClass downloadLocation: Value(downloadLocation), localLibraryLocation: Value(localLibraryLocation), pipedInstance: Value(pipedInstance), + neteaseApiInstance: Value(neteaseApiInstance), themeMode: Value(themeMode), audioSource: Value(audioSource), streamMusicCodec: Value(streamMusicCodec), downloadMusicCodec: Value(downloadMusicCodec), - discordPresence: Value(discordPresence), endlessPlayback: Value(endlessPlayback), playerWakelock: Value(playerWakelock), ); @@ -930,6 +929,8 @@ class PreferencesTableData extends DataClass localLibraryLocation: serializer.fromJson>(json['localLibraryLocation']), pipedInstance: serializer.fromJson(json['pipedInstance']), + neteaseApiInstance: + serializer.fromJson(json['neteaseApiInstance']), themeMode: $PreferencesTableTable.$converterthemeMode .fromJson(serializer.fromJson(json['themeMode'])), audioSource: $PreferencesTableTable.$converteraudioSource @@ -938,7 +939,6 @@ class PreferencesTableData extends DataClass .fromJson(serializer.fromJson(json['streamMusicCodec'])), downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec .fromJson(serializer.fromJson(json['downloadMusicCodec'])), - discordPresence: serializer.fromJson(json['discordPresence']), endlessPlayback: serializer.fromJson(json['endlessPlayback']), playerWakelock: serializer.fromJson(json['playerWakelock']), ); @@ -970,6 +970,7 @@ class PreferencesTableData extends DataClass 'localLibraryLocation': serializer.toJson>(localLibraryLocation), 'pipedInstance': serializer.toJson(pipedInstance), + 'neteaseApiInstance': serializer.toJson(neteaseApiInstance), 'themeMode': serializer.toJson( $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), 'audioSource': serializer.toJson( @@ -980,7 +981,6 @@ class PreferencesTableData extends DataClass 'downloadMusicCodec': serializer.toJson($PreferencesTableTable .$converterdownloadMusicCodec .toJson(downloadMusicCodec)), - 'discordPresence': serializer.toJson(discordPresence), 'endlessPlayback': serializer.toJson(endlessPlayback), 'playerWakelock': serializer.toJson(playerWakelock), }; @@ -1004,11 +1004,11 @@ class PreferencesTableData extends DataClass String? downloadLocation, List? localLibraryLocation, String? pipedInstance, + String? neteaseApiInstance, ThemeMode? themeMode, AudioSource? audioSource, SourceCodecs? streamMusicCodec, SourceCodecs? downloadMusicCodec, - bool? discordPresence, bool? endlessPlayback, bool? playerWakelock}) => PreferencesTableData( @@ -1029,11 +1029,11 @@ class PreferencesTableData extends DataClass downloadLocation: downloadLocation ?? this.downloadLocation, localLibraryLocation: localLibraryLocation ?? this.localLibraryLocation, pipedInstance: pipedInstance ?? this.pipedInstance, + neteaseApiInstance: neteaseApiInstance ?? this.neteaseApiInstance, themeMode: themeMode ?? this.themeMode, audioSource: audioSource ?? this.audioSource, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, - discordPresence: discordPresence ?? this.discordPresence, endlessPlayback: endlessPlayback ?? this.endlessPlayback, playerWakelock: playerWakelock ?? this.playerWakelock, ); @@ -1081,6 +1081,9 @@ class PreferencesTableData extends DataClass pipedInstance: data.pipedInstance.present ? data.pipedInstance.value : this.pipedInstance, + neteaseApiInstance: data.neteaseApiInstance.present + ? data.neteaseApiInstance.value + : this.neteaseApiInstance, themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, audioSource: data.audioSource.present ? data.audioSource.value : this.audioSource, @@ -1090,9 +1093,6 @@ class PreferencesTableData extends DataClass downloadMusicCodec: data.downloadMusicCodec.present ? data.downloadMusicCodec.value : this.downloadMusicCodec, - discordPresence: data.discordPresence.present - ? data.discordPresence.value - : this.discordPresence, endlessPlayback: data.endlessPlayback.present ? data.endlessPlayback.value : this.endlessPlayback, @@ -1122,11 +1122,11 @@ class PreferencesTableData extends DataClass ..write('downloadLocation: $downloadLocation, ') ..write('localLibraryLocation: $localLibraryLocation, ') ..write('pipedInstance: $pipedInstance, ') + ..write('neteaseApiInstance: $neteaseApiInstance, ') ..write('themeMode: $themeMode, ') ..write('audioSource: $audioSource, ') ..write('streamMusicCodec: $streamMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ') - ..write('discordPresence: $discordPresence, ') ..write('endlessPlayback: $endlessPlayback, ') ..write('playerWakelock: $playerWakelock') ..write(')')) @@ -1152,11 +1152,11 @@ class PreferencesTableData extends DataClass downloadLocation, localLibraryLocation, pipedInstance, + neteaseApiInstance, themeMode, audioSource, streamMusicCodec, downloadMusicCodec, - discordPresence, endlessPlayback, playerWakelock ]); @@ -1181,11 +1181,11 @@ class PreferencesTableData extends DataClass other.downloadLocation == this.downloadLocation && other.localLibraryLocation == this.localLibraryLocation && other.pipedInstance == this.pipedInstance && + other.neteaseApiInstance == this.neteaseApiInstance && other.themeMode == this.themeMode && other.audioSource == this.audioSource && other.streamMusicCodec == this.streamMusicCodec && other.downloadMusicCodec == this.downloadMusicCodec && - other.discordPresence == this.discordPresence && other.endlessPlayback == this.endlessPlayback && other.playerWakelock == this.playerWakelock); } @@ -1208,11 +1208,11 @@ class PreferencesTableCompanion extends UpdateCompanion { final Value downloadLocation; final Value> localLibraryLocation; final Value pipedInstance; + final Value neteaseApiInstance; final Value themeMode; final Value audioSource; final Value streamMusicCodec; final Value downloadMusicCodec; - final Value discordPresence; final Value endlessPlayback; final Value playerWakelock; const PreferencesTableCompanion({ @@ -1233,11 +1233,11 @@ class PreferencesTableCompanion extends UpdateCompanion { this.downloadLocation = const Value.absent(), this.localLibraryLocation = const Value.absent(), this.pipedInstance = const Value.absent(), + this.neteaseApiInstance = const Value.absent(), this.themeMode = const Value.absent(), this.audioSource = const Value.absent(), this.streamMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(), - this.discordPresence = const Value.absent(), this.endlessPlayback = const Value.absent(), this.playerWakelock = const Value.absent(), }); @@ -1259,11 +1259,11 @@ class PreferencesTableCompanion extends UpdateCompanion { this.downloadLocation = const Value.absent(), this.localLibraryLocation = const Value.absent(), this.pipedInstance = const Value.absent(), + this.neteaseApiInstance = const Value.absent(), this.themeMode = const Value.absent(), this.audioSource = const Value.absent(), this.streamMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(), - this.discordPresence = const Value.absent(), this.endlessPlayback = const Value.absent(), this.playerWakelock = const Value.absent(), }); @@ -1285,11 +1285,11 @@ class PreferencesTableCompanion extends UpdateCompanion { Expression? downloadLocation, Expression? localLibraryLocation, Expression? pipedInstance, + Expression? neteaseApiInstance, Expression? themeMode, Expression? audioSource, Expression? streamMusicCodec, Expression? downloadMusicCodec, - Expression? discordPresence, Expression? endlessPlayback, Expression? playerWakelock, }) { @@ -1313,12 +1313,13 @@ class PreferencesTableCompanion extends UpdateCompanion { if (localLibraryLocation != null) 'local_library_location': localLibraryLocation, if (pipedInstance != null) 'piped_instance': pipedInstance, + if (neteaseApiInstance != null) + 'netease_api_instance': neteaseApiInstance, if (themeMode != null) 'theme_mode': themeMode, if (audioSource != null) 'audio_source': audioSource, if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec, if (downloadMusicCodec != null) 'download_music_codec': downloadMusicCodec, - if (discordPresence != null) 'discord_presence': discordPresence, if (endlessPlayback != null) 'endless_playback': endlessPlayback, if (playerWakelock != null) 'player_wakelock': playerWakelock, }); @@ -1342,11 +1343,11 @@ class PreferencesTableCompanion extends UpdateCompanion { Value? downloadLocation, Value>? localLibraryLocation, Value? pipedInstance, + Value? neteaseApiInstance, Value? themeMode, Value? audioSource, Value? streamMusicCodec, Value? downloadMusicCodec, - Value? discordPresence, Value? endlessPlayback, Value? playerWakelock}) { return PreferencesTableCompanion( @@ -1367,11 +1368,11 @@ class PreferencesTableCompanion extends UpdateCompanion { downloadLocation: downloadLocation ?? this.downloadLocation, localLibraryLocation: localLibraryLocation ?? this.localLibraryLocation, pipedInstance: pipedInstance ?? this.pipedInstance, + neteaseApiInstance: neteaseApiInstance ?? this.neteaseApiInstance, themeMode: themeMode ?? this.themeMode, audioSource: audioSource ?? this.audioSource, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, - discordPresence: discordPresence ?? this.discordPresence, endlessPlayback: endlessPlayback ?? this.endlessPlayback, playerWakelock: playerWakelock ?? this.playerWakelock, ); @@ -1443,6 +1444,9 @@ class PreferencesTableCompanion extends UpdateCompanion { if (pipedInstance.present) { map['piped_instance'] = Variable(pipedInstance.value); } + if (neteaseApiInstance.present) { + map['netease_api_instance'] = Variable(neteaseApiInstance.value); + } if (themeMode.present) { map['theme_mode'] = Variable( $PreferencesTableTable.$converterthemeMode.toSql(themeMode.value)); @@ -1462,9 +1466,6 @@ class PreferencesTableCompanion extends UpdateCompanion { .$converterdownloadMusicCodec .toSql(downloadMusicCodec.value)); } - if (discordPresence.present) { - map['discord_presence'] = Variable(discordPresence.value); - } if (endlessPlayback.present) { map['endless_playback'] = Variable(endlessPlayback.value); } @@ -1494,11 +1495,11 @@ class PreferencesTableCompanion extends UpdateCompanion { ..write('downloadLocation: $downloadLocation, ') ..write('localLibraryLocation: $localLibraryLocation, ') ..write('pipedInstance: $pipedInstance, ') + ..write('neteaseApiInstance: $neteaseApiInstance, ') ..write('themeMode: $themeMode, ') ..write('audioSource: $audioSource, ') ..write('streamMusicCodec: $streamMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ') - ..write('discordPresence: $discordPresence, ') ..write('endlessPlayback: $endlessPlayback, ') ..write('playerWakelock: $playerWakelock') ..write(')')) @@ -3950,11 +3951,11 @@ typedef $$PreferencesTableTableCreateCompanionBuilder Value downloadLocation, Value> localLibraryLocation, Value pipedInstance, + Value neteaseApiInstance, Value themeMode, Value audioSource, Value streamMusicCodec, Value downloadMusicCodec, - Value discordPresence, Value endlessPlayback, Value playerWakelock, }); @@ -3977,11 +3978,11 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder Value downloadLocation, Value> localLibraryLocation, Value pipedInstance, + Value neteaseApiInstance, Value themeMode, Value audioSource, Value streamMusicCodec, Value downloadMusicCodec, - Value discordPresence, Value endlessPlayback, Value playerWakelock, }); @@ -4021,11 +4022,11 @@ class $$PreferencesTableTableTableManager extends RootTableManager< Value downloadLocation = const Value.absent(), Value> localLibraryLocation = const Value.absent(), Value pipedInstance = const Value.absent(), + Value neteaseApiInstance = const Value.absent(), Value themeMode = const Value.absent(), Value audioSource = const Value.absent(), Value streamMusicCodec = const Value.absent(), Value downloadMusicCodec = const Value.absent(), - Value discordPresence = const Value.absent(), Value endlessPlayback = const Value.absent(), Value playerWakelock = const Value.absent(), }) => @@ -4047,11 +4048,11 @@ class $$PreferencesTableTableTableManager extends RootTableManager< downloadLocation: downloadLocation, localLibraryLocation: localLibraryLocation, pipedInstance: pipedInstance, + neteaseApiInstance: neteaseApiInstance, themeMode: themeMode, audioSource: audioSource, streamMusicCodec: streamMusicCodec, downloadMusicCodec: downloadMusicCodec, - discordPresence: discordPresence, endlessPlayback: endlessPlayback, playerWakelock: playerWakelock, ), @@ -4073,11 +4074,11 @@ class $$PreferencesTableTableTableManager extends RootTableManager< Value downloadLocation = const Value.absent(), Value> localLibraryLocation = const Value.absent(), Value pipedInstance = const Value.absent(), + Value neteaseApiInstance = const Value.absent(), Value themeMode = const Value.absent(), Value audioSource = const Value.absent(), Value streamMusicCodec = const Value.absent(), Value downloadMusicCodec = const Value.absent(), - Value discordPresence = const Value.absent(), Value endlessPlayback = const Value.absent(), Value playerWakelock = const Value.absent(), }) => @@ -4099,11 +4100,11 @@ class $$PreferencesTableTableTableManager extends RootTableManager< downloadLocation: downloadLocation, localLibraryLocation: localLibraryLocation, pipedInstance: pipedInstance, + neteaseApiInstance: neteaseApiInstance, themeMode: themeMode, audioSource: audioSource, streamMusicCodec: streamMusicCodec, downloadMusicCodec: downloadMusicCodec, - discordPresence: discordPresence, endlessPlayback: endlessPlayback, playerWakelock: playerWakelock, ), @@ -4214,6 +4215,11 @@ class $$PreferencesTableTableFilterComposer builder: (column, joinBuilders) => ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnFilters get neteaseApiInstance => $state.composableBuilder( + column: $state.table.neteaseApiInstance, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnWithTypeConverterFilters get themeMode => $state.composableBuilder( column: $state.table.themeMode, @@ -4242,11 +4248,6 @@ class $$PreferencesTableTableFilterComposer column, joinBuilders: joinBuilders)); - ColumnFilters get discordPresence => $state.composableBuilder( - column: $state.table.discordPresence, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - ColumnFilters get endlessPlayback => $state.composableBuilder( column: $state.table.endlessPlayback, builder: (column, joinBuilders) => @@ -4346,6 +4347,11 @@ class $$PreferencesTableTableOrderingComposer builder: (column, joinBuilders) => ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get neteaseApiInstance => $state.composableBuilder( + column: $state.table.neteaseApiInstance, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + ColumnOrderings get themeMode => $state.composableBuilder( column: $state.table.themeMode, builder: (column, joinBuilders) => @@ -4366,11 +4372,6 @@ class $$PreferencesTableTableOrderingComposer builder: (column, joinBuilders) => ColumnOrderings(column, joinBuilders: joinBuilders)); - ColumnOrderings get discordPresence => $state.composableBuilder( - column: $state.table.discordPresence, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - ColumnOrderings get endlessPlayback => $state.composableBuilder( column: $state.table.endlessPlayback, builder: (column, joinBuilders) => diff --git a/lib/services/database/tables/preferences.dart b/lib/services/database/tables/preferences.dart index 6aa59ad..28df2cc 100755 --- a/lib/services/database/tables/preferences.dart +++ b/lib/services/database/tables/preferences.dart @@ -13,7 +13,8 @@ enum CloseBehavior { enum AudioSource { youtube, - piped; + piped, + netease; String get label => name[0].toUpperCase() + name.substring(1); } @@ -74,6 +75,8 @@ class PreferencesTable extends Table { text().withDefault(const Constant('')).map(const StringListConverter())(); TextColumn get pipedInstance => text().withDefault(const Constant('https://pipedapi.kavin.rocks'))(); + TextColumn get neteaseApiInstance => text().withDefault( + const Constant('https://rhythmbox-netease-music-api.vercel.app'))(); TextColumn get themeMode => textEnum().withDefault(Constant(ThemeMode.system.name))(); TextColumn get audioSource => @@ -82,8 +85,6 @@ class PreferencesTable extends Table { textEnum().withDefault(Constant(SourceCodecs.weba.name))(); TextColumn get downloadMusicCodec => textEnum().withDefault(Constant(SourceCodecs.m4a.name))(); - BoolColumn get discordPresence => - boolean().withDefault(const Constant(true))(); BoolColumn get endlessPlayback => boolean().withDefault(const Constant(true))(); BoolColumn get playerWakelock => @@ -108,12 +109,12 @@ class PreferencesTable extends Table { searchMode: SearchMode.youtube, downloadLocation: '', localLibraryLocation: [], + neteaseApiInstance: 'https://rhythmbox-netease-music-api.vercel.app', pipedInstance: 'https://pipedapi.kavin.rocks', themeMode: ThemeMode.system, audioSource: AudioSource.youtube, streamMusicCodec: SourceCodecs.weba, downloadMusicCodec: SourceCodecs.m4a, - discordPresence: true, endlessPlayback: true, playerWakelock: true, ); diff --git a/lib/services/database/tables/source_match.dart b/lib/services/database/tables/source_match.dart index f31c559..60eaad7 100755 --- a/lib/services/database/tables/source_match.dart +++ b/lib/services/database/tables/source_match.dart @@ -2,7 +2,8 @@ part of '../database.dart'; enum SourceType { youtube._('YouTube'), - youtubeMusic._('YouTube Music'); + youtubeMusic._('YouTube Music'), + netease._('Netease Music'); final String label; diff --git a/lib/services/rhythm_media.dart b/lib/services/rhythm_media.dart index 6622287..54dab49 100644 --- a/lib/services/rhythm_media.dart +++ b/lib/services/rhythm_media.dart @@ -86,7 +86,7 @@ abstract class AudioPlayerInterface { ), ) { _mkPlayer.stream.error.listen((event) { - Get.find().logError('[Playback] Error: $event'); + Get.find().logError('[Playback][Media] Error: $event'); }); } diff --git a/lib/services/server/routes/playback.dart b/lib/services/server/routes/playback.dart index fcc282c..cb18459 100755 --- a/lib/services/server/routes/playback.dart +++ b/lib/services/server/routes/playback.dart @@ -6,6 +6,7 @@ import 'package:rhythm_box/providers/error_notifier.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/server/active_sourced_track.dart'; import 'package:rhythm_box/services/server/sourced_track.dart'; +import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:shelf/shelf.dart'; class ServerPlaybackRoutesProvider { @@ -24,14 +25,25 @@ class ServerPlaybackRoutesProvider { activeSourcedTrack.updateTrack(sourcedTrack); + var url = sourcedTrack!.url; + + if (sourcedTrack is NeteaseSourcedTrack) { + // Special processing for netease to get real assets url + final resp = await GetConnect(timeout: const Duration(seconds: 30)).get( + '${sourcedTrack.url}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}', + ); + final realUrl = resp.body['data'][0]['url']; + url = realUrl; + } + final res = await Dio().get( - sourcedTrack!.url, + 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, + 'host': Uri.parse(url).host, 'Cache-Control': 'max-age=0', 'Connection': 'keep-alive', }, diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 004da20..1cf6a1c 100755 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:get/get.dart'; import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/services/database/database.dart'; +import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:rhythm_box/services/utils.dart'; import 'package:spotify/spotify.dart'; @@ -55,6 +56,12 @@ abstract class SourcedTrack extends Track { .cast(); return switch (audioSource) { + AudioSource.netease => NeteaseSourcedTrack( + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), AudioSource.piped => PipedSourcedTrack( source: source, siblings: siblings, @@ -94,14 +101,20 @@ abstract class SourcedTrack extends Track { try { return switch (audioSource) { + AudioSource.netease => + await NeteaseSourcedTrack.fetchFromTrack(track: track), AudioSource.piped => await PipedSourcedTrack.fetchFromTrack(track: track), _ => await YoutubeSourcedTrack.fetchFromTrack(track: track), }; } on TrackNotFoundError catch (_) { - // TODO Try to look it up in other source - // But the youtube and piped.video are the same, and there is no extra sources, so i ignored this for temporary - rethrow; + return switch (preferences.audioSource) { + AudioSource.piped || + AudioSource.youtube => + await NeteaseSourcedTrack.fetchFromTrack(track: track), + AudioSource.netease => + await YoutubeSourcedTrack.fetchFromTrack(track: track), + }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track); } on VideoUnplayableException catch (_) { diff --git a/lib/services/sourced_track/sources/netease.dart b/lib/services/sourced_track/sources/netease.dart new file mode 100755 index 0000000..9ef22a8 --- /dev/null +++ b/lib/services/sourced_track/sources/netease.dart @@ -0,0 +1,235 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:get/get.dart' hide Value; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:spotify/spotify.dart'; +import 'package:rhythm_box/services/sourced_track/enums.dart'; +import 'package:rhythm_box/services/sourced_track/exceptions.dart'; +import 'package:rhythm_box/services/sourced_track/models/source_info.dart'; +import 'package:rhythm_box/services/sourced_track/models/source_map.dart'; +import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; + +class NeteaseSourceInfo extends SourceInfo { + NeteaseSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class NeteaseSourcedTrack extends SourcedTrack { + NeteaseSourcedTrack({ + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static String _getBaseUrl() { + final preferences = Get.find().state.value; + return preferences.neteaseApiInstance; + } + + static GetConnect _getClient() { + final client = GetConnect(); + client.baseUrl = _getBaseUrl(); + client.timeout = const Duration(seconds: 30); + return client; + } + + static String? _lookedUpRealIp; + + static Future lookupRealIp() async { + if (_lookedUpRealIp != null) return _lookedUpRealIp!; + const ipCheckUrl = 'https://api.ipify.org'; + final client = GetConnect(timeout: const Duration(seconds: 30)); + final resp = await client.get(ipCheckUrl); + _lookedUpRealIp = resp.body; + return _lookedUpRealIp!; + } + + static Future fetchFromTrack({ + required Track track, + }) async { + final DatabaseProvider db = Get.find(); + final cachedSource = await (db.database.select(db.database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .get() + .then((s) => s.firstOrNull); + + if (cachedSource == null) { + final siblings = await fetchSiblings(track: track); + if (siblings.isEmpty) { + throw TrackNotFoundError(track); + } + + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.netease), + ), + ); + + return NeteaseSourcedTrack( + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } + + final client = _getClient(); + final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}'); + final item = resp.body['songs'][0]; + + return NeteaseSourcedTrack( + siblings: [], + source: toSourceMap(item), + sourceInfo: NeteaseSourceInfo( + id: item['id'].toString(), + artist: item['ar'].map((x) => x['name']).join(','), + artistUrl: 'https://music.163.com/#/artist?id=${item['ar'][0]['id']}', + pageUrl: 'https://music.163.com/#/song?id=${item['id']}', + thumbnail: item['al']['picUrl'], + title: item['name'], + duration: Duration(milliseconds: item['dt']), + album: item['al']['name'], + ), + track: track, + ); + } + + static SourceMap toSourceMap(dynamic manifest) { + final baseUrl = _getBaseUrl(); + + // Due to netease may provide m4a, mp3 and others, we cannot decide this so mock this data. + return SourceMap( + m4a: SourceQualityMap( + high: '$baseUrl/song/url?id=${manifest['id']}', + medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000', + low: '$baseUrl/song/url?id=${manifest['id']}&br=128000', + ), + weba: SourceQualityMap( + high: '$baseUrl/song/url?id=${manifest['id']}', + medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000', + low: '$baseUrl/song/url?id=${manifest['id']}&br=128000', + ), + ); + } + + static Future> fetchSiblings({ + required Track track, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final client = _getClient(); + final resp = + await client.get('/search?keywords=${Uri.encodeComponent(query)}'); + final results = resp.body['result']['songs']; + + // We can just trust netease music for now + // If we need to check is the result correct, refer to this code + // https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129 + final matchedResults = results.map(toSiblingType).toList(); + + return matchedResults.cast(); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(track: this); + + return NeteaseSourcedTrack( + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final client = _getClient(); + final resp = await client.get('/song/detail?ids=${newSourceInfo.id}'); + final item = resp.body['songs'][0]; + + final (:info, :source) = toSiblingType(item); + + final db = Get.find(); + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: info.id, + sourceType: const Value(SourceType.netease), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); + + return NeteaseSourcedTrack( + siblings: newSiblings, + source: source!, + sourceInfo: info, + track: this, + ); + } + + static SiblingType toSiblingType(dynamic item) { + final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0]; + + final SiblingType sibling = ( + info: NeteaseSourceInfo( + id: item['id'].toString(), + artist: item['ar'] != null + ? item['ar'].map((x) => x['name']).join(',') + : item['artists'].map((x) => x['name']).toString(), + artistUrl: 'https://music.163.com/#/artist?id=${firstArtist['id']}', + pageUrl: 'https://music.163.com/#/song?id=${item['id']}', + thumbnail: item['al']?['picUrl'] ?? + 'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg', + title: item['name'], + duration: item['dt'] != null + ? Duration(milliseconds: item['dt']) + : Duration.zero, + album: item['al']?['name'], + ), + source: toSourceMap(item), + ); + + return sibling; + } +} diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 3138296..6f994f2 100755 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -85,7 +85,7 @@ class YoutubeSourcedTrack extends SourcedTrack { cachedSource.sourceId, ) .timeout( - const Duration(seconds: 5), + const Duration(seconds: 30), onTimeout: () => throw ClientException('Timeout'), ); return YoutubeSourcedTrack( @@ -140,7 +140,7 @@ class YoutubeSourcedTrack extends SourcedTrack { if (index == 0) { final manifest = await youtubeClient.videos.streamsClient.getManifest(item.id).timeout( - const Duration(seconds: 5), + const Duration(seconds: 30), onTimeout: () => throw ClientException('Timeout'), ); sourceMap = toSourceMap(manifest); @@ -285,7 +285,7 @@ class YoutubeSourcedTrack extends SourcedTrack { final manifest = await youtubeClient.videos.streamsClient .getManifest(newSourceInfo.id) .timeout( - const Duration(seconds: 5), + const Duration(seconds: 30), onTimeout: () => throw ClientException('Timeout'), ); diff --git a/pubspec.lock b/pubspec.lock index ad543dd..e231198 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -347,10 +347,10 @@ packages: dependency: "direct main" description: name: dio - sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0" + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" url: "https://pub.dev" source: hosted - version: "5.6.0" + version: "5.7.0" dio_web_adapter: dependency: transitive description: @@ -383,6 +383,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.19.1" + dropdown_button2: + dependency: "direct main" + description: + name: dropdown_button2 + sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1 + url: "https://pub.dev" + source: hosted + version: "2.3.9" duration: dependency: "direct main" description: @@ -1514,10 +1522,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 url: "https://pub.dev" source: hosted - version: "4.4.2" + version: "4.5.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 59faa04..267a0c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,6 +101,7 @@ dependencies: timezone: ^0.9.4 url_launcher: ^6.3.0 wakelock_plus: ^1.2.8 + dropdown_button2: ^2.3.9 dev_dependencies: flutter_test: