Merge pull request #2288 from KRTirtho/new-ui

feat: shadcn_flutter based UI
This commit is contained in:
Kingkor Roy Tirtho 2025-01-30 21:53:07 +06:00 committed by GitHub
commit 2b0b5eae1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
276 changed files with 11644 additions and 13843 deletions

View File

@ -14,3 +14,4 @@ LASTFM_API_SECRET=$LASTFM_API_SECRET
RELEASE_CHANNEL=$RELEASE_CHANNEL
HIDE_DONATIONS=$HIDE_DONATIONS
DISABLE_SPOTIFY_IMAGES=$DISABLE_SPOTIFY_IMAGES

View File

@ -1,3 +1,3 @@
{
"flutterSdkVersion": "3.27.0"
"flutterSdkVersion": "3.27.3"
}

2
.fvmrc
View File

@ -1,4 +1,4 @@
{
"flutter": "3.27.0",
"flutter": "3.27.3",
"flavors": {}
}

View File

@ -20,7 +20,8 @@ on:
description: Dry run without uploading to release
env:
FLUTTER_VERSION: 3.27.0
FLUTTER_VERSION: 3.27.3
FLUTTER_CHANNEL: master
permissions:
contents: write
@ -30,44 +31,52 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-latest
- os: ubuntu-22.04
platform: linux
arch: x86
files: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-*-x86_64.tar.xz
- os: ubuntu-latest
platform: linux_arm
- os: ubuntu-22.04-arm
platform: linux
arch: arm64
files: |
dist/Spotube-linux-aarch64.deb
dist/spotube-linux-*-aarch64.tar.xz
- os: ubuntu-latest
- os: ubuntu-22.04
platform: android
arch: all
files: |
build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab
- os: windows-latest
platform: windows
arch: x86
files: |
dist/Spotube-windows-x86_64.nupkg
dist/Spotube-windows-x86_64-setup.exe
- os: macos-latest
platform: ios
arch: all
files: |
Spotube-iOS.ipa
- os: macos-14
platform: macos
arch: all
files: |
build/Spotube-macos-universal.dmg
build/Spotube-macos-universal.pkg
runs-on: ${{matrix.os}}
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
- uses: subosito/flutter-action@v2.18.0
with:
cache: true
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }}
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: ${{ env.FLUTTER_CHANNEL }}
cache: true
git-source: https://github.com/flutter/flutter.git
- name: Setup Java
if: ${{matrix.platform == 'android'}}
uses: actions/setup-java@v4
@ -76,14 +85,8 @@ jobs:
java-version: '17'
cache: 'gradle'
check-latest: true
- name: Set up QEMU
if: ${{matrix.platform == 'linux_arm'}}
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: ${{matrix.platform == 'linux_arm'}}
uses: docker/setup-buildx-action@v3
- name: Setup Rust toolchain
if: ${{matrix.platform != 'linux_arm'}}
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
@ -105,28 +108,16 @@ jobs:
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
- name: Unessary hosted tools
if: ${{matrix.platform == 'linux_arm'}}
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
swap-storage: false
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
- name: Build ${{matrix.platform}} binaries
run: dart cli/cli.dart build ${{matrix.platform}}
run: dart cli/cli.dart build --arch=${{matrix.arch}} ${{matrix.platform}}
env:
CHANNEL: ${{inputs.channel}}
DOTENV: ${{secrets.DOTENV_RELEASE}}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
if-no-files-found: error
name: Spotube-Release-Binaries
name: ${{matrix.platform}}-${{matrix.arch}}
path: ${{matrix.files}}
- name: Debug With SSH When fails
@ -136,14 +127,13 @@ jobs:
limit-access-to-actor: true
upload:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
needs:
- build_platform
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
name: Spotube-Release-Binaries
path: ./Spotube-Release-Binaries
- name: Install dependencies
@ -152,18 +142,19 @@ jobs:
- name: Generate Checksums
run: |
tree .
md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum
sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum
find Spotube-Release-Binaries -type f -exec md5sum {} \; >> RELEASE.md5sum
find Spotube-Release-Binaries -type f -exec sha256sum {} \; >> RELEASE.sha256sum
sed -i 's|Spotube-Release-Binaries/.*/\([^/]*\)$|\1|' RELEASE.sha256sum RELEASE.md5sum
sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum
- name: Extract pubspec version
run: |
echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
if-no-files-found: error
name: Spotube-Release-Binaries
name: sums
path: |
RELEASE.md5sum
RELEASE.sha256sum
@ -178,7 +169,7 @@ jobs:
omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true
allowUpdates: true
artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum
artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum
- name: Upload Release Binaries (nightly)
if: ${{ !inputs.dry_run && inputs.channel == 'nightly' }}
@ -190,9 +181,16 @@ jobs:
omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true
allowUpdates: true
artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum
artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum
body: |
Build Number: ${{github.run_number}}
Nightly release includes newest features but may contain bugs
It is preferred to use the stable version unless you know what you're doing
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true

11
.vscode/launch.json vendored
View File

@ -30,6 +30,17 @@
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "release"
},
{
"name": "spotube (mobile) (release)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "release",
"args": [
"--flavor",
"dev"
]
}
],
"compounds": []

View File

@ -13,6 +13,7 @@
"RGBO",
"riverpod",
"Scrobblenaut",
"shadcn",
"skeletonizer",
"songlink",
"speechiness",
@ -27,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.27.0"
"dart.flutterSdkPath": ".fvm/versions/3.27.3"
}

View File

