feat: desktop mini player support

This commit is contained in:
Kingkor Roy Tirtho 2023-04-15 12:29:07 +06:00
parent 62ad86e88d
commit 471812d789
10 changed files with 392 additions and 91 deletions

View File

@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
@ -31,42 +32,51 @@ final router = GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: "/", path: "/",
pageBuilder: (context, state) => SpotubePage(child: const HomePage()), pageBuilder: (context, state) => const SpotubePage(child: HomePage()),
), ),
GoRoute( GoRoute(
path: "/search", path: "/search",
name: "Search", name: "Search",
pageBuilder: (context, state) => pageBuilder: (context, state) =>
SpotubePage(child: const SearchPage()), const SpotubePage(child: SearchPage()),
), ),
GoRoute( GoRoute(
path: "/library", path: "/library",
name: "Library", name: "Library",
pageBuilder: (context, state) => pageBuilder: (context, state) =>
SpotubePage(child: const LibraryPage()), const SpotubePage(child: LibraryPage()),
), ),
GoRoute( GoRoute(
path: "/lyrics", path: "/lyrics",
name: "Lyrics", name: "Lyrics",
pageBuilder: (context, state) => pageBuilder: (context, state) =>
SpotubePage(child: const LyricsPage()), const SpotubePage(child: LyricsPage()),
routes: [
GoRoute(
path: "mini",
parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage(
child: MiniLyricsPage(),
),
),
],
), ),
GoRoute( GoRoute(
path: "/settings", path: "/settings",
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: const SettingsPage(), child: SettingsPage(),
), ),
routes: [ routes: [
GoRoute( GoRoute(
path: "blacklist", path: "blacklist",
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: const BlackListPage(), child: BlackListPage(),
), ),
), ),
GoRoute( GoRoute(
path: "about", path: "about",
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: const AboutSpotube(), child: AboutSpotube(),
), ),
), ),
], ],
@ -106,16 +116,16 @@ final router = GoRouter(
GoRoute( GoRoute(
path: "/login-tutorial", path: "/login-tutorial",
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: const LoginTutorial(), child: LoginTutorial(),
), ),
), ),
GoRoute( GoRoute(
path: "/player", path: "/player",
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) { pageBuilder: (context, state) {
return SpotubePage( return const SpotubePage(
child: const PlayerView(), child: PlayerView(),
); );
}, },
), ),

View File

@ -68,4 +68,8 @@ abstract class SpotubeIcons {
static const zoomIn = FeatherIcons.zoomIn; static const zoomIn = FeatherIcons.zoomIn;
static const zoomOut = FeatherIcons.zoomOut; static const zoomOut = FeatherIcons.zoomOut;
static const tray = FeatherIcons.chevronDown; static const tray = FeatherIcons.chevronDown;
static const miniPlayer = Icons.picture_in_picture_rounded;
static const maximize = FeatherIcons.maximize2;
static const pinOn = Icons.push_pin_rounded;
static const pinOff = Icons.push_pin_outlined;
} }

View File

@ -30,7 +30,6 @@ class PlayerActions extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(PlaylistQueueNotifier.provider); final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.provider.notifier);
final isLocalTrack = playlist?.activeTrack is LocalTrack; final isLocalTrack = playlist?.activeTrack is LocalTrack;
final downloader = ref.watch(downloaderProvider); final downloader = ref.watch(downloaderProvider);
final isInQueue = downloader.inQueue final isInQueue = downloader.inQueue

View File

