feat: Local music library (#1479)

* feat: add one additional library folder

This folder just doesn't get downloaded to.
I think I'm going to rework it so that it can be multiple folders,
but I'm going to commit my progress so far anyway.

Signed-off-by: Blake Leonard <me@blakes.dev>

* chore: update dependencies so that it builds

I'm not sure if this breaks CI or something, but I couldn't build
it locally to test my changes, so I made these changes and it
builds again.

Signed-off-by: Blake Leonard <me@blakes.dev>

* feat: index multiple folders of local music

If you used a previous commit from this branch, this is a breaking
change, because it changes the type of a configuration field. but
since this is still in development, it should be fine.

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: manage local library in local tracks tab

This also refactors the list to use slivers instead. That's the
easiest way to have multiple scrolling lists here...

The console keeps getting spammed with some intermediate layout
error but I can't hold it long enough to figure out what's causing
it.

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: use folder add/remove icons in library

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: remove redundant settings page

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: rename "Local Tracks" to just "Local"

Not sure if this would be the recommended way to do it...

Signed-off-by: Blake Leonard <me@blakes.dev>

* fix: console spam about useless Expanded

Signed-off-by: Blake Leonard <me@blakes.dev>

* chore: remove completed TODO

Signed-off-by: Blake Leonard <me@blakes.dev>

* chore: use new Platform constants; regenerate plugins

Signed-off-by: Blake Leonard <me@blakes.dev>

* refactor: put local libraries on separate pages

Signed-off-by: Blake Leonard <me@blakes.dev>

---------

Signed-off-by: Blake Leonard <me@blakes.dev>
This commit is contained in:
Blake Leonard 2024-05-23 05:18:01 -04:00 committed by GitHub
parent 9aea35468f
commit 22caa818f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 619 additions and 236 deletions

View File

@ -14,6 +14,7 @@ import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/local_folder.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
@ -113,6 +114,17 @@ final routerProvider = Provider((ref) {
),
),
]),
GoRoute(
path: "local",
pageBuilder: (context, state) {
assert(state.extra is String);
return SpotubePage(
child: LocalLibraryPage(state.extra as String,
isDownloads: state.uri.queryParameters["downloads"] != null
),
);
},
),
]),
GoRoute(
path: "/lyrics",

View File

@ -121,4 +121,6 @@ abstract class SpotubeIcons {
static const monitor = FeatherIcons.monitor;
static const power = FeatherIcons.power;
static const bluetooth = FeatherIcons.bluetooth;
static const folderAdd = FeatherIcons.folderPlus;
static const folderRemove = FeatherIcons.folderMinus;
}

View File

@ -1,11 +1,14 @@
import 'dart:io';
import 'package:catcher_2/catcher_2.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:mime/mime.dart';
@ -27,6 +30,7 @@ import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
@ -59,116 +63,125 @@ enum SortBy {
album,
}
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
final localTracksProvider = FutureProvider<Map<String, List<LocalTrack>>>((ref) async {
try {
if (kIsWeb) return [];
if (kIsWeb) return {};
final Map<String, List<LocalTrack>> tracks = {};
final downloadLocation = ref.watch(
userPreferencesProvider.select((s) => s.downloadLocation),
);
if (downloadLocation.isEmpty) return [];
final downloadDir = Directory(downloadLocation);
if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true);
return [];
}
final entities = downloadDir.listSync(recursive: true);
final localLibraryLocations = ref.watch(
userPreferencesProvider.select((s) => s.localLibraryLocation),
);
final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) {
final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map(
(file) async {
try {
final metadata = await MetadataGod.readMetadata(file: file.path);
for (var location in [downloadLocation, ...localLibraryLocations]) {
if (location.isEmpty) continue;
final entities = <FileSystemEntity>[];
final dir = Directory(location);
if (await Directory(location).exists()) {
entities.addAll(Directory(location).listSync(recursive: true));
}
final imageFile = File(join(
(await getTemporaryDirectory()).path,
"spotube",
basenameWithoutExtension(file.path) +
imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!,
));
if (!await imageFile.exists() && metadata.picture != null) {
await imageFile.create(recursive: true);
await imageFile.writeAsBytes(
metadata.picture?.data ?? [],
mode: FileMode.writeOnly,
);
final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) {
final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map(
(file) async {
try {
final metadata = await MetadataGod.readMetadata(file: file.path);
final imageFile = File(join(
(await getTemporaryDirectory()).path,
"spotube",
basenameWithoutExtension(file.path) +
imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!,
));
if (!await imageFile.exists() && metadata.picture != null) {
await imageFile.create(recursive: true);
await imageFile.writeAsBytes(
metadata.picture?.data ?? [],
mode: FileMode.writeOnly,
);
}
return {"metadata": metadata, "file": file, "art": imageFile.path};
} catch (e, stack) {
if (e is FfiException) {
return {"file": file};
}
Catcher2.reportCheckedError(e, stack);
return {};
}
},
),
))
.where((e) => e.isNotEmpty)
.toList();
return {"metadata": metadata, "file": file, "art": imageFile.path};
} catch (e, stack) {
if (e is FfiException) {
return {"file": file};
}
Catcher2.reportCheckedError(e, stack);
return {};
}
},
),
))
.where((e) => e.isNotEmpty)
.toList();
final tracks = filesWithMetadata
.map(
(fileWithMetadata) => LocalTrack.fromTrack(
track: Track().fromFile(
fileWithMetadata["file"],
metadata: fileWithMetadata["metadata"],
art: fileWithMetadata["art"],
// ignore: no_leading_underscores_for_local_identifiers
final _tracks = filesWithMetadata
.map(
(fileWithMetadata) => LocalTrack.fromTrack(
track: Track().fromFile(
fileWithMetadata["file"],
metadata: fileWithMetadata["metadata"],
art: fileWithMetadata["art"],
),
path: fileWithMetadata["file"].path,
),
path: fileWithMetadata["file"].path,
),
)
.toList();
)
.toList();
tracks[location] = _tracks;
}
return tracks;
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
return [];
return {};
}
});
class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({super.key});
Future<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
}) async {
final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(proxyPlaylistProvider.notifier);
currentTrack ??= tracks.first;
final isPlaylistPlaying = playlist.containsTracks(tracks);
if (!isPlaylistPlaying) {
await playback.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override
Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(proxyPlaylistProvider);
final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.asData?.value ?? []);
final searchController = useTextEditingController();
useValueListenable(searchController);
final searchFocus = useFocusNode();
final isFiltering = useState(false);
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
final preferences = ref.watch(userPreferencesProvider);
final controller = useScrollController();
final addLocalLibraryLocation = useCallback(() async {
if (kIsMobile || kIsMacOS) {
final dirStr = await FilePicker.platform.getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
if (preferences.localLibraryLocation.contains(dirStr)) return;
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]);
} else {
String? dirStr = await getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
if (preferences.localLibraryLocation.contains(dirStr)) return;
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]);
}
}, [preferences.localLibraryLocation]);
final removeLocalLibraryLocation = useCallback((String location) {
if (!preferences.localLibraryLocation.contains(location)) return;
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location));
}, [preferences.localLibraryLocation]);
// This is just to pre-load the tracks.
// For now, this gets all of them.
ref.watch(localTracksProvider);
return Column(
children: [
@ -177,155 +190,42 @@ class UserLocalTracks extends HookConsumerWidget {
child: Row(
children: [
const SizedBox(width: 5),
FilledButton(
onPressed: trackSnapshot.asData?.value != null
? () async {
if (trackSnapshot.asData?.value.isNotEmpty == true) {
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value,
);
}
}
}
: null,
child: Row(
children: [
Text(context.l10n.play),
Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
)
],
),
),
const Spacer(),
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
),
const SizedBox(width: 10),
SortTracksDropdown(
value: sortBy.value,
onChanged: (value) {
sortBy.value = value;
},
),
const SizedBox(width: 5),
FilledButton(
child: const Icon(SpotubeIcons.refresh),
onPressed: () {
ref.invalidate(localTracksProvider);
},
TextButton.icon(
icon: const Icon(SpotubeIcons.folderAdd),
label: Text(context.l10n.add_library_location),
onPressed: addLocalLibraryLocation,
)
],
),
]
)
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks, sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return sortedTracks;
Expanded(
child: ListView.builder(
itemCount: preferences.localLibraryLocation.length+1,
itemBuilder: (context, index) {
late final String location;
if (index == 0) {
location = preferences.downloadLocation;
} else {
location = preferences.localLibraryLocation[index-1];
}
return sortedTracks
.map((e) => (
weightedRatio(
"${e.name} - ${e.artists?.asString() ?? ""}",
searchController.text,
),
e,
))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
return ListTile(
title: preferences.downloadLocation != location ? Text(location)
: Text(context.l10n.downloads),
trailing: preferences.downloadLocation != location ? Tooltip(
message: context.l10n.remove_library_location,
child: IconButton(
icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]),
onPressed: () => removeLocalLibraryLocation(location),
),
) : null,
onTap: () async {
context.go("/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", extra: location);
}
);
}
return Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: Skeletonizer(
enabled: trackSnapshot.isLoading,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
}
final track = filteredTracks[index];
return TrackTile(
index: index,
playlist: playlist,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
),
),
),
),
);
},
loading: () => Expanded(
child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)
],
),
]
);
}
}

