diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 3b0dfccf8..5db3617b3 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify/spotify.dart' hide Search; 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/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; @@ -31,42 +32,51 @@ final router = GoRouter( routes: [ GoRoute( path: "/", - pageBuilder: (context, state) => SpotubePage(child: const HomePage()), + pageBuilder: (context, state) => const SpotubePage(child: HomePage()), ), GoRoute( path: "/search", name: "Search", pageBuilder: (context, state) => - SpotubePage(child: const SearchPage()), + const SpotubePage(child: SearchPage()), ), GoRoute( path: "/library", name: "Library", pageBuilder: (context, state) => - SpotubePage(child: const LibraryPage()), + const SpotubePage(child: LibraryPage()), ), GoRoute( path: "/lyrics", name: "Lyrics", 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( path: "/settings", - pageBuilder: (context, state) => SpotubePage( - child: const SettingsPage(), + pageBuilder: (context, state) => const SpotubePage( + child: SettingsPage(), ), routes: [ GoRoute( path: "blacklist", - pageBuilder: (context, state) => SpotubePage( - child: const BlackListPage(), + pageBuilder: (context, state) => const SpotubePage( + child: BlackListPage(), ), ), GoRoute( path: "about", - pageBuilder: (context, state) => SpotubePage( - child: const AboutSpotube(), + pageBuilder: (context, state) => const SpotubePage( + child: AboutSpotube(), ), ), ], @@ -106,16 +116,16 @@ final router = GoRouter( GoRoute( path: "/login-tutorial", parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: const LoginTutorial(), + pageBuilder: (context, state) => const SpotubePage( + child: LoginTutorial(), ), ), GoRoute( path: "/player", parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) { - return SpotubePage( - child: const PlayerView(), + return const SpotubePage( + child: PlayerView(), ); }, ), diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 697ae2072..8b0fc062c 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -68,4 +68,8 @@ abstract class SpotubeIcons { static const zoomIn = FeatherIcons.zoomIn; static const zoomOut = FeatherIcons.zoomOut; 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; } diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index a667a0683..673c3e15b 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -30,7 +30,6 @@ class PlayerActions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final playlist = ref.watch(PlaylistQueueNotifier.provider); - final playlistNotifier = ref.watch(PlaylistQueueNotifier.provider.notifier); final isLocalTrack = playlist?.activeTrack is LocalTrack; final downloader = ref.watch(downloaderProvider); final isInQueue = downloader.inQueue diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index b381b8115..b052512b7 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -13,9 +13,11 @@ import 'package:spotube/utils/primitive_utils.dart'; class PlayerControls extends HookConsumerWidget { final PaletteGenerator? palette; + final bool compact; PlayerControls({ this.palette, + this.compact = false, Key? key, }) : super(key: key); @@ -78,8 +80,8 @@ class PlayerControls extends HookConsumerWidget { backgroundColor: accentColor?.color ?? theme.colorScheme.primary, foregroundColor: accentColor?.titleTextColor ?? theme.colorScheme.onPrimary, - padding: const EdgeInsets.all(12), - iconSize: 24, + padding: EdgeInsets.all(compact ? 10 : 12), + iconSize: compact ? 18 : 24, ); return GestureDetector( @@ -97,82 +99,83 @@ class PlayerControls extends HookConsumerWidget { constraints: const BoxConstraints(maxWidth: 600), child: Column( children: [ - HookBuilder( - builder: (context) { - final progressObj = useProgress(ref); + if (!compact) + HookBuilder( + builder: (context) { + final progressObj = useProgress(ref); - final progressStatic = progressObj.item1; - final position = progressObj.item2; - final duration = progressObj.item3; + final progressStatic = progressObj.item1; + final position = progressObj.item2; + final duration = progressObj.item3; - final totalMinutes = PrimitiveUtils.zeroPadNumStr( - duration.inMinutes.remainder(60), - ); - final totalSeconds = PrimitiveUtils.zeroPadNumStr( - duration.inSeconds.remainder(60), - ); - final currentMinutes = PrimitiveUtils.zeroPadNumStr( - position.inMinutes.remainder(60), - ); - final currentSeconds = PrimitiveUtils.zeroPadNumStr( - position.inSeconds.remainder(60), - ); + final totalMinutes = PrimitiveUtils.zeroPadNumStr( + duration.inMinutes.remainder(60), + ); + final totalSeconds = PrimitiveUtils.zeroPadNumStr( + duration.inSeconds.remainder(60), + ); + final currentMinutes = PrimitiveUtils.zeroPadNumStr( + position.inMinutes.remainder(60), + ); + final currentSeconds = PrimitiveUtils.zeroPadNumStr( + position.inSeconds.remainder(60), + ); - final progress = useState( - useMemoized(() => progressStatic, []), - ); + final progress = useState( + useMemoized(() => progressStatic, []), + ); - useEffect(() { - progress.value = progressStatic; - return null; - }, [progressStatic]); + useEffect(() { + progress.value = progressStatic; + return null; + }, [progressStatic]); - return Column( - children: [ - Tooltip( - message: "Slide to seek forward or backward", - child: Slider( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: progress.value.toDouble(), - onChanged: playlist?.isLoading == true - ? null - : (v) { - progress.value = v; - }, - onChangeEnd: (value) async { - await playlistNotifier.seek( - Duration( - seconds: (value * duration.inSeconds).toInt(), - ), - ); - }, - activeColor: sliderColor, - inactiveColor: sliderColor.withOpacity(0.15), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, + return Column( + children: [ + Tooltip( + message: "Slide to seek forward or backward", + child: Slider( + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: progress.value.toDouble(), + onChanged: playlist?.isLoading == true + ? null + : (v) { + progress.value = v; + }, + onChangeEnd: (value) async { + await playlistNotifier.seek( + Duration( + seconds: (value * duration.inSeconds).toInt(), + ), + ); + }, + activeColor: sliderColor, + inactiveColor: sliderColor.withOpacity(0.15), + ), ), - child: DefaultTextStyle( - style: theme.textTheme.bodySmall!.copyWith( - color: palette?.dominantColor?.bodyTextColor, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("$currentMinutes:$currentSeconds"), - Text("$totalMinutes:$totalSeconds"), - ], + child: DefaultTextStyle( + style: theme.textTheme.bodySmall!.copyWith( + color: palette?.dominantColor?.bodyTextColor, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("$currentMinutes:$currentSeconds"), + Text("$totalMinutes:$totalSeconds"), + ], + ), ), ), - ), - ], - ); - }, - ), + ], + ); + }, + ), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 416701709..37e89a2bf 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -2,9 +2,11 @@ import 'dart:ui'; import 'package:flutter/gestures.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.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_overlay.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'); + }, + ), + ], + ) ], ), ) diff --git a/lib/components/shared/bordered_text.dart b/lib/components/shared/bordered_text.dart new file mode 100644 index 000000000..627b2a3c5 --- /dev/null +++ b/lib/components/shared/bordered_text.dart @@ -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: [ + 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, + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 11c80b6be..70d370276 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,8 +26,9 @@ import 'package:spotube/services/pocketbase.dart'; import 'package:spotube/services/youtube.dart'; import 'package:spotube/themes/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 main(List rawArgs) async { final parser = ArgParser(); @@ -78,7 +79,10 @@ Future main(List rawArgs) async { await SystemTheme.accentColor.load(); 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(CacheTrackEngagementAdapter()); Hive.registerAdapter(CacheTrackSkipSegmentAdapter()); diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart new file mode 100644 index 000000000..73b2b091c --- /dev/null +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -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(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)) + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 17a4785cb..774fb275a 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -15,9 +15,11 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class PlainLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; + final int defaultTextZoom; const PlainLyrics({ required this.palette, this.isModal, + this.defaultTextZoom = 100, Key? key, }) : super(key: key); @@ -31,7 +33,7 @@ class PlainLyrics extends HookConsumerWidget { final breakpoint = useBreakpoints(); final textTheme = Theme.of(context).textTheme; - final textZoomLevel = useState(100); + final textZoomLevel = useState(defaultTextZoom); return Stack( children: [ diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 6f39228be..4eef2d904 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -20,10 +20,12 @@ final _delay = StateProvider((ref) => 0); class SyncedLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; + final int defaultTextZoom; const SyncedLyrics({ required this.palette, this.isModal, + this.defaultTextZoom = 100, Key? key, }) : super(key: key); @@ -51,7 +53,7 @@ class SyncedLyrics extends HookConsumerWidget { [lyricValue], ); final currentTime = useSyncedLyrics(ref, lyricsMap, delay); - final textZoomLevel = useState(100); + final textZoomLevel = useState(defaultTextZoom); final textTheme = Theme.of(context).textTheme;