@ -13,9 +13,11 @@ import 'package:spotube/utils/primitive_utils.dart';
class PlayerControls extends HookConsumerWidget { class PlayerControls extends HookConsumerWidget {
final PaletteGenerator? palette; final PaletteGenerator? palette;
final bool compact;
PlayerControls({ PlayerControls({
this.palette, this.palette,
this.compact = false,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -78,8 +80,8 @@ class PlayerControls extends HookConsumerWidget {
backgroundColor: accentColor?.color ?? theme.colorScheme.primary, backgroundColor: accentColor?.color ?? theme.colorScheme.primary,
foregroundColor: foregroundColor:
accentColor?.titleTextColor ?? theme.colorScheme.onPrimary, accentColor?.titleTextColor ?? theme.colorScheme.onPrimary,
padding: const EdgeInsets.all(12), padding: EdgeInsets.all(compact ? 10 : 12),
iconSize: 24, iconSize: compact ? 18 : 24,
); );
return GestureDetector( return GestureDetector(
@ -97,82 +99,83 @@ class PlayerControls extends HookConsumerWidget {
constraints: const BoxConstraints(maxWidth: 600), constraints: const BoxConstraints(maxWidth: 600),
child: Column( child: Column(
children: [ children: [
HookBuilder( if (!compact)
builder: (context) { HookBuilder(
final progressObj = useProgress(ref); builder: (context) {
final progressObj = useProgress(ref);
final progressStatic = progressObj.item1; final progressStatic = progressObj.item1;
final position = progressObj.item2; final position = progressObj.item2;
final duration = progressObj.item3; final duration = progressObj.item3;
final totalMinutes = PrimitiveUtils.zeroPadNumStr( final totalMinutes = PrimitiveUtils.zeroPadNumStr(
duration.inMinutes.remainder(60), duration.inMinutes.remainder(60),
); );
final totalSeconds = PrimitiveUtils.zeroPadNumStr( final totalSeconds = PrimitiveUtils.zeroPadNumStr(
duration.inSeconds.remainder(60), duration.inSeconds.remainder(60),
); );
final currentMinutes = PrimitiveUtils.zeroPadNumStr( final currentMinutes = PrimitiveUtils.zeroPadNumStr(
position.inMinutes.remainder(60), position.inMinutes.remainder(60),
); );
final currentSeconds = PrimitiveUtils.zeroPadNumStr( final currentSeconds = PrimitiveUtils.zeroPadNumStr(
position.inSeconds.remainder(60), position.inSeconds.remainder(60),
); );
final progress = useState<num>( final progress = useState<num>(
useMemoized(() => progressStatic, []), useMemoized(() => progressStatic, []),
); );
useEffect(() { useEffect(() {
progress.value = progressStatic; progress.value = progressStatic;
return null; return null;
}, [progressStatic]); }, [progressStatic]);
return Column( return Column(
children: [ children: [
Tooltip( Tooltip(
message: "Slide to seek forward or backward", message: "Slide to seek forward or backward",
child: Slider( child: Slider(
// cannot divide by zero // cannot divide by zero
// there's an edge case for value being bigger // there's an edge case for value being bigger
// than total duration. Keeping it resolved // than total duration. Keeping it resolved
value: progress.value.toDouble(), value: progress.value.toDouble(),
onChanged: playlist?.isLoading == true onChanged: playlist?.isLoading == true
? null ? null
: (v) { : (v) {
progress.value = v; progress.value = v;
}, },
onChangeEnd: (value) async { onChangeEnd: (value) async {
await playlistNotifier.seek( await playlistNotifier.seek(
Duration( Duration(
seconds: (value * duration.inSeconds).toInt(), seconds: (value * duration.inSeconds).toInt(),
), ),
); );
}, },
activeColor: sliderColor, activeColor: sliderColor,
inactiveColor: sliderColor.withOpacity(0.15), inactiveColor: sliderColor.withOpacity(0.15),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
),
child: DefaultTextStyle(
style: theme.textTheme.bodySmall!.copyWith(
color: palette?.dominantColor?.bodyTextColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("$currentMinutes:$currentSeconds"),
Text("$totalMinutes:$totalSeconds"),
],
), ),
), ),
), Padding(
], padding: const EdgeInsets.symmetric(
); horizontal: 8.0,
}, ),
), child: DefaultTextStyle(
style: theme.textTheme.bodySmall!.copyWith(
color: palette?.dominantColor?.bodyTextColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("$currentMinutes:$currentSeconds"),
Text("$totalMinutes:$totalSeconds"),
],
),
),
),
],
);
},
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [

View File

@ -2,9 +2,11 @@ import 'dart:ui';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_actions.dart'; import 'package:spotube/components/player/player_actions.dart';
import 'package:spotube/components/player/player_overlay.dart'; import 'package:spotube/components/player/player_overlay.dart';
import 'package:spotube/components/player/player_track_details.dart'; import 'package:spotube/components/player/player_track_details.dart';
@ -123,7 +125,17 @@ class BottomPlayer extends HookConsumerWidget {
); );
}), }),
), ),
PlayerActions() PlayerActions(
extraActions: [
IconButton(
tooltip: 'Mini Player',
icon: const Icon(SpotubeIcons.miniPlayer),
onPressed: () {
GoRouter.of(context).push('/lyrics/mini');
},
),
],
)
], ],
), ),
) )