View File

@ -107,6 +107,9 @@
"always_on_top": "Always on top",
"exit_mini_player": "Exit Mini player",
"download_location": "Download location",
"local_library": "Local library",
"add_library_location": "Add to library",
"remove_library_location": "Remove from library",
"account": "Account",
"login_with_spotify": "Login with your Spotify account",
"connect_with_spotify": "Connect with Spotify",
@ -295,6 +298,7 @@
"delete_playlist": "Delete Playlist",
"delete_playlist_confirmation": "Are you sure you want to delete this playlist?",
"local_tracks": "Local Tracks",
"local_tab": "Local",
"song_link": "Song Link",
"skip_this_nonsense": "Skip this nonsense",
"freedom_of_music": "“Freedom of Music”",

View File

@ -27,7 +27,7 @@ class LibraryPage extends HookConsumerWidget {
leading: ThemedButtonsTabBar(
tabs: [
Tab(text: " ${context.l10n.playlists} "),
Tab(text: " ${context.l10n.local_tracks} "),
Tab(text: " ${context.l10n.local_tab} "),
Tab(
child: Badge(
isLabelVisible: downloadingCount > 0,

View File

@ -0,0 +1,236 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart';
class LocalLibraryPage extends HookConsumerWidget {
final String location;
final bool isDownloads;
const LocalLibraryPage(this.location, {super.key, this.isDownloads = false});
Future<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
}) async {
final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(proxyPlaylistProvider.notifier);
currentTrack ??= tracks.first;
final isPlaylistPlaying = playlist.containsTracks(tracks);
if (!isPlaylistPlaying) {
await playback.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override
Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(proxyPlaylistProvider);
final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []);
final searchController = useTextEditingController();
useValueListenable(searchController);
final searchFocus = useFocusNode();
final isFiltering = useState(false);
final controller = useScrollController();
return SafeArea(
bottom: false,
child: Scaffold(
appBar: PageWindowTitleBar(
leading: const BackButton(),
centerTitle: true,
title: Text(isDownloads ? context.l10n.downloads : location),
backgroundColor: Colors.transparent,
),
extendBodyBehindAppBar: true,
body: Column(
children: [
const SizedBox(height: 56),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const SizedBox(width: 5),
FilledButton(
onPressed: trackSnapshot.asData?.value != null
? () async {
if (trackSnapshot.asData?.value.isNotEmpty == true) {
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value[location] ?? [],
);
}
}
}
: null,
child: Row(
children: [
Text(context.l10n.play),
Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
)
],
),
),
const Spacer(),
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
),
const SizedBox(width: 10),
SortTracksDropdown(
value: sortBy.value,
onChanged: (value) {
sortBy.value = value;
},
),
const SizedBox(width: 5),
FilledButton(
child: const Icon(SpotubeIcons.refresh),
onPressed: () {
ref.invalidate(localTracksProvider);
},
)
],
),
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks[location] ?? <LocalTrack>[], sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return sortedTracks;
}
return sortedTracks
.map((e) => (
weightedRatio(
"${e.name} - ${e.artists?.asString() ?? ""}",
searchController.text,
),
e,
))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
);
}
return Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: Skeletonizer(
enabled: trackSnapshot.isLoading,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
}
final track = filteredTracks[index];
return TrackTile(
index: index,
playlist: playlist,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
),
),
),
),
);
},
loading: () => Expanded(
child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)
],
)
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart';
import 'package:spotube/extensions/context.dart';

