mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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('-');
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user