View File

@ -0,0 +1,88 @@
library bordered_text;
import 'package:flutter/widgets.dart';
/// Adds stroke to text widget
/// We can apply a very thin and subtle stroke to a [Text]
/// ```dart
/// BorderedText(
/// strokeWidth: 1.0,
/// text: Text(
/// 'Bordered Text',
/// style: TextStyle(
/// decoration: TextDecoration.none,
/// decorationStyle: TextDecorationStyle.wavy,
/// decorationColor: Colors.red,
/// ),
/// ),
/// )
/// ```
class BorderedText extends StatelessWidget {
const BorderedText({
super.key,
required this.child,
this.strokeCap = StrokeCap.round,
this.strokeJoin = StrokeJoin.round,
this.strokeWidth = 6.0,
this.strokeColor = const Color.fromRGBO(53, 0, 71, 1),
});
/// the stroke cap style
final StrokeCap strokeCap;
/// the stroke joint style
final StrokeJoin strokeJoin;
/// the stroke width
final double strokeWidth;
/// the stroke color
final Color strokeColor;
/// the [Text] widget to apply stroke on
final Text child;
@override
Widget build(BuildContext context) {
TextStyle style;
if (child.style != null) {
style = child.style!.copyWith(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeCap = strokeCap
..strokeJoin = strokeJoin
..strokeWidth = strokeWidth
..color = strokeColor,
color: null,
);
} else {
style = TextStyle(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeCap = strokeCap
..strokeJoin = strokeJoin
..strokeWidth = strokeWidth
..color = strokeColor,
);
}
return Stack(
alignment: Alignment.center,
textDirection: child.textDirection,
children: <Widget>[
Text(
child.data!,
style: style,
maxLines: child.maxLines,
overflow: child.overflow,
semanticsLabel: child.semanticsLabel,
softWrap: child.softWrap,
strutStyle: child.strutStyle,
textAlign: child.textAlign,
textDirection: child.textDirection,
textScaleFactor: child.textScaleFactor,
),
child,
],
);
}
}

View File