View File

@ -69,6 +69,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(downloadLocation: downloadDir);
}
void setLocalLibraryLocation(List<String> localLibraryDirs) {
//if (localLibraryDir.isEmpty) return;
state = state.copyWith(localLibraryLocation: localLibraryDirs);
}
void setLayoutMode(LayoutMode mode) {
state = state.copyWith(layoutMode: mode);
}

View File

@ -84,6 +84,7 @@ class UserPreferences with _$UserPreferences {
@Default(Market.US) Market recommendationMarket,
@Default(SearchMode.youtube) SearchMode searchMode,
@Default("") String downloadLocation,
@Default([]) List<String> localLibraryLocation,
@Default("https://pipedapi.kavin.rocks") String pipedInstance,
@Default(ThemeMode.system) ThemeMode themeMode,
@Default(AudioSource.youtube) AudioSource audioSource,

View File

@ -43,6 +43,7 @@ mixin _$UserPreferences {
Market get recommendationMarket => throw _privateConstructorUsedError;
SearchMode get searchMode => throw _privateConstructorUsedError;
String get downloadLocation => throw _privateConstructorUsedError;
List<String> get localLibraryLocation => throw _privateConstructorUsedError;
String get pipedInstance => throw _privateConstructorUsedError;
ThemeMode get themeMode => throw _privateConstructorUsedError;
AudioSource get audioSource => throw _privateConstructorUsedError;
@ -88,6 +89,7 @@ abstract class $UserPreferencesCopyWith<$Res> {
Market recommendationMarket,
SearchMode searchMode,
String downloadLocation,
List<String> localLibraryLocation,
String pipedInstance,
ThemeMode themeMode,
AudioSource audioSource,
@ -126,6 +128,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
Object? recommendationMarket = null,
Object? searchMode = null,
Object? downloadLocation = null,
Object? localLibraryLocation = null,
Object? pipedInstance = null,
Object? themeMode = null,
Object? audioSource = null,
@ -196,6 +199,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
? _value.downloadLocation
: downloadLocation // ignore: cast_nullable_to_non_nullable
as String,
localLibraryLocation: null == localLibraryLocation
? _value.localLibraryLocation
: localLibraryLocation // ignore: cast_nullable_to_non_nullable
as List<String>,
pipedInstance: null == pipedInstance
? _value.pipedInstance
: pipedInstance // ignore: cast_nullable_to_non_nullable
@ -264,6 +271,7 @@ abstract class _$$UserPreferencesImplCopyWith<$Res>
Market recommendationMarket,
SearchMode searchMode,
String downloadLocation,
List<String> localLibraryLocation,
String pipedInstance,
ThemeMode themeMode,
AudioSource audioSource,
@ -300,6 +308,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
Object? recommendationMarket = null,
Object? searchMode = null,
Object? downloadLocation = null,
Object? localLibraryLocation = null,
Object? pipedInstance = null,
Object? themeMode = null,
Object? audioSource = null,
@ -370,6 +379,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
? _value.downloadLocation
: downloadLocation // ignore: cast_nullable_to_non_nullable
as String,
localLibraryLocation: null == localLibraryLocation
? _value._localLibraryLocation
: localLibraryLocation // ignore: cast_nullable_to_non_nullable
as List<String>,
pipedInstance: null == pipedInstance
? _value.pipedInstance
: pipedInstance // ignore: cast_nullable_to_non_nullable
@ -433,6 +446,7 @@ class _$UserPreferencesImpl implements _UserPreferences {
this.recommendationMarket = Market.US,
this.searchMode = SearchMode.youtube,
this.downloadLocation = "",
final List<String> localLibraryLocation = const [],
this.pipedInstance = "https://pipedapi.kavin.rocks",
this.themeMode = ThemeMode.system,
this.audioSource = AudioSource.youtube,
@ -440,7 +454,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
this.downloadMusicCodec = SourceCodecs.m4a,
this.discordPresence = true,
this.endlessPlayback = true,
this.enableConnect = false});
this.enableConnect = false})
: _localLibraryLocation = localLibraryLocation;
factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) =>
_$$UserPreferencesImplFromJson(json);
@ -496,6 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences {
@override
@JsonKey()
final String downloadLocation;
final List<String> _localLibraryLocation;
@override
@JsonKey()
List<String> get localLibraryLocation {
if (_localLibraryLocation is EqualUnmodifiableListView)
return _localLibraryLocation;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_localLibraryLocation);
}
@override
@JsonKey()
final String pipedInstance;
@ -523,7 +548,7 @@ class _$UserPreferencesImpl implements _UserPreferences {
@override
String toString() {
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)';
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)';
}
@override
@ -560,6 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
other.searchMode == searchMode) &&
(identical(other.downloadLocation, downloadLocation) ||
other.downloadLocation == downloadLocation) &&
const DeepCollectionEquality()
.equals(other._localLibraryLocation, _localLibraryLocation) &&
(identical(other.pipedInstance, pipedInstance) ||
other.pipedInstance == pipedInstance) &&
(identical(other.themeMode, themeMode) ||
@ -597,6 +624,7 @@ class _$UserPreferencesImpl implements _UserPreferences {
recommendationMarket,
searchMode,
downloadLocation,
const DeepCollectionEquality().hash(_localLibraryLocation),
pipedInstance,
themeMode,
audioSource,
@ -647,6 +675,7 @@ abstract class _UserPreferences implements UserPreferences {
final Market recommendationMarket,
final SearchMode searchMode,
final String downloadLocation,
final List<String> localLibraryLocation,
final String pipedInstance,
final ThemeMode themeMode,
final AudioSource audioSource,
@ -698,6 +727,8 @@ abstract class _UserPreferences implements UserPreferences {
@override
String get downloadLocation;
@override
List<String> get localLibraryLocation;
@override
String get pipedInstance;
@override
ThemeMode get themeMode;

View File

@ -44,6 +44,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(
$enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ??
SearchMode.youtube,
downloadLocation: json['downloadLocation'] as String? ?? "",
localLibraryLocation: (json['localLibraryLocation'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
pipedInstance:
json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks",
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??
@ -81,6 +85,7 @@ Map<String, dynamic> _$$UserPreferencesImplToJson(
'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!,
'searchMode': _$SearchModeEnumMap[instance.searchMode]!,
'downloadLocation': instance.downloadLocation,
'localLibraryLocation': instance.localLibraryLocation,
'pipedInstance': instance.pipedInstance,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'audioSource': _$AudioSourceEnumMap[instance.audioSource]!,

View File

@ -14,6 +14,7 @@
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <system_theme/system_theme_plugin.h>
#include <system_tray/system_tray_plugin.h>
#include <tray_manager/tray_manager_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
@ -44,6 +45,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) system_theme_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin");
system_theme_plugin_register_with_registrar(system_theme_registrar);
g_autoptr(FlPluginRegistrar) system_tray_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin");
system_tray_plugin_register_with_registrar(system_tray_registrar);
g_autoptr(FlPluginRegistrar) tray_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin");
tray_manager_plugin_register_with_registrar(tray_manager_registrar);

View File

@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_linux
screen_retriever
system_theme
system_tray
tray_manager
url_launcher_linux
window_manager

View File

@ -21,6 +21,7 @@ import screen_retriever
import shared_preferences_foundation
import sqflite
import system_theme
import system_tray
import tray_manager
import url_launcher_macos
import window_manager
@ -43,6 +44,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin"))
SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin"))
TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))

View File

@ -1455,12 +1455,13 @@ packages:
source: hosted
version: "1.0.9"
media_kit_native_event_loop:
dependency: transitive
dependency: "direct overridden"
description:
name: media_kit_native_event_loop
sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e
url: "https://pub.dev"
source: hosted
path: media_kit_native_event_loop
ref: main
resolved-ref: "285f7919bbf4a7d89a62615b14a3766a171ad575"
url: "https://github.com/media-kit/media-kit"
source: git
version: "1.0.8"
menu_base:
dependency: transitive
@ -2156,6 +2157,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.2"
system_tray:
dependency: "direct overridden"
description:
path: "."
ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c
resolved-ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c
url: "https://github.com/antler119/system_tray"
source: git
version: "2.0.2"
term_glyph:
dependency: transitive
description:

View File

@ -150,6 +150,17 @@ dev_dependencies:
dependency_overrides:
uuid: ^4.4.0
system_tray:
# TODO: remove this when flutter_desktop_tools gets updated
# to use [MenuItemBase] instead of [MenuItem]
git:
url: https://github.com/antler119/system_tray
ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c
media_kit_native_event_loop: # to fix "macro name must be an identifier"
git:
url: https://github.com/media-kit/media-kit
path: media_kit_native_event_loop
ref: main
flutter:
generate: true

View File

@ -1 +1,155 @@
{}
{
"ar": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"bn": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"ca": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"cs": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"de": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"es": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"fa": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"fr": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"hi": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"it": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"ja": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"ko": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"ne": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"nl": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"pl": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"pt": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"ru": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"th": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"tr": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"uk": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"vi": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
],
"zh": [
"local_library",
"add_library_location",
"remove_library_location",
"local_tab"
]
}

View File

@ -16,6 +16,7 @@
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <system_theme/system_theme_plugin.h>
#include <system_tray/system_tray_plugin.h>
#include <tray_manager/tray_manager_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
@ -42,6 +43,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
SystemThemePluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SystemThemePlugin"));
SystemTrayPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SystemTrayPlugin"));
TrayManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("TrayManagerPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
screen_retriever
system_theme
system_tray
tray_manager
url_launcher_windows
window_manager