diff --git a/assets/patterns/black_white_visualized.jpg b/assets/patterns/black_white_visualized.jpg new file mode 100644 index 00000000..e56a2780 Binary files /dev/null and b/assets/patterns/black_white_visualized.jpg differ diff --git a/assets/patterns/brazil_carnival.jpg b/assets/patterns/brazil_carnival.jpg new file mode 100644 index 00000000..a7cdb3a1 Binary files /dev/null and b/assets/patterns/brazil_carnival.jpg differ diff --git a/assets/patterns/cotton_balls.jpg b/assets/patterns/cotton_balls.jpg new file mode 100644 index 00000000..db6f02a8 Binary files /dev/null and b/assets/patterns/cotton_balls.jpg differ diff --git a/assets/patterns/cute_worms.jpg b/assets/patterns/cute_worms.jpg new file mode 100644 index 00000000..0c9f4fbb Binary files /dev/null and b/assets/patterns/cute_worms.jpg differ diff --git a/assets/patterns/flash_cross_axis.jpg b/assets/patterns/flash_cross_axis.jpg new file mode 100644 index 00000000..c6e52283 Binary files /dev/null and b/assets/patterns/flash_cross_axis.jpg differ diff --git a/assets/patterns/memphis_shapes.jpg b/assets/patterns/memphis_shapes.jpg new file mode 100644 index 00000000..2db8e775 Binary files /dev/null and b/assets/patterns/memphis_shapes.jpg differ diff --git a/assets/patterns/oval_gloomy.jpg b/assets/patterns/oval_gloomy.jpg new file mode 100644 index 00000000..b44bf945 Binary files /dev/null and b/assets/patterns/oval_gloomy.jpg differ diff --git a/assets/patterns/oval_sunny.jpg b/assets/patterns/oval_sunny.jpg new file mode 100644 index 00000000..bc07ae83 Binary files /dev/null and b/assets/patterns/oval_sunny.jpg differ diff --git a/assets/patterns/red_nimbuses.jpg b/assets/patterns/red_nimbuses.jpg new file mode 100644 index 00000000..6527999c Binary files /dev/null and b/assets/patterns/red_nimbuses.jpg differ diff --git a/assets/patterns/tree_bark.jpg b/assets/patterns/tree_bark.jpg new file mode 100644 index 00000000..0dac37d7 Binary files /dev/null and b/assets/patterns/tree_bark.jpg differ diff --git a/assets/patterns/vibrant_pentagons.jpg b/assets/patterns/vibrant_pentagons.jpg new file mode 100644 index 00000000..d9e8d537 Binary files /dev/null and b/assets/patterns/vibrant_pentagons.jpg differ diff --git a/assets/patterns/wiring_pattern.jpg b/assets/patterns/wiring_pattern.jpg new file mode 100644 index 00000000..9fc3b781 Binary files /dev/null and b/assets/patterns/wiring_pattern.jpg differ diff --git a/assets/patterns/zigzags_gloomy.jpg b/assets/patterns/zigzags_gloomy.jpg new file mode 100644 index 00000000..c6ccd2a3 Binary files /dev/null and b/assets/patterns/zigzags_gloomy.jpg differ diff --git a/assets/patterns/zigzags_sunny.jpg b/assets/patterns/zigzags_sunny.jpg new file mode 100644 index 00000000..7470d5ef Binary files /dev/null and b/assets/patterns/zigzags_sunny.jpg differ diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index e098ff9a..004001f2 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -35,6 +35,84 @@ class $AssetsLogosGen { List get values => [songlinkTransparent, songlink]; } +class $AssetsPatternsGen { + const $AssetsPatternsGen(); + + /// File path: assets/patterns/black_white_visualized.jpg + AssetGenImage get blackWhiteVisualized => + const AssetGenImage('assets/patterns/black_white_visualized.jpg'); + + /// File path: assets/patterns/brazil_carnival.jpg + AssetGenImage get brazilCarnival => + const AssetGenImage('assets/patterns/brazil_carnival.jpg'); + + /// File path: assets/patterns/cotton_balls.jpg + AssetGenImage get cottonBalls => + const AssetGenImage('assets/patterns/cotton_balls.jpg'); + + /// File path: assets/patterns/cute_worms.jpg + AssetGenImage get cuteWorms => + const AssetGenImage('assets/patterns/cute_worms.jpg'); + + /// File path: assets/patterns/flash_cross_axis.jpg + AssetGenImage get flashCrossAxis => + const AssetGenImage('assets/patterns/flash_cross_axis.jpg'); + + /// File path: assets/patterns/memphis_shapes.jpg + AssetGenImage get memphisShapes => + const AssetGenImage('assets/patterns/memphis_shapes.jpg'); + + /// File path: assets/patterns/oval_gloomy.jpg + AssetGenImage get ovalGloomy => + const AssetGenImage('assets/patterns/oval_gloomy.jpg'); + + /// File path: assets/patterns/oval_sunny.jpg + AssetGenImage get ovalSunny => + const AssetGenImage('assets/patterns/oval_sunny.jpg'); + + /// File path: assets/patterns/red_nimbuses.jpg + AssetGenImage get redNimbuses => + const AssetGenImage('assets/patterns/red_nimbuses.jpg'); + + /// File path: assets/patterns/tree_bark.jpg + AssetGenImage get treeBark => + const AssetGenImage('assets/patterns/tree_bark.jpg'); + + /// File path: assets/patterns/vibrant_pentagons.jpg + AssetGenImage get vibrantPentagons => + const AssetGenImage('assets/patterns/vibrant_pentagons.jpg'); + + /// File path: assets/patterns/wiring_pattern.jpg + AssetGenImage get wiringPattern => + const AssetGenImage('assets/patterns/wiring_pattern.jpg'); + + /// File path: assets/patterns/zigzags_gloomy.jpg + AssetGenImage get zigzagsGloomy => + const AssetGenImage('assets/patterns/zigzags_gloomy.jpg'); + + /// File path: assets/patterns/zigzags_sunny.jpg + AssetGenImage get zigzagsSunny => + const AssetGenImage('assets/patterns/zigzags_sunny.jpg'); + + /// List of all assets + List get values => [ + blackWhiteVisualized, + brazilCarnival, + cottonBalls, + cuteWorms, + flashCrossAxis, + memphisShapes, + ovalGloomy, + ovalSunny, + redNimbuses, + treeBark, + vibrantPentagons, + wiringPattern, + zigzagsGloomy, + zigzagsSunny + ]; +} + class $AssetsTutorialGen { const $AssetsTutorialGen(); @@ -67,6 +145,7 @@ class Assets { static const AssetGenImage likedTracks = AssetGenImage('assets/liked-tracks.jpg'); static const $AssetsLogosGen logos = $AssetsLogosGen(); + static const $AssetsPatternsGen patterns = $AssetsPatternsGen(); static const AssetGenImage placeholder = AssetGenImage('assets/placeholder.png'); static const AssetGenImage spotubeHeroBanner = diff --git a/lib/collections/env.dart b/lib/collections/env.dart index eb60851f..eb6c5639 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -38,6 +38,11 @@ abstract class Env { @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") static final String _releaseChannel = _Env._releaseChannel; + @EnviedField(varName: "DISABLE_SPOTIFY_IMAGES", defaultValue: "0") + static final int _disableSpotifyImages = _Env._disableSpotifyImages; + + static bool get disableSpotifyImages => _disableSpotifyImages == 1; + static ReleaseChannel get releaseChannel => _releaseChannel == "stable" ? ReleaseChannel.stable : ReleaseChannel.nightly; diff --git a/lib/components/playbutton_view/playbutton_card.dart b/lib/components/playbutton_view/playbutton_card.dart index 21016d57..05efef38 100644 --- a/lib/components/playbutton_view/playbutton_card.dart +++ b/lib/components/playbutton_view/playbutton_card.dart @@ -11,14 +11,14 @@ class PlaybuttonCard extends StatelessWidget { final void Function()? onAddToQueuePressed; final String? description; - final String imageUrl; + final String? imageUrl; + final Widget? image; final bool isPlaying; final bool isLoading; final String title; final bool isOwner; const PlaybuttonCard({ - required this.imageUrl, required this.isPlaying, required this.isLoading, required this.title, @@ -27,8 +27,13 @@ class PlaybuttonCard extends StatelessWidget { this.onAddToQueuePressed, this.onTap, this.isOwner = false, + this.imageUrl, + this.image, super.key, - }); + }) : assert( + imageUrl != null || image != null, + "imageUrl and image can't be null at the same time", + ); @override Widget build(BuildContext context) { @@ -40,17 +45,27 @@ class PlaybuttonCard extends StatelessWidget { child: CardImage( image: Stack( children: [ - Container( - width: 150 * scale, - height: 150 * scale, - decoration: BoxDecoration( - borderRadius: context.theme.borderRadiusMd, - image: DecorationImage( - image: UniversalImage.imageProvider(imageUrl), - fit: BoxFit.cover, + if (imageUrl != null) + Container( + width: 150 * scale, + height: 150 * scale, + decoration: BoxDecoration( + borderRadius: context.theme.borderRadiusMd, + image: DecorationImage( + image: UniversalImage.imageProvider(imageUrl!), + fit: BoxFit.cover, + ), + ), + ) + else + SizedBox( + width: 150 * scale, + height: 150 * scale, + child: ClipRRect( + borderRadius: context.theme.borderRadiusMd, + child: image!, ), ), - ), StatedWidget.builder( builder: (context, states) { return Positioned( diff --git a/lib/components/playbutton_view/playbutton_tile.dart b/lib/components/playbutton_view/playbutton_tile.dart index 3daaf75c..ec1ca95f 100644 --- a/lib/components/playbutton_view/playbutton_tile.dart +++ b/lib/components/playbutton_view/playbutton_tile.dart @@ -11,14 +11,14 @@ class PlaybuttonTile extends StatelessWidget { final void Function()? onAddToQueuePressed; final String? description; - final String imageUrl; + final String? imageUrl; + final Widget? image; final bool isPlaying; final bool isLoading; final String title; final bool isOwner; const PlaybuttonTile({ - required this.imageUrl, required this.isPlaying, required this.isLoading, required this.title, @@ -27,8 +27,13 @@ class PlaybuttonTile extends StatelessWidget { this.onAddToQueuePressed, this.onTap, this.isOwner = false, + this.imageUrl, + this.image, super.key, - }); + }) : assert( + imageUrl != null || image != null, + "imageUrl and image can't be null at the same time", + ); @override Widget build(BuildContext context) { @@ -36,17 +41,26 @@ class PlaybuttonTile extends StatelessWidget { final scale = context.theme.scaling; return Button( - leading: Container( - width: 50 * scale, - height: 50 * scale, - decoration: BoxDecoration( - borderRadius: context.theme.borderRadiusMd, - image: DecorationImage( - image: UniversalImage.imageProvider(imageUrl), - fit: BoxFit.cover, - ), - ), - ), + leading: imageUrl != null + ? Container( + width: 50 * scale, + height: 50 * scale, + decoration: BoxDecoration( + borderRadius: context.theme.borderRadiusMd, + image: DecorationImage( + image: UniversalImage.imageProvider(imageUrl!), + fit: BoxFit.cover, + ), + ), + ) + : SizedBox( + width: 50 * scale, + height: 50 * scale, + child: ClipRRect( + borderRadius: context.theme.borderRadiusMd, + child: image, + ), + ), style: ButtonVariance.ghost.copyWith( padding: (context, states, value) { return (ButtonVariance.ghost.padding(context, states) as EdgeInsets) diff --git a/lib/components/track_presentation/presentation_top.dart b/lib/components/track_presentation/presentation_top.dart index 59854aaf..8da2f51c 100644 --- a/lib/components/track_presentation/presentation_top.dart +++ b/lib/components/track_presentation/presentation_top.dart @@ -3,6 +3,8 @@ import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/heart_button/heart_button.dart'; import 'package:spotube/components/image/universal_image.dart'; @@ -12,6 +14,7 @@ import 'package:spotube/components/track_presentation/use_is_user_playlist.dart' import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class TrackPresentationTopSection extends HookConsumerWidget { const TrackPresentationTopSection({super.key}); @@ -23,6 +26,26 @@ class TrackPresentationTopSection extends HookConsumerWidget { final scale = context.theme.scaling; final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); + final playlistImage = (options.collection is PlaylistSimple && + (options.collection as PlaylistSimple).owner?.displayName == + "Spotify" && + Env.disableSpotifyImages) + ? ref.watch(playlistImageProvider(options.collectionId)) + : null; + final decorationImage = playlistImage != null + ? DecorationImage( + image: AssetImage(playlistImage.src), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + playlistImage.color, + playlistImage.colorBlendMode, + ), + ) + : DecorationImage( + image: UniversalImage.imageProvider(options.image), + fit: BoxFit.cover, + ); + final imageDimension = mediaQuery.mdAndUp ? 200 : 120; final (:isLoading, :isActive, :onPlay, :onShuffle) = @@ -153,10 +176,7 @@ class TrackPresentationTopSection extends HookConsumerWidget { children: [ DecoratedBox( decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(options.image), - fit: BoxFit.cover, - ), + image: decorationImage, borderRadius: BorderRadius.circular(45), ), child: OutlinedContainer( @@ -179,11 +199,7 @@ class TrackPresentationTopSection extends HookConsumerWidget { width: imageDimension * scale, decoration: BoxDecoration( borderRadius: context.theme.borderRadiusXl, - image: DecorationImage( - image: - UniversalImage.imageProvider(options.image), - fit: BoxFit.cover, - ), + image: decorationImage, ), ), Flexible( diff --git a/lib/models/spotify/home_feed.dart b/lib/models/spotify/home_feed.dart index e5c2f666..ad764304 100644 --- a/lib/models/spotify/home_feed.dart +++ b/lib/models/spotify/home_feed.dart @@ -29,7 +29,7 @@ class SpotifySectionPlaylist with _$SpotifySectionPlaylist { ..description = description ..collaborative = false ..images = images.map((e) => e.asImage).toList() - ..owner = (User()..displayName = "Spotify") + ..owner = (User()..displayName = owner) ..uri = uri ..type = "playlist"; } diff --git a/lib/modules/home/sections/genres/genre_card_playlist_card.dart b/lib/modules/home/sections/genres/genre_card_playlist_card.dart index bbc42c61..0e2284b3 100644 --- a/lib/modules/home/sections/genres/genre_card_playlist_card.dart +++ b/lib/modules/home/sections/genres/genre_card_playlist_card.dart @@ -1,11 +1,14 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Image; +import 'package:spotube/collections/env.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:stroke_text/stroke_text.dart'; class GenreSectionCardPlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; @@ -58,15 +61,58 @@ class GenreSectionCardPlaylistCard extends HookConsumerWidget { children: [ ClipRRect( borderRadius: theme.borderRadiusSm, - child: UniversalImage( - path: (playlist.images)!.asUrlString( - placeholder: ImagePlaceholder.collection, - index: 1, - ), - fit: BoxFit.cover, - height: 100 * theme.scaling, - width: 100 * theme.scaling, - ), + child: playlist.owner?.displayName == "Spotify" && + Env.disableSpotifyImages + ? Consumer( + builder: (context, ref, _) { + final (:src, :color, :colorBlendMode, :placement) = + ref.watch(playlistImageProvider(playlist.id!)); + return SizedBox( + height: 100 * theme.scaling, + width: 100 * theme.scaling, + child: Stack( + children: [ + Positioned.fill( + child: Image.asset( + src, + color: color, + colorBlendMode: colorBlendMode, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + top: placement == Alignment.topLeft + ? 10 + : null, + left: 10, + bottom: placement == Alignment.bottomLeft + ? 10 + : null, + child: StrokeText( + text: playlist.name!, + strokeColor: Colors.white, + strokeWidth: 3, + textColor: Colors.black, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ); + }, + ) + : UniversalImage( + path: (playlist.images)!.asUrlString( + placeholder: ImagePlaceholder.collection, + index: 1, + ), + fit: BoxFit.cover, + height: 100 * theme.scaling, + width: 100 * theme.scaling, + ), ), Text( playlist.name!, diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 43f2ee4e..c24eb24b 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -1,8 +1,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Offset, Image; +import 'package:spotube/collections/env.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/playbutton_view/playbutton_card.dart'; import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; import 'package:spotube/extensions/context.dart'; @@ -16,6 +18,7 @@ import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; +import 'package:stroke_text/stroke_text.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; @@ -168,11 +171,52 @@ class PlaylistCard extends HookConsumerWidget { final isOwner = playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null; + final image = + playlist.owner?.displayName == "Spotify" && Env.disableSpotifyImages + ? Consumer( + builder: (context, ref, child) { + final (:color, :colorBlendMode, :src, :placement) = + ref.watch(playlistImageProvider(playlist.id!)); + + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + src, + color: color, + colorBlendMode: colorBlendMode, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + top: placement == Alignment.topLeft ? 10 : null, + left: 10, + bottom: placement == Alignment.bottomLeft ? 10 : null, + child: StrokeText( + text: playlist.name!, + strokeColor: Colors.white, + strokeWidth: 3, + textColor: Colors.black, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + }, + ) + : UniversalImage( + path: imageUrl, + fit: BoxFit.cover, + ); + if (_isTile) { return PlaybuttonTile( title: playlist.name!, description: playlist.description, - imageUrl: imageUrl, + image: image, isPlaying: isPlaylistPlaying, isLoading: isLoading, isOwner: isOwner, @@ -185,7 +229,7 @@ class PlaylistCard extends HookConsumerWidget { return PlaybuttonCard( title: playlist.name!, description: playlist.description, - imageUrl: imageUrl, + image: image, isPlaying: isPlaylistPlaying, isLoading: isLoading, isOwner: isOwner, diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart index 0eec3a87..6782fb35 100644 --- a/lib/provider/spotify/playlist/playlist.dart +++ b/lib/provider/spotify/playlist/playlist.dart @@ -104,3 +104,39 @@ final playlistProvider = AsyncNotifierProvider.family( () => PlaylistNotifier(), ); + +final _blendModes = BlendMode.values + .where((e) => switch (e) { + BlendMode.clear || + BlendMode.src || + BlendMode.srcATop || + BlendMode.srcIn || + BlendMode.srcOut || + BlendMode.srcOver || + BlendMode.dstOut || + BlendMode.xor => + false, + _ => true + }) + .toList(); + +typedef PlaylistImageInfo = ({ + Color color, + BlendMode colorBlendMode, + String src, + Alignment placement, +}); + +final playlistImageProvider = Provider.family( + (ref, playlistId) { + final random = Random(); + + return ( + color: Colors.primaries[random.nextInt(Colors.primaries.length)], + colorBlendMode: _blendModes[random.nextInt(_blendModes.length)], + src: Assets + .patterns.values[random.nextInt(Assets.patterns.values.length)].path, + placement: random.nextBool() ? Alignment.topLeft : Alignment.bottomLeft, + ); + }, +); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index dbf3802b..344116cd 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -1,8 +1,10 @@ library spotify; import 'dart:async'; +import 'dart:math'; import 'package:drift/drift.dart'; +import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/database/database.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 35738548..7cd4cbbd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -172,6 +172,7 @@ flutter: - assets/tutorial/ - assets/logos/ - assets/backgrounds/ + - assets/patterns/ - LICENSE fonts: - family: GeistSans