@ -26,8 +26,9 @@ import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/services/youtube.dart'; import 'package:spotube/services/youtube.dart';
import 'package:spotube/themes/theme.dart'; import 'package:spotube/themes/theme.dart';
import 'package:system_theme/system_theme.dart'; import 'package:system_theme/system_theme.dart';
import 'package:path_provider/path_provider.dart';
import 'hooks/use_init_sys_tray.dart'; import 'package:spotube/hooks/use_init_sys_tray.dart';
Future<void> main(List<String> rawArgs) async { Future<void> main(List<String> rawArgs) async {
final parser = ArgParser(); final parser = ArgParser();
@ -78,7 +79,10 @@ Future<void> main(List<String> rawArgs) async {
await SystemTheme.accentColor.load(); await SystemTheme.accentColor.load();
MetadataGod.initialize(); MetadataGod.initialize();
await QueryClient.initialize(cachePrefix: "oss.krtirtho.spotube"); await QueryClient.initialize(
cachePrefix: "oss.krtirtho.spotube",
cacheDir: (await getApplicationSupportDirectory()).path,
);
Hive.registerAdapter(CacheTrackAdapter()); Hive.registerAdapter(CacheTrackAdapter());
Hive.registerAdapter(CacheTrackEngagementAdapter()); Hive.registerAdapter(CacheTrackEngagementAdapter());
Hive.registerAdapter(CacheTrackSkipSegmentAdapter()); Hive.registerAdapter(CacheTrackSkipSegmentAdapter());

View File

@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/root/sidebar.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/hooks/use_force_update.dart';
import 'package:spotube/pages/lyrics/plain_lyrics.dart';
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
class MiniLyricsPage extends HookConsumerWidget {
const MiniLyricsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final update = useForceUpdate();
final prevSize = useRef<Size?>(null);
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
prevSize.value = await DesktopTools.window.getSize();
await DesktopTools.window.setMinimumSize(const Size(300, 300));
await DesktopTools.window.setAlwaysOnTop(true);
await DesktopTools.window.setSize(const Size(400, 500));
});
return null;
}, []);
final auth = ref.watch(AuthenticationNotifier.provider);
if (auth == null) {
return const Scaffold(
appBar: PageWindowTitleBar(),
body: AnonymousFallback(),
);
}
return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: theme.colorScheme.surface.withOpacity(0.4),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: DragToMoveArea(
child: Row(
children: [
const SizedBox(width: 10),
SizedBox(
height: 30,
width: 30,
child: Sidebar.brandLogo(),
),
const Spacer(),
const SizedBox(
height: 30,
child: TabBar(
tabs: [Tab(text: 'Synced'), Tab(text: 'Plain')],
isScrollable: true,
),
),
const Spacer(),
FutureBuilder(
future: DesktopTools.window.isAlwaysOnTop(),
builder: (context, snapshot) {
return IconButton(
tooltip: 'Always on top',
icon: Icon(
snapshot.data == true
? SpotubeIcons.pinOn
: SpotubeIcons.pinOff,
),
style: ButtonStyle(
foregroundColor: snapshot.data == true
? MaterialStateProperty.all(
theme.colorScheme.primary)
: null,
),
onPressed: snapshot.data == null
? null
: () async {
await DesktopTools.window.setAlwaysOnTop(
snapshot.data == true ? false : true,
);
update();
},
);
}),
IconButton(
tooltip: 'Exit Mini Player',
icon: const Icon(SpotubeIcons.maximize),
onPressed: () async {
try {
await DesktopTools.window
.setMinimumSize(const Size(300, 700));
await DesktopTools.window.setAlwaysOnTop(false);
await DesktopTools.window.setSize(prevSize.value!);
await DesktopTools.window.setAlignment(Alignment.center);
} finally {
if (context.mounted) GoRouter.of(context).go('/');
}
},
),
],
),
),
),
body: Column(
children: [
if (playlistQueue != null)
Text(
playlistQueue.activeTrack.name!,
style: theme.textTheme.titleMedium,
),
Expanded(
child: TabBarView(
children: [
SyncedLyrics(
palette: PaletteColor(theme.colorScheme.background, 0),
isModal: true,
defaultTextZoom: 65,
),
PlainLyrics(
palette: PaletteColor(theme.colorScheme.background, 0),
isModal: true,
defaultTextZoom: 65,
),
],
),
),
Row(
children: [
IconButton(
icon: const Icon(SpotubeIcons.queue),
tooltip: 'Queue',
onPressed: playlistQueue != null
? () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black12,
barrierColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height * .7,
),
builder: (context) {
return const PlayerQueue(floating: true);
},
);
}
: null,
),
Flexible(child: PlayerControls(compact: true))
],
)
],
),
),
);
}
}

View File

@ -15,9 +15,11 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class PlainLyrics extends HookConsumerWidget { class PlainLyrics extends HookConsumerWidget {
final PaletteColor palette; final PaletteColor palette;
final bool? isModal; final bool? isModal;
final int defaultTextZoom;
const PlainLyrics({ const PlainLyrics({
required this.palette, required this.palette,
this.isModal, this.isModal,
this.defaultTextZoom = 100,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -31,7 +33,7 @@ class PlainLyrics extends HookConsumerWidget {
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final textZoomLevel = useState<int>(100); final textZoomLevel = useState<int>(defaultTextZoom);
return Stack( return Stack(
children: [ children: [

View File

@ -20,10 +20,12 @@ final _delay = StateProvider<int>((ref) => 0);
class SyncedLyrics extends HookConsumerWidget { class SyncedLyrics extends HookConsumerWidget {
final PaletteColor palette; final PaletteColor palette;
final bool? isModal; final bool? isModal;
final int defaultTextZoom;
const SyncedLyrics({ const SyncedLyrics({
required this.palette, required this.palette,
this.isModal, this.isModal,
this.defaultTextZoom = 100,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -51,7 +53,7 @@ class SyncedLyrics extends HookConsumerWidget {
[lyricValue], [lyricValue],
); );
final currentTime = useSyncedLyrics(ref, lyricsMap, delay); final currentTime = useSyncedLyrics(ref, lyricsMap, delay);
final textZoomLevel = useState<int>(100); final textZoomLevel = useState<int>(defaultTextZoom);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;