Compare commits

...

10 Commits

Author SHA1 Message Date
$urasek-805
f92e2a0b1d
Merge 053db0bbdc into bbe3394e9e 2025-03-13 22:21:12 +07:00
Kingkor Roy Tirtho
bbe3394e9e fix: lastfm form broken in other locales #2447 2025-03-12 15:05:32 +06:00
Kingkor Roy Tirtho
7cde803bee feat(local_library): add support for x-flac, opus and x-wav 2025-03-12 14:20:19 +06:00
Kingkor Roy Tirtho
cd475e93d0 chore: upgrade action flutter to 3.29.1 2025-03-12 13:48:07 +06:00
Kingkor Roy Tirtho
3d334d96fd fix(desktop): double titlebar in local library folders and massive space in overlay player 2025-03-11 21:28:57 +06:00
Kingkor Roy Tirtho
bd4cd22e4e fix(generate_playlist): create playlist not adding tracks nor navigating to playlist page 2025-03-11 00:16:53 +06:00
Kingkor Roy Tirtho
c709de6bf1 fix: language picker search broken 2025-03-10 20:44:52 +06:00
Kingkor Roy Tirtho
ccbac85171 chore: remove GeistMono 2025-03-10 20:15:28 +06:00
Kingkor Roy Tirtho
50123b235c fix: add to playlist not working in smaller screen devices 2025-03-10 20:07:51 +06:00
Kingkor Roy Tirtho
4072531c62 fix(android): navigation overlaying in app navigation 2025-03-09 10:05:02 +06:00
24 changed files with 142 additions and 274 deletions

View File

@ -1,3 +1,3 @@
{
"flutterSdkVersion": "3.29.0"
"flutterSdkVersion": "3.29.1"
}

2
.fvmrc
View File

@ -1,4 +1,4 @@
{
"flutter": "3.29.0",
"flutter": "3.29.1",
"flavors": {}
}

View File

@ -4,7 +4,7 @@ on:
pull_request:
env:
FLUTTER_VERSION: 3.29.0
FLUTTER_VERSION: 3.29.1
jobs:
lint:

View File

@ -20,7 +20,7 @@ on:
description: Dry run without uploading to release
env:
FLUTTER_VERSION: 3.29.0
FLUTTER_VERSION: 3.29.1
FLUTTER_CHANNEL: master
permissions:

View File

@ -28,5 +28,5 @@
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md",
"*.dart": "${capture}.g.dart,${capture}.freezed.dart"
},
"dart.flutterSdkPath": ".fvm/versions/3.29.0"
"dart.flutterSdkPath": ".fvm/versions/3.29.1"
}

View File

@ -25,9 +25,9 @@
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
<!-- Enable Impeller -->
<!-- <meta-data
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" /> -->
android:value="false" />
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"

View File

