refactor: remove dead code across Dart and Go layers

- drop unused widgets (AnimatedStateSwitcher, GridSkeleton,
  adjacentHorizontalPageRoute, buildRemovalAnimation, SwingIcon,
  BottomSheetOptionTile) and empty _buildInfoCard placeholders
- drop test-only Go wrapper methods (GetDownloadURL, MatchTrack,
  CheckAvailability 7-arg, CustomSearchForItemID) and their parsers
- drop unused httputil getters/validators and hand-rolled base64
  decoder in favor of encoding/base64
- remove unused riverpod_generator dev dependency
This commit is contained in:
zarzet
2026-07-12 09:35:11 +07:00
parent cea6a58c18
commit aa2cdef1d2
19 changed files with 16 additions and 611 deletions
+12 -65
View File
@@ -2,6 +2,7 @@ package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
@@ -1460,13 +1461,20 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
b64Data := comment[len(key):]
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
n, err := base64StdDecode(decoded, b64Data)
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, string(comment[len(key):]))
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
}
if err != nil {
continue
}
decoded = decoded[:n]
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
@@ -1520,67 +1528,6 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
return imageData, mimeType
}
func base64StdDecodeLen(n int) int {
return n * 6 / 8
}
func base64StdDecode(dst, src []byte) (int, error) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
decodeMap := make([]byte, 256)
for i := range decodeMap {
decodeMap[i] = 0xFF
}
for i := 0; i < len(alphabet); i++ {
decodeMap[alphabet[i]] = byte(i)
}
si, di := 0, 0
for si < len(src) {
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
si++
}
if si >= len(src) {
break
}
var vals [4]byte
var valCount int
for valCount < 4 && si < len(src) {
c := src[si]
si++
if c == '=' {
vals[valCount] = 0
valCount++
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
continue
} else if decodeMap[c] != 0xFF {
vals[valCount] = decodeMap[c]
valCount++
}
}
if valCount < 2 {
break
}
if di < len(dst) {
dst[di] = vals[0]<<2 | vals[1]>>4
di++
}
if valCount >= 3 && di < len(dst) {
dst[di] = vals[1]<<4 | vals[2]>>2
di++
}
if valCount >= 4 && di < len(dst) {
dst[di] = vals[2]<<6 | vals[3]
di++
}
}
return di, nil
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractAnyCoverArtWithHint(filePath, "")
}
@@ -164,12 +164,6 @@ func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
}
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
}
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
t.Fatal("expected opus stream")
}
-31
View File
@@ -57,17 +57,6 @@ func gojaObjectInt64(obj *goja.Object, keys ...string) int64 {
return 0
}
func gojaObjectFloat(obj *goja.Object, keys ...string) float64 {
for _, key := range keys {
value := obj.Get(key)
if gojaValueIsEmpty(value) {
continue
}
return value.ToFloat()
}
return 0
}
func gojaObjectBool(obj *goja.Object, keys ...string) bool {
for _, key := range keys {
value := obj.Get(key)
@@ -467,16 +456,6 @@ func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvai
}
}
func parseExtensionDownloadURLValue(vm *goja.Runtime, value goja.Value) ExtDownloadURLResult {
obj := value.ToObject(vm)
return ExtDownloadURLResult{
URL: gojaObjectString(obj, "url"),
Format: gojaObjectString(obj, "format"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
}
}
func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo {
if gojaValueIsEmpty(value) {
return nil
@@ -577,16 +556,6 @@ func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHan
return handleResult, nil
}
func parseExtensionMatchTrackValue(vm *goja.Runtime, value goja.Value) MatchTrackResult {
obj := value.ToObject(vm)
return MatchTrackResult{
Matched: gojaObjectBool(obj, "matched"),
TrackID: gojaObjectString(obj, "track_id", "trackId"),
Confidence: gojaObjectFloat(obj, "confidence"),
Reason: gojaObjectString(obj, "reason"),
}
}
func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult {
obj := value.ToObject(vm)
return PostProcessResult{
@@ -57,22 +57,14 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
t.Fatalf("enriched = %#v", enriched)
}
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
availability, err := provider.CheckAvailabilityForItemID("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz", 0, "")
if err != nil {
t.Fatalf("CheckAvailability: %v", err)
t.Fatalf("CheckAvailabilityForItemID: %v", err)
}
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
t.Fatalf("availability = %#v", availability)
}
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
if err != nil {
t.Fatalf("GetDownloadURL: %v", err)
}
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
t.Fatalf("download URL = %#v", downloadURL)
}
progress := []int{}
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
progress = append(progress, percent)
@@ -100,17 +92,6 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
t.Fatalf("url result = %#v", urlResult)
}
match, err := provider.MatchTrack(
map[string]any{"name": "Song", "artists": "Artist"},
[]map[string]any{{"id": "download-track", "name": "Song"}},
)
if err != nil {
t.Fatalf("MatchTrack: %v", err)
}
if !match.Matched || match.TrackID != "download-track" {
t.Fatalf("match = %#v", match)
}
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]any{"title": "Song"}, "hook")
if err != nil {
t.Fatalf("PostProcess: %v", err)
-7
View File
@@ -91,13 +91,6 @@ type ExtAvailabilityResult struct {
SkipFallback bool `json:"skip_fallback,omitempty"`
}
type ExtDownloadURLResult struct {
URL string `json:"url"`
Format string `json:"format"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
}
type DownloadDecryptionInfo struct {
Strategy string `json:"strategy,omitempty"`
Key string `json:"key,omitempty"`
-114
View File
@@ -413,10 +413,6 @@ func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata,
return &enrichedTrack, nil
}
func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string) (*ExtAvailabilityResult, error) {
return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, 0, "")
}
func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, durationMS int, itemID string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -488,54 +484,6 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
return &availability, nil
}
func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
perf := newExtensionCallPerf(p.extension.ID, "getDownloadUrl")
defer perf.finish()
initStartedAt := time.Now()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
perf.recordInit(time.Since(initStartedAt))
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
return extension.getDownloadUrl(%q, %q);
}
return null;
})()
`, trackID, quality)
jsStartedAt := time.Now()
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
perf.recordJS(time.Since(jsStartedAt))
perf.recordPayload(result)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getDownloadUrl failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return nil, fmt.Errorf("getDownloadUrl returned null")
}
parseStartedAt := time.Now()
urlResult := parseExtensionDownloadURLValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
return &urlResult, nil
}
const ExtDownloadTimeout = DownloadTimeout
func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
@@ -656,10 +604,6 @@ func (p *extensionProviderWrapper) CustomSearchForRequestID(query string, option
return p.customSearch(query, options, "", requestID)
}
func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options map[string]any, itemID string) ([]ExtTrackMetadata, error) {
return p.customSearch(query, options, itemID, "")
}
func (p *extensionProviderWrapper) customSearch(query string, options map[string]any, itemID, requestID string) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
@@ -875,64 +819,6 @@ func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return &handleResult, nil
}
type MatchTrackResult struct {
Matched bool `json:"matched"`
TrackID string `json:"track_id,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
Reason string `json:"reason,omitempty"`
}
func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]any, candidates []map[string]any) (*MatchTrackResult, error) {
if !p.extension.Manifest.HasCustomMatching() {
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
perf := newExtensionCallPerf(p.extension.ID, "matchTrack")
defer perf.finish()
initStartedAt := time.Now()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
perf.recordInit(time.Since(initStartedAt))
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
candidatesJSON, _ := json.Marshal(candidates)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.matchTrack === 'function') {
return extension.matchTrack(%s, %s);
}
return null;
})()
`, string(sourceJSON), string(candidatesJSON))
jsStartedAt := time.Now()
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
perf.recordJS(time.Since(jsStartedAt))
perf.recordPayload(result)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond")
}
return nil, fmt.Errorf("matchTrack failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return &MatchTrackResult{Matched: false, Reason: "not implemented"}, nil
}
parseStartedAt := time.Now()
matchResult := parseExtensionMatchTrackValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
return &matchResult, nil
}
type PostProcessResult struct {
Success bool `json:"success"`
NewFilePath string `json:"new_file_path,omitempty"`
-9
View File
@@ -745,15 +745,6 @@ func TestParseExtensionURLHandleResult(t *testing.T) {
func TestParseExtensionAuxiliaryResults(t *testing.T) {
vm := goja.New()
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
if err != nil {
t.Fatalf("build match value: %v", err)
}
match := parseExtensionMatchTrackValue(vm, matchValue)
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
t.Fatalf("unexpected match result: %+v", match)
}
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
if err != nil {
t.Fatalf("build post-process value: %v", err)
-39
View File
@@ -125,11 +125,6 @@ var sharedClient = &http.Client{
Timeout: DefaultTimeout,
}
var downloadClient = &http.Client{
Transport: newCompatibilityTransport(sharedTransport),
Timeout: DownloadTimeout,
}
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: newCompatibilityTransport(sharedTransport),
@@ -144,14 +139,6 @@ func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
}
}
func GetSharedClient() *http.Client {
return sharedClient
}
func GetDownloadClient() *http.Client {
return downloadClient
}
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
extensionAPITransport.CloseIdleConnections()
@@ -403,32 +390,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil
}
func ValidateResponse(resp *http.Response) error {
if resp == nil {
return fmt.Errorf("response is nil")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
return nil
}
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
}
if responsePreview != "" {
if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..."
}
msg += fmt.Sprintf(": %s", responsePreview)
}
return msg
}
type ISPBlockingError struct {
Domain string
Reason string
-15
View File
@@ -26,9 +26,6 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
t.Fatal("client timeout mismatch")
}
if GetSharedClient() == nil || GetDownloadClient() == nil {
t.Fatal("expected shared clients")
}
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
t.Fatal("expected supplemental TLS root pool")
}
@@ -115,18 +112,6 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
t.Fatalf("ReadResponseBody = %q/%v", body, err)
}
if err := ValidateResponse(nil); err == nil {
t.Fatal("expected nil response validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
t.Fatal("expected bad status validation error")
}
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
t.Fatalf("ValidateResponse: %v", err)
}
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
t.Fatalf("BuildErrorMessage = %q", msg)
}
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
t.Fatal("calculateNextDelay mismatch")
}
+2 -2
View File
@@ -227,8 +227,8 @@ func TestExtensionHealthInitializeVMAndCustomSearchWrappers(t *testing.T) {
cancelMu.Lock()
delete(cancelMap, "custom-item-unique")
cancelMu.Unlock()
if tracks, err := provider.CustomSearchForItemID("needle", nil, "custom-item-unique"); err != nil || len(tracks) == 0 {
t.Fatalf("CustomSearchForItemID = %#v/%v", tracks, err)
if tracks, err := provider.customSearch("needle", nil, "custom-item-unique", ""); err != nil || len(tracks) == 0 {
t.Fatalf("customSearch (item ID) = %#v/%v", tracks, err)
}
if healthJSON, err := CheckExtensionHealthJSON(ext.ID); err != nil || !strings.Contains(healthJSON, `"status":"offline"`) {
t.Fatalf("CheckExtensionHealthJSON = %q/%v", healthJSON, err)
-5
View File
@@ -455,7 +455,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme, pageBackgroundColor),
_buildInfoCard(context, colorScheme),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
@@ -628,10 +627,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildAlbumFooter(
BuildContext context,
ColorScheme colorScheme,
-9
View File
@@ -395,7 +395,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme, tracks),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
@@ -639,14 +638,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (identical(tracks, _commonQualitySourceCache)) {
return _commonQualityCache;
-9
View File
@@ -283,7 +283,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
@@ -385,14 +384,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _metaWhiteItem(IconData? icon, String label) {
const textStyle = TextStyle(
color: Colors.white,
-54
View File
@@ -812,60 +812,6 @@ class _SlidingIconState extends State<SlidingIcon>
}
}
class SwingIcon extends StatefulWidget {
final Widget child;
const SwingIcon({super.key, required this.child});
@override
State<SwingIcon> createState() => _SwingIconState();
}
class _SwingIconState extends State<SwingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_rotationAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: -0.2), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.2, end: 0.15), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
]).animate(_controller);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value,
alignment: Alignment.topCenter,
child: child,
);
},
child: widget.child,
);
}
}
class SpinIcon extends StatefulWidget {
final Widget child;
const SpinIcon({super.key, required this.child});
-5
View File
@@ -202,7 +202,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackList(context, colorScheme),
_buildPlaylistFooter(context, colorScheme),
SliverToBoxAdapter(
@@ -296,10 +295,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
String _formatReleaseDate(String date) {
if (date.length >= 10) {
final parts = date.substring(0, 10).split('-');
-147
View File
@@ -56,34 +56,6 @@ class StaggeredListItem extends StatelessWidget {
}
}
/// A convenience wrapper around [AnimatedSwitcher] that crossfades between
/// different widget states (loading, content, empty, error).
///
/// Assign a unique [ValueKey] to each child so the switcher detects changes.
class AnimatedStateSwitcher extends StatelessWidget {
final Widget child;
final Duration duration;
const AnimatedStateSwitcher({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 250),
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: child,
);
}
}
/// Creates a platform-aware material route.
///
/// This intentionally defers route transitions to Flutter's material route and
@@ -93,58 +65,6 @@ Route<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(builder: (context) => page);
}
/// A directional horizontal transition for adjacent content, such as moving
/// between next/previous items within the same detail context.
Route<T> adjacentHorizontalPageRoute<T>({
required Widget page,
required bool fromRight,
}) {
return _AdjacentHorizontalPageRoute<T>(
builder: (context) => page,
fromRight: fromRight,
);
}
class _AdjacentHorizontalPageRoute<T> extends MaterialPageRoute<T> {
final bool fromRight;
_AdjacentHorizontalPageRoute({
required super.builder,
required this.fromRight,
});
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
if (animation.status == AnimationStatus.reverse) {
return super.buildTransitions(
context,
animation,
secondaryAnimation,
child,
);
}
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
);
final begin = Offset(fromRight ? 0.22 : -0.22, 0);
return SlideTransition(
position: Tween<Offset>(begin: begin, end: Offset.zero).animate(curved),
child: FadeTransition(
opacity: Tween<double>(begin: 0.92, end: 1.0).animate(curved),
child: child,
),
);
}
}
/// A shimmer effect widget that can wrap skeleton placeholders.
class ShimmerLoading extends StatefulWidget {
final Widget child;
@@ -331,8 +251,6 @@ class TrackListSkeleton extends StatelessWidget {
}
}
/// Grid skeleton mimics a grid of album/playlist cards while loading.
/// Album track list skeleton mimics the album screen track list layout
/// (track number + title + artist + trailing icon, no cover art thumbnail).
class AlbumTrackListSkeleton extends StatelessWidget {
@@ -470,59 +388,6 @@ class _CollectionHeaderSkeleton extends StatelessWidget {
}
}
class GridSkeleton extends StatelessWidget {
final int itemCount;
final int crossAxisCount;
const GridSkeleton({super.key, this.itemCount = 6, this.crossAxisCount = 2});
@override
Widget build(BuildContext context) {
return ShimmerLoading(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: itemCount,
itemBuilder: (context, index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
child: SkeletonBox(
width: double.infinity,
height: double.infinity,
borderRadius: 12,
),
),
const SizedBox(height: 8),
SkeletonBox(
width: 80 + (index % 3) * 20,
height: 12,
borderRadius: 4,
),
const SizedBox(height: 4),
SkeletonBox(
width: 50 + (index % 2) * 15,
height: 10,
borderRadius: 4,
),
],
);
},
),
),
);
}
}
/// Artist screen skeleton shown below the SliverAppBar header while the
/// discography loads: optional cover placeholder, "Popular" section, and the
/// horizontal album sections.
@@ -939,15 +804,3 @@ class _AnimatedBadgeState extends State<AnimatedBadge>
return ScaleTransition(scale: _scaleAnimation, child: widget.child);
}
}
/// Build a removal animation for [AnimatedList] items.
/// Use as the `builder` callback in [AnimatedListState.removeItem].
Widget buildRemovalAnimation(Widget child, Animation<double> animation) {
return SizeTransition(
sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
child: FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn),
child: child,
),
);
}
-40
View File
@@ -1,40 +0,0 @@
import 'package:flutter/material.dart';
/// Reusable option tile for bottom sheets.
/// Used in playlist options, track options, cover options, etc.
class BottomSheetOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const BottomSheetOptionTile({
super.key,
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
-32
View File
@@ -281,14 +281,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
@@ -829,14 +821,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mockito:
dependency: transitive
description:
name: mockito
sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422
url: "https://pub.dev"
source: hosted
version: "5.6.4"
nm:
dependency: transitive
description:
@@ -1077,22 +1061,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0-dev.10"
riverpod_annotation:
dependency: transitive
description:
name: riverpod_annotation
sha256: "674dbb26e2db3d9253166faf4758c796af14146b8fbcf5e7102bc8a04cd359b8"
url: "https://pub.dev"
source: hosted
version: "4.0.3"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "54d790c3fee1ae281c448801bfdbfaa9fd961a9d3998494e0fcd9ee32184d7eb"
url: "https://pub.dev"
source: hosted
version: "4.0.4"
riverpod_lint:
dependency: "direct dev"
description:
-1
View File
@@ -74,7 +74,6 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^6.0.0
build_runner: ^2.15.0
riverpod_generator: ^4.0.4
riverpod_lint: ^3.1.4
json_serializable: ^6.14.0
flutter_launcher_icons: ^0.14.3