@ -2,6 +2,7 @@ package oss.krtirtho.spotube.glance
import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition
import android.R
import android.content.Context
import android.graphics.drawable.Icon
import android.net.Uri
@ -119,16 +120,6 @@ class HomePlayerWidget : GlanceAppWidget() {
}
,
) {
Image(
provider = FlutterAssetImageProvider(
context,
"assets/backgrounds/xmas-effect.png"
),
contentDescription = "Background",
modifier = GlanceModifier
.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = GlanceModifier
.background(

View File

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="762"
android:viewportHeight="762">
<path
android:pathData="M309.08,370.99L309.08,479.87C309.08,486.36 314.33,491.6 320.83,491.6C327.31,491.6 332.58,486.36 332.58,479.87L332.58,370.99C332.58,364.51 327.31,359.26 320.83,359.26C314.33,359.26 309.08,364.51 309.08,370.99Z"
android:strokeLineJoin="miter"
android:strokeWidth="14"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M254.59,491.73L280.46,491.73L280.46,362.47C280.53,361.85 280.64,361.23 280.64,360.6C280.64,304.83 325.72,259.46 381.12,259.46C436.51,259.46 481.59,304.83 481.59,360.6C481.59,361.45 481.71,362.27 481.84,363.1L481.84,491.73L507.71,491.73C525.72,491.73 540.33,476.65 540.33,458.03L540.33,390.62C540.33,375.26 530.37,362.33 516.78,358.26C515.53,284.17 455.17,224.26 381.12,224.26C307.05,224.26 246.69,284.18 245.45,358.29C231.88,362.36 221.96,375.29 221.96,390.63L221.96,458.03C221.96,476.64 236.56,491.73 254.59,491.73Z"
android:strokeLineJoin="miter"
android:strokeWidth="20"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M431.08,370.99L431.08,479.87C431.08,486.36 436.33,491.6 442.83,491.6C449.31,491.6 454.58,486.36 454.58,479.87L454.58,370.99C454.58,364.51 449.31,359.26 442.83,359.26C436.33,359.26 431.08,364.51 431.08,370.99Z"
android:strokeLineJoin="miter"
android:strokeWidth="14"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
</vector>

View File

@ -1,9 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -3,7 +3,6 @@ import 'package:args/command_runner.dart';
import 'build/android.dart';
import 'build/ios.dart';
import 'build/linux.dart';
import 'build/linux_arm.dart';
import 'build/macos.dart';
import 'build/windows.dart';
@ -18,8 +17,13 @@ class BuildCommand extends Command {
addSubcommand(AndroidBuildCommand());
addSubcommand(IosBuildCommand());
addSubcommand(LinuxBuildCommand());
addSubcommand(LinuxArmBuildCommand());
addSubcommand(MacosBuildCommand());
addSubcommand(WindowsBuildCommand());
argParser.addOption(
"arch",
abbr: "a",
defaultsTo: "x86",
allowed: ["x86", "arm64", "all"],
);
}
}

View File

@ -63,4 +63,6 @@ mixin BuildCommandCommonSteps on Command {
""",
);
}
String get architecture => parent?.argResults?.option("arch") as String;
}

View File

@ -37,23 +37,32 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
await bootstrap();
await shell.run(
"""
flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=rpm
""",
"flutter_distributor package --platform=linux --targets=deb",
);
final tempDir = join(Directory.systemTemp.path, "spotube-tar");
if (architecture == "x86") {
await shell.run(
"flutter_distributor package --platform=linux --targets=rpm",
);
}
final bundleDirPath =
join(cwd.path, "build", "linux", "x64", "release", "bundle");
final tempDir = join(Directory.systemTemp.path, "spotube-tar");
final bundleArchName = architecture == "x86" ? "x86_64" : "aarch64";
final bundleDirPath = join(
cwd.path,
"build",
"linux",
architecture == "x86" ? "x64" : architecture,
"release",
"bundle",
);
final tarFile = File(join(
cwd.path,
"dist",
"spotube-linux-"
"${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}"
"-x86_64.tar.xz",
"-$bundleArchName.tar.xz",
));
await copyPath(bundleDirPath, tempDir);
@ -81,25 +90,31 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
"spotube-${pubspec.version}-linux.deb",
),
);
final ogRpm = File(
await ogDeb.copy(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-linux.rpm",
"Spotube-linux-$bundleArchName.deb",
),
);
await ogDeb.copy(
join(cwd.path, "dist", "Spotube-linux-x86_64.deb"),
);
await ogRpm.copy(
join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"),
);
await ogDeb.delete();
await ogRpm.delete();
if (architecture == "x86") {
final ogRpm = File(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-linux.rpm",
),
);
await ogRpm.copy(
join(cwd.path, "dist", "Spotube-linux-$bundleArchName.rpm"),
);
await ogRpm.delete();
}
stdout.writeln("✅ Linux building done");
}

View File

@ -1,37 +0,0 @@
import 'dart:async';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import '../../core/env.dart';
import 'common.dart';
class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Build Linux Arm";
@override
String get name => "linux_arm";
@override
FutureOr? run() async {
await bootstrap();
await shell.run(
"docker buildx build --platform=linux/arm64 "
"-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} "
"--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} "
"--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} "
"-t krtirtho/spotube_linux_arm:latest "
"--load",
);
await shell.run(
"""
docker images ls
docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest
docker cp spotube_linux_arm:/app/dist/ dist/
""",
);
}
}

View File

@ -24,6 +24,13 @@ class InstallDependenciesCommand extends Command {
],
mandatory: true,
);
argParser.addOption(
"arch",
abbr: "a",
allowed: ["x86", "arm64", "all"],
defaultsTo: "x86",
);
}
@override
@ -41,14 +48,6 @@ class InstallDependenciesCommand extends Command {
""",
);
break;
case "linux_arm":
await shell.run(
"""
sudo apt-get update -y
sudo apt-get install -y pkg-config make python3-pip python3-setuptools
""",
);
break;
case "macos":
await shell.run(
"""

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */

View File

@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -9,6 +9,17 @@
import 'package:flutter/widgets.dart';
class $AssetsBackgroundsGen {
const $AssetsBackgroundsGen();
/// File path: assets/backgrounds/xmas-effect.png
AssetGenImage get xmasEffect =>
const AssetGenImage('assets/backgrounds/xmas-effect.png');
/// List of all assets
List<AssetGenImage> get values => [xmasEffect];
}
class $AssetsLogosGen {
const $AssetsLogosGen();
@ -24,6 +35,84 @@ class $AssetsLogosGen {
List<AssetGenImage> 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<AssetGenImage> get values => [
blackWhiteVisualized,
brazilCarnival,
cottonBalls,
cuteWorms,
flashCrossAxis,
memphisShapes,
ovalGloomy,
ovalSunny,
redNimbuses,
treeBark,
vibrantPentagons,
wiringPattern,
zigzagsGloomy,
zigzagsSunny
];
}
class $AssetsTutorialGen {
const $AssetsTutorialGen();
@ -46,6 +135,7 @@ class Assets {
static const String license = 'LICENSE';
static const AssetGenImage albumPlaceholder =
AssetGenImage('assets/album-placeholder.png');
static const $AssetsBackgroundsGen backgrounds = $AssetsBackgroundsGen();
static const AssetGenImage bengaliPatternsBg =
AssetGenImage('assets/bengali-patterns-bg.jpg');
static const AssetGenImage branding = AssetGenImage('assets/branding.png');
@ -55,12 +145,15 @@ 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 =
AssetGenImage('assets/spotube-hero-banner.png');
static const AssetGenImage spotubeLogoForeground =
AssetGenImage('assets/spotube-logo-foreground.jpg');
static const AssetGenImage spotubeLogoMacos =
AssetGenImage('assets/spotube-logo-macos.png');
static const AssetGenImage spotubeLogoBmp =
AssetGenImage('assets/spotube-logo.bmp');
static const String spotubeLogoIco = 'assets/spotube-logo.ico';
@ -104,6 +197,7 @@ class Assets {
placeholder,
spotubeHeroBanner,
spotubeLogoForeground,
spotubeLogoMacos,
spotubeLogoBmp,
spotubeLogoIco,
spotubeLogoPng,

View File

@ -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 String _disableSpotifyImages = _Env._disableSpotifyImages;
static bool get disableSpotifyImages => _disableSpotifyImages == "1";
static ReleaseChannel get releaseChannel => _releaseChannel == "stable"
? ReleaseChannel.stable
: ReleaseChannel.nightly;

View File

@ -0,0 +1,24 @@
/// GENERATED CODE - DO NOT MODIFY BY HAND
/// *****************************************************
/// FlutterGen
/// *****************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use
class FontFamily {
FontFamily._();
/// Font family: BootstrapIcons
static const String bootstrapIcons = 'BootstrapIcons';
/// Font family: GeistMono
static const String geistMono = 'GeistMono';
/// Font family: GeistSans
static const String geistSans = 'GeistSans';
/// Font family: RadixIcons
static const String radixIcons = 'RadixIcons';
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
const gradients = [
LinearGradient(colors: [

View File

@ -7,7 +7,11 @@ import 'package:go_router/go_router.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/modules/player/player_controls.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/library/user_albums.dart';
import 'package:spotube/pages/library/user_artists.dart';
import 'package:spotube/pages/library/user_downloads.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/pages/library/user_playlists.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart';
@ -52,8 +56,13 @@ class NavigationAction extends Action<NavigationIntent> {
enum HomeTabs {
browse,
search,
library,
lyrics,
userPlaylists,
userArtists,
userAlbums,
userLocalLibrary,
userDownloads,
}
class HomeTabIntent extends Intent {
@ -73,12 +82,24 @@ class HomeTabAction extends Action<HomeTabIntent> {
case HomeTabs.search:
router.goNamed(SearchPage.name);
break;
case HomeTabs.library:
router.goNamed(LibraryPage.name);
break;
case HomeTabs.lyrics:
router.goNamed(LyricsPage.name);
break;
case HomeTabs.userPlaylists:
router.goNamed(UserPlaylistsPage.name);
break;
case HomeTabs.userArtists:
router.goNamed(UserArtistsPage.name);
break;
case HomeTabs.userAlbums:
router.goNamed(UserAlbumsPage.name);
break;
case HomeTabs.userLocalLibrary:
router.goNamed(UserLocalLibraryPage.name);
break;
case HomeTabs.userDownloads:
router.goNamed(UserDownloadsPage.name);
break;
}
return null;
}

View File

@ -13,9 +13,14 @@ 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/user_local_tracks/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/library/user_albums.dart';
import 'package:spotube/pages/library/user_artists.dart';
import 'package:spotube/pages/library/user_downloads.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/pages/library/user_playlists.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart';
@ -99,45 +104,76 @@ final routerProvider = Provider((ref) {
pageBuilder: (context, state) =>
const SpotubePage(child: SearchPage()),
),
GoRoute(
path: "/library",
name: LibraryPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: LibraryPage()),
routes: [
GoRoute(
path: "generate",
name: PlaylistGeneratorPage.name,
ShellRoute(
pageBuilder: (context, state, child) =>
SpotubePage(child: LibraryPage(child: child)),
routes: [
GoRoute(
path: "/library/playlists",
name: UserPlaylistsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserPlaylistsPage()),
),
GoRoute(
path: "/library/artists",
name: UserArtistsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserArtistsPage()),
),
GoRoute(
path: "/library/album",
name: UserAlbumsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserAlbumsPage()),
),
GoRoute(
path: "/library/local",
name: UserLocalLibraryPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: PlaylistGeneratorPage()),
const SpotubePage(child: UserLocalLibraryPage()),
routes: [
GoRoute(
path: "result",
name: PlaylistGenerateResultPage.name,
pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage(
state: state.extra as GeneratePlaylistProviderInput,
),
),
)
],
path: "folder",
name: LocalLibraryPage.name,
parentNavigatorKey: shellRouteNavigatorKey,
pageBuilder: (context, state) {
assert(state.extra is String);
return SpotubePage(
child: LocalLibraryPage(
state.extra as String,
isDownloads:
state.uri.queryParameters["downloads"] != null,
isCache: state.uri.queryParameters["cache"] != null,
),
);
},
),
]),
GoRoute(
path: "/library/downloads",
name: UserDownloadsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserDownloadsPage()),
),
],
),
GoRoute(
path: "/library/generate",
name: PlaylistGeneratorPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: PlaylistGeneratorPage()),
routes: [
GoRoute(
path: "result",
name: PlaylistGenerateResultPage.name,
pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage(
state: state.extra as GeneratePlaylistProviderInput,
),
),
GoRoute(
path: "local",
name: LocalLibraryPage.name,
pageBuilder: (context, state) {
assert(state.extra is String);
return SpotubePage(
child: LocalLibraryPage(
state.extra as String,
isDownloads:
state.uri.queryParameters["downloads"] != null,
isCache: state.uri.queryParameters["cache"] != null,
),
);
},
),
]),
)
],
),
GoRoute(
path: "/lyrics",
name: LyricsPage.name,

View File

@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/library/user_albums.dart';
import 'package:spotube/pages/library/user_artists.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/pages/library/user_playlists.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/stats/stats.dart';
@ -34,12 +37,6 @@ List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [
icon: SpotubeIcons.search,
title: l10n.search,
),
SideBarTiles(
id: "library",
name: LibraryPage.name,
icon: SpotubeIcons.library,
title: l10n.library,
),
SideBarTiles(
id: "lyrics",
name: LyricsPage.name,
@ -54,6 +51,33 @@ List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [
),
];
List<SideBarTiles> getSidebarLibraryTileList(AppLocalizations l10n) => [
SideBarTiles(
id: "playlists",
title: l10n.playlists,
name: UserPlaylistsPage.name,
icon: SpotubeIcons.playlist,
),
SideBarTiles(
id: "artists",
title: l10n.artists,
name: UserArtistsPage.name,
icon: SpotubeIcons.artist,
),
SideBarTiles(
id: "albums",
title: l10n.albums,
name: UserAlbumsPage.name,
icon: SpotubeIcons.album,
),
SideBarTiles(
id: "local_library",
title: l10n.local_library,
name: UserLocalLibraryPage.name,
icon: SpotubeIcons.device,
),
];
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
SideBarTiles(
id: "browse",
@ -69,7 +93,7 @@ List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
),
SideBarTiles(
id: "library",
name: LibraryPage.name,
name: UserPlaylistsPage.name,
icon: SpotubeIcons.library,
title: l10n.library,
),

View File

@ -1,5 +1,5 @@
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:simple_icons/simple_icons.dart';
@ -37,6 +37,7 @@ abstract class SpotubeIcons {
static const share = FeatherIcons.share2;
static const playlistAdd = Icons.playlist_add_rounded;
static const playlistRemove = Icons.playlist_remove_rounded;
static const playlist = Icons.playlist_play_rounded;
static const trash = FeatherIcons.trash2;
static const clock = FeatherIcons.clock;
static const lyrics = Icons.lyrics_rounded;
@ -127,4 +128,10 @@ abstract class SpotubeIcons {
static const cache = FeatherIcons.hardDrive;
static const export = Icons.file_open_outlined;
static const delete = FeatherIcons.trash2;
static const open = FeatherIcons.externalLink;
static const radioChecked = Icons.radio_button_on_rounded;
static const radioUnchecked = Icons.radio_button_off_rounded;
static const grid = FeatherIcons.grid;
static const list = FeatherIcons.list;
static const device = FeatherIcons.smartphone;
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/constrains.dart';
class AdaptiveListTile extends HookWidget {
@ -24,41 +25,39 @@ class AdaptiveListTile extends HookWidget {
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return ListTile(
return ButtonTile(
title: title,
subtitle: subtitle,
trailing: breakOn ?? mediaQuery.smAndDown
? null
: trailing?.call(context, null),
leading: leading,
onTap: breakOn ?? mediaQuery.smAndDown
? () {
onTap?.call();
showDialog(
context: context,
barrierDismissible: true,
builder: (context) {
return StatefulBuilder(builder: (context, update) {
return AlertDialog(
title: title != null
? Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 5)
],
Flexible(child: title!),
],
)
: Container(),
content: trailing?.call(context, update),
);
});
},
enabled: breakOn ?? mediaQuery.smAndDown,
onPressed: () {
onTap?.call();
showDialog(
context: context,
barrierDismissible: true,
builder: (context) {
return StatefulBuilder(builder: (context, update) {
return AlertDialog(
title: title != null
? Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 5,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) leading!,
Flexible(child: title!),
],
)
: const SizedBox.shrink(),
content: Center(child: trailing?.call(context, update)),
);
}
: null,
});
},
);
},
);
}
}

View File

@ -1,67 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' show showModalBottomSheet;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
_emptyCB() {}
class PopSheetEntry<T> extends ListTile {
class AdaptiveMenuButton<T> extends MenuButton {
final T? value;
const PopSheetEntry({
this.value,
const AdaptiveMenuButton({
super.key,
super.leading,
super.title,
super.subtitle,
this.value,
required super.child,
super.subMenu,
super.onPressed,
super.trailing,
super.isThreeLine = false,
super.dense,
super.visualDensity,
super.shape,
super.style,
super.selectedColor,
super.iconColor,
super.textColor,
super.titleTextStyle,
super.subtitleTextStyle,
super.leadingAndTrailingTextStyle,
super.contentPadding,
super.leading,
super.enabled = true,
super.onTap = _emptyCB,
super.onLongPress,
super.onFocusChange,
super.mouseCursor,
super.selected = false,
super.focusColor,
super.hoverColor,
super.splashColor,
super.focusNode,
super.autofocus = false,
super.tileColor,
super.selectedTileColor,
super.enableFeedback,
super.horizontalTitleGap,
super.minVerticalPadding,
super.minLeadingWidth,
super.titleAlignment,
});
super.autoClose = true,
super.popoverController,
}) : assert(
value != null || onPressed != null,
'Either value or onPressed must be provided',
);
}
/// An adaptive widget that shows a [PopupMenuButton] when screen size is above
/// or equal to 640px
/// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown
class AdaptivePopSheetList<T> extends StatelessWidget {
final List<PopSheetEntry<T>> children;
final List<AdaptiveMenuButton<T>> children;
final Widget? icon;
final Widget? child;
final bool useRootNavigator;
final List<Widget>? headings;
final String? tooltip;
final String tooltip;
final ValueChanged<T>? onSelected;
final BorderRadius borderRadius;
final Offset offset;
final ButtonVariance variance;
const AdaptivePopSheetList({
super.key,
required this.children,
@ -70,166 +49,141 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
this.useRootNavigator = true,
this.headings,
this.onSelected,
this.borderRadius = const BorderRadius.all(Radius.circular(999)),
this.tooltip,
required this.tooltip,
this.offset = Offset.zero,
this.variance = ButtonVariance.ghost,
}) : assert(
!(icon != null && child != null),
'Either icon or child must be provided',
);
Future<T?> showPopupMenu(BuildContext context, RelativeRect position) {
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();
return showMenu<T>(
if (mediaQuery.mdAndUp) {
await showDropdown<T?>(
context: context,
rootOverlay: useRootNavigator,
// heightConstraint: PopoverConstraint.anchorFixedSize,
// constraints: BoxConstraints(
// maxHeight: mediaQuery.size.height * 0.6,
// ),
position: position,
builder: (context) {
return DropdownMenu(
children: childrenModified,
);
},
).future;
return;
}
showModalBottomSheet(
context: context,
useRootNavigator: useRootNavigator,
constraints: BoxConstraints(
maxHeight: mediaQuery.size.height * 0.6,
enableDrag: true,
showDragHandle: true,
useRootNavigator: true,
shape: RoundedRectangleBorder(
borderRadius: context.theme.borderRadiusMd,
),
position: position,
items: children
.map(
(item) => PopupMenuItem<T>(
padding: EdgeInsets.zero,
enabled: false,
child: _AdaptivePopSheetListItem<T>(
item: item,
onSelected: onSelected,
backgroundColor: context.theme.colorScheme.card,
builder: (context) {
return ListView.builder(
itemCount: childrenModified.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final data = childrenModified[index];
return Button(
enabled: data.enabled,
style: ButtonVariance.ghost.copyWith(
padding: (context, state, value) => const EdgeInsets.all(16),
),
),
)
.toList(),
onPressed: () {
data.onPressed?.call(context);
if (data.autoClose) {
Navigator.of(context).pop();
}
},
leading: data.leading,
trailing: data.trailing,
alignment: Alignment.centerLeft,
child: data.child,
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final theme = Theme.of(context);
if (mediaQuery.mdAndUp) {
return PopupMenuButton(
icon: icon,
tooltip: tooltip,
offset: offset,
child: child == null ? null : IgnorePointer(child: child),
itemBuilder: (context) => children
.map(
(item) => PopupMenuItem(
padding: EdgeInsets.zero,
enabled: false,
child: _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
),
),
)
.toList(),
);
}
void showSheet() {
showModalBottomSheet(
context: context,
useRootNavigator: useRootNavigator,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxHeight: mediaQuery.size.height * 0.6,
return Tooltip(
tooltip: TooltipContainer(
child: Text(tooltip),
),
builder: (context) {
return Padding(
padding: const EdgeInsets.all(8.0).copyWith(top: 0),
child: DefaultTextStyle(
style: theme.textTheme.titleMedium!,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (headings != null) ...[
...headings!,
const SizedBox(height: 8),
Divider(
color: theme.colorScheme.primary,
thickness: 0.3,
endIndent: 16,
indent: 16,
),
],
...children.map(
(item) => _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
),
)
],
),
child: IconButton(
variance: variance,
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () {
final renderBox = context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
renderBox.localToGlobal(Offset.zero,
ancestor: context.findRenderObject()),
renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero),
ancestor: context.findRenderObject()),
),
),
);
},
Offset.zero & mediaQuery.size,
);
final offset = Offset(position.left, position.top);
showDropdownMenu(context, offset);
},
),
);
}
if (child != null) {
return Tooltip(
message: tooltip ?? '',
child: InkWell(
onTap: showSheet,
borderRadius: borderRadius,
tooltip: TooltipContainer(child: Text(tooltip)),
child: Button(
onPressed: () => showDropdownMenu(context, Offset.zero),
style: variance,
child: IgnorePointer(child: child),
),
);
}
return IconButton(
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
tooltip: tooltip,
style: theme.iconButtonTheme.style?.copyWith(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: borderRadius,
),
),
),
onPressed: showSheet,
);
}
}
class _AdaptivePopSheetListItem<T> extends StatelessWidget {
final PopSheetEntry<T> item;
final ValueChanged<T>? onSelected;
const _AdaptivePopSheetListItem({
super.key,
required this.item,
this.onSelected,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?)
?.borderRadius as BorderRadius? ??
const BorderRadius.all(Radius.circular(10)),
onTap: !item.enabled
? null
: () {
item.onTap?.call();
if (item.value != null) {
Navigator.pop(context);
onSelected?.call(item.value as T);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: IconTheme.merge(
data: const IconThemeData(opacity: 1),
child: IgnorePointer(child: item),
),
return Tooltip(
tooltip: TooltipContainer(child: Text(tooltip)),
child: IconButton(
variance: variance,
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () => showDropdownMenu(context, Offset.zero),
),
);
}

View File

@ -1,106 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:popover/popover.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
class Action extends StatelessWidget {
final Widget text;
final Widget icon;
final void Function() onPressed;
final bool isExpanded;
final Color? backgroundColor;
const Action({
super.key,
required this.icon,
required this.text,
required this.onPressed,
this.isExpanded = true,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
if (isExpanded != true) {
return IconButton(
icon: icon,
onPressed: onPressed,
style: IconButton.styleFrom(
backgroundColor: backgroundColor,
),
tooltip: text is Text
? (text as Text).data
: text.toStringShallow().split(",").last.replaceAll(
"\"",
"",
),
);
}
return ListTile(
tileColor: backgroundColor,
onTap: onPressed,
leading: icon,
title: text,
);
}
}
class AdaptiveActions extends HookWidget {
final List<Action> actions;
final bool? breakOn;
const AdaptiveActions({
required this.actions,
this.breakOn,
super.key,
});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
if (breakOn ?? mediaQuery.lgAndUp) {
return IconButton(
icon: const Icon(SpotubeIcons.moreHorizontal),
onPressed: () {
showPopover(
context: context,
direction: PopoverDirection.left,
bodyBuilder: (context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: actions
.map(
(action) => SizedBox(
width: 200,
child: Row(
children: [
Expanded(child: action),
],
),
),
)
.toList(),
);
},
backgroundColor: Theme.of(context).cardColor,
);
},
);
}
return Row(
children: actions.map((action) {
return Action(
icon: action.icon,
onPressed: action.onPressed,
text: action.text,
backgroundColor: action.backgroundColor,
isExpanded: false,
);
}).toList(),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' show ListTile, ListTileControlAffinity;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
@ -11,7 +12,7 @@ class AdaptiveSelectTile<T> extends HookWidget {
final T value;
final ValueChanged<T?>? onChanged;
final List<DropdownMenuItem<T>> options;
final List<SelectItemButton<T>> options;
/// Show the smaller value when the breakpoint is reached
///
@ -22,6 +23,9 @@ class AdaptiveSelectTile<T> extends HookWidget {
final bool? breakLayout;
final BoxConstraints? popupConstraints;
final PopoverConstraint? popupWidthConstraint;
const AdaptiveSelectTile({
required this.title,
required this.value,
@ -33,61 +37,35 @@ class AdaptiveSelectTile<T> extends HookWidget {
this.breakLayout,
this.showValueWhenUnfolded = true,
super.key,
this.popupConstraints,
this.popupWidthConstraint,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final rawControl = DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: DropdownButton<T>(
items: options,
value: value,
onChanged: onChanged,
menuMaxHeight: mediaQuery.size.height * 0.6,
underline: const SizedBox.shrink(),
padding: const EdgeInsets.symmetric(horizontal: 10),
borderRadius: BorderRadius.circular(10),
icon: const Icon(SpotubeIcons.angleDown),
dropdownColor: theme.colorScheme.secondaryContainer,
),
);
final controlPlaceholder = useMemoized(
() => options
.firstWhere(
(element) => element.value == value,
orElse: () => DropdownMenuItem<T>(
value: null,
child: Container(),
),
)
.child,
[value, options]);
final control = breakLayout ?? mediaQuery.mdAndUp
? rawControl
: showValueWhenUnfolded
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(
color: theme.colorScheme.primary,
width: 2,
),
borderRadius: BorderRadius.circular(10),
),
child: DefaultTextStyle(
style: TextStyle(
color: theme.colorScheme.primary,
),
child: controlPlaceholder,
),
)
: const SizedBox.shrink();
Widget? control = Select<T>(
itemBuilder: (context, item) {
return options.firstWhere((element) => element.value == item).child;
},
value: value,
onChanged: onChanged,
popupConstraints: popupConstraints ?? const BoxConstraints(maxWidth: 200),
popupWidthConstraint: popupWidthConstraint ?? PopoverConstraint.flexible,
children: options,
);
if (mediaQuery.smAndDown) {
if (showValueWhenUnfolded) {
control = OutlineBadge(
child: options.firstWhere((element) => element.value == value).child,
);
} else {
control = null;
}
}
return ListTile(
title: title,
@ -104,20 +82,26 @@ class AdaptiveSelectTile<T> extends HookWidget {
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: title,
children: [
for (final option in options)
RadioListTile<T>(
title: option.child,
value: option.value as T,
groupValue: value,
onChanged: (v) {
Navigator.pop(context);
onChanged?.call(v);
return AlertDialog(
content: ListView.builder(
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final item = options[index];
return ListTile(
iconColor: theme.colorScheme.primary,
leading: item.value == value
? const Icon(SpotubeIcons.radioChecked)
: const Icon(SpotubeIcons.radioUnchecked),
title: item.child,
onTap: () {
onChanged?.call(item.value);
Navigator.of(context).pop();
},
),
],
);
},
),
);
},
);

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class AnimateGradient extends HookWidget {

View File

@ -1,88 +0,0 @@
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,
textScaler: child.textScaler,
),
child,
],
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
class BackButton extends StatelessWidget {
final Color? color;
const BackButton({
super.key,
this.color,
});
@override
Widget build(BuildContext context) {
return IconButton.ghost(
size: const ButtonSize(.9),
icon: color != null
? Icon(SpotubeIcons.angleLeft, color: color)
: const Icon(SpotubeIcons.angleLeft),
onPressed: () => Navigator.of(context).pop(),
);
}
}

View File

@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:popover/popover.dart';
import 'package:spotube/collections/spotube_icons.dart';
class CompactSearch extends HookWidget {
final ValueChanged<String>? onChanged;
final String placeholder;
final IconData icon;
final Color? iconColor;
const CompactSearch({
super.key,
this.onChanged,
this.placeholder = "Search...",
this.icon = SpotubeIcons.search,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {
showPopover(
context: context,
backgroundColor: Theme.of(context).cardColor,
transitionDuration: const Duration(milliseconds: 100),
barrierColor: Colors.transparent,
arrowDxOffset: -6,
bodyBuilder: (context) {
return Container(
padding: const EdgeInsets.all(8.0),
width: 300,
child: TextField(
autofocus: true,
onChanged: onChanged,
decoration: InputDecoration(
hintText: placeholder,
prefixIcon: Icon(icon),
),
),
);
},
height: 60,
);
},
tooltip: placeholder,
icon: Icon(icon, color: iconColor),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
@ -9,13 +8,15 @@ class ConfirmDownloadDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Padding(
padding: const EdgeInsets.all(15),
child: Row(
final screenSize = MediaQuery.sizeOf(context);
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: Breakpoints.sm),
child: AlertDialog(
title: Row(
spacing: 10,
children: [
Text(context.l10n.are_you_sure),
const SizedBox(width: 10),
const UniversalImage(
path:
"https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif",
@ -24,58 +25,53 @@ class ConfirmDownloadDialog extends StatelessWidget {
)
],
),
),
content: Container(
padding: const EdgeInsets.all(15),
constraints: BoxConstraints(maxWidth: Breakpoints.sm),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.download_warning,
textAlign: TextAlign.justify,
),
const SizedBox(height: 10),
Text(
context.l10n.download_ip_ban_warning,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
content: Expanded(
flex: screenSize.smAndUp ? 0 : 1,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.download_warning,
textAlign: TextAlign.justify,
),
textAlign: TextAlign.justify,
),
const SizedBox(height: 10),
Text(
context.l10n.by_clicking_accept_terms,
),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_1),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_2),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_3),
],
const SizedBox(height: 10),
Text(
context.l10n.download_ip_ban_warning,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.justify,
),
const SizedBox(height: 10),
Text(
context.l10n.by_clicking_accept_terms,
),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_1),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_2),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_3),
],
),
),
),
actions: [
Button.outline(
child: Text(context.l10n.decline),
onPressed: () {
Navigator.pop(context, false);
},
),
Button.destructive(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.accept),
),
],
),
actions: [
OutlinedButton(
child: Text(context.l10n.decline),
onPressed: () {
Navigator.pop(context, false);
},
),
FilledButton(
style: FilledButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.red,
),
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.accept),
),
],
);
}
}

View File

@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class PipedDownDialog extends HookConsumerWidget {
const PipedDownDialog({super.key});
@override
Widget build(BuildContext context, ref) {
final pipedInstance =
ref.watch(userPreferencesProvider.select((s) => s.pipedInstance));
final ThemeData(:colorScheme) = Theme.of(context);
return AlertDialog(
insetPadding: const EdgeInsets.all(6),
contentPadding: const EdgeInsets.all(6),
icon: Icon(
SpotubeIcons.error,
color: colorScheme.error,
),
title: Text(
context.l10n.piped_api_down,
style: TextStyle(color: colorScheme.error),
),
content: Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child:
Text(context.l10n.piped_down_error_instructions(pipedInstance)),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.ok),
),
FilledButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.settings),
),
],
);
}
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
@ -22,7 +21,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final typography = Theme.of(context).typography;
final userPlaylists = ref.watch(favoritePlaylistsProvider);
final favoritePlaylistsNotifier =
ref.watch(favoritePlaylistsProvider.notifier);
@ -64,67 +63,86 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
tracks.map((e) => e.id!).toList(),
),
),
).then((_) => Navigator.pop(context, true));
).then((_) => context.mounted ? Navigator.pop(context, true) : null);
}
return AlertDialog(
insetPadding: EdgeInsets.zero,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.add_to_playlist,
style: textTheme.titleMedium,
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: AlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.add_to_playlist,
style: typography.large,
),
const Spacer(),
const PlaylistCreateDialogButton(),
],
),
actions: [
OutlineButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context, false);
},
),
PrimaryButton(
onPressed: onAdd,
child: Text(context.l10n.add),
),
const Gap(20),
const PlaylistCreateDialogButton(),
],
),
actions: [
OutlinedButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context, false);
},
),
FilledButton(
onPressed: onAdd,
child: Text(context.l10n.add),
),
],
content: SizedBox(
height: 300,
width: 300,
child: userPlaylists.isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
shrinkWrap: true,
itemCount: filteredPlaylists.length,
itemBuilder: (context, index) {
final playlist = filteredPlaylists.elementAt(index);
return CheckboxListTile(
secondary: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
content: SizedBox(
height: 300,
child: userPlaylists.isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
shrinkWrap: true,
itemCount: filteredPlaylists.length,
itemBuilder: (context, index) {
final playlist = filteredPlaylists.elementAt(index);
return Button.ghost(
style: ButtonVariance.ghost.copyWith(
padding: (context, _, __) {
return const EdgeInsets.symmetric(vertical: 8);
},
),
leading: Avatar(
initials:
Avatar.getInitials(playlist.name ?? "Playlist"),
provider: UniversalImage.imageProvider(
playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
),
),
),
contentPadding: EdgeInsets.zero,
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(playlist.name!),
),
value: playlistsCheck.value[playlist.id] ?? false,
onChanged: (val) {
playlistsCheck.value = {
...playlistsCheck.value,
playlist.id!: val == true
};
},
);
},
),
trailing: Checkbox(
state: (playlistsCheck.value[playlist.id] ?? false)
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (val) {
playlistsCheck.value = {
...playlistsCheck.value,
playlist.id!: val == CheckboxState.checked,
};
},
),
onPressed: () {
playlistsCheck.value = {
...playlistsCheck.value,
playlist.id!:
!(playlistsCheck.value[playlist.id] ?? false),
};
},
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(playlist.name!),
),
);
},
),
),
),
);
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/context.dart';
Future<bool> showPromptDialog({
@ -16,13 +16,13 @@ Future<bool> showPromptDialog({
content: Text(message),
actions: [
if (cancelText != null)
OutlinedButton(
Button.outline(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
cancelText == "Cancel" ? context.l10n.cancel : cancelText,
),
),
FilledButton(
Button.primary(
child: Text(okText == "Ok" ? context.l10n.ok : okText),
onPressed: () => Navigator.of(context).pop(true),
),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart';
@ -13,45 +13,35 @@ class ReplaceDownloadedDialog extends ConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final groupValue = ref.watch(replaceDownloadedFileState);
final theme = Theme.of(context);
final replaceAll = ref.watch(replaceDownloadedFileState);
return AlertDialog(
title: Text(context.l10n.track_exists(track.name ?? "")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.do_you_want_to_replace),
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: theme.colorScheme.primary,
value: true,
groupValue: groupValue,
onChanged: (value) {
if (value != null) {
ref.read(replaceDownloadedFileState.notifier).state = true;
}
},
title: Text(context.l10n.replace_downloaded_tracks),
),
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: theme.colorScheme.primary,
value: false,
groupValue: groupValue,
onChanged: (value) {
if (value != null) {
ref.read(replaceDownloadedFileState.notifier).state = false;
}
},
title: Text(context.l10n.skip_download_tracks),
),
],
content: RadioGroup(
value: groupValue,
onChanged: (value) {
ref.read(replaceDownloadedFileState.notifier).state = value;
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.do_you_want_to_replace),
const Gap(16),
RadioItem<bool>(
value: true,
trailing: Text(context.l10n.replace_downloaded_tracks),
),
const Gap(8),
RadioItem<bool>(
value: false,
trailing: Text(context.l10n.skip_download_tracks),
),
],
),
),
actions: [
OutlinedButton(
Button.outline(
onPressed: replaceAll == true
? null
: () {
@ -59,7 +49,7 @@ class ReplaceDownloadedDialog extends ConsumerWidget {
},
child: Text(context.l10n.skip),
),
FilledButton(
Button.primary(
onPressed: replaceAll == false
? null
: () {

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
@ -16,31 +16,31 @@ class SelectDeviceDialog extends HookConsumerWidget {
return AlertDialog(
title: Text(context.l10n.choose_the_device),
insetPadding: const EdgeInsets.all(16),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.multiple_device_connected),
RadioListTile.adaptive(
title: Text(remoteService.name),
value: true,
groupValue: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = value!;
},
),
RadioListTile.adaptive(
title: Text(context.l10n.this_device),
value: false,
groupValue: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = !value!;
},
),
],
content: RadioGroup(
value: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = value;
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.multiple_device_connected),
const Gap(16),
RadioItem(
trailing: Text(remoteService.name),
value: true,
),
const Gap(8),
RadioItem(
trailing: Text(context.l10n.this_device),
value: false,
),
],
),
),
actions: [
TextButton(
Button.primary(
onPressed: () {
Navigator.of(context).pop(isRemoteService.value);
},
@ -51,7 +51,8 @@ class SelectDeviceDialog extends HookConsumerWidget {
}
}
Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
Future<bool?> showSelectDeviceDialog(
BuildContext context, WidgetRef ref) async {
final connectClients = ref.read(connectClientsProvider);
if (connectClients.asData?.value.resolvedService == null) {
@ -63,5 +64,5 @@ Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
builder: (context) => const SelectDeviceDialog(),
);
return isRemote ?? false;
return isRemote;
}

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/links/artist_link.dart';
@ -73,17 +73,15 @@ class TrackDetailsDialog extends HookWidget {
};
return AlertDialog(
contentPadding: const EdgeInsets.all(16),
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 100),
scrollable: true,
surfaceBlur: 0,
surfaceOpacity: 1,
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
const Icon(SpotubeIcons.info),
const SizedBox(width: 8),
Text(
context.l10n.details,
style: theme.textTheme.titleMedium,
style: theme.typography.h4,
),
],
),
@ -91,65 +89,64 @@ class TrackDetailsDialog extends HookWidget {
width: mediaQuery.mdAndUp ? double.infinity : 700,
child: Table(
columnWidths: const {
0: FixedColumnWidth(95),
1: FixedColumnWidth(10),
2: FlexColumnWidth(1),
0: FixedTableSize(95),
1: FixedTableSize(10),
2: FlexTableSize(),
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
theme: const TableTheme(
backgroundColor: Colors.transparent,
cellTheme: TableCellTheme(
backgroundColor: WidgetStatePropertyAll(Colors.transparent),
),
),
rowHeights: const {0: FixedTableSize(40)},
rows: [
for (final entry in detailsMap.entries)
TableRow(
children: [
cells: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(
entry.key,
style: theme.textTheme.titleMedium,
style: theme.typography.bold,
),
),
const TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"),
),
if (entry.value is Widget)
entry.value as Widget
else if (entry.value is String)
Text(
entry.value as String,
style: theme.textTheme.bodyMedium,
),
TableCell(
child: entry.value is Widget
? entry.value as Widget
: (entry.value is String)
? Text(
entry.value as String,
style: theme.typography.normal,
)
: const Text(""),
),
],
),
const TableRow(
children: [
SizedBox(height: 16),
SizedBox(height: 16),
SizedBox(height: 16),
],
),
for (final entry in ytTracksDetailsMap.entries)
TableRow(
children: [
cells: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(
entry.key,
style: theme.textTheme.titleMedium,
style: theme.typography.bold,
),
),
const TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"),
),
if (entry.value is Widget)
entry.value as Widget
else
Text(
entry.value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
TableCell(
child: entry.value is Widget
? entry.value as Widget
: Text(
entry.value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.typography.normal,
),
),
],
),
],

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
@ -39,11 +39,8 @@ class ExpandableSearchField extends StatelessWidget {
child: TextField(
focusNode: searchFocus,
controller: searchController,
decoration: InputDecoration(
hintText: context.l10n.search_tracks,
isDense: true,
prefixIcon: const Icon(SpotubeIcons.search),
),
placeholder: Text(context.l10n.search_tracks),
leading: const Icon(SpotubeIcons.search),
),
),
),
@ -69,16 +66,9 @@ class ExpandableSearchButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: icon,
style: IconButton.styleFrom(
backgroundColor:
isFiltering ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25),
),
variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline,
onPressed: () {
if (isFiltering) {
searchFocus.requestFocus();

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart';
class AnonymousFallback extends ConsumerWidget {
@ -25,10 +28,17 @@ class AnonymousFallback extends ConsumerWidget {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [
Undraw(
illustration: kIsMobile
? UndrawIllustration.accessDenied
: UndrawIllustration.secureLogin,
height: 200 * context.theme.scaling,
color: context.theme.colorScheme.primary,
),
Text(context.l10n.not_logged_in),
const SizedBox(height: 10),
FilledButton(
Button.primary(
child: Text(context.l10n.login_with_spotify),
onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name),
)

View File

@ -1,32 +1,27 @@
import 'package:flutter/material.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/extensions/context.dart';
class NotFound extends StatelessWidget {
final bool vertical;
const NotFound({super.key, this.vertical = false});
const NotFound({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final widgets = [
SizedBox(
height: 150,
width: 150,
child: Assets.emptyBox.image(),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge),
Text(
context.l10n.the_box_is_empty,
style: theme.textTheme.titleMedium,
),
],
),
];
return vertical ? Column(children: widgets) : Row(children: widgets);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Undraw(
illustration: UndrawIllustration.empty,
height: 200 * context.theme.scaling,
color: context.theme.colorScheme.primary,
),
const Gap(10),
Text(
context.l10n.nothing_found,
textAlign: TextAlign.center,
).muted().small()
],
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class CheckboxFormBuilderField extends StatelessWidget {
final String name;
final FormFieldValidator<bool>? validator;
final ValueChanged<CheckboxState>? onChanged;
final Widget? leading;
final Widget? trailing;
final bool tristate;
const CheckboxFormBuilderField({
super.key,
required this.name,
this.validator,
this.onChanged,
this.leading,
this.trailing,
this.tristate = false,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<bool>(
name: name,
validator: validator,
builder: (field) {
return Checkbox(
state: tristate && field.value == null
? CheckboxState.indeterminate
: field.value == true
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (state) {
field.didChange(state == CheckboxState.checked);
onChanged?.call(state);
},
leading: leading,
trailing: trailing,
tristate: tristate,
);
},
);
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
class TextFormBuilderField extends StatelessWidget {
final String name;
final FormFieldValidator<String>? validator;
final Widget? label;
final TextEditingController? controller;
final bool filled;
final Widget? placeholder;
final AlignmentGeometry? placeholderAlignment;
final AlignmentGeometry? leadingAlignment;
final AlignmentGeometry? trailingAlignment;
final bool border;
final Widget? leading;
final Widget? trailing;
final EdgeInsetsGeometry? padding;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final FocusNode? focusNode;
final VoidCallback? onTap;
final bool enabled;
final bool readOnly;
final bool obscureText;
final String obscuringCharacter;
final String? initialValue;
final int? maxLength;
final MaxLengthEnforcement? maxLengthEnforcement;
final int? maxLines;
final int? minLines;
final BorderRadiusGeometry? borderRadius;
final TextAlign textAlign;
final bool expands;
final TextAlignVertical? textAlignVertical;
final UndoHistoryController? undoController;
final ValueChanged<String>? onChanged;
final Iterable<String>? autofillHints;
final void Function(PointerDownEvent event)? onTapOutside;
final List<TextInputFormatter>? inputFormatters;
final TextStyle? style;
final EditableTextContextMenuBuilder? contextMenuBuilder;
final bool useNativeContextMenu;
final bool? isCollapsed;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final Clip clipBehavior;
final bool autofocus;
final WidgetStatesController? statesController;
const TextFormBuilderField({
super.key,
required this.name,
this.label,
this.validator,
this.controller,
this.maxLength,
this.maxLengthEnforcement,
this.maxLines = 1,
this.minLines,
this.filled = false,
this.placeholder,
this.border = true,
this.leading,
this.trailing,
this.padding,
this.onSubmitted,
this.onEditingComplete,
this.focusNode,
this.onTap,
this.enabled = true,
this.readOnly = false,
this.obscureText = false,
this.obscuringCharacter = '',
this.initialValue,
this.borderRadius,
this.keyboardType,
this.textAlign = TextAlign.start,
this.expands = false,
this.textAlignVertical = TextAlignVertical.center,
this.autofillHints,
this.undoController,
this.onChanged,
this.onTapOutside,
this.inputFormatters,
this.style,
this.contextMenuBuilder = TextField.defaultContextMenuBuilder,
this.useNativeContextMenu = false,
this.isCollapsed,
this.textInputAction,
this.clipBehavior = Clip.hardEdge,
this.autofocus = false,
this.placeholderAlignment,
this.leadingAlignment,
this.trailingAlignment,
this.statesController,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<String>(
name: name,
validator: validator,
onChanged: (value) {
if (value == null) return;
onChanged?.call(value);
},
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
if (label != null)
DefaultTextStyle(
style: context.theme.typography.semiBold.copyWith(
color: field.hasError
? context.theme.colorScheme.destructive
: context.theme.colorScheme.foreground,
),
child: label!,
),
TextField(
controller: controller,
maxLength: maxLength,
maxLengthEnforcement: maxLengthEnforcement,
maxLines: maxLines,
minLines: minLines,
filled: filled,
placeholder: placeholder,
border: border,
leading: leading,
trailing: trailing,
padding: padding,
onSubmitted: (value) {
field.validate();
field.save();
onSubmitted?.call(value);
},
onEditingComplete: () {
field.save();
onEditingComplete?.call();
},
focusNode: focusNode,
onTap: onTap,
enabled: enabled,
readOnly: readOnly,
obscureText: obscureText,
obscuringCharacter: obscuringCharacter,
initialValue: field.value,
borderRadius: borderRadius,
textAlign: textAlign,
expands: expands,
textAlignVertical: textAlignVertical,
autofillHints: autofillHints,
undoController: undoController,
onChanged: (value) {
field.didChange(value);
},
onTapOutside: onTapOutside,
inputFormatters: inputFormatters,
style: style,
contextMenuBuilder: contextMenuBuilder,
useNativeContextMenu: useNativeContextMenu,
isCollapsed: isCollapsed,
keyboardType: keyboardType,
textInputAction: textInputAction,
clipBehavior: clipBehavior,
autofocus: autofocus,
placeholderAlignment: placeholderAlignment,
leadingAlignment: leadingAlignment,
trailingAlignment: trailingAlignment,
statesController: statesController,
),
if (field.hasError)
Text(
field.errorText ?? "",
style: TextStyle(
color: context.theme.colorScheme.destructive,
),
),
],
),
);
}
}

View File

@ -1,6 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
/// A temporary workaround for [WillPopScope] and [PopScope] not working in GoRouter
/// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget {
final IconData? icon;
final Color? color;
final String? tooltip;
final ButtonVariance variance;
final ButtonSize size;
const HeartButton({
required this.isLiked,
required this.onPressed,
this.color,
this.tooltip,
this.icon,
this.variance = ButtonVariance.ghost,
this.size = ButtonSize.normal,
super.key,
});
@ -28,28 +32,32 @@ class HeartButton extends HookConsumerWidget {
if (auth.asData?.value == null) return const SizedBox.shrink();
return IconButton(
tooltip: tooltip,
icon: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: Icon(
icon ??
(isLiked
? Icons.favorite_rounded
: Icons.favorite_outline_rounded),
key: ValueKey(isLiked),
color: color ?? (isLiked ? color ?? Colors.red : null),
return Tooltip(
tooltip: TooltipContainer(child: Text(tooltip ?? "")),
child: IconButton(
variance: variance,
size: size,
icon: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: Icon(
icon ??
(isLiked
? Icons.favorite_rounded
: Icons.favorite_outline_rounded),
key: ValueKey(isLiked),
color: color ?? (isLiked ? color ?? Colors.red : null),
),
),
onPressed: onPressed,
),
onPressed: onPressed,
);
}
}

View File

@ -1,14 +1,14 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class HorizontalPlaybuttonCardView<T> extends HookWidget {
@ -36,14 +36,9 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
@override
Widget build(BuildContext context) {
final ThemeData(:textTheme) = Theme.of(context);
final scrollController = useScrollController();
final height = useBreakpointValue<double>(
xs: 226,
sm: 226,
md: 236,
others: 266,
);
final isArtist = items.every((s) => s is Artist);
final scale = context.theme.scaling;
return Padding(
padding: const EdgeInsets.all(8.0),
@ -54,15 +49,21 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DefaultTextStyle(
style: textTheme.titleMedium!,
child: title,
Flexible(
child: DefaultTextStyle(
style: context.theme.typography.h4.copyWith(
color: context.theme.colorScheme.foreground,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: title,
),
),
if (titleTrailing != null) titleTrailing!,
],
),
SizedBox(
height: height,
height: isArtist ? 250 : 225,
child: NotificationListener(
// disable multiple scrollbar to use this
onNotification: (notification) => true,
@ -86,10 +87,13 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: AlbumCard(FakeData.albumSimple),
child: isArtist
? ArtistCard(FakeData.artist)
: AlbumCard(FakeData.albumSimple),
),
isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage,
separatorBuilder: (context, index) => Gap(12 * scale),
itemBuilder: (context, index) {
final item = items[index];
@ -97,11 +101,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple),
AlbumSimple() => AlbumCard(item as AlbumSimple),
Artist() => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0),
child: ArtistCard(item as Artist),
),
Artist() => ArtistCard(item as Artist),
_ => const SizedBox.shrink(),
};
}),

View File

@ -1,5 +1,5 @@
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/utils/platform.dart';

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class AnchorButton<T> extends HookWidget {
final String text;

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/links/anchor_button.dart';
import 'package:spotube/extensions/context.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/links/anchor_button.dart';
import 'package:url_launcher/url_launcher_string.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/links/anchor_button.dart';
import 'package:spotube/utils/service_utils.dart';

View File

@ -1,146 +0,0 @@
part of 'sliding_up_panel.dart';
class PanelController extends ChangeNotifier {
SlidingUpPanelState? _panelState;
void _addState(SlidingUpPanelState panelState) {
_panelState = panelState;
notifyListeners();
}
bool _forceScrollChange = false;
/// use this function when scroll change in func
/// Example:
/// panelController.forseScrollChange(scrollController.animateTo(100, duration: Duration(milliseconds: 400), curve: Curves.ease))
Future<void> forceScrollChange(Future func) async {
_forceScrollChange = true;
_panelState!._scrollingEnabled = true;
await func;
// if (_panelState!._sc.offset == 0) {
// _panelState!._scrollingEnabled = true;
// }
if (panelPosition < 1) {
_panelState!._scMinOffset = _panelState!._scrollController.offset;
}
_forceScrollChange = false;
}
bool __nowTargetForceDraggable = false;
bool get _nowTargetForceDraggable => __nowTargetForceDraggable;
set _nowTargetForceDraggable(bool value) {
__nowTargetForceDraggable = value;
notifyListeners();
}
/// Determine if the panelController is attached to an instance
/// of the SlidingUpPanel (this property must return true before any other
/// functions can be used)
bool get isAttached => _panelState != null;
/// Closes the sliding panel to its collapsed state (i.e. to the minHeight)
Future<void> close() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._close();
notifyListeners();
}
/// Opens the sliding panel fully
/// (i.e. to the maxHeight)
Future<void> open() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._open();
notifyListeners();
}
/// Hides the sliding panel (i.e. is invisible)
Future<void> hide() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._hide();
notifyListeners();
}
/// Shows the sliding panel in its collapsed state
/// (i.e. "un-hide" the sliding panel)
Future<void> show() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._show();
notifyListeners();
}
/// Animates the panel position to the value.
/// The value must between 0.0 and 1.0
/// where 0.0 is fully collapsed and 1.0 is completely open.
/// (optional) duration specifies the time for the animation to complete
/// (optional) curve specifies the easing behavior of the animation.
Future<void> animatePanelToPosition(double value,
{Duration? duration, Curve curve = Curves.linear}) {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
assert(0.0 <= value && value <= 1.0);
return _panelState!
._animatePanelToPosition(value, duration: duration, curve: curve);
}
/// Animates the panel position to the snap point
/// Requires that the SlidingUpPanel snapPoint property is not null
/// (optional) duration specifies the time for the animation to complete
/// (optional) curve specifies the easing behavior of the animation.
Future<void> animatePanelToSnapPoint(
{Duration? duration, Curve curve = Curves.linear}) {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
assert(_panelState!.widget.snapPoint != null,
"SlidingUpPanel snapPoint property must not be null");
return _panelState!
._animatePanelToSnapPoint(duration: duration, curve: curve);
}
/// Sets the panel position (without animation).
/// The value must between 0.0 and 1.0
/// where 0.0 is fully collapsed and 1.0 is completely open.
set panelPosition(double value) {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
assert(0.0 <= value && value <= 1.0);
_panelState!._panelPosition = value;
}
/// Gets the current panel position.
/// Returns the % offset from collapsed state
/// to the open state
/// as a decimal between 0.0 and 1.0
/// where 0.0 is fully collapsed and
/// 1.0 is full open.
double get panelPosition {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._panelPosition;
}
/// Returns whether or not the panel is
/// currently animating.
bool get isPanelAnimating {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelAnimating;
}
/// Returns whether or not the
/// panel is open.
bool get isPanelOpen {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelOpen;
}
/// Returns whether or not the
/// panel is closed.
bool get isPanelClosed {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelClosed;
}
/// Returns whether or not the
/// panel is shown/hidden.
bool get isPanelShown {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelShown;
}
}

View File

@ -1,95 +0,0 @@
part of "sliding_up_panel.dart";
/// if you want to prevent the panel from being dragged using the widget,
/// wrap the widget with this
class IgnoreDraggableWidget extends SingleChildRenderObjectWidget {
const IgnoreDraggableWidget({
super.key,
required super.child,
});
@override
IgnoreDraggableWidgetWidgetRenderBox createRenderObject(
BuildContext context,
) {
return IgnoreDraggableWidgetWidgetRenderBox();
}
}
class IgnoreDraggableWidgetWidgetRenderBox extends RenderPointerListener {
@override
HitTestBehavior get behavior => HitTestBehavior.opaque;
}
/// if you want to force the panel to be dragged using the widget,
/// wrap the widget with this
/// For example, use [Scrollable] inside to allow the panel to be dragged
/// even if the scroll is not at position 0.
class ForceDraggableWidget extends SingleChildRenderObjectWidget {
const ForceDraggableWidget({
super.key,
required super.child,
});
@override
ForceDraggableWidgetRenderBox createRenderObject(
BuildContext context,
) {
return ForceDraggableWidgetRenderBox();
}
}
class ForceDraggableWidgetRenderBox extends RenderPointerListener {
@override
HitTestBehavior get behavior => HitTestBehavior.opaque;
}
/// To make [ForceDraggableWidget] work in [Scrollable] widgets
class PanelScrollPhysics extends ScrollPhysics {
final PanelController controller;
const PanelScrollPhysics({required this.controller, super.parent});
@override
PanelScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PanelScrollPhysics(
controller: controller, parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
if (controller._nowTargetForceDraggable) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position, double velocity) {
if (controller._nowTargetForceDraggable) {
return super.createBallisticSimulation(position, 0);
}
return super.createBallisticSimulation(position, velocity);
}
@override
bool get allowImplicitScrolling => false;
}
/// if you want to prevent unwanted panel dragging when scrolling widgets [Scrollable] with horizontal axis
/// wrap the widget with this
class HorizontalScrollableWidget extends SingleChildRenderObjectWidget {
const HorizontalScrollableWidget({
super.key,
required super.child,
});
@override
HorizontalScrollableWidgetRenderBox createRenderObject(
BuildContext context,
) {
return HorizontalScrollableWidgetRenderBox();
}
}
class HorizontalScrollableWidgetRenderBox extends RenderPointerListener {
@override
HitTestBehavior get behavior => HitTestBehavior.opaque;
}

View File

@ -1,685 +0,0 @@
/*
Name: Zotov Vladimir
Date: 18/06/22
Purpose: Defines the package: sliding_up_panel2
Copyright: © 2022, Zotov Vladimir. All rights reserved.
Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE
This product includes software developed by Akshath Jain (https://akshathjain.com)
*/
library panels;
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
part 'controller.dart';
part 'helpers.dart';
enum SlideDirection { up, down }
enum PanelState { open, closed }
class SlidingUpPanel extends StatefulWidget {
/// Returns the Widget that slides into view. When the
/// panel is collapsed and if [collapsed] is null,
/// then top portion of this Widget will be displayed;
/// otherwise, [collapsed] will be displayed overtop
/// of this Widget.
final Widget? Function(double position)? panelBuilder;
/// The Widget displayed overtop the [panel] when collapsed.
/// This fades out as the panel is opened.
final Widget? collapsed;
/// The Widget that lies underneath the sliding panel.
/// This Widget automatically sizes itself
/// to fill the screen.
final Widget? body;
/// Optional persistent widget that floats above the [panel] and attaches
/// to the top of the [panel]. Content at the top of the panel will be covered
/// by this widget. Add padding to the bottom of the `panel` to
/// avoid coverage.
final Widget? header;
/// Optional persistent widget that floats above the [panel] and
/// attaches to the bottom of the [panel]. Content at the bottom of the panel
/// will be covered by this widget. Add padding to the bottom of the `panel`
/// to avoid coverage.
final Widget? footer;
/// The height of the sliding panel when fully collapsed.
final double minHeight;
/// The height of the sliding panel when fully open.
final double maxHeight;
/// A point between [minHeight] and [maxHeight] that the panel snaps to
/// while animating. A fast swipe on the panel will disregard this point
/// and go directly to the open/close position. This value is represented as a
/// percentage of the total animation distance ([maxHeight] - [minHeight]),
/// so it must be between 0.0 and 1.0, exclusive.
final double? snapPoint;
/// The amount to inset the children of the sliding panel sheet.
final EdgeInsetsGeometry? padding;
/// Empty space surrounding the sliding panel sheet.
final EdgeInsetsGeometry? margin;
/// Set to false to disable the panel from snapping open or closed.
final bool panelSnapping;
/// Disable panel draggable on scrolling. Defaults to false.
final bool disableDraggableOnScrolling;
/// If non-null, this can be used to control the state of the panel.
final PanelController? controller;
/// If non-null, shows a darkening shadow over the [body] as the panel slides open.
final bool backdropEnabled;
/// Shows a darkening shadow of this [Color] over the [body] as the panel slides open.
final Color backdropColor;
/// The opacity of the backdrop when the panel is fully open.
/// This value can range from 0.0 to 1.0 where 0.0 is completely transparent
/// and 1.0 is completely opaque.
final double backdropOpacity;
/// Flag that indicates whether or not tapping the
/// backdrop closes the panel. Defaults to true.
final bool backdropTapClosesPanel;
/// If non-null, this callback
/// is called as the panel slides around with the
/// current position of the panel. The position is a double
/// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open.
final void Function(double position)? onPanelSlide;
/// If non-null, this callback is called when the
/// panel is fully opened
final VoidCallback? onPanelOpened;
/// If non-null, this callback is called when the panel
/// is fully collapsed.
final VoidCallback? onPanelClosed;
/// If non-null and true, the SlidingUpPanel exhibits a
/// parallax effect as the panel slides up. Essentially,
/// the body slides up as the panel slides up.
final bool parallaxEnabled;
/// Allows for specifying the extent of the parallax effect in terms
/// of the percentage the panel has slid up/down. Recommended values are
/// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a
/// one-to-one scrolling effect. Defaults to a 10% parallax.
final double parallaxOffset;
/// Allows toggling of the draggability of the SlidingUpPanel.
/// Set this to false to prevent the user from being able to drag
/// the panel up and down. Defaults to true.
final bool isDraggable;
/// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way
/// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches
/// itself to the top of the screen and is fully opened when the user swipes
/// down on the panel.
final SlideDirection slideDirection;
/// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED.
/// This value defaults to PanelState.CLOSED which indicates that the panel is
/// in the closed position and must be opened. PanelState.OPEN indicates that
/// by default the Panel is open and must be swiped closed by the user.
final PanelState defaultPanelState;
/// To attach to a [Scrollable] on a panel that
/// links the panel's position to the scroll position. Useful for implementing
/// infinite scroll behavior
final ScrollController? scrollController;
final BoxDecoration? panelDecoration;
const SlidingUpPanel(
{super.key,
this.body,
this.collapsed,
this.minHeight = 100.0,
this.maxHeight = 500.0,
this.snapPoint,
this.padding,
this.margin,
this.panelDecoration,
this.panelSnapping = true,
this.disableDraggableOnScrolling = false,
this.controller,
this.backdropEnabled = false,
this.backdropColor = Colors.black,
this.backdropOpacity = 0.5,
this.backdropTapClosesPanel = true,
this.onPanelSlide,
this.onPanelOpened,
this.onPanelClosed,
this.parallaxEnabled = false,
this.parallaxOffset = 0.1,
this.isDraggable = true,
this.slideDirection = SlideDirection.up,
this.defaultPanelState = PanelState.closed,
this.header,
this.footer,
this.scrollController,
this.panelBuilder})
: assert(panelBuilder != null),
assert(0 <= backdropOpacity && backdropOpacity <= 1.0),
assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0);
@override
SlidingUpPanelState createState() => SlidingUpPanelState();
}
class SlidingUpPanelState extends State<SlidingUpPanel>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late final ScrollController _scrollController;
bool _scrollingEnabled = false;
final VelocityTracker _velocityTracker =
VelocityTracker.withKind(PointerDeviceKind.touch);
bool _isPanelVisible = true;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: widget.defaultPanelState == PanelState.closed
? 0.0
: 1.0 //set the default panel state (i.e. set initial value of _ac)
)
..addListener(() {
if (widget.onPanelSlide != null) {
widget.onPanelSlide!(_animationController.value);
}
if (widget.onPanelOpened != null &&
(_animationController.value == 1.0 ||
_animationController.value == 0.0)) {
widget.onPanelOpened!();
}
});
// prevent the panel content from being scrolled only if the widget is
// draggable and panel scrolling is enabled
_scrollController = widget.scrollController ?? ScrollController();
_scrollController.addListener(() {
if (widget.isDraggable &&
!widget.disableDraggableOnScrolling &&
(!_scrollingEnabled || _panelPosition < 1) &&
widget.controller?._forceScrollChange != true) {
_scrollController.jumpTo(_scMinOffset);
}
});
widget.controller?._addState(this);
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return Stack(
alignment: widget.slideDirection == SlideDirection.up
? Alignment.bottomCenter
: Alignment.topCenter,
children: <Widget>[
//make the back widget take up the entire back side
if (widget.body != null)
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Positioned(
top: widget.parallaxEnabled ? _getParallax() : 0.0,
child: child ?? const SizedBox(),
);
},
child: SizedBox(
height: mediaQuery.size.height,
width: mediaQuery.size.width,
child: widget.body,
),
),
//the backdrop to overlay on the body
if (widget.backdropEnabled)
GestureDetector(
onVerticalDragEnd: widget.backdropTapClosesPanel
? (DragEndDetails details) {
// only trigger a close if the drag is towards panel close position
if ((widget.slideDirection == SlideDirection.up ? 1 : -1) *
details.velocity.pixelsPerSecond.dy >
0) _close();
}
: null,
onTap: widget.backdropTapClosesPanel ? () => _close() : null,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
return Container(
height: mediaQuery.size.height,
width: mediaQuery.size.width,
//set color to null so that touch events pass through
//to the body when the panel is closed, otherwise,
//if a color exists, then touch events won't go through
color: _animationController.value == 0.0
? null
: widget.backdropColor.withOpacity(
widget.backdropOpacity * _animationController.value,
),
);
}),
),
//the actual sliding part
if (_isPanelVisible)
_gestureHandler(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Container(
height: _animationController.value *
(widget.maxHeight - widget.minHeight) +
widget.minHeight,
margin: widget.margin,
padding: widget.padding,
decoration: widget.panelDecoration,
child: child,
);
},
child: Stack(
children: <Widget>[
//open panel
Positioned(
top:
widget.slideDirection == SlideDirection.up ? 0.0 : null,
bottom: widget.slideDirection == SlideDirection.down
? 0.0
: null,
width: mediaQuery.size.width -
(widget.margin != null
? widget.margin!.horizontal
: 0) -
(widget.padding != null
? widget.padding!.horizontal
: 0),
child: SizedBox(
height: widget.maxHeight,
child: widget.panelBuilder!(
_animationController.value,
),
),
),
// footer
if (widget.footer != null)
Positioned(
top: widget.slideDirection == SlideDirection.up
? null
: 0.0,
bottom: widget.slideDirection == SlideDirection.down
? null
: 0.0,
child: widget.footer ?? const SizedBox()),
// header
if (widget.header != null)
Positioned(
top: widget.slideDirection == SlideDirection.up
? 0.0
: null,
bottom: widget.slideDirection == SlideDirection.down
? 0.0
: null,
child: widget.header ?? const SizedBox(),
),
// collapsed panel
Positioned(
top:
widget.slideDirection == SlideDirection.up ? 0.0 : null,
bottom: widget.slideDirection == SlideDirection.down
? 0.0
: null,
width: mediaQuery.size.width -
(widget.margin != null
? widget.margin!.horizontal
: 0) -
(widget.padding != null
? widget.padding!.horizontal
: 0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
height: widget.minHeight,
child: widget.collapsed == null
? null
: FadeTransition(
opacity: Tween(begin: 1.0, end: 0.0)
.animate(_animationController),
// if the panel is open ignore pointers (touch events) on the collapsed
// child so that way touch events go through to whatever is underneath
child: IgnorePointer(
ignoring: _animationController.value == 1.0,
child: widget.collapsed,
),
),
),
),
],
),
),
),
],
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
double _getParallax() {
if (widget.slideDirection == SlideDirection.up) {
return -_animationController.value *
(widget.maxHeight - widget.minHeight) *
widget.parallaxOffset;
} else {
return _animationController.value *
(widget.maxHeight - widget.minHeight) *
widget.parallaxOffset;
}
}
bool _ignoreScrollable = false;
bool _isHorizontalScrollableWidget = false;
Axis? _scrollableAxis;
// returns a gesture detector if panel is used
// and a listener if panelBuilder is used.
// this is because the listener is designed only for use with linking the scrolling of
// panels and using it for panels that don't want to linked scrolling yields odd results
Widget _gestureHandler({required Widget child}) {
if (!widget.isDraggable) return child;
return Listener(
onPointerDown: (PointerDownEvent e) {
var rb = context.findRenderObject() as RenderBox;
var result = BoxHitTestResult();
rb.hitTest(result, position: e.position);
if (_panelPosition == 1) {
_scMinOffset = 0.0;
}
// if there any widget in the path that must force graggable,
// stop it right here
if (result.path.any((entry) =>
entry.target.runtimeType == ForceDraggableWidgetRenderBox)) {
widget.controller?._nowTargetForceDraggable = true;
_scMinOffset = _scrollController.offset;
_isHorizontalScrollableWidget = false;
} else if (result.path.any((entry) =>
entry.target.runtimeType == HorizontalScrollableWidgetRenderBox)) {
_isHorizontalScrollableWidget = true;
widget.controller?._nowTargetForceDraggable = false;
} else if (result.path.any((entry) =>
entry.target.runtimeType == IgnoreDraggableWidgetWidgetRenderBox)) {
_ignoreScrollable = true;
widget.controller?._nowTargetForceDraggable = false;
_isHorizontalScrollableWidget = false;
return;
} else {
widget.controller?._nowTargetForceDraggable = false;
_isHorizontalScrollableWidget = false;
}
_ignoreScrollable = false;
_velocityTracker.addPosition(e.timeStamp, e.position);
},
onPointerMove: (PointerMoveEvent e) {
if (_scrollableAxis == null) {
if (e.delta.dx.abs() > e.delta.dy.abs()) {
_scrollableAxis = Axis.horizontal;
} else {
_scrollableAxis = Axis.vertical;
}
}
if (_isHorizontalScrollableWidget &&
_scrollableAxis == Axis.horizontal) {
return;
}
if (_ignoreScrollable) return;
_velocityTracker.addPosition(
e.timeStamp,
e.position,
); // add current position for velocity tracking
_onGestureSlide(e.delta.dy);
},
onPointerUp: (PointerUpEvent e) {
if (_ignoreScrollable) return;
_scrollableAxis = null;
_onGestureEnd(_velocityTracker.getVelocity());
},
child: child,
);
}
double _scMinOffset = 0.0;
// handles the sliding gesture
void _onGestureSlide(double dy) {
// only slide the panel if scrolling is not enabled
if (widget.controller?._nowTargetForceDraggable == false &&
widget.disableDraggableOnScrolling) {
return;
}
if ((!_scrollingEnabled) ||
_panelPosition < 1 ||
widget.controller?._nowTargetForceDraggable == true) {
if (widget.slideDirection == SlideDirection.up) {
_animationController.value -=
dy / (widget.maxHeight - widget.minHeight);
} else {
_animationController.value +=
dy / (widget.maxHeight - widget.minHeight);
}
}
// if the panel is open and the user hasn't scrolled, we need to determine
// whether to enable scrolling if the user swipes up, or disable closing and
// begin to close the panel if the user swipes down
if (_isPanelOpen &&
_scrollController.hasClients &&
_scrollController.offset <= _scMinOffset) {
setState(() {
if (dy < 0) {
_scrollingEnabled = true;
} else {
_scrollingEnabled = false;
}
});
}
}
// handles when user stops sliding
void _onGestureEnd(Velocity v) {
if (widget.controller?._nowTargetForceDraggable == false &&
widget.disableDraggableOnScrolling) {
return;
}
double minFlingVelocity = 365.0;
double kSnap = 8;
//let the current animation finish before starting a new one
if (_animationController.isAnimating) return;
// if scrolling is allowed and the panel is open, we don't want to close
// the panel if they swipe up on the scrollable
if (_isPanelOpen && _scrollingEnabled) return;
//check if the velocity is sufficient to constitute fling to end
double visualVelocity =
-v.pixelsPerSecond.dy / (widget.maxHeight - widget.minHeight);
// reverse visual velocity to account for slide direction
if (widget.slideDirection == SlideDirection.down) {
visualVelocity = -visualVelocity;
}
// get minimum distances to figure out where the panel is at
double d2Close = _animationController.value;
double d2Open = 1 - _animationController.value;
double d2Snap = ((widget.snapPoint ?? 3) - _animationController.value)
.abs(); // large value if null results in not every being the min
double minDistance = min(d2Close, min(d2Snap, d2Open));
// check if velocity is sufficient for a fling
if (v.pixelsPerSecond.dy.abs() >= minFlingVelocity) {
// snapPoint exists
if (widget.panelSnapping && widget.snapPoint != null) {
if (v.pixelsPerSecond.dy.abs() >= kSnap * minFlingVelocity ||
minDistance == d2Snap) {
_animationController.fling(velocity: visualVelocity);
} else {
_flingPanelToPosition(widget.snapPoint!, visualVelocity);
}
// no snap point exists
} else if (widget.panelSnapping) {
_animationController.fling(velocity: visualVelocity);
// panel snapping disabled
} else {
_animationController.animateTo(
_animationController.value + visualVelocity * 0.16,
duration: const Duration(milliseconds: 410),
curve: Curves.decelerate,
);
}
return;
}
// check if the controller is already halfway there
if (widget.panelSnapping) {
if (minDistance == d2Close) {
_close();
} else if (minDistance == d2Snap) {
_flingPanelToPosition(widget.snapPoint!, visualVelocity);
} else {
_open();
}
}
}
void _flingPanelToPosition(double targetPos, double velocity) {
final Simulation simulation = SpringSimulation(
SpringDescription.withDampingRatio(
mass: 1.0,
stiffness: 500.0,
ratio: 1.0,
),
_animationController.value,
targetPos,
velocity);
_animationController.animateWith(simulation);
}
//---------------------------------
//PanelController related functions
//---------------------------------
//close the panel
Future<void> _close() {
return _animationController.fling(velocity: -1.0);
}
//open the panel
Future<void> _open() {
return _animationController.fling(velocity: 1.0);
}
//hide the panel (completely offscreen)
Future<void> _hide() {
return _animationController.fling(velocity: -1.0).then((x) {
setState(() {
_isPanelVisible = false;
});
});
}
//show the panel (in collapsed mode)
Future<void> _show() {
return _animationController.fling(velocity: -1.0).then((x) {
setState(() {
_isPanelVisible = true;
});
});
}
//animate the panel position to value - must
//be between 0.0 and 1.0
Future<void> _animatePanelToPosition(double value,
{Duration? duration, Curve curve = Curves.linear}) {
assert(0.0 <= value && value <= 1.0);
return _animationController.animateTo(value,
duration: duration, curve: curve);
}
//animate the panel position to the snap point
//REQUIRES that widget.snapPoint != null
Future<void> _animatePanelToSnapPoint(
{Duration? duration, Curve curve = Curves.linear}) {
assert(widget.snapPoint != null);
return _animationController.animateTo(widget.snapPoint!,
duration: duration, curve: curve);
}
//set the panel position to value - must
//be between 0.0 and 1.0
set _panelPosition(double value) {
assert(0.0 <= value && value <= 1.0);
_animationController.value = value;
}
//get the current panel position
//returns the % offset from collapsed state
//as a decimal between 0.0 and 1.0
double get _panelPosition => _animationController.value;
//returns whether or not
//the panel is still animating
bool get _isPanelAnimating => _animationController.isAnimating;
//returns whether or not the
//panel is open
bool get _isPanelOpen => _animationController.value == 1.0;
//returns whether or not the
//panel is closed
bool get _isPanelClosed => _animationController.value == 0.0;
//returns whether or not the
//panel is shown/hidden
bool get _isPanelShown => _isPanelVisible;
}

View File

@ -1,220 +0,0 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/hover_builder.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
class PlaybuttonCard extends HookWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl;
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,
this.margin,
this.description,
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
super.key,
});
@override
Widget build(BuildContext context) {
final textsKey = useMemoized(() => GlobalKey(), []);
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final radius = BorderRadius.circular(15);
final double size = useBreakpointValue<double>(
xs: 130,
sm: 130,
md: 150,
others: 170,
);
final end = useBreakpointValue<double>(
xs: 7,
sm: 7,
others: 15,
);
final unescapeHtml = description?.unescapeHtml().cleanHtml();
return Container(
constraints: BoxConstraints(maxWidth: size),
margin: margin,
child: Material(
color: Color.lerp(
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.surface,
useBrightnessValue(.9, .7),
),
borderRadius: radius,
shadowColor: theme.colorScheme.surface,
elevation: 3,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: onTap,
borderRadius: radius,
splashFactory: theme.splashFactory,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
margin: const EdgeInsets.fromLTRB(8, 8, 8, 0),
padding: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
),
height: mediaQuery.smAndDown
? 120
: mediaQuery.mdAndDown
? 130
: 150,
decoration: BoxDecoration(
borderRadius: radius,
image: DecorationImage(
image: UniversalImage.imageProvider(imageUrl),
fit: BoxFit.cover,
),
),
),
if (isOwner)
Positioned(
top: 15,
left: 15,
child: AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.centerLeft,
curve: Curves.easeInExpo,
child: HoverBuilder(builder: (context, isHovered) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
SpotubeIcons.user,
color: Colors.white,
size: 16,
),
if (isHovered)
Text(
context.l10n.owned_by_you,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
),
],
),
);
}),
),
),
Positioned(
right: end,
bottom: -15,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isPlaying)
Skeleton.keep(
child: IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: isLoading ? null : onAddToQueuePressed,
),
),
const Gap(5),
IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
icon: Skeleton.keep(
child: isLoading
? SizedBox.fromSize(
size: const Size.square(15),
child: const CircularProgressIndicator(
strokeWidth: 2),
)
: isPlaying
? const Icon(SpotubeIcons.pause)
: const Icon(SpotubeIcons.play),
),
onPressed: isLoading ? null : onPlaybuttonPressed,
),
],
),
),
],
),
Column(
key: textsKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 15),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: AutoSizeText(
title,
maxLines: 1,
minFontSize: theme.textTheme.bodyMedium!.fontSize!,
overflow: TextOverflow.ellipsis,
),
),
if (description != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: AutoSizeText(
unescapeHtml!,
maxLines: 2,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(.5),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 10),
],
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,166 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/utils/platform.dart';
class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final String? imageUrl;
final Widget? image;
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonCard({
required this.isPlaying,
required this.isLoading,
required this.title,
this.description,
this.onPlaybuttonPressed,
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) {
final unescapeHtml = description?.unescapeHtml().cleanHtml() ?? "";
final scale = context.theme.scaling;
return SizedBox(
width: 150 * scale,
child: CardImage(
image: Stack(
children: [
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(
right: 8,
bottom: 8,
child: Column(
children: [
AnimatedScale(
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 300),
scale: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1
: 0.7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1
: 0,
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: onAddToQueuePressed,
size: ButtonSize.small,
),
),
),
const Gap(5),
AnimatedScale(
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 150),
scale: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1
: 0.7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1
: 0,
child: IconButton.secondary(
icon: switch ((isLoading, isPlaying)) {
(true, _) => const CircularProgressIndicator(
size: 15,
),
(false, false) => const Icon(SpotubeIcons.play),
(false, true) => const Icon(SpotubeIcons.pause)
},
enabled: !isLoading,
onPressed: onPlaybuttonPressed,
size: ButtonSize.small,
),
),
),
],
),
);
},
),
if (isOwner)
const Positioned(
right: 5,
top: 5,
child: SecondaryBadge(
style: ButtonStyle.secondaryIcon(
shape: ButtonShape.circle,
size: ButtonSize.small,
),
child: Icon(SpotubeIcons.user),
),
),
],
),
title: Tooltip(
tooltip: TooltipContainer(child: Text(title)),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
subtitle: Text(
unescapeHtml.isEmpty ? "\n" : unescapeHtml,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onPressed: onTap,
),
);
}
}

View File

@ -0,0 +1,115 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/string.dart';
class PlaybuttonTile extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final String? imageUrl;
final Widget? image;
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonTile({
required this.isPlaying,
required this.isLoading,
required this.title,
this.description,
this.onPlaybuttonPressed,
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) {
final cleanDescription = description?.unescapeHtml().cleanHtml() ?? "";
final scale = context.theme.scaling;
return Button(
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)
.copyWith(right: 0, left: 0);
},
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
tooltip: TooltipContainer(child: Text(context.l10n.add_to_queue)),
child: IconButton.outline(
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: onAddToQueuePressed,
enabled: !isLoading,
),
),
const Gap(8),
Tooltip(
tooltip: TooltipContainer(child: Text(context.l10n.play)),
child: IconButton.secondary(
icon: switch ((isLoading, isPlaying)) {
(true, _) => const CircularProgressIndicator(
size: 22,
),
(false, false) => const Icon(SpotubeIcons.play),
(false, true) => const Icon(SpotubeIcons.pause)
},
onPressed: onPlaybuttonPressed,
enabled: !isLoading,
),
),
],
),
enabled: !isLoading,
onPressed: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
if (cleanDescription.isNotEmpty)
Text(
description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).xSmall().muted(),
],
),
);
}
}

View File

@ -0,0 +1,200 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart';
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const _dummyPlaybuttonCard = PlaybuttonCard(
imageUrl: 'https://placehold.co/150x150.png',
isLoading: false,
isPlaying: false,
title: "Playbutton",
description: "A really cool playbutton",
isOwner: false,
);
const _dummyPlaybuttonTile = PlaybuttonTile(
imageUrl: 'https://placehold.co/150x150.png',
isLoading: false,
isPlaying: false,
title: "Playbutton",
description: "A really cool playbutton",
isOwner: false,
);
/// A [PlaybuttonCard] grid/list view (selectable) sliver widget
/// with support for infinite scrolling
class PlaybuttonView extends StatelessWidget {
final int itemCount;
final Widget Function(BuildContext context, int index) gridItemBuilder;
final Widget Function(BuildContext context, int index) listItemBuilder;
final bool hasMore;
final bool isLoading;
final VoidCallback onRequestMore;
final ScrollController controller;
const PlaybuttonView({
super.key,
required this.itemCount,
required this.gridItemBuilder,
required this.listItemBuilder,
required this.hasMore,
required this.isLoading,
required this.onRequestMore,
required this.controller,
});
@override
Widget build(BuildContext context) {
final scale = context.theme.scaling;
return SliverLayoutBuilder(
builder: (context, constrains) => HookBuilder(builder: (context) {
final isGrid = useState(constrains.mdAndUp);
final hasUserInteracted = useRef(false);
useEffect(() {
if (hasUserInteracted.value) return null;
if (isGrid.value != constrains.mdAndUp) {
isGrid.value = constrains.mdAndUp;
}
return null;
}, [constrains]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Toggle(
value: isGrid.value,
style:
const ButtonStyle.outline(density: ButtonDensity.icon),
onChanged: (value) {
isGrid.value = value;
hasUserInteracted.value = true;
},
child: const Icon(SpotubeIcons.grid),
),
const SizedBox(width: 8),
Toggle(
value: !isGrid.value,
style:
const ButtonStyle.outline(density: ButtonDensity.icon),
onChanged: (value) {
isGrid.value = !value;
hasUserInteracted.value = true;
},
child: const Icon(SpotubeIcons.list),
),
],
),
),
const SliverGap(10),
// Toggle between grid and list view
switch ((isGrid.value, isLoading)) {
(true, _) => !isLoading && itemCount == 0
? SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Undraw(
height: 200 * context.theme.scaling,
illustration: UndrawIllustration.taken,
color: Theme.of(context).colorScheme.primary,
),
Text(
context.l10n.nothing_found,
textAlign: TextAlign.center,
).muted().small()
],
),
),
)
: SliverGrid.builder(
itemCount: isLoading ? 6 : itemCount + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150 * scale,
mainAxisExtent: 225 * scale,
crossAxisSpacing: 12 * scale,
mainAxisSpacing: 12 * scale,
),
itemBuilder: (context, index) {
if (isLoading) {
return const Skeletonizer(
enabled: true,
child: _dummyPlaybuttonCard,
);
}
if (index == itemCount) {
if (!hasMore) return const SizedBox.shrink();
return Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: onRequestMore,
child: const Skeletonizer(
enabled: true,
child: _dummyPlaybuttonCard,
),
);
}
return gridItemBuilder(context, index);
},
),
(false, true) => Skeletonizer.sliver(
enabled: true,
child: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _dummyPlaybuttonTile,
childCount: 6,
),
),
),
(false, false) => SliverInfiniteList(
itemCount: itemCount,
loadingBuilder: (context) => const Skeletonizer(
enabled: true,
child: _dummyPlaybuttonTile,
),
itemBuilder: listItemBuilder,
onFetchData: onRequestMore,
hasReachedMax: !hasMore,
isLoading: isLoading,
emptyBuilder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Undraw(
height: 200 * context.theme.scaling,
illustration: UndrawIllustration.taken,
color: Theme.of(context).colorScheme.primary,
),
Text(
context.l10n.nothing_found,
textAlign: TextAlign.center,
).muted().small()
],
);
},
),
}
],
);
}),
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';

View File

@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {
final SortBy? value;
final void Function(SortBy)? onChanged;
const SortTracksDropdown({
this.onChanged,
this.value,
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: AdaptivePopSheetList<SortBy>(
children: [
PopSheetEntry(
value: SortBy.none,
enabled: value != SortBy.none,
title: Text(context.l10n.none),
),
PopSheetEntry(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
title: Text(context.l10n.sort_a_z),
),
PopSheetEntry(
value: SortBy.descending,
enabled: value != SortBy.descending,
title: Text(context.l10n.sort_z_a),
),
PopSheetEntry(
value: SortBy.newest,
enabled: value != SortBy.newest,
title: Text(context.l10n.sort_newest),
),
PopSheetEntry(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
title: Text(context.l10n.sort_oldest),
),
PopSheetEntry(
value: SortBy.duration,
enabled: value != SortBy.duration,
title: Text(context.l10n.sort_duration),
),
PopSheetEntry(
value: SortBy.artist,
enabled: value != SortBy.artist,
title: Text(context.l10n.sort_artist),
),
PopSheetEntry(
value: SortBy.album,
enabled: value != SortBy.album,
title: Text(context.l10n.sort_album),
),
],
headings: [
Text(context.l10n.sort_tracks),
],
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DefaultTextStyle(
style: theme.textTheme.titleSmall!,
child: Row(
children: [
const Icon(SpotubeIcons.sort),
const SizedBox(width: 8),
Text(context.l10n.sort_tracks),
],
),
),
),
),
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:go_router/go_router.dart';
class SpotubePage<T> extends MaterialPage<T> {

View File

@ -1,50 +0,0 @@
import 'package:buttons_tabbar/buttons_tabbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs;
final TabController? controller;
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = useBrightnessValue(
theme.colorScheme.primaryContainer,
Color.lerp(theme.colorScheme.primary, Colors.black, 0.7)!,
);
return Padding(
padding: const EdgeInsets.only(
top: 8,
bottom: 8,
),
child: ButtonsTabBar(
controller: controller,
radius: 100,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15),
),
labelStyle: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
borderWidth: 0,
unselectedDecoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(15),
),
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
tabs: tabs,
),
);
}
@override
Size get preferredSize => const Size.fromHeight(50);
}

View File

@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
typedef MouseStateBuilderCB = Widget Function(
BuildContext context, MouseState mouseState);
class MouseState {
bool isMouseOver = false;
bool isMouseDown = false;
MouseState();
@override
String toString() {
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
}
}
T? _ambiguate<T>(T? value) => value;
class MouseStateBuilder extends StatefulWidget {
final MouseStateBuilderCB builder;
final VoidCallback? onPressed;
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
@override
// ignore: library_private_types_in_public_api
_MouseStateBuilderState createState() => _MouseStateBuilderState();
}
class _MouseStateBuilderState extends State<MouseStateBuilder> {
late MouseState _mouseState;
_MouseStateBuilderState() {
_mouseState = MouseState();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setState(() {
_mouseState.isMouseOver = true;
});
},
onExit: (event) {
setState(() {
_mouseState.isMouseOver = false;
});
},
child: GestureDetector(
onTapDown: (_) {
setState(() {
_mouseState.isMouseDown = true;
});
},
onTapCancel: () {
setState(() {
_mouseState.isMouseDown = false;
});
},
onTap: () {
setState(() {
_mouseState.isMouseDown = false;
_mouseState.isMouseOver = false;
});
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) {
if (widget.onPressed != null) {
widget.onPressed!();
}
});
},
onTapUp: (_) {},
child: widget.builder(context, _mouseState),
),
);
}
}

View File

@ -1,88 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:spotube/components/button/back_button.dart';
import 'package:spotube/components/titlebar/titlebar_buttons.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart';
class PageWindowTitleBar extends StatefulHookConsumerWidget
implements PreferredSizeWidget {
final Widget? leading;
final kTitlebarVisible = kIsWindows || kIsLinux;
class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
final bool automaticallyImplyLeading;
final List<Widget>? actions;
final List<Widget> trailing;
final List<Widget> leading;
final Widget? child;
final Widget? title;
final Widget? header; // small widget placed on top of title
final Widget? subtitle; // small widget placed below title
final bool
trailingExpanded; // expand the trailing instead of the main content
final AlignmentGeometry alignment;
final Color? backgroundColor;
final Color? foregroundColor;
final IconThemeData? actionsIconTheme;
final bool? centerTitle;
final double? titleSpacing;
final double toolbarOpacity;
final double? leadingWidth;
final TextStyle? toolbarTextStyle;
final TextStyle? titleTextStyle;
final double? titleWidth;
final Widget? title;
final double? leadingGap;
final double? trailingGap;
final EdgeInsetsGeometry? padding;
final double? height;
final bool useSafeArea;
final double? surfaceBlur;
final double? surfaceOpacity;
final bool _sliver;
const PageWindowTitleBar({
const TitleBar({
super.key,
this.actions,
this.automaticallyImplyLeading = true,
this.trailing = const [],
this.leading = const [],
this.title,
this.toolbarOpacity = 1,
this.header,
this.subtitle,
this.child,
this.trailingExpanded = false,
this.alignment = Alignment.center,
this.padding,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
}) : _sliver = false,
pinned = false,
floating = false,
snap = false,
stretch = false;
this.leadingGap,
this.trailingGap,
this.height,
this.surfaceBlur,
this.surfaceOpacity,
this.useSafeArea = false,
});
final bool pinned;
final bool floating;
final bool snap;
final bool stretch;
const PageWindowTitleBar.sliver({
super.key,
this.actions,
this.title,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
this.pinned = false,
this.floating = false,
this.snap = false,
this.stretch = false,
}) : _sliver = true,
toolbarOpacity = 1;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
ConsumerState<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
}
class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
void onDrag(details) {
void onDrag(WidgetRef ref) {
final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) {
@ -91,89 +62,75 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
Widget build(BuildContext context, ref) {
final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context);
final lastClicked = useRef<int>(DateTime.now().millisecondsSinceEpoch);
if (widget._sliver) {
return SliverLayoutBuilder(
return SizedBox(
height: height ?? (48 * context.theme.scaling),
child: LayoutBuilder(
builder: (context, constraints) {
final hasFullscreen =
mediaQuery.size.width == constraints.crossAxisExtent;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
MediaQuery.sizeOf(context).width == constraints.maxWidth;
return SliverPadding(
padding: EdgeInsets.only(
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
),
sliver: SliverAppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
return GestureDetector(
onHorizontalDragStart: (_) => onDrag(ref),
onVerticalDragStart: (_) => onDrag(ref),
onTapDown: (details) async {
final systemTitlebar = ref.read(
userPreferencesProvider.select((s) => s.systemTitleBar));
if (!kIsDesktop || systemTitlebar) return;
int currMills = DateTime.now().millisecondsSinceEpoch;
if ((currMills - lastClicked.value) < 500) {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
} else {
lastClicked.value = currMills;
}
},
child: AppBar(
leading: leading.isEmpty &&
automaticallyImplyLeading &&
Navigator.canPop(context)
? [
const BackButton(),
]
: leading,
trailing: [
...trailing,
Align(
alignment: Alignment.topRight,
child:
WindowTitleBarButtons(foregroundColor: foregroundColor),
),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: SizedBox(
width: double.infinity, // workaround to force dragging
child: widget.title ?? const Text(""),
),
pinned: widget.pinned,
floating: widget.floating,
snap: widget.snap,
stretch: widget.stretch,
),
title: title,
header: header,
subtitle: subtitle,
trailingExpanded: trailingExpanded,
alignment: alignment,
padding: padding ?? EdgeInsets.zero,
backgroundColor: backgroundColor,
leadingGap: leadingGap,
trailingGap: trailingGap,
height: height ?? (48 * context.theme.scaling),
surfaceBlur: surfaceBlur,
surfaceOpacity: surfaceOpacity,
useSafeArea: useSafeArea,
child: child,
).withPadding(
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0),
);
},
);
}
return LayoutBuilder(builder: (context, constrains) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return GestureDetector(
onHorizontalDragStart: onDrag,
onVerticalDragStart: onDrag,
child: Padding(
padding: EdgeInsets.only(
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
),
child: AppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
toolbarOpacity: widget.toolbarOpacity,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: SizedBox(
width: double.infinity, // workaround to force dragging
child: widget.title ?? const Text(""),
),
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
forceMaterialTransparency: true,
elevation: 0,
),
),
);
});
),
);
}
@override
Size get preferredSize => Size.fromHeight(height ?? 48);
}

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/hover_builder.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart';
import 'package:spotube/components/titlebar/window_button.dart';
import 'package:spotube/hooks/configurators/use_window_listener.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:titlebar_buttons/titlebar_buttons.dart';
@ -25,6 +29,15 @@ class WindowTitleBarButtons extends HookConsumerWidget {
await windowManager.close();
}
useWindowListener(
onWindowMaximize: () {
isMaximized.value = true;
},
onWindowUnmaximize: () {
isMaximized.value = false;
},
);
useEffect(() {
if (kIsDesktop) {
windowManager.isMaximized().then((value) {
@ -34,91 +47,73 @@ class WindowTitleBarButtons extends HookConsumerWidget {
return null;
}, []);
if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) {
if (!kTitlebarVisible || preferences.systemTitleBar) {
return const SizedBox.shrink();
}
if (kIsWindows) {
final theme = Theme.of(context);
final colors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
mouseOver: theme.colorScheme.onSurface.withOpacity(0.1),
mouseDown: theme.colorScheme.onSurface.withOpacity(0.2),
iconMouseOver: theme.colorScheme.onSurface,
iconMouseDown: theme.colorScheme.onSurface,
);
final closeColors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
mouseOver: Colors.red,
mouseDown: Colors.red[800]!,
iconMouseOver: Colors.white,
iconMouseDown: Colors.black,
);
return Padding(
padding: const EdgeInsets.only(bottom: 25),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MinimizeWindowButton(
onPressed: windowManager.minimize,
colors: colors,
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadcnWindowButton(
icon: MinimizeIcon(color: context.theme.colorScheme.foreground),
onPressed: windowManager.minimize,
),
if (isMaximized.value != true)
ShadcnWindowButton(
icon: MaximizeIcon(color: context.theme.colorScheme.foreground),
onPressed: () {
windowManager.maximize();
isMaximized.value = true;
},
)
else
ShadcnWindowButton(
icon: RestoreIcon(color: context.theme.colorScheme.foreground),
onPressed: () {
windowManager.unmaximize();
isMaximized.value = false;
},
),
if (isMaximized.value != true)
MaximizeWindowButton(
colors: colors,
onPressed: () {
windowManager.maximize();
isMaximized.value = true;
},
)
else
RestoreWindowButton(
colors: colors,
onPressed: () {
windowManager.unmaximize();
isMaximized.value = false;
},
HoverBuilder(builder: (context, isHovered) {
return ShadcnWindowButton(
icon: CloseIcon(
color: isHovered
? Colors.white
: context.theme.colorScheme.foreground,
),
CloseWindowButton(
colors: closeColors,
onPressed: onClose,
),
],
),
hoverBackgroundColor: const Color(0xFFD32F2F),
);
}),
],
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 20, left: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedMinimizeButton(
type: type,
onPressed: windowManager.minimize,
),
DecoratedMaximizeButton(
type: type,
onPressed: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
isMaximized.value = false;
} else {
await windowManager.maximize();
isMaximized.value = true;
}
},
),
DecoratedCloseButton(
type: type,
onPressed: onClose,
),
],
),
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedMinimizeButton(
type: type,
onPressed: windowManager.minimize,
),
DecoratedMaximizeButton(
type: type,
onPressed: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
isMaximized.value = false;
} else {
await windowManager.maximize();
isMaximized.value = true;
}
},
),
DecoratedCloseButton(
type: type,
onPressed: onClose,
),
],
);
}
}

View File

@ -1,56 +1,50 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:spotube/components/titlebar/window_button.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/button_variance.dart';
class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton(
{super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MinimizeIcon(color: buttonContext.iconColor),
);
class ShadcnWindowButton extends StatelessWidget {
final Widget icon;
final VoidCallback onPressed;
final Color? hoverBackgroundColor;
const ShadcnWindowButton({
super.key,
required this.icon,
required this.onPressed,
this.hoverBackgroundColor,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 45,
height: 32,
child: IconButton(
variance: ButtonVariance.ghost.copyWith(
decoration: (context, states) {
final decoration = ButtonVariance.ghost.decoration(context, states)
as BoxDecoration;
if (hoverBackgroundColor != null &&
states.contains(WidgetState.hovered)) {
return decoration.copyWith(
borderRadius: BorderRadius.zero,
color: hoverBackgroundColor,
);
}
return decoration.copyWith(
borderRadius: BorderRadius.zero,
);
},
),
icon: icon,
onPressed: onPressed,
),
);
}
}
class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton(
{super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MaximizeIcon(color: buttonContext.iconColor),
);
}
class RestoreWindowButton extends WindowButton {
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
RestoreIcon(color: buttonContext.iconColor),
);
}
final _defaultCloseButtonColors = WindowButtonColors(
mouseOver: const Color(0xFFD32F2F),
mouseDown: const Color(0xFFB71C1C),
iconNormal: const Color(0xFF805306),
iconMouseOver: const Color(0xFFFFFFFF));
class CloseWindowButton extends WindowButton {
CloseWindowButton(
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
: super(
colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
CloseIcon(color: buttonContext.iconColor),
);
}
// Switched to CustomPaint icons by https://github.com/esDotDev
/// Close
class CloseIcon extends StatelessWidget {
final Color color;
@ -149,8 +143,9 @@ class _AlignedPaint extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.center,
child: CustomPaint(size: const Size(10, 10), painter: painter));
alignment: Alignment.center,
child: CustomPaint(size: const Size(10, 10), painter: painter),
);
}
}

View File

@ -1,133 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:spotube/components/titlebar/mouse_state.dart';
typedef WindowButtonIconBuilder = Widget Function(
WindowButtonContext buttonContext);
typedef WindowButtonBuilder = Widget Function(
WindowButtonContext buttonContext, Widget icon);
class WindowButtonContext {
BuildContext context;
MouseState mouseState;
Color? backgroundColor;
Color iconColor;
WindowButtonContext(
{required this.context,
required this.mouseState,
this.backgroundColor,
required this.iconColor});
}
class WindowButtonColors {
late Color normal;
late Color mouseOver;
late Color mouseDown;
late Color iconNormal;
late Color iconMouseOver;
late Color iconMouseDown;
WindowButtonColors(
{Color? normal,
Color? mouseOver,
Color? mouseDown,
Color? iconNormal,
Color? iconMouseOver,
Color? iconMouseDown}) {
this.normal = normal ?? _defaultButtonColors.normal;
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
}
}
final _defaultButtonColors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: const Color(0xFF805306),
mouseOver: const Color(0xFF404040),
mouseDown: const Color(0xFF202020),
iconMouseOver: const Color(0xFFFFFFFF),
iconMouseDown: const Color(0xFFF0F0F0),
);
class WindowButton extends StatelessWidget {
final WindowButtonBuilder? builder;
final WindowButtonIconBuilder? iconBuilder;
late final WindowButtonColors colors;
final bool animate;
final EdgeInsets? padding;
final VoidCallback? onPressed;
WindowButton(
{super.key,
WindowButtonColors? colors,
this.builder,
@required this.iconBuilder,
this.padding,
this.onPressed,
this.animate = false}) {
this.colors = colors ?? _defaultButtonColors;
}
Color getBackgroundColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.mouseDown;
if (mouseState.isMouseOver) return colors.mouseOver;
return colors.normal;
}
Color getIconColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.iconMouseDown;
if (mouseState.isMouseOver) return colors.iconMouseOver;
return colors.iconNormal;
}
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return Container();
} else {
// Don't show button on macOS
if (Platform.isMacOS) {
return Container();
}
}
return MouseStateBuilder(
builder: (context, mouseState) {
WindowButtonContext buttonContext = WindowButtonContext(
mouseState: mouseState,
context: context,
backgroundColor: getBackgroundColor(mouseState),
iconColor: getIconColor(mouseState));
var icon =
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
var fadeOutColor =
getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0);
var padding = this.padding ?? const EdgeInsets.all(10);
var animationMs =
mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0);
Widget iconWithPadding = Padding(padding: padding, child: icon);
iconWithPadding = AnimatedContainer(
curve: Curves.easeOut,
duration: Duration(milliseconds: animationMs),
color: buttonContext.backgroundColor ?? fadeOutColor,
child: iconWithPadding);
var button =
(builder != null) ? builder!(buttonContext, icon) : iconWithPadding;
return SizedBox(
width: 45,
height: 32,
child: button,
);
},
onPressed: () {
if (onPressed != null) onPressed!();
},
);
}
}

View File

@ -0,0 +1,221 @@
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/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class TrackPresentationActionsSection extends HookConsumerWidget {
const TrackPresentationActionsSection({super.key});
showToastForAction(BuildContext context, String action, int count) {
final message = switch (action) {
"download" => (context.l10n.download_count(count), SpotubeIcons.download),
"add-to-playlist" => (
context.l10n.add_count_to_playlist(count),
SpotubeIcons.playlistAdd
),
"add-to-queue" => (
context.l10n.add_count_to_queue(count),
SpotubeIcons.queueAdd
),
"play-next" => (
context.l10n.play_count_next(count),
SpotubeIcons.lightning
),
_ => ("", SpotubeIcons.error),
};
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
leading: Icon(message.$2),
title: Text(message.$1),
leadingAlignment: Alignment.center,
trailing: IconButton.ghost(
size: ButtonSize.small,
icon: const Icon(SpotubeIcons.close),
onPressed: () {
overlay.close();
},
),
),
);
},
);
}
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final state = ref.watch(presentationStateProvider(options.collection));
final notifier =
ref.watch(presentationStateProvider(options.collection).notifier);
final selectedTracks = state.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: context.theme.typography.large,
),
],
onSelected: (action) async {
var tracks = selectedTracks;
if (selectedTracks.isEmpty) {
tracks = await options.pagination.onFetchAll();
notifier.selectAllTracks();
}
if (!context.mounted) return;
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
(await showDialog<bool?>(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
) ??
false);
if (confirmed != true) return;
downloader.batchAddToQueue(tracks);
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
case "add-to-playlist":
{
if (context.mounted) {
final worked = await showDialog<bool>(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: options.collectionId,
tracks: tracks.toList(),
);
},
);
if (!context.mounted || worked != true) return;
showToastForAction(context, action, tracks.length);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(tracks);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([options.collection as PlaylistSimple]);
}
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([options.collection as PlaylistSimple]);
}
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
default:
}
if (!context.mounted) return;
},
icon: const Icon(SpotubeIcons.moreVertical),
variance: ButtonVariance.outline,
children: [
AdaptiveMenuButton(
value: "download",
leading: const Icon(SpotubeIcons.download),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.download_all,
)
: Text(
context.l10n.download_count(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.add_all_to_playlist,
)
: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.add_all_to_queue,
)
: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.play_all_next,
)
: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -0,0 +1,111 @@
import 'package:flutter/services.dart';
import 'package:flutter_undraw/flutter_undraw.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:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PresentationListSection extends HookConsumerWidget {
const PresentationListSection({super.key});
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final state = ref.watch(presentationStateProvider(options.collection));
final notifier =
ref.read(presentationStateProvider(options.collection).notifier);
final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId);
final onTileTap = useTrackTilePlayCallback(ref);
if (state.presentationTracks.isEmpty && !options.pagination.isLoading) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Undraw(
illustration: UndrawIllustration.dreamer,
color: context.theme.colorScheme.primary,
height: 200 * context.theme.scaling,
),
Text(
isUserPlaylist
? context.l10n.no_tracks_added_yet
: context.l10n.no_tracks,
textAlign: TextAlign.center,
).muted().small(),
],
),
),
);
}
return SliverInfiniteList(
isLoading: options.pagination.isLoading,
onFetchData: options.pagination.onFetchMore,
itemCount: state.presentationTracks.length,
hasReachedMax: !options.pagination.hasNextPage,
loadingBuilder: (context) {
return Skeletonizer(
enabled: true,
child: TrackTile(
index: 0,
playlist: playlist,
track: FakeData.track,
),
);
},
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
itemBuilder: (context, index) {
final track = state.presentationTracks[index];
final isSelected = state.selectedTracks.any((e) => e.id == track.id);
return TrackTile(
userPlaylist: isUserPlaylist,
playlistId: options.collectionId,
index: index,
playlist: playlist,
track: track,
selected: isSelected,
onTap: () => onTileTap(track, index),
onChanged: state.selectedTracks.isEmpty
? null
: (isSelected) {
if (isSelected == true) {
notifier.selectTrack(track);
} else {
notifier.deselectTrack(track);
}
},
onLongPress: () {
notifier.selectTrack(track);
HapticFeedback.selectionClick();
},
);
},
);
}
}

View File

@ -0,0 +1,124 @@
import 'package:flutter_hooks/flutter_hooks.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:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart';
import 'package:spotube/components/track_presentation/presentation_actions.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackPresentationModifiersSection extends HookConsumerWidget {
final FocusNode? focusNode;
const TrackPresentationModifiersSection({
super.key,
this.focusNode,
});
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
final state = ref.watch(presentationStateProvider(options.collection));
final notifier = ref.watch(
presentationStateProvider(options.collection).notifier,
);
final controller = useTextEditingController();
final scale = context.theme.scaling;
return LayoutBuilder(builder: (context, constrains) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: (constrains.mdAndUp ? 16 : 8) * scale,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
state: state.selectedTracks.length == options.tracks.length
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (value) {
if (value == CheckboxState.checked) {
notifier.selectAllTracks();
} else {
notifier.deselectAllTracks();
}
},
),
],
),
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 320 * scale,
maxHeight: 38 * scale,
),
child: TextField(
controller: controller,
focusNode: focusNode,
leading: Icon(
SpotubeIcons.search,
color: context.theme.colorScheme.mutedForeground,
),
placeholder: Text(context.l10n.search_tracks),
onChanged: (value) {
if (value.isEmpty) {
notifier.clearFilter();
} else {
notifier.filterTracks(value);
}
},
trailing: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: controller.text.isEmpty
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild:
const SizedBox.square(dimension: 20),
secondChild: AnimatedScale(
duration: const Duration(milliseconds: 300),
scale: controller.text.isEmpty ? 0 : 1,
child: IconButton.ghost(
size: const ButtonSize(.6),
icon: const Icon(SpotubeIcons.close),
onPressed: () {
controller.clear();
notifier.clearFilter();
},
),
),
);
}),
),
),
),
SortTracksDropdown(
value: state.sortBy,
onChanged: (value) {
notifier.sortTracks(value);
},
),
const TrackPresentationActionsSection(),
],
),
),
],
),
);
});
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart' hide Page;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
class PaginationProps {
@ -38,31 +38,33 @@ class PaginationProps {
onRefresh.hashCode;
}
class InheritedTrackView extends InheritedWidget {
class TrackPresentationOptions {
final Object collection;
final String title;
final String? description;
final String? owner;
final String? ownerImage;
final String image;
final String routePath;
final List<Track> tracks;
final PaginationProps pagination;
final bool isLiked;
final String shareUrl;
final String? shareUrl;
// events
final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden
const InheritedTrackView({
super.key,
required super.child,
const TrackPresentationOptions({
required this.collection,
required this.title,
this.description,
this.owner,
this.ownerImage,
required this.image,
required this.tracks,
required this.pagination,
required this.routePath,
required this.shareUrl,
this.shareUrl,
this.isLiked = false,
this.onHeart,
}) : assert(collection is AlbumSimple || collection is PlaylistSimple);
@ -71,29 +73,36 @@ class InheritedTrackView extends InheritedWidget {
? (collection as AlbumSimple).id!
: (collection as PlaylistSimple).id!;
@override
bool updateShouldNotify(InheritedTrackView oldWidget) {
return oldWidget.title != title ||
oldWidget.description != description ||
oldWidget.image != image ||
oldWidget.tracks != tracks ||
oldWidget.pagination != pagination ||
oldWidget.isLiked != isLiked ||
oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath ||
oldWidget.collection != collection ||
oldWidget.child != child;
static TrackPresentationOptions of(BuildContext context) {
return Data.of<TrackPresentationOptions>(context);
}
static InheritedTrackView of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<InheritedTrackView>();
if (widget == null) {
throw Exception(
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]',
);
}
return widget;
@override
operator ==(Object other) {
return other is TrackPresentationOptions &&
other.collection == collection &&
other.title == title &&
other.description == description &&
other.image == image &&
other.routePath == routePath &&
other.tracks == tracks &&
other.pagination == pagination &&
other.isLiked == isLiked &&
other.shareUrl == shareUrl &&
other.onHeart == onHeart;
}
@override
int get hashCode =>
super.hashCode ^
collection.hashCode ^
title.hashCode ^
description.hashCode ^
image.hashCode ^
routePath.hashCode ^
tracks.hashCode ^
pagination.hashCode ^
isLiked.hashCode ^
shareUrl.hashCode ^
onHeart.hashCode;
}

View File

@ -0,0 +1,173 @@
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/service_utils.dart';
class PresentationState {
final List<Track> selectedTracks;
final List<Track> presentationTracks;
final SortBy sortBy;
const PresentationState({
required this.selectedTracks,
required this.presentationTracks,
required this.sortBy,
});
PresentationState copyWith({
List<Track>? selectedTracks,
List<Track>? presentationTracks,
SortBy? sortBy,
}) {
return PresentationState(
selectedTracks: selectedTracks ?? this.selectedTracks,
presentationTracks: presentationTracks ?? this.presentationTracks,
sortBy: sortBy ?? this.sortBy,
);
}
}
class PresentationStateNotifier
extends AutoDisposeFamilyNotifier<PresentationState, Object> {
@override
PresentationState build(collection) {
if (arg case PlaylistSimple() || AlbumSimple()) {
if (isSavedTrackPlaylist) {
ref.listen(
likedTracksProvider,
(previous, next) {
next.whenData((value) {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
value,
state.sortBy,
),
);
});
},
);
} else {
ref.listen(
arg is PlaylistSimple
? playlistTracksProvider((arg as PlaylistSimple).id!)
: albumTracksProvider((arg as AlbumSimple)),
(previous, next) {
next.whenData((value) {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
value.items,
state.sortBy,
),
);
});
},
);
}
}
return PresentationState(
selectedTracks: [],
presentationTracks: tracks,
sortBy: SortBy.none,
);
}
bool get isSavedTrackPlaylist =>
arg is PlaylistSimple &&
(arg as PlaylistSimple).id == "user-liked-tracks";
List<Track> get tracks {
assert(
arg is PlaylistSimple || arg is AlbumSimple,
"arg must be PlaylistSimple or AlbumSimple",
);
final isPlaylist = arg is PlaylistSimple;
final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) {
(true, true) => ref.read(likedTracksProvider).asData?.value,
(true, false) => ref
.read(playlistTracksProvider((arg as PlaylistSimple).id!))
.asData
?.value
.items,
_ => ref
.read(albumTracksProvider((arg as AlbumSimple)))
.asData
?.value
.items,
} ??
[];
return tracks;
}
void selectTrack(Track track) {
if (state.selectedTracks.any((e) => e.id == track.id)) {
return;
}
state = state.copyWith(
selectedTracks: [...state.selectedTracks, track],
);
}
void selectAllTracks() {
state = state.copyWith(
selectedTracks: tracks,
);
}
void deselectTrack(Track track) {
state = state.copyWith(
selectedTracks: state.selectedTracks.where((e) => e != track).toList(),
);
}
void deselectAllTracks() {
state = state.copyWith(
selectedTracks: [],
);
}
void filterTracks(String query) {
if (query.isEmpty) {
return;
}
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
tracks
.map((e) => (weightedRatio(e.name!, query), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList(),
state.sortBy,
),
);
}
void clearFilter() {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(tracks, state.sortBy),
);
}
void sortTracks(SortBy sortBy) {
state = state.copyWith(
presentationTracks: sortBy == SortBy.none
? tracks
: ServiceUtils.sortTracks(state.presentationTracks, sortBy),
sortBy: sortBy,
);
}
}
final presentationStateProvider = AutoDisposeNotifierProviderFamily<
PresentationStateNotifier, PresentationState, Object>(
() => PresentationStateNotifier(),
);

View File

@ -0,0 +1,278 @@
import 'package:auto_size_text/auto_size_text.dart';
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';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/use_action_callbacks.dart';
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});
@override
Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.sizeOf(context);
final options = TrackPresentationOptions.of(context);
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) =
useActionCallbacks(ref);
final playbackActions = Row(
spacing: 8 * scale,
children: [
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.shuffle_playlist),
),
child: IconButton.secondary(
icon: isLoading
? const Center(
child:
CircularProgressIndicator(onSurface: false, size: 20),
)
: const Icon(SpotubeIcons.shuffle),
enabled: !isLoading && !isActive,
onPressed: onShuffle,
),
),
if (mediaQuery.width <= 320)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.add_to_queue),
),
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd),
enabled: !isLoading && !isActive,
onPressed: () {},
),
)
else
Button.secondary(
leading: const Icon(SpotubeIcons.add),
enabled: !isLoading && !isActive,
child: Text(context.l10n.queue),
onPressed: () {},
),
Button.primary(
alignment: Alignment.center,
leading: switch ((isActive, isLoading)) {
(true, false) => const Icon(SpotubeIcons.pause),
(false, true) => const Center(
child: CircularProgressIndicator(onSurface: true, size: 18),
),
_ => const Icon(SpotubeIcons.play),
},
onPressed: onPlay,
enabled: !isLoading && !isActive,
child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play),
),
],
);
final additionalActions = Row(
spacing: 8 * scale,
children: [
if (isUserPlaylist)
IconButton.outline(
size: ButtonSize.small,
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: options.collectionId,
trackIds: options.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
if (options.shareUrl != null)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.share),
),
child: IconButton.outline(
icon: const Icon(SpotubeIcons.share),
size: ButtonSize.small,
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: options.shareUrl!),
);
if (!context.mounted) return;
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n
.copied_shareurl_to_clipboard(options.shareUrl!),
).small(),
);
},
);
},
),
),
if (options.onHeart != null)
HeartButton(
isLiked: options.isLiked,
tooltip: options.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
variance: ButtonVariance.outline,
size: ButtonSize.small,
onPressed: options.onHeart,
),
],
);
return SliverMainAxisGroup(
slivers: [
if (mediaQuery.mdAndUp) SliverGap(16 * scale),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale,
),
sliver: SliverList.list(
children: [
DecoratedBox(
decoration: BoxDecoration(
image: decorationImage,
borderRadius: BorderRadius.circular(45),
),
child: OutlinedContainer(
surfaceOpacity: context.theme.surfaceOpacity,
surfaceBlur: context.theme.surfaceBlur,
padding: EdgeInsets.all(24 * scale),
borderRadius: BorderRadius.circular(22 * scale),
borderWidth: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16 * scale,
children: [
Row(
spacing: 16 * scale,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: imageDimension * scale,
width: imageDimension * scale,
decoration: BoxDecoration(
borderRadius: context.theme.borderRadiusXl,
image: decorationImage,
),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
options.title,
maxLines: 2,
minFontSize: 16,
style: context.theme.typography.h3,
),
if (options.description != null)
AutoSizeText(
options.description!,
maxLines: 2,
minFontSize: 14,
maxFontSize: 18,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: context
.theme.colorScheme.mutedForeground,
fontSize: 18,
),
),
const Gap(16),
Flex(
crossAxisAlignment: CrossAxisAlignment.start,
direction: mediaQuery.smAndUp
? Axis.horizontal
: Axis.vertical,
spacing: 8 * scale,
children: [
if (options.owner != null)
OutlineBadge(
leading: options.ownerImage != null
? Avatar(
initials:
options.owner?[0] ?? "U",
provider: UniversalImage
.imageProvider(
options.ownerImage!,
),
)
: null,
child: Text(
options.owner!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small(),
),
additionalActions,
],
),
if (mediaQuery.mdAndUp) ...[
const Gap(16),
playbackActions
],
],
),
),
],
),
if (mediaQuery.smAndDown) playbackActions,
],
),
),
),
],
),
)
],
);
}
}

View File

@ -0,0 +1,70 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {
final SortBy? value;
final void Function(SortBy)? onChanged;
const SortTracksDropdown({
this.onChanged,
this.value,
super.key,
});
@override
Widget build(BuildContext context) {
return AdaptivePopSheetList<SortBy>(
variance: ButtonVariance.outline,
headings: [
Text(context.l10n.sort_tracks),
],
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
icon: const Icon(SpotubeIcons.sort),
children: [
AdaptiveMenuButton(
value: SortBy.none,
enabled: value != SortBy.none,
child: Text(context.l10n.none),
),
AdaptiveMenuButton(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
child: Text(context.l10n.sort_a_z),
),
AdaptiveMenuButton(
value: SortBy.descending,
enabled: value != SortBy.descending,
child: Text(context.l10n.sort_z_a),
),
AdaptiveMenuButton(
value: SortBy.newest,
enabled: value != SortBy.newest,
child: Text(context.l10n.sort_newest),
),
AdaptiveMenuButton(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
child: Text(context.l10n.sort_oldest),
),
AdaptiveMenuButton(
value: SortBy.duration,
enabled: value != SortBy.duration,
child: Text(context.l10n.sort_duration),
),
AdaptiveMenuButton(
value: SortBy.artist,
enabled: value != SortBy.artist,
child: Text(context.l10n.sort_artist),
),
AdaptiveMenuButton(
value: SortBy.album,
enabled: value != SortBy.album,
child: Text(context.l10n.sort_album),
),
],
);
}
}

View File

@ -0,0 +1,98 @@
import 'package:flutter_hooks/flutter_hooks.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:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/track_presentation/presentation_list.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_top.dart';
import 'package:spotube/components/track_presentation/presentation_modifiers.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
class TrackPresentation extends HookConsumerWidget {
final TrackPresentationOptions options;
const TrackPresentation({
super.key,
required this.options,
});
@override
Widget build(BuildContext context, ref) {
final scrollController = useScrollController();
final focusNode = useFocusNode();
final scale = context.theme.scaling;
useEffect(() {
if (!kIsMobile) return null;
void listener() {
if (!scrollController.hasClients) return;
if (focusNode.hasFocus) {
scrollController.animateTo(
300 * scale,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
focusNode.addListener(listener);
return () {
focusNode.removeListener(listener);
};
}, [focusNode, scrollController, scale]);
return Data<TrackPresentationOptions>.inherit(
data: options,
child: SafeArea(
bottom: false,
child: Scaffold(
headers: const [TitleBar()],
child: CustomScrollView(
controller: scrollController,
slivers: [
const TrackPresentationTopSection(),
const SliverGap(16),
SliverLayoutBuilder(
builder: (context, constrains) {
return SliverList.list(
children: [
TrackPresentationModifiersSection(
focusNode: focusNode,
),
Basic(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
leading: constrains.mdAndUp ? const Text(" #") : null,
title: Row(
children: [
Expanded(
flex: constrains.lgAndUp ? 5 : 6,
child: Text(context.l10n.title),
),
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Text(context.l10n.album),
),
Text(context.l10n.duration),
],
),
).small().muted(),
],
);
},
),
const PresentationListSection(),
const SliverSafeArea(sliver: SliverGap(10)),
],
),
),
),
);
}
}

View File

@ -0,0 +1,137 @@
import 'dart:math';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
typedef UseActionCallbacks = ({
bool isActive,
bool isLoading,
Future<void> Function() onShuffle,
Future<void> Function() onPlay,
});
UseActionCallbacks useActionCallbacks(WidgetRef ref) {
final isLoading = useState(false);
final context = useContext();
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = useMemoized(
() => playlist.collections.contains(options.collectionId),
[playlist.collections, options.collectionId],
);
final onShuffle = useCallback(() async {
try {
isLoading.value = true;
final initialTracks = options.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final allTracks = await options.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: options.collection as AlbumSimple,
initialIndex: Random().nextInt(allTracks.length))
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: options.collection as PlaylistSimple,
initialIndex: Random().nextInt(allTracks.length),
),
);
await remotePlayback.setShuffle(true);
} else {
await playlistNotifier.load(
initialTracks,
autoPlay: true,
initialIndex: Random().nextInt(initialTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
final allTracks = await options.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
isLoading.value = false;
}
}, [options, playlistNotifier, historyNotifier]);
final onPlay = useCallback(() async {
try {
isLoading.value = true;
final initialTracks = options.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final allTracks = await options.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: options.collection as AlbumSimple,
)
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: options.collection as PlaylistSimple,
),
);
} else {
await playlistNotifier.load(initialTracks, autoPlay: true);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
final allTracks = await options.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
if (context.mounted) {
isLoading.value = false;
}
}
}, [options, playlistNotifier, historyNotifier]);
return (
isActive: isActive,
isLoading: isLoading.value,
onShuffle: onShuffle,
onPlay: onPlay,
);
}

View File

@ -0,0 +1,89 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
Future<void> Function(Track track, int index) useTrackTilePlayCallback(
WidgetRef ref,
) {
final context = useContext();
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = useMemoized(
() => playlist.collections.contains(options.collectionId),
[playlist.collections, options.collectionId],
);
final onTapTrackTile = useCallback((Track track, int index) async {
final state = ref.read(presentationStateProvider(options.collection));
final notifier =
ref.read(presentationStateProvider(options.collection).notifier);
if (state.selectedTracks.isNotEmpty) {
if (state.selectedTracks.contains(track)) {
notifier.deselectTrack(track);
} else {
notifier.selectTrack(track);
}
return;
}
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(options.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await options.pagination.onFetchAll();
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks,
collection: options.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: options.collection as PlaylistSimple,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await options.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
}
}
}, [isActive, playlist, options, playlistNotifier, historyNotifier]);
return onTapTrackTile;
}

View File

@ -1,11 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart' hide Page;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
@ -67,16 +69,20 @@ class TrackOptions extends HookConsumerWidget {
void actionShare(BuildContext context, Track track) {
final data = "https://open.spotify.com/track/${track.id}";
Clipboard.setData(ClipboardData(text: data)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
context.l10n.copied_to_clipboard(data),
textAlign: TextAlign.center,
),
),
);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.copied_to_clipboard(data),
textAlign: TextAlign.center,
),
);
},
);
}
});
}
@ -159,7 +165,6 @@ class TrackOptions extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
@ -202,6 +207,7 @@ class TrackOptions extends HookConsumerWidget {
final isLocalTrack = track is LocalTrack;
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>(
tooltip: context.l10n.more_actions,
onSelected: (value) async {
switch (value) {
case TrackOptionValue.album:
@ -217,36 +223,57 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.addToQueue:
await playback.addTrack(track);
if (context.mounted) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.added_track_to_queue(track.name!),
),
),
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.added_track_to_queue(track.name!),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.track_will_play_next(track.name!),
),
),
);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.track_will_play_next(track.name!),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.removeFromQueue:
playback.removeTrack(track.id!);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.removed_track_from_queue(
track.name!,
),
),
),
);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.removed_track_from_queue(
track.name!,
),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.favorite:
favorites.toggleTrackLike(track);
@ -283,7 +310,10 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.details:
showDialog(
context: context,
builder: (context) => TrackDetailsDialog(track: track),
builder: (context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: TrackDetailsDialog(track: track),
),
);
break;
case TrackOptionValue.download:
@ -296,8 +326,7 @@ class TrackOptions extends HookConsumerWidget {
},
icon: icon ?? const Icon(SpotubeIcons.moreHorizontal),
headings: [
ListTile(
dense: true,
Basic(
leading: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
@ -313,8 +342,7 @@ class TrackOptions extends HookConsumerWidget {
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
).semiBold(),
subtitle: Align(
alignment: Alignment.centerLeft,
child: ArtistLink(
@ -332,38 +360,47 @@ class TrackOptions extends HookConsumerWidget {
],
children: [
if (isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.delete,
leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete),
child: Text(context.l10n.delete),
),
if (mediaQuery.smAndDown && !isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.album,
leading: const Icon(SpotubeIcons.album),
title: Text(context.l10n.go_to_album),
subtitle: Text(track.album!.name!),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.go_to_album),
Text(
track.album!.name!,
style: context.theme.typography.xSmall,
),
],
),
),
if (!playlist.containsTrack(track)) ...[
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.addToQueue,
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(context.l10n.add_to_queue),
child: Text(context.l10n.add_to_queue),
),
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.playNext,
leading: const Icon(SpotubeIcons.lightning),
title: Text(context.l10n.play_next),
child: Text(context.l10n.play_next),
),
] else
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.removeFromQueue,
enabled: playlist.activeTrack?.id != track.id,
leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue),
child: Text(context.l10n.remove_from_queue),
),
if (me.asData?.value != null && !isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.favorite,
leading: favorites.isLiked
? const Icon(
@ -371,32 +408,32 @@ class TrackOptions extends HookConsumerWidget {
color: Colors.pink,
)
: const Icon(SpotubeIcons.heart),
title: Text(
child: Text(
favorites.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
),
),
if (auth.asData?.value != null && !isLocalTrack) ...[
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.startRadio,
leading: const Icon(SpotubeIcons.radio),
title: Text(context.l10n.start_a_radio),
child: Text(context.l10n.start_a_radio),
),
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.addToPlaylist,
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
child: Text(context.l10n.add_to_playlist),
),
],
if (userPlaylist && auth.asData?.value != null && !isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.removeFromPlaylist,
leading: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
child: Text(context.l10n.remove_from_playlist),
),
if (!isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.download,
enabled: !isInQueue,
leading: isInQueue
@ -407,55 +444,58 @@ class TrackOptions extends HookConsumerWidget {
);
})
: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track),
child: Text(context.l10n.download_track),
),
if (!isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.blacklist,
leading: const Icon(SpotubeIcons.playlistRemove),
iconColor: isBlackListed != true ? Colors.red[400] : null,
textColor: isBlackListed != true ? Colors.red[400] : null,
title: Text(
leading: Icon(
SpotubeIcons.playlistRemove,
color: isBlackListed != true ? Colors.red[400] : null,
),
child: Text(
isBlackListed == true
? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist,
style: TextStyle(
color: isBlackListed != true ? Colors.red[400] : null,
),
),
),
if (!isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.share,
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share),
child: Text(context.l10n.share),
),
if (!isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.songlink,
leading: Assets.logos.songlinkTransparent.image(
width: 22,
height: 22,
color: colorScheme.onSurface.withOpacity(0.5),
color: colorScheme.foreground.withOpacity(0.5),
),
title: Text(context.l10n.song_link),
child: Text(context.l10n.song_link),
),
if (!isLocalTrack)
PopSheetEntry(
AdaptiveMenuButton(
value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info),
title: Text(context.l10n.details),
child: Text(context.l10n.details),
),
],
);
//! This is the most ANTI pattern I've ever done, but it works
showMenuCbRef?.value = (relativeRect) {
adaptivePopSheetList.showPopupMenu(context, relativeRect);
final offsetFromRect = Offset(
relativeRect.left,
relativeRect.top,
);
adaptivePopSheetList.showDropdownMenu(context, offsetFromRect);
};
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: adaptivePopSheetList,
);
return adaptivePopSheetList;
}
}

View File

@ -1,10 +1,11 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
@ -13,7 +14,9 @@ import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/links/artist_link.dart';
import 'package:spotube/components/links/link_text.dart';
import 'package:spotube/components/track_tile/track_options.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/button_variance.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart';
@ -88,9 +91,10 @@ class TrackTile extends HookConsumerWidget {
},
child: HoverBuilder(
permanentState: isSelected || constrains.smAndDown ? true : null,
builder: (context, isHovering) => ListTile(
builder: (context, isHovering) => ButtonTile(
selected: isSelected,
onTap: () async {
onPressed: () async {
if (isBlackListed) return;
try {
isLoading.value = true;
await onTap?.call();
@ -101,46 +105,58 @@ class TrackTile extends HookConsumerWidget {
}
},
onLongPress: onLongPress,
enabled: !isBlackListed,
contentPadding: EdgeInsets.zero,
tileColor: isBlackListed ? theme.colorScheme.errorContainer : null,
horizontalTitleGap: 12,
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
style: (isBlackListed
? ButtonVariance.destructive
: ButtonVariance.ghost)
.copyWith(
padding: (context, states) =>
const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
...?leadingActions,
if (index != null && onChanged == null && constrains.mdAndUp)
SizedBox(
width: 50,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${(index ?? 0) + 1}',
maxLines: 1,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
)
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox(
value: selected,
onChanged: onChanged,
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: index != null && onChanged == null
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: Checkbox(
state: selected
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (state) =>
onChanged?.call(state == CheckboxState.checked),
),
secondChild: constrains.smAndDown
? const SizedBox(width: 16)
: SizedBox(
width: 50,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${(index ?? 0) + 1}',
maxLines: 1,
style: theme.typography.small,
textAlign: TextAlign.center,
),
),
),
),
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
path: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
borderRadius: theme.borderRadiusMd,
image: DecorationImage(
fit: BoxFit.cover,
image: UniversalImage.imageProvider(
(track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
),
),
),
),
@ -148,46 +164,49 @@ class TrackTile extends HookConsumerWidget {
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
borderRadius: theme.borderRadiusMd,
color: isHovering
? Colors.black.withOpacity(0.4)
? Colors.black.withAlpha(102)
: Colors.transparent,
),
),
),
Positioned.fill(
child: Center(
child: IconTheme(
data: theme.iconTheme
.copyWith(size: 26, color: Colors.white),
child: Skeleton.ignore(
child: Consumer(
builder: (context, ref, _) {
final isFetchingActiveTrack =
ref.watch(queryingTrackInfoProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: (isPlaying && isFetchingActiveTrack) ||
isLoading.value
? const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Colors.white,
),
)
: isPlaying
? Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
)
: !isHovering
? const SizedBox.shrink()
: const Icon(SpotubeIcons.play),
);
},
),
child: Skeleton.ignore(
child: Consumer(
builder: (context, ref, _) {
final isFetchingActiveTrack =
ref.watch(queryingTrackInfoProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch ((
isPlaying,
isFetchingActiveTrack,
isPlaying,
isHovering,
isLoading.value
)) {
(true, true, _, _, _) ||
(_, _, _, _, true) =>
const SizedBox(
width: 26,
height: 26,
child:
CircularProgressIndicator(size: 1.5),
),
(_, _, true, _, _) => Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
),
(_, _, _, true, _) => const Icon(
SpotubeIcons.play,
color: Colors.white,
),
_ => const SizedBox.shrink(),
},
);
},
),
),
),
@ -206,12 +225,30 @@ class TrackTile extends HookConsumerWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
_ => LinkText(
track.name!,
"/track/${track.id}",
push: true,
maxLines: 1,
overflow: TextOverflow.ellipsis,
_ => Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Button(
style: ButtonVariance.link.copyWith(
padding: (context, states) => EdgeInsets.zero,
),
onPressed: () {
context.pushNamed(
TrackPage.name,
pathParameters: {
"id": track.id!,
},
);
},
child: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
},
),

View File

@ -1,192 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TrackViewBodySection extends HookConsumerWidget {
const TrackViewBodySection({super.key});
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final searchQuery = searchController.text;
final isFiltering = useState(false);
final uniqTracks = useMemoized(() {
final trackIds = props.tracks.map((e) => e.id).toSet();
return props.tracks.where((e) => trackIds.remove(e.id)).toList();
}, [props.tracks]);
final tracks = useMemoized(() {
List<Track> filteredTracks;
if (searchQuery.isEmpty) {
filteredTracks = uniqTracks;
} else {
filteredTracks = uniqTracks
.map((e) => (weightedRatio(e.name!, searchQuery), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}
return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy);
}, [trackViewState.sortBy, searchQuery, uniqTracks]);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final isActive = playlist.collections.contains(props.collectionId);
final onTapTrackTile = useCallback((Track track, int index) async {
if (trackViewState.isSelecting) {
trackViewState.toggleTrackSelection(track.id!);
return;
}
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(props.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await remotePlayback.load(
props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks,
collection: props.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: props.collection as PlaylistSimple,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
}
}
}, [isActive, playlist, props, playlistNotifier, historyNotifier]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: TrackViewBodyHeaders(
isFiltering: isFiltering,
searchFocus: searchFocus,
),
),
const SliverGap(8),
SliverToBoxAdapter(
child: ExpandableSearchField(
isFiltering: isFiltering.value,
onChangeFiltering: (value) {
isFiltering.value = value;
},
searchController: searchController,
searchFocus: searchFocus,
),
),
SliverSafeArea(
top: false,
sliver: SliverInfiniteList(
itemCount: tracks.length,
onFetchData: props.pagination.onFetchMore,
isLoading: props.pagination.isLoading,
hasReachedMax: !props.pagination.hasNextPage,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: TrackTile(
playlist: playlist,
track: FakeData.track,
index: 0,
),
),
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
playlist: playlist,
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
playlistId: props.collectionId,
userPlaylist: isUserPlaylist,
onChanged: !trackViewState.isSelecting
? null
: (value) {
trackViewState.toggleTrackSelection(track.id!);
},
onLongPress: () {
trackViewState.selectTrack(track.id!);
HapticFeedback.selectionClick();
},
onTap: () => onTapTrackTile(track, index),
);
},
),
),
],
);
}
}

View File

@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/sort_tracks_dropdown.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewBodyHeaders extends HookConsumerWidget {
final ValueNotifier<bool> isFiltering;
final FocusNode searchFocus;
const TrackViewBodyHeaders({
super.key,
required this.isFiltering,
required this.searchFocus,
});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
return LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: Checkbox(
value: trackViewState.hasSelectedAll,
onChanged: (checked) {
if (checked == true) {
trackViewState.selectAll();
} else {
trackViewState.deselectAll();
}
},
),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: textTheme.bodyLarge,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge,
),
],
),
),
SortTracksDropdown(
value: trackViewState.sortBy,
onChanged: (value) {
trackViewState.sort(value);
},
),
ExpandableSearchButton(
isFiltering: isFiltering.value,
searchFocus: searchFocus,
onPressed: (value) {
isFiltering.value = value;
if (value) {
searchFocus.requestFocus();
} else {
searchFocus.unfocus();
}
},
),
const TrackViewBodyOptions(),
if (kIsDesktop) const Gap(10),
],
);
},
);
}
}

View File

@ -1,140 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:textTheme) = Theme.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final selectedTracks = trackViewState.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: textTheme.bodyLarge,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader.batchAddToQueue(selectedTracks);
trackViewState.deselectAll();
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: props.collectionId,
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll();
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll();
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -1,167 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:gap/gap.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final mediaQuery = MediaQuery.of(context);
final palette = usePaletteColor(props.image, ref);
return IconTheme(
data: iconTheme.copyWith(color: palette.bodyTextColor),
child: SliverLayoutBuilder(
builder: (context, constrains) {
final isExpanded = constrains.scrollOffset < 350;
final headingStyle = (mediaQuery.mdAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium)
?.copyWith(
color: palette.bodyTextColor,
);
return SliverAppBar(
iconTheme: iconTheme.copyWith(
color: palette.bodyTextColor,
size: 16,
),
actions: isExpanded
? []
: [
const TrackViewHeaderActions(),
TrackViewHeaderButtons(compact: true, color: palette),
],
floating: false,
pinned: true,
expandedHeight: 450,
automaticallyImplyLeading: kIsMobile,
backgroundColor: palette.color,
title: isExpanded ? null : Text(props.title, style: headingStyle),
flexibleSpace: FlexibleSpaceBar(
background: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(props.image),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: mediaQuery.mdAndDown
? mediaQuery.size.width
: 800,
),
child: Flex(
direction: mediaQuery.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: props.image,
width: 200,
height: 200,
placeholder: Assets.albumPlaceholder.path,
),
),
const Gap(20),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: mediaQuery.mdAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
Text(
props.title,
style: headingStyle,
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
if (props.description != null &&
props.description!.isNotEmpty)
Text(
props.description!
.unescapeHtml()
.cleanHtml(),
style:
defaultTextStyle.style.copyWith(
color: palette.bodyTextColor,
),
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(10),
const TrackViewHeaderActions(),
const Gap(10),
TrackViewHeaderButtons(color: palette),
],
),
),
],
),
),
],
),
),
),
),
),
),
),
);
},
),
);
}
}

View File

@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/heart_button/heart_button.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(authenticationProvider);
final copiedText =
context.l10n.copied_shareurl_to_clipboard(props.shareUrl);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: context.l10n.share,
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: props.shareUrl),
);
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
copiedText,
textAlign: TextAlign.center,
),
),
);
},
),
IconButton(
icon: const Icon(SpotubeIcons.queueAdd),
tooltip: context.l10n.add_to_queue,
onPressed: isActive || props.tracks.isEmpty
? null
: () async {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier
.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
},
),
if (props.onHeart != null && auth.asData?.value != null)
HeartButton(
isLiked: props.isLiked,
icon: isUserPlaylist ? SpotubeIcons.trash : null,
tooltip: props.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () async {
final shouldPop = await props.onHeart?.call();
if (isUserPlaylist && shouldPop == true && context.mounted) {
context.pop();
}
},
),
if (isUserPlaylist)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: props.collectionId,
trackIds: props.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More