@ -75,17 +75,17 @@ class AppRouter extends RootStackRouter {
path: "local",
page: UserLocalLibraryRoute.page,
),
AutoRoute(
path: "local/folder",
page: LocalLibraryRoute.page,
// parentNavigatorKey: shellRouteNavigatorKey,
),
AutoRoute(
path: "downloads",
page: UserDownloadsRoute.page,
),
],
),
AutoRoute(
path: "local/folder",
page: LocalLibraryRoute.page,
// parentNavigatorKey: shellRouteNavigatorKey,
),
AutoRoute(
path: "library/generate",
page: PlaylistGeneratorRoute.page,

View File

@ -28,7 +28,7 @@ class AdaptiveMenuButton<T> extends MenuButton {
/// or equal to 640px
/// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown
class AdaptivePopSheetList<T> extends StatelessWidget {
final List<AdaptiveMenuButton<T>> children;
final List<AdaptiveMenuButton<T>> Function(BuildContext context) items;
final Widget? icon;
final Widget? child;
final bool useRootNavigator;
@ -43,7 +43,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
const AdaptivePopSheetList({
super.key,
required this.children,
required this.items,
this.icon,
this.child,
this.useRootNavigator = true,
@ -59,27 +59,28 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
Future<void> showDropdownMenu(BuildContext context, Offset position) async {
final mediaQuery = MediaQuery.of(context);
final childrenModified = children.map((s) {
if (s.onPressed == null) {
return MenuButton(
key: s.key,
autoClose: s.autoClose,
enabled: s.enabled,
leading: s.leading,
focusNode: s.focusNode,
onPressed: (context) {
if (s.value != null) {
onSelected?.call(s.value as T);
}
},
popoverController: s.popoverController,
subMenu: s.subMenu,
trailing: s.trailing,
child: s.child,
);
}
return s;
}).toList();
List<MenuButton> childrenModified(BuildContext context) =>
items(context).map((s) {
if (s.onPressed == null) {
return MenuButton(
key: s.key,
autoClose: s.autoClose,
enabled: s.enabled,
leading: s.leading,
focusNode: s.focusNode,
onPressed: (context) {
if (s.value != null) {
onSelected?.call(s.value as T);
}
},
popoverController: s.popoverController,
subMenu: s.subMenu,
trailing: s.trailing,
child: s.child,
);
}
return s;
}).toList();
if (mediaQuery.mdAndUp) {
await showDropdown<T?>(
@ -92,7 +93,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
position: position,
builder: (context) {
return DropdownMenu(
children: childrenModified,
children: childrenModified(context),
);
},
).future;
@ -109,11 +110,12 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
),
backgroundColor: context.theme.colorScheme.card,
builder: (context) {
final children = childrenModified(context);
return ListView.builder(
itemCount: childrenModified.length,
itemCount: children.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final data = childrenModified[index];
final data = children[index];
return Button(
enabled: data.enabled,

View File

@ -1,126 +0,0 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class AnimateGradient extends HookWidget {
const AnimateGradient({
super.key,
required this.primaryColors,
required this.secondaryColors,
this.child,
this.primaryBegin,
this.primaryEnd,
this.secondaryBegin,
this.secondaryEnd,
AnimationController? controller,
this.duration = const Duration(seconds: 4),
this.animateAlignments = true,
this.reverse = true,
}) : assert(primaryColors.length >= 2),
assert(primaryColors.length == secondaryColors.length),
_controller = controller;
/// [controller]: pass this to have a fine control over the [Animation]
final AnimationController? _controller;
/// [duration]: Time to switch between [Gradient].
/// By default its value is [Duration(seconds:4)]
final Duration duration;
/// [primaryColors]: These will be the starting colors of the [Animation].
final List<Color> primaryColors;
/// [secondaryColors]: These Colors are those in which the [primaryColors] will transition into.
final List<Color> secondaryColors;
/// [primaryBegin]: This is begin [Alignment] for [primaryColors].
/// By default its value is [Alignment.topLeft]
final Alignment? primaryBegin;
/// [primaryBegin]: This is end [Alignment] for [primaryColors].
/// By default its value is [Alignment.topRight]
final Alignment? primaryEnd;
/// [secondaryBegin]: This is begin [Alignment] for [secondaryColors].
/// By default its value is [Alignment.bottomLeft]
final Alignment? secondaryBegin;
/// [secondaryEnd]: This is end [Alignment] for [secondaryColors].
/// By default its value is [Alignment.bottomRight]
final Alignment? secondaryEnd;
/// [animateAlignments]: set to false if you don't want to animate the alignments.
/// This can provide you way cooler animations
final bool animateAlignments;
/// [reverse]: set it to false if you don't want to reverse the animation.
/// using that it will go into one direction only
final bool reverse;
final Widget? child;
@override
Widget build(BuildContext context) {
// ignore: no_leading_underscores_for_local_identifiers
final __controller = useAnimationController(
duration: duration,
)..repeat(reverse: reverse);
final controller = _controller ?? __controller;
final animation = useMemoized(
() => CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
),
[controller]);
final colorTween = useMemoized(
() => primaryColors.map((color) {
return ColorTween(
begin: color,
end: color,
);
}).toList(),
[primaryColors]);
final colors = useMemoized(
() => colorTween.map((color) {
return color.evaluate(animation)!;
}).toList(),
[colorTween, animation]);
final begin = useMemoized(
() => AlignmentTween(
begin: primaryBegin ?? Alignment.topLeft,
end: primaryEnd ?? Alignment.topRight,
),
[primaryBegin, primaryEnd]);
final end = useMemoized(
() => AlignmentTween(
begin: secondaryBegin ?? Alignment.bottomLeft,
end: secondaryEnd ?? Alignment.bottomRight,
),
[secondaryBegin, secondaryEnd]);
return AnimatedBuilder(
animation: animation,
child: useMemoized(() => child, [child]),
builder: (BuildContext context, Widget? child) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: animateAlignments
? begin.evaluate(animation)
: (primaryBegin as Alignment),
end: animateAlignments
? end.evaluate(animation)
: primaryEnd as Alignment,
colors: colors,
),
),
child: child,
);
},
);
}
}

View File

@ -1,24 +0,0 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
class SpotubePage<T> extends MaterialPage<T> {
const SpotubePage({required super.child});
}
// class SpotubeSlidePage extends CustomTransitionPage {
// SpotubeSlidePage({
// required super.child,
// super.key,
// }) : super(
// reverseTransitionDuration: const Duration(milliseconds: 150),
// transitionDuration: const Duration(milliseconds: 150),
// transitionsBuilder: (context, animation, secondaryAnimation, child) {
// return SlideTransition(
// position: Tween<Offset>(
// begin: const Offset(1, 0),
// end: Offset.zero,
// ).animate(animation),
// child: child,
// );
// },
// );
// }

View File

@ -166,7 +166,7 @@ class TrackPresentationActionsSection extends HookConsumerWidget {
},
icon: const Icon(SpotubeIcons.moreVertical),
variance: ButtonVariance.outline,
children: [
items: (context) => [
AdaptiveMenuButton(
value: "download",
leading: const Icon(SpotubeIcons.download),

View File

@ -23,7 +23,7 @@ class SortTracksDropdown extends StatelessWidget {
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
icon: const Icon(SpotubeIcons.sort),
children: [
items: (context) => [
AdaptiveMenuButton(
value: SortBy.none,
enabled: value != SortBy.none,

View File

@ -90,11 +90,25 @@ class TrackOptions extends HookConsumerWidget {
BuildContext context,
Track track,
) {
showDialog(
context: context,
builder: (context) => PlaylistAddTrackDialog(
tracks: [track],
openFromPlaylist: playlistId,
/// showDialog doesn't work for some reason. So we have to
/// manually push a Dialog Route in the Navigator to get it working
Navigator.push(
context,
DialogRoute(
alignment: Alignment.bottomCenter,
transitionBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
context: context,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (context) {
return Center(
child: PlaylistAddTrackDialog(
tracks: [track],
openFromPlaylist: playlistId,
),
);
},
),
);
}
@ -352,7 +366,7 @@ class TrackOptions extends HookConsumerWidget {
),
),
],
children: [
items: (context) => [
if (isLocalTrack)
AdaptiveMenuButton(
value: TrackOptionValue.delete,

View File

@ -27,6 +27,7 @@ import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/platform.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -48,7 +49,7 @@ class PlayerView extends HookConsumerWidget {
ref.watch(audioPlayerProvider.select((s) => s.activeTrack));
final currentTrack = sourcedCurrentTrack ?? currentActiveTrack;
final isLocalTrack = currentTrack is LocalTrack;
final mediaQuery = MediaQuery.of(context);
final mediaQuery = MediaQuery.sizeOf(context);
final shouldHide = useState(true);
@ -101,6 +102,9 @@ class PlayerView extends HookConsumerWidget {
backgroundColor: Colors.transparent,
headers: [
SafeArea(
minimum:
kIsMobile ? const EdgeInsets.only(top: 80) : EdgeInsets.zero,
bottom: false,
child: TitleBar(
surfaceOpacity: 0,
surfaceBlur: 0,

View File

@ -191,7 +191,7 @@ class PlayerActions extends HookConsumerWidget {
sleepTimerNotifier.setSleepTimer(value);
}
},
children: [
items: (context) => [
for (final entry in sleepTimerEntries.entries)
AdaptiveMenuButton(
value: entry.value,

View File

@ -101,11 +101,17 @@ class PlaylistCreateDialog extends HookConsumerWidget {
} else {
await playlistNotifier.create(payload, onError);
}
if (trackIds.isNotEmpty) {
await playlistNotifier.addTracks(trackIds, onError);
}
} finally {
isSubmitting.value = false;
if (context.mounted &&
!ref.read(playlistProvider(playlistId ?? "")).hasError) {
context.router.maybePop();
context.router.maybePop<Playlist>(
await ref.read(playlistProvider(playlistId ?? "").future),
);
}
}
}

View File

@ -133,37 +133,38 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget {
popup: SelectPopup.builder(
searchPlaceholder: Text(context.l10n.search),
builder: (context, searchQuery) {
final filteredLocale = searchQuery?.isNotEmpty != true
? L10n.all
final hasNotQueried =
searchQuery == null || searchQuery.trim().isEmpty;
final filteredLocale = hasNotQueried
? [
const Locale("system", "system"),
...L10n.all,
]
: L10n.all
.where(
(element) =>
filterLocale(element, searchQuery!),
(element) => filterLocale(
element,
searchQuery.trim(),
),
)
.toList();
return SelectItemBuilder(
childCount: filteredLocale.length + 1,
childCount: filteredLocale.length,
builder: (context, index) {
if (index == 0 &&
searchQuery?.isNotEmpty != true) {
final locale = filteredLocale[index];
if (locale == const Locale("system", "system")) {
return SelectItemButton(
value: const Locale("system", "system"),
value: locale,
child: Text(context.l10n.system_default),
);
}
final indexThen = searchQuery?.isNotEmpty != true
? index
: index - 1;
final locale = filteredLocale[indexThen];
return SelectItemButton(
value: locale,
child: Text(
LanguageLocals.getDisplayLanguage(
locale.languageCode)
.toString(),
locale.languageCode,
).toString(),
),
);
},

View File

@ -96,7 +96,9 @@ class LastFMLoginPage extends HookConsumerWidget {
FormField(
label: Text(context.l10n.username),
key: usernameKey,
validator: const NotEmptyValidator(),
validator: const NotEmptyValidator(
message: "Username is required",
),
child: TextField(
autofillHints: const [
AutofillHints.username,
@ -107,7 +109,9 @@ class LastFMLoginPage extends HookConsumerWidget {
),
FormField(
key: passwordKey,
validator: const NotEmptyValidator(),
validator: const NotEmptyValidator(
message: "Password is required",
),
label: Text(context.l10n.password),
child: TextField(
autofillHints: const [

View File

@ -81,6 +81,7 @@ class LyricsPage extends HookConsumerWidget {
title: tabbar,
height: 58 * context.theme.scaling,
surfaceBlur: 0,
automaticallyImplyLeading: false,
)
: tabbar
],

View File

@ -43,13 +43,16 @@ class RootAppPage extends HookConsumerWidget {
final scaffold = MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: const Scaffold(
footers: [
BottomPlayer(),
SpotubeNavigationBar(),
],
floatingFooter: true,
child: Sidebar(child: AutoRouter()),
child: const SafeArea(
top: false,
child: Scaffold(
footers: [
BottomPlayer(),
SpotubeNavigationBar(),
],
floatingFooter: true,
child: Sidebar(child: AutoRouter()),
),
),
);

View File

@ -24,6 +24,9 @@ const supportedAudioTypes = [
"audio/opus",
"audio/wav",
"audio/aac",
"audio/flac",
"audio/x-flac",
"audio/x-wav",
];
const imgMimeToExt = {
@ -68,13 +71,16 @@ final localTracksProvider =
await Directory(location).list(recursive: true).toList();
entities.addAll(
dirEntities
.where(
(e) =>
e is File &&
supportedAudioTypes.contains(lookupMimeType(e.path)),
)
.cast<File>(),
dirEntities.where(
(e) {
final mime = lookupMimeType(e.path) ??
(extension(e.path) == ".opus" ? "audio/opus" : null);
print("${basename(e.path)}: $mime");
return e is File && supportedAudioTypes.contains(mime);
},
).cast<File>(),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);

View File

@ -98,6 +98,23 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
}
});
}
Future<void> addTracks(List<String> trackIds, [ValueChanged? onError]) async {
try {
if (state.value == null) return;
final spotify = ref.read(spotifyProvider);
await spotify.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
state.value!.id!,
);
} catch (e, stack) {
onError?.call(e);
AppLogger.reportError(e, stack);
rethrow;
}
}
}
final playlistProvider =

View File

@ -1539,10 +1539,10 @@ packages:
dependency: "direct main"
description:
name: mime
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
version: "2.0.0"
nm:
dependency: transitive
description:

View File

@ -88,7 +88,7 @@ dependencies:
media_kit: ^1.1.10+1
media_kit_libs_audio: ^1.0.4
metadata_god: ^1.0.0
mime: ^1.0.2
mime: ^2.0.0
open_file: ^3.5.10
package_info_plus: ^6.0.0
palette_generator: ^0.3.3
@ -193,46 +193,6 @@ flutter:
- packages/flutter_undraw/assets/undraw/empty.svg
- packages/flutter_undraw/assets/undraw/no_data.svg
fonts:
- family: GeistSans
fonts:
- asset: packages/shadcn_flutter/fonts/Geist-Black.otf
weight: 800
- asset: packages/shadcn_flutter/fonts/Geist-Bold.otf
weight: 700
- asset: packages/shadcn_flutter/fonts/Geist-Light.otf
weight: 300
- asset: packages/shadcn_flutter/fonts/Geist-Medium.otf
weight: 500
- asset: packages/shadcn_flutter/fonts/Geist-SemiBold.otf
weight: 600
- asset: packages/shadcn_flutter/fonts/Geist-Thin.otf
weight: 100
- asset: packages/shadcn_flutter/fonts/Geist-UltraBlack.otf
weight: 900
- asset: packages/shadcn_flutter/fonts/Geist-UltraLight.otf
weight: 200
- asset: packages/shadcn_flutter/fonts/Geist-Regular.otf
weight: 400
- family: GeistMono
fonts:
- asset: packages/shadcn_flutter/fonts/GeistMono-Black.otf
weight: 800
- asset: packages/shadcn_flutter/fonts/GeistMono-Bold.otf
weight: 700
- asset: packages/shadcn_flutter/fonts/GeistMono-Light.otf
weight: 300
- asset: packages/shadcn_flutter/fonts/GeistMono-Medium.otf
weight: 500
- asset: packages/shadcn_flutter/fonts/GeistMono-Regular.otf
weight: 400
- asset: packages/shadcn_flutter/fonts/GeistMono-SemiBold.otf
weight: 600
- asset: packages/shadcn_flutter/fonts/GeistMono-Thin.otf
weight: 100
- asset: packages/shadcn_flutter/fonts/GeistMono-UltraBlack.otf
weight: 900
- asset: packages/shadcn_flutter/fonts/GeistMono-UltraLight.otf
weight: 200
- family: RadixIcons
fonts:
- asset: packages/shadcn_flutter/icons/RadixIcons.otf