Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf00ecb756 | |||
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a | |||
| 51b2ad5c77 | |||
| d641a517b8 | |||
| 608fa2ca74 | |||
| 343b309314 | |||
| 0787b32dd8 | |||
| 6927fdf7a9 | |||
| fe6af34478 | |||
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c | |||
| 852335f794 | |||
| b87de1f00a | |||
| 8fcb389bb2 | |||
| 08bca30fcd | |||
| a7c5afdd20 | |||
| 5eac386eba | |||
| d35d60ac7d | |||
| 7c43d4bf70 | |||
| 2043370b6c | |||
| 39ddb7a14f | |||
| bd9b527161 | |||
| 39bcc2c547 | |||
| 973c2e3b41 | |||
| 62805720da |
@@ -0,0 +1,77 @@
|
|||||||
|
name: Auto Tag on Version Change
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
- name: Get current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Current version: $VERSION"
|
||||||
|
|
||||||
|
- name: Get previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
|
||||||
|
if [ -f pubspec.yaml.old ]; then
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
else
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
fi
|
||||||
|
git checkout HEAD -- pubspec.yaml
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Previous version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check if version changed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
CURRENT="${{ steps.current.outputs.version }}"
|
||||||
|
PREVIOUS="${{ steps.previous.outputs.version }}"
|
||||||
|
|
||||||
|
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
||||||
|
echo "Version changed from $PREVIOUS to $CURRENT"
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version unchanged: $CURRENT"
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check if tag exists
|
||||||
|
id: tag_exists
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
|
||||||
|
echo "Tag $TAG already exists"
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Tag $TAG does not exist"
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "Release $TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Created and pushed tag: $TAG"
|
||||||
@@ -17,14 +17,26 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
else
|
||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
|
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected pre-release version: $VERSION"
|
||||||
|
else
|
||||||
|
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected stable version: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Android and iOS build in PARALLEL
|
# Android and iOS build in PARALLEL
|
||||||
@@ -33,6 +45,20 @@ jobs:
|
|||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
# Remove large unused tools (~15GB total)
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo rm -rf /usr/local/share/boost
|
||||||
|
sudo rm -rf /usr/share/swift
|
||||||
|
sudo rm -rf /usr/local/.ghcup
|
||||||
|
# Clean docker images
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
# Show available space
|
||||||
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -203,6 +229,23 @@ jobs:
|
|||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||||
|
- name: Use iOS pubspec with FFmpeg plugin
|
||||||
|
run: |
|
||||||
|
cp pubspec.yaml pubspec_android_backup.yaml
|
||||||
|
cp pubspec_ios.yaml pubspec.yaml
|
||||||
|
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
||||||
|
|
||||||
|
# Swap FFmpeg service for iOS
|
||||||
|
- name: Use iOS FFmpeg service
|
||||||
|
run: |
|
||||||
|
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
||||||
|
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
||||||
|
# Update class name in the swapped file
|
||||||
|
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
||||||
|
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
||||||
|
echo "Swapped to iOS FFmpeg service"
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -316,6 +359,6 @@ jobs:
|
|||||||
body_path: /tmp/release_body.txt
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,5 +1,256 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.6] - 2026-01-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
||||||
|
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
||||||
|
- Now properly converts milliseconds to seconds before display
|
||||||
|
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
|
||||||
|
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
|
||||||
|
- Also reads quality from existing files when skipping duplicates
|
||||||
|
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
|
||||||
|
- Verifies artist matches between Spotify metadata and streaming service
|
||||||
|
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
|
||||||
|
- Applied to Tidal, Qobuz, and Amazon downloads
|
||||||
|
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
|
||||||
|
- Now uses case-insensitive comparison when replacing existing Vorbis comments
|
||||||
|
- Fixes issue where Amazon downloads could have duplicate metadata tags
|
||||||
|
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
|
||||||
|
- Added proper PopScope handling for predictive back gesture on Android 14+
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
||||||
|
|
||||||
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
||||||
|
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
||||||
|
- Shows explanation dialog before opening system settings
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
||||||
|
- Toggle to enable/disable custom credentials without deleting them
|
||||||
|
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||||
|
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
|
||||||
|
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
||||||
|
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
||||||
|
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||||
|
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
|
||||||
|
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
||||||
|
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
||||||
|
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
||||||
|
- Tertiary color highlight for Hi-Res (24-bit) downloads
|
||||||
|
- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability
|
||||||
|
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
||||||
|
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
||||||
|
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
||||||
|
- Tidal API v2 response provides exact quality info
|
||||||
|
- Qobuz uses track metadata for quality info
|
||||||
|
- Amazon now reads quality from downloaded FLAC file (previously returned unknown)
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||||
|
- Tap to expand long track titles
|
||||||
|
- Expand icon only shows when title is truncated
|
||||||
|
- Ripple effect follows rounded corners including drag handle
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
||||||
|
- All downloads now use item-based progress tracking
|
||||||
|
- Fixes duplicate notification bug when finalizing
|
||||||
|
- Cleaner codebase with single progress system
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
||||||
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
||||||
|
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
||||||
|
- Container with `primaryContainer` background for each option
|
||||||
|
- Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max)
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Artist Search Results**: Search now shows artists alongside tracks
|
||||||
|
- Horizontal scrollable artist cards with circular avatars
|
||||||
|
- Tap artist to view their discography
|
||||||
|
- **Multi-Layer Caching System**: Aggressive caching to minimize API calls
|
||||||
|
- Go backend cache: Artist (10 min), Album (10 min), Search (5 min)
|
||||||
|
- Flutter memory cache: Instant navigation for previously viewed artists/albums
|
||||||
|
- Duplicate search prevention: Same query won't trigger new API call
|
||||||
|
- **Real-time Download Status**: Track items show live download progress
|
||||||
|
- Queued: Hourglass icon
|
||||||
|
- Downloading: Circular progress with percentage
|
||||||
|
- Completed: Check icon
|
||||||
|
- Works in Home search, Album, and Playlist screens
|
||||||
|
- **Downloaded Track Indicator**: Tracks already in history show check mark
|
||||||
|
- Lazy file verification: Only checks file existence when tapped
|
||||||
|
- Auto-removes from history if file was deleted, allowing re-download
|
||||||
|
- Prevents accidental duplicate downloads
|
||||||
|
- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags
|
||||||
|
- Stable users won't receive update notifications for preview versions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
||||||
|
- Header (name, cover) shows instantly from available data
|
||||||
|
- Content (albums/tracks) loads in background inside the screen
|
||||||
|
- Second visit to same artist/album is instant from Flutter cache
|
||||||
|
- **Search Results UI Redesign**:
|
||||||
|
- Removed "Download All" button from search results
|
||||||
|
- Added "Songs" section header (matches "Artists" header style)
|
||||||
|
- Track list now in grouped card with rounded corners (like Settings)
|
||||||
|
- Track items with dividers and InkWell ripple effect
|
||||||
|
- **Larger UI Elements**: Improved touch targets and visual hierarchy
|
||||||
|
- Recent downloads: Album art 56→100px, section height 80→130px
|
||||||
|
- Artist cards: Avatar 72→88px, container 90→100px
|
||||||
|
- Track items: Album art 48→56px
|
||||||
|
- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search
|
||||||
|
- **Smoother Progress Animation**: Progress jumps to 100% after download completes
|
||||||
|
- Embedding (cover, metadata, lyrics) happens in background without blocking UI
|
||||||
|
- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata
|
||||||
|
- Distinct icon (edit_note) with tertiary color
|
||||||
|
- User knows download is complete, just processing metadata
|
||||||
|
- **Consistent Download Button Sizes**: All download/status buttons now 44x44px
|
||||||
|
- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color
|
||||||
|
- Settings cards use overlay colors for better contrast
|
||||||
|
- Theme/view mode chips have visible borders in light mode
|
||||||
|
- **Navigation Bar Styling**: Distinct background color from content area
|
||||||
|
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||||
|
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
||||||
|
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
||||||
|
- Uses Riverpod `select()` for granular state watching
|
||||||
|
- Prevents entire list rebuild on progress updates
|
||||||
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download complete
|
||||||
|
|
||||||
|
## [1.6.3] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
||||||
|
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
||||||
|
- Collapsing header with cover art and gradient overlay
|
||||||
|
- Card-based info section with rounded corners (20px radius)
|
||||||
|
- Tonal download buttons with circular shape
|
||||||
|
- Quality picker bottom sheet with drag handle
|
||||||
|
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
||||||
|
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
||||||
|
- Enables native predictive back gesture animations
|
||||||
|
- Search results stay on Home tab for quick downloads
|
||||||
|
- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||||
|
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
||||||
|
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
||||||
|
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
||||||
|
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||||
|
- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
|
||||||
|
- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
|
||||||
|
- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
|
||||||
|
- **Stream Error Handling**: Share intent stream now has proper error handling
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Background Download Service**: Downloads now continue running when app is in background
|
||||||
|
- Foreground service with wake lock prevents Android from killing downloads
|
||||||
|
- Persistent notification shows download progress
|
||||||
|
- No more "connection abort" errors when switching apps
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
||||||
|
- Download queue is now persisted to storage and automatically restored on app restart
|
||||||
|
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
||||||
|
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
|
||||||
|
- Added `onNewIntent` handler to properly receive new share intents
|
||||||
|
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Manual Quality Selection**: New option to choose audio quality before each download
|
||||||
|
- Toggle "Ask Before Download" in Download Settings
|
||||||
|
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
||||||
|
- Works for both single track and batch downloads
|
||||||
|
- **Live Search**: Search results appear as you type with 400ms debounce
|
||||||
|
- Animated search bar moves from center to top when typing
|
||||||
|
- Keyboard stays open during transition
|
||||||
|
- Back button navigates through search history (album → artist → idle)
|
||||||
|
- Clear button to reset search
|
||||||
|
- URLs still require manual submit
|
||||||
|
- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs
|
||||||
|
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
||||||
|
- Users on hotfix versions now properly receive update notifications
|
||||||
|
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
||||||
|
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
||||||
|
- Items in same group are connected with rounded card container
|
||||||
|
- Section headers outside cards for clear visual hierarchy
|
||||||
|
- Better contrast with white overlay for dark mode dynamic colors
|
||||||
|
- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility
|
||||||
|
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
||||||
|
- **Dependencies Updated**:
|
||||||
|
- `share_plus`: 10.1.4 → 12.0.1
|
||||||
|
- `flutter_local_notifications`: 18.0.1 → 19.0.0
|
||||||
|
- `build_runner`: 2.4.15 → 2.10.4
|
||||||
|
|
||||||
## [1.5.5] - 2026-01-02
|
## [1.5.5] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -13,23 +13,13 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Download tracks, albums, and playlists from Spotify links
|
|
||||||
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
|
|
||||||
- Material Expressive 3 design with dynamic colors
|
|
||||||
- High performance rendering with Impeller (Vulkan)
|
|
||||||
- Concurrent downloads up to 3 simultaneous
|
|
||||||
- Real-time download progress tracking
|
|
||||||
- Download notifications
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/images/1.jpg" width="200" />
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/2.jpg" width="200" />
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/3.jpg" width="200" />
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/4.jpg" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
@@ -39,6 +29,8 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
implementation(files("libs/gobackend.aar"))
|
||||||
|
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,30 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
<!-- For Android 11+ (API 30-32) - full storage access -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
|
|||||||
@@ -5,19 +5,77 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service to keep downloads running when app is in background.
|
||||||
|
* This prevents Android from killing the download process or throttling network.
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
private const val CHANNEL_ID = "download_channel"
|
||||||
const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1001
|
||||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
|
||||||
|
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
|
||||||
|
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
|
||||||
|
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
|
||||||
|
|
||||||
|
const val EXTRA_TRACK_NAME = "track_name"
|
||||||
|
const val EXTRA_ARTIST_NAME = "artist_name"
|
||||||
|
const val EXTRA_PROGRESS = "progress"
|
||||||
|
const val EXTRA_TOTAL = "total"
|
||||||
|
const val EXTRA_QUEUE_COUNT = "queue_count"
|
||||||
|
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
fun isServiceRunning(): Boolean = isRunning
|
||||||
|
|
||||||
|
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_PROGRESS
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_PROGRESS, progress)
|
||||||
|
putExtra(EXTRA_TOTAL, total)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var currentTrackName = ""
|
||||||
|
private var currentArtistName = ""
|
||||||
|
private var queueCount = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
@@ -25,10 +83,25 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startForegroundService()
|
ACTION_START -> {
|
||||||
ACTION_STOP -> stopSelf()
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
|
||||||
|
startForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_UPDATE_PROGRESS -> {
|
||||||
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
|
||||||
|
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
|
||||||
|
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
updateNotification(progress, total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -37,10 +110,10 @@ class DownloadService : Service() {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Download Progress",
|
"Download Service",
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
).apply {
|
).apply {
|
||||||
description = "Shows download progress for SpotiFLAC"
|
description = "Shows download progress"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
@@ -49,39 +122,94 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
val notification = createNotification("Downloading...", 0)
|
isRunning = true
|
||||||
|
|
||||||
|
// Acquire wake lock to prevent CPU sleep
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
WAKELOCK_TAG
|
||||||
|
).apply {
|
||||||
|
acquire(60 * 60 * 1000L) // 1 hour max
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = buildNotification(0, 0)
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProgress(trackName: String, progress: Int) {
|
private fun stopForegroundService() {
|
||||||
val notification = createNotification(trackName, progress)
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(progress: Long, total: Long) {
|
||||||
|
if (!isRunning) return
|
||||||
|
|
||||||
|
val notification = buildNotification(progress, total)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(title: String, progress: Int): Notification {
|
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
this, 0, intent,
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
val title = if (queueCount > 1) {
|
||||||
action = ACTION_STOP
|
"Downloading $queueCount tracks"
|
||||||
|
} else if (currentTrackName.isNotEmpty()) {
|
||||||
|
currentTrackName
|
||||||
|
} else {
|
||||||
|
"Downloading..."
|
||||||
}
|
}
|
||||||
val stopPendingIntent = PendingIntent.getService(
|
|
||||||
this, 0, stopIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||||
.setContentTitle("SpotiFLAC")
|
currentArtistName
|
||||||
.setContentText(title)
|
} else if (total > 0) {
|
||||||
|
val progressPercent = (progress * 100 / total).toInt()
|
||||||
|
val progressMB = progress / (1024.0 * 1024.0)
|
||||||
|
val totalMB = total / (1024.0 * 1024.0)
|
||||||
|
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
|
||||||
|
} else {
|
||||||
|
"Preparing download..."
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
.setProgress(100, progress, progress == 0)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
.setOngoing(true)
|
||||||
.build()
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
builder.setProgress(100, (progress * 100 / total).toInt(), false)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -12,8 +15,15 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
|
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Update the intent so receive_sharing_intent can access the new data
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
@@ -43,6 +53,15 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"searchSpotifyAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -141,8 +160,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -160,6 +180,37 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"startDownloadService" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopDownloadService" -> {
|
||||||
|
DownloadService.stop(this@MainActivity)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"updateDownloadServiceProgress" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val progress = call.argument<Long>("progress") ?: 0L
|
||||||
|
val total = call.argument<Long>("total") ?: 0L
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isDownloadServiceRunning" -> {
|
||||||
|
result.success(DownloadService.isServiceRunning())
|
||||||
|
}
|
||||||
|
"setSpotifyCredentials" -> {
|
||||||
|
val clientId = call.argument<String>("client_id") ?: ""
|
||||||
|
val clientSecret = call.argument<String>("client_secret") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -167,5 +218,37 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFmpeg method channel
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
when (call.method) {
|
||||||
|
"execute" -> {
|
||||||
|
val command = call.argument<String>("command") ?: ""
|
||||||
|
val session = withContext(Dispatchers.IO) {
|
||||||
|
FFmpegKit.execute(command)
|
||||||
|
}
|
||||||
|
val returnCode = session.returnCode
|
||||||
|
val output = session.output ?: ""
|
||||||
|
result.success(mapOf(
|
||||||
|
"success" to ReturnCode.isSuccess(returnCode),
|
||||||
|
"returnCode" to (returnCode?.value ?: -1),
|
||||||
|
"output" to output
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"getVersion" -> {
|
||||||
|
val session = withContext(Dispatchers.IO) {
|
||||||
|
FFmpegKit.execute("-version")
|
||||||
|
}
|
||||||
|
result.success(session.output ?: "unknown")
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("FFMPEG_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,136 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
|
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||||
|
class FFmpegServiceIOS {
|
||||||
|
/// Execute FFmpeg command and return result
|
||||||
|
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute(command);
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
final output = await session.getOutput() ?? '';
|
||||||
|
return FFmpegResultIOS(
|
||||||
|
success: ReturnCode.isSuccess(returnCode),
|
||||||
|
returnCode: returnCode?.getValue() ?? -1,
|
||||||
|
output: output,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('FFmpeg execute error: $e');
|
||||||
|
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert M4A (DASH segments) to FLAC
|
||||||
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
|
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||||
|
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to MP3
|
||||||
|
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||||
|
|
||||||
|
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to M4A
|
||||||
|
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||||
|
|
||||||
|
String command;
|
||||||
|
if (codec == 'alac') {
|
||||||
|
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed cover art to FLAC file
|
||||||
|
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after cover embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if FFmpeg is available
|
||||||
|
static Future<bool> isAvailable() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
return ReturnCode.isSuccess(returnCode);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get FFmpeg version info
|
||||||
|
static Future<String?> getVersion() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
return await session.getOutput();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FFmpegResultIOS {
|
||||||
|
final bool success;
|
||||||
|
final int returnCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
} `json:"current"`
|
} `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// amazonArtistsMatch checks if the artist names are similar enough
|
||||||
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func amazonIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
return &AmazonDownloader{
|
||||||
@@ -203,12 +260,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -232,11 +284,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -245,14 +294,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
var bytesWritten int64
|
var bytesWritten int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(out, itemID)
|
pw := NewItemProgressWriter(out, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
pw := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
bytesWritten, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
@@ -262,40 +311,56 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonDownloadResult contains download result with quality info
|
||||||
|
type AmazonDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
// Uses DoubleDouble service (same as PC version)
|
// Uses DoubleDouble service (same as PC version)
|
||||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if needed
|
// Create output directory if needed
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download using DoubleDouble service (same as PC)
|
// Download using DoubleDouble service (same as PC)
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
|
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -310,12 +375,19 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
@@ -364,17 +436,6 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
fmt.Println("[Amazon] No lyrics found for this track")
|
fmt.Println("[Amazon] No lyrics found for this track")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||||
|
|
||||||
// Convert Japanese lyrics to romaji if enabled
|
|
||||||
if req.ConvertLyricsToRomaji {
|
|
||||||
for i := range lyrics.Lines {
|
|
||||||
if ContainsKana(lyrics.Lines[i].Words) {
|
|
||||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println("[Amazon] Converted Japanese lyrics to romaji")
|
|
||||||
}
|
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
lrcContent := convertToLRC(lyrics)
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
@@ -385,5 +446,24 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
return outputPath, nil
|
|
||||||
|
// Read actual quality from the downloaded FLAC file
|
||||||
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
|
quality, err := GetAudioQuality(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
|
// Return 0 to indicate unknown quality
|
||||||
|
return AmazonDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: 0,
|
||||||
|
SampleRate: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
return AmazonDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: quality.BitDepth,
|
||||||
|
SampleRate: quality.SampleRate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,12 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
|
SetSpotifyCredentials(clientID, clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// Returns JSON with track/album/playlist data
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
@@ -70,6 +77,26 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchSpotifyAll searches for tracks and artists on Spotify
|
||||||
|
// Returns JSON with tracks and artists arrays
|
||||||
|
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := NewSpotifyMetadataClient()
|
||||||
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckAvailability checks track availability on streaming services
|
// CheckAvailability checks track availability on streaming services
|
||||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
@@ -102,12 +129,12 @@ type DownloadRequest struct {
|
|||||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
@@ -117,6 +144,17 @@ type DownloadResponse struct {
|
|||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
// Actual quality info from the source
|
||||||
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
|
type DownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -135,16 +173,40 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -154,21 +216,44 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -210,35 +295,82 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -347,14 +479,24 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) {
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||||
|
// Try to extract from file first (much faster)
|
||||||
|
if filePath != "" {
|
||||||
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
|
if err == nil && lyrics != "" {
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to fetching from internet
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
lrcContent := convertToLRC(lyricsData)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,12 +516,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
|
||||||
// Kanji characters are preserved as-is
|
|
||||||
func ConvertToRomaji(text string) string {
|
|
||||||
return ToRomaji(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing
|
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||||
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
comment := cmt.Comments[i]
|
||||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
eqIdx := strings.Index(comment, "=")
|
||||||
|
if eqIdx > 0 {
|
||||||
|
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||||
|
if existingKey == keyUpper {
|
||||||
|
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
// Add new
|
||||||
@@ -335,3 +342,92 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||||
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try LYRICS tag first
|
||||||
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to UNSYNCEDLYRICS
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
|
type AudioQuality struct {
|
||||||
|
BitDepth int `json:"bit_depth"`
|
||||||
|
SampleRate int `json:"sample_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
|
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||||
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read FLAC marker (4 bytes: "fLaC")
|
||||||
|
marker := make([]byte, 4)
|
||||||
|
if _, err := file.Read(marker); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
|
}
|
||||||
|
if string(marker) != "fLaC" {
|
||||||
|
return AudioQuality{}, fmt.Errorf("not a FLAC file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read metadata block header (4 bytes)
|
||||||
|
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
|
||||||
|
// Bytes 1-3: block length (24-bit big-endian)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := file.Read(header); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockType := header[0] & 0x7F
|
||||||
|
if blockType != 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read STREAMINFO block (34 bytes minimum)
|
||||||
|
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
|
||||||
|
streamInfo := make([]byte, 34)
|
||||||
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse sample rate (20 bits starting at byte 10)
|
||||||
|
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
|
||||||
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
|
// Parse bits per sample (5 bits)
|
||||||
|
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
|
||||||
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
|
return AudioQuality{
|
||||||
|
BitDepth: bitsPerSample,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress (legacy single download)
|
// DownloadProgress represents current download progress
|
||||||
|
// Now unified - returns data from multi-progress system
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -13,6 +14,7 @@ type DownloadProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgress represents progress for a single download item
|
// ItemProgress represents progress for a single download item
|
||||||
@@ -22,6 +24,7 @@ type ItemProgress struct {
|
|||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiProgress holds progress for multiple concurrent downloads
|
// MultiProgress holds progress for multiple concurrent downloads
|
||||||
@@ -30,21 +33,33 @@ type MultiProgress struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress DownloadProgress
|
downloadDir string
|
||||||
progressMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
downloadDir string
|
|
||||||
downloadDirMu sync.RWMutex
|
|
||||||
|
|
||||||
// Multi-download progress tracking
|
// Multi-download progress tracking (unified system)
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress (legacy)
|
// getProgress returns current download progress from multi-progress system
|
||||||
|
// Returns first active item's progress for backward compatibility
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
progressMu.RLock()
|
multiMu.RLock()
|
||||||
defer progressMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
return currentProgress
|
|
||||||
|
// Find first active item
|
||||||
|
for _, item := range multiProgress.Items {
|
||||||
|
return DownloadProgress{
|
||||||
|
CurrentFile: item.ItemID,
|
||||||
|
Progress: item.Progress * 100, // Convert to percentage
|
||||||
|
BytesTotal: item.BytesTotal,
|
||||||
|
BytesReceived: item.BytesReceived,
|
||||||
|
IsDownloading: item.IsDownloading,
|
||||||
|
Status: item.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMultiProgress returns progress for all active downloads as JSON
|
// GetMultiProgress returns progress for all active downloads as JSON
|
||||||
@@ -82,6 +97,7 @@ func StartItemProgress(itemID string) {
|
|||||||
BytesReceived: 0,
|
BytesReceived: 0,
|
||||||
Progress: 0,
|
Progress: 0,
|
||||||
IsDownloading: true,
|
IsDownloading: true,
|
||||||
|
Status: "downloading",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +132,34 @@ func CompleteItemProgress(itemID string) {
|
|||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
item.IsDownloading = false
|
||||||
|
item.Status = "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemProgress sets progress for an item directly
|
||||||
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = progress
|
||||||
|
if bytesReceived > 0 {
|
||||||
|
item.BytesReceived = bytesReceived
|
||||||
|
}
|
||||||
|
if bytesTotal > 0 {
|
||||||
|
item.BytesTotal = bytesTotal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||||
|
func SetItemFinalizing(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.Status = "finalizing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,41 +179,6 @@ func ClearAllItemProgress() {
|
|||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy functions for backward compatibility
|
|
||||||
|
|
||||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
|
||||||
func SetDownloadProgress(mbDownloaded float64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.Progress = mbDownloaded
|
|
||||||
currentProgress.IsDownloading = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDownloadSpeed sets the current download speed
|
|
||||||
func SetDownloadSpeed(speedMBps float64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.Speed = speedMBps
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
|
||||||
func SetCurrentFile(filename string) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesReceived = 0
|
|
||||||
currentProgress.BytesTotal = 0
|
|
||||||
currentProgress.Progress = 0
|
|
||||||
currentProgress.CurrentFile = filename
|
|
||||||
currentProgress.IsDownloading = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetProgress resets the download progress
|
|
||||||
func ResetProgress() {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress = DownloadProgress{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
// setDownloadDir sets the default download directory
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
downloadDirMu.Lock()
|
downloadDirMu.Lock()
|
||||||
@@ -185,64 +194,6 @@ func getDownloadDir() string {
|
|||||||
return downloadDir
|
return downloadDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloading sets the download status
|
|
||||||
func SetDownloading(status bool) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.IsDownloading = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBytesTotal sets total bytes to download
|
|
||||||
func SetBytesTotal(total int64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesTotal = total
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBytesReceived sets bytes received so far
|
|
||||||
func SetBytesReceived(received int64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesReceived = received
|
|
||||||
if currentProgress.BytesTotal > 0 {
|
|
||||||
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProgressWriter wraps io.Writer to track download progress (legacy single)
|
|
||||||
type ProgressWriter struct {
|
|
||||||
writer interface{ Write([]byte) (int, error) }
|
|
||||||
total int64
|
|
||||||
current int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
|
||||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
|
||||||
SetBytesReceived(0)
|
|
||||||
return &ProgressWriter{
|
|
||||||
writer: w,
|
|
||||||
current: 0,
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write implements io.Writer
|
|
||||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|
||||||
n, err := pw.writer.Write(p)
|
|
||||||
if err != nil {
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
pw.current += int64(n)
|
|
||||||
pw.total += int64(n)
|
|
||||||
SetBytesReceived(pw.current)
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTotal returns total bytes written
|
|
||||||
func (pw *ProgressWriter) GetTotal() int64 {
|
|
||||||
return pw.total
|
|
||||||
}
|
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
@@ -267,7 +218,5 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
SetItemBytesReceived(pw.itemID, pw.current)
|
||||||
// Also update legacy progress for backward compatibility
|
|
||||||
SetBytesReceived(pw.current)
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -39,6 +40,63 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||||
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func qobuzIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// NewQobuzDownloader creates a new Qobuz downloader
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
return &QobuzDownloader{
|
return &QobuzDownloader{
|
||||||
@@ -112,8 +170,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ISRC matches
|
||||||
|
var isrcMatches []*QobuzTrack
|
||||||
|
for i := range result.Tracks.Items {
|
||||||
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
|
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration if provided
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationVerifiedMatches []*QobuzTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't
|
||||||
|
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, return first match
|
||||||
|
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Tracks.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies
|
||||||
@@ -129,6 +275,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allTracks []QobuzTrack
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
||||||
|
|
||||||
@@ -159,19 +307,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
// Return first result with best quality
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
for i := range result.Tracks.Items {
|
}
|
||||||
track := &result.Tracks.Items[i]
|
}
|
||||||
|
|
||||||
|
if len(allTracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If duration verification is requested
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationMatches = append(durationMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationMatches) > 0 {
|
||||||
|
// Return best quality among duration matches
|
||||||
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return first result if no hi-res found
|
return durationMatches[0], nil
|
||||||
return &result.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No duration match found
|
||||||
|
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
// No duration verification, return best quality
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &allTracks[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -262,12 +441,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -289,11 +463,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -302,47 +473,72 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QobuzDownloadResult contains download result with quality info
|
||||||
|
type QobuzDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Qobuz"
|
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -357,7 +553,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map quality from Tidal format to Qobuz format
|
// Map quality from Tidal format to Qobuz format
|
||||||
@@ -374,15 +570,27 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
|
// Get actual quality from track metadata
|
||||||
|
actualBitDepth := track.MaximumBitDepth
|
||||||
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
|
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata
|
||||||
@@ -426,17 +634,6 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
fmt.Println("[Qobuz] No lyrics found for this track")
|
fmt.Println("[Qobuz] No lyrics found for this track")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||||
|
|
||||||
// Convert Japanese lyrics to romaji if enabled
|
|
||||||
if req.ConvertLyricsToRomaji {
|
|
||||||
for i := range lyrics.Lines {
|
|
||||||
if ContainsKana(lyrics.Lines[i].Words) {
|
|
||||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println("[Qobuz] Converted Japanese lyrics to romaji")
|
|
||||||
}
|
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
lrcContent := convertToLRC(lyrics)
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
@@ -446,5 +643,9 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputPath, nil
|
return QobuzDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: actualBitDepth,
|
||||||
|
SampleRate: actualSampleRate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Japanese character ranges
|
|
||||||
const (
|
|
||||||
hiraganaStart = 0x3040
|
|
||||||
hiraganaEnd = 0x309F
|
|
||||||
katakanaStart = 0x30A0
|
|
||||||
katakanaEnd = 0x30FF
|
|
||||||
kanjiStart = 0x4E00
|
|
||||||
kanjiEnd = 0x9FFF
|
|
||||||
)
|
|
||||||
|
|
||||||
// hiraganaToRomaji maps hiragana characters to romaji
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
|
||||||
// Basic vowels
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
|
||||||
// K-row
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
|
||||||
// S-row
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
|
||||||
// T-row
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
|
||||||
// N-row
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
|
||||||
// H-row
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
|
||||||
// M-row
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
|
||||||
// Y-row
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
|
||||||
// R-row
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
|
||||||
// W-row
|
|
||||||
'わ': "wa", 'を': "wo",
|
|
||||||
// N
|
|
||||||
'ん': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
|
||||||
// Z-row
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
|
||||||
// D-row
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
|
||||||
// B-row
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
|
||||||
// P-row (handakuten)
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
|
||||||
// Small characters
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
|
||||||
'っ': "", // Small tsu - handled specially
|
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
|
||||||
|
|
||||||
// katakanaToRomaji maps katakana characters to romaji
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
|
||||||
// Basic vowels
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
|
||||||
// K-row
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
|
||||||
// S-row
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
|
||||||
// T-row
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
|
||||||
// N-row
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
|
||||||
// H-row
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
|
||||||
// M-row
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
|
||||||
// Y-row
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
|
||||||
// R-row
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
|
||||||
// W-row
|
|
||||||
'ワ': "wa", 'ヲ': "wo",
|
|
||||||
// N
|
|
||||||
'ン': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
|
||||||
// Z-row
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
|
||||||
// D-row
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
|
||||||
// B-row
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
|
||||||
// P-row (handakuten)
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
|
||||||
// Small characters
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
|
||||||
'ッ': "", // Small tsu - handled specially
|
|
||||||
// Extended katakana
|
|
||||||
'ヴ': "vu",
|
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extended katakana combinations (multi-character)
|
|
||||||
var katakanaExtended = map[string]string{
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combination mappings for small ya/yu/yo
|
|
||||||
var hiraganaCombo = map[string]string{
|
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
|
||||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
|
||||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
|
||||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
|
||||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
|
||||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
|
||||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
|
||||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
|
||||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
|
||||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
|
||||||
}
|
|
||||||
|
|
||||||
var katakanaCombo = map[string]string{
|
|
||||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
|
||||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
|
||||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
|
||||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
|
||||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
|
||||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
|
||||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
|
||||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
|
||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
|
||||||
// Extended katakana combinations
|
|
||||||
"ティ": "ti", "ディ": "di",
|
|
||||||
"トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
|
||||||
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
|
|
||||||
func ContainsJapanese(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
|
|
||||||
func ContainsKana(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
|
||||||
return r >= hiraganaStart && r <= hiraganaEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
|
||||||
return r >= katakanaStart && r <= katakanaEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
|
||||||
return r >= kanjiStart && r <= kanjiEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
|
||||||
// Kanji characters are preserved as-is since they require dictionary lookup
|
|
||||||
func ToRomaji(s string) string {
|
|
||||||
if !ContainsKana(s) {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
runes := []rune(s)
|
|
||||||
var result strings.Builder
|
|
||||||
result.Grow(len(s) * 2) // Romaji is typically longer
|
|
||||||
|
|
||||||
i := 0
|
|
||||||
for i < len(runes) {
|
|
||||||
r := runes[i]
|
|
||||||
|
|
||||||
// Check for two-character combinations first
|
|
||||||
if i+1 < len(runes) {
|
|
||||||
combo := string(runes[i : i+2])
|
|
||||||
if romaji, ok := hiraganaCombo[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if romaji, ok := katakanaCombo[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle small tsu (っ/ッ) - doubles the next consonant
|
|
||||||
if r == 'っ' || r == 'ッ' {
|
|
||||||
if i+1 < len(runes) {
|
|
||||||
nextRune := runes[i+1]
|
|
||||||
var nextRomaji string
|
|
||||||
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0]) // Double the consonant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle long vowel mark (ー)
|
|
||||||
if r == 'ー' {
|
|
||||||
// Extend the previous vowel
|
|
||||||
resultStr := result.String()
|
|
||||||
if len(resultStr) > 0 {
|
|
||||||
lastChar := resultStr[len(resultStr)-1]
|
|
||||||
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
|
|
||||||
result.WriteByte(lastChar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single character conversion
|
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep non-Japanese characters as-is
|
|
||||||
if unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(' ')
|
|
||||||
} else {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRomajiVariants returns search variants for Japanese text
|
|
||||||
// Returns the original string plus romaji version if applicable
|
|
||||||
func GetRomajiVariants(s string) []string {
|
|
||||||
variants := []string{s}
|
|
||||||
|
|
||||||
if ContainsKana(s) {
|
|
||||||
romaji := ToRomaji(s)
|
|
||||||
if romaji != s && strings.TrimSpace(romaji) != "" {
|
|
||||||
variants = append(variants, romaji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return variants
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,10 +24,25 @@ const (
|
|||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
|
// Cache TTL settings
|
||||||
|
artistCacheTTL = 10 * time.Minute
|
||||||
|
searchCacheTTL = 5 * time.Minute
|
||||||
|
albumCacheTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
|
|
||||||
|
// cacheEntry holds cached data with expiration
|
||||||
|
type cacheEntry struct {
|
||||||
|
data interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) isExpired() bool {
|
||||||
|
return time.Now().After(e.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
// SpotifyMetadataClient handles Spotify API interactions
|
// SpotifyMetadataClient handles Spotify API interactions
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -34,31 +50,76 @@ type SpotifyMetadataClient struct {
|
|||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
|
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
|
// Caches to reduce API calls
|
||||||
|
artistCache map[string]*cacheEntry // key: artistID
|
||||||
|
searchCache map[string]*cacheEntry // key: query+type
|
||||||
|
albumCache map[string]*cacheEntry // key: albumID
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom credentials storage (set from Flutter)
|
||||||
|
var (
|
||||||
|
customClientID string
|
||||||
|
customClientSecret string
|
||||||
|
credentialsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
|
credentialsMu.Lock()
|
||||||
|
defer credentialsMu.Unlock()
|
||||||
|
customClientID = clientID
|
||||||
|
customClientSecret = clientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCredentials returns the current credentials (custom or default)
|
||||||
|
func getCredentials() (string, string) {
|
||||||
|
credentialsMu.RLock()
|
||||||
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
if customClientID != "" && customClientSecret != "" {
|
||||||
|
return customClientID, customClientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default credentials
|
||||||
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
if clientID == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||||
|
clientID = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
if clientSecret == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||||
|
clientSecret = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientID, clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
// Decode credentials from base64
|
// Get credentials (custom or default)
|
||||||
clientID := ""
|
clientID, clientSecret := getCredentials()
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret := ""
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
|
||||||
clientSecret = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c
|
||||||
@@ -170,6 +231,21 @@ type SearchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchArtistResult represents an artist in search results
|
||||||
|
type SearchArtistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAllResult represents combined search results for tracks and artists
|
||||||
|
type SearchAllResult struct {
|
||||||
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
Type string
|
Type string
|
||||||
ID string
|
ID string
|
||||||
@@ -293,6 +369,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchAll searches for tracks and artists on Spotify
|
||||||
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
// Create cache key
|
||||||
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
token, err := c.getAccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []trackFull `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Artists struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
||||||
|
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range response.Tracks.Items {
|
||||||
|
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||||
|
SpotifyID: track.ID,
|
||||||
|
Artists: joinArtists(track.Artists),
|
||||||
|
Name: track.Name,
|
||||||
|
AlbumName: track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(track.Album.Artists),
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
Images: firstImageURL(track.Album.Images),
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: track.Album.TotalTracks,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit artists to artistLimit
|
||||||
|
artistCount := len(response.Artists.Items)
|
||||||
|
if artistCount > artistLimit {
|
||||||
|
artistCount = artistLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < artistCount; i++ {
|
||||||
|
artist := response.Artists.Items[i]
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: artist.ID,
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: firstImageURL(artist.Images),
|
||||||
|
Followers: artist.Followers.Total,
|
||||||
|
Popularity: artist.Popularity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(searchCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||||
var data trackFull
|
var data trackFull
|
||||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||||
@@ -319,6 +487,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -374,13 +550,24 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
result := &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(albumCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -391,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
|
Next string `json:"next"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
info.Owner.Name = data.Name
|
info.Owner.Name = data.Name
|
||||||
info.Owner.Images = firstImageURL(data.Images)
|
info.Owner.Images = firstImageURL(data.Images)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Pre-allocate with expected capacity
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||||
|
|
||||||
|
// Add first batch of tracks
|
||||||
for _, item := range data.Tracks.Items {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
@@ -429,6 +620,55 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
||||||
|
nextURL := data.Tracks.Next
|
||||||
|
maxTracks := 1000
|
||||||
|
|
||||||
|
for nextURL != "" && len(tracks) < maxTracks {
|
||||||
|
var pageData struct {
|
||||||
|
Items []struct {
|
||||||
|
Track *trackFull `json:"track"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
// Log error but return what we have so far
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range pageData.Items {
|
||||||
|
if item.Track == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(tracks) >= maxTracks {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: item.Track.ID,
|
||||||
|
Artists: joinArtists(item.Track.Artists),
|
||||||
|
Name: item.Track.Name,
|
||||||
|
AlbumName: item.Track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||||
|
DurationMS: item.Track.DurationMS,
|
||||||
|
Images: firstImageURL(item.Track.Album.Images),
|
||||||
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||||
|
TrackNumber: item.Track.TrackNumber,
|
||||||
|
TotalTracks: item.Track.Album.TotalTracks,
|
||||||
|
DiscNumber: item.Track.DiscNumber,
|
||||||
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
|
ISRC: item.Track.ExternalID.ISRC,
|
||||||
|
AlbumID: item.Track.Album.ID,
|
||||||
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
|
||||||
|
|
||||||
return &PlaylistResponsePayload{
|
return &PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
@@ -436,6 +676,14 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch artist info
|
// Fetch artist info
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -511,10 +759,20 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ArtistResponsePayload{
|
result := &ArtistResponsePayload{
|
||||||
ArtistInfo: artistInfo,
|
ArtistInfo: artistInfo,
|
||||||
Albums: albums,
|
Albums: albums,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(artistCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||||
|
|||||||
@@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
||||||
|
func normalizeTitle(title string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
|
||||||
|
// Remove common suffixes in parentheses or brackets
|
||||||
|
suffixPatterns := []string{
|
||||||
|
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
|
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||||
|
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||||
|
}
|
||||||
|
for _, suffix := range suffixPatterns {
|
||||||
|
normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(normalized, " ") {
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -335,33 +357,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected
|
// Strategy 3: Artist only as last resort
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
|
||||||
// Try romaji version of track name
|
|
||||||
if ContainsKana(trackName) {
|
|
||||||
romajiTrack := ToRomaji(trackName)
|
|
||||||
if romajiTrack != trackName {
|
|
||||||
if artistName != "" {
|
|
||||||
queries = append(queries, artistName+" "+romajiTrack)
|
|
||||||
}
|
|
||||||
queries = append(queries, romajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try romaji version of artist name
|
|
||||||
if ContainsKana(artistName) {
|
|
||||||
romajiArtist := ToRomaji(artistName)
|
|
||||||
if romajiArtist != artistName {
|
|
||||||
queries = append(queries, romajiArtist+" "+trackName)
|
|
||||||
// Try both romaji
|
|
||||||
if ContainsKana(trackName) {
|
|
||||||
romajiTrack := ToRomaji(trackName)
|
|
||||||
queries = append(queries, romajiArtist+" "+romajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: Artist only as last resort
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
queries = append(queries, artistName)
|
queries = append(queries, artistName)
|
||||||
}
|
}
|
||||||
@@ -416,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
return nil, fmt.Errorf("no tracks found for any search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match)
|
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
|
var isrcMatches []*TidalTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
if track.ISRC == spotifyISRC {
|
if track.ISRC == spotifyISRC {
|
||||||
return track, nil
|
isrcMatches = append(isrcMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration first (most important check)
|
||||||
|
if expectedDuration > 0 {
|
||||||
|
var durationVerifiedMatches []*TidalTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDuration
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance for duration
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
// Return first duration-verified match
|
||||||
|
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't - this is likely wrong version
|
||||||
|
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, just return first ISRC match
|
||||||
|
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
// If ISRC was provided but no match found, return error
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
@@ -483,11 +515,18 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TidalDownloadInfo contains download URL and quality info
|
||||||
|
type TidalDownloadInfo struct {
|
||||||
|
URL string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
// getDownloadURLSequential requests download URL from APIs sequentially
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
@@ -519,7 +558,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
// Try v2 format first (object with manifest)
|
// Try v2 format first (object with manifest)
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
info := TidalDownloadInfo{
|
||||||
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
|
SampleRate: v2Response.Data.SampleRate,
|
||||||
|
}
|
||||||
|
return apiURL, info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||||
@@ -529,7 +573,13 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
for _, item := range v1Responses {
|
for _, item := range v1Responses {
|
||||||
if item.OriginalTrackURL != "" {
|
if item.OriginalTrackURL != "" {
|
||||||
return apiURL, item.OriginalTrackURL, nil
|
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: item.OriginalTrackURL,
|
||||||
|
BitDepth: 16,
|
||||||
|
SampleRate: 44100,
|
||||||
|
}
|
||||||
|
return apiURL, info, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,22 +587,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
||||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||||
apis := t.GetAvailableAPIs()
|
apis := t.GetAvailableAPIs()
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", fmt.Errorf("no API URL configured")
|
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, downloadURL, err := getDownloadURLSequential(apis, trackID, quality)
|
_, info, err := getDownloadURLSequential(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadURL, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
||||||
@@ -646,12 +696,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -673,11 +718,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -686,13 +728,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -709,12 +751,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -736,11 +773,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -749,13 +783,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -828,15 +862,83 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TidalDownloadResult contains download result with quality info
|
||||||
|
type TidalDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistsMatch checks if the artist names are similar enough
|
||||||
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
|
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||||
|
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normSpotify == normTidal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||||
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||||
|
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||||
|
|
||||||
|
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||||
|
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||||
|
|
||||||
|
if spotifyFirst == tidalFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||||
|
spotifyASCII := isASCIIString(spotifyArtist)
|
||||||
|
tidalASCII := isASCIIString(tidalArtist)
|
||||||
|
if spotifyASCII != tidalASCII {
|
||||||
|
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
|
func isASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromTidal downloads a track using the request parameters
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (string, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -848,28 +950,103 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
|
if track != nil {
|
||||||
|
// Get artist name from track
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify duration if we have expected duration
|
||||||
|
if track != nil && expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff > 30 {
|
||||||
|
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
|
expectedDurationSec, track.Duration)
|
||||||
|
track = nil // Reject this match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by ISRC with multi-strategy fallback
|
// Strategy 2: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist for ISRC match too
|
||||||
|
if track != nil {
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
|
// Verify artist for metadata search too
|
||||||
|
if track != nil {
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Tidal"
|
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final verification logging
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -884,7 +1061,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine quality to use (default to LOSSLESS if not specified)
|
// Determine quality to use (default to LOSSLESS if not specified)
|
||||||
@@ -895,14 +1072,24 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log actual quality received
|
||||||
|
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
||||||
@@ -915,7 +1102,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
// Neither FLAC nor M4A exists
|
// Neither FLAC nor M4A exists
|
||||||
return "", fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata
|
||||||
@@ -961,17 +1148,6 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Println("[Tidal] No lyrics found for this track")
|
fmt.Println("[Tidal] No lyrics found for this track")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||||
|
|
||||||
// Convert Japanese lyrics to romaji if enabled
|
|
||||||
if req.ConvertLyricsToRomaji {
|
|
||||||
for i := range lyrics.Lines {
|
|
||||||
if ContainsKana(lyrics.Lines[i].Words) {
|
|
||||||
lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println("[Tidal] Converted Japanese lyrics to romaji")
|
|
||||||
}
|
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
lrcContent := convertToLRC(lyrics)
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
||||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
@@ -984,5 +1160,9 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return actualOutputPath, nil
|
return TidalDownloadResult{
|
||||||
|
FilePath: actualOutputPath,
|
||||||
|
BitDepth: downloadInfo.BitDepth,
|
||||||
|
SampleRate: downloadInfo.SampleRate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "searchSpotifyAll":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
|
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -155,7 +164,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error)
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '1.5.5';
|
static const String version = '2.0.7-preview';
|
||||||
static const String buildNumber = '22';
|
static const String buildNumber = '37';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC';
|
static const String appName = 'SpotiFLAC';
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
|
|||||||
enum DownloadStatus {
|
enum DownloadStatus {
|
||||||
queued,
|
queued,
|
||||||
downloading,
|
downloading,
|
||||||
|
finalizing, // Embedding metadata, cover, lyrics
|
||||||
completed,
|
completed,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
@@ -22,6 +23,7 @@ class DownloadItem {
|
|||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -32,6 +34,7 @@ class DownloadItem {
|
|||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -43,6 +46,7 @@ class DownloadItem {
|
|||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -53,6 +57,7 @@ class DownloadItem {
|
|||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
@@ -29,11 +30,13 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
DownloadStatus.queued: 'queued',
|
DownloadStatus.queued: 'queued',
|
||||||
DownloadStatus.downloading: 'downloading',
|
DownloadStatus.downloading: 'downloading',
|
||||||
|
DownloadStatus.finalizing: 'finalizing',
|
||||||
DownloadStatus.completed: 'completed',
|
DownloadStatus.completed: 'completed',
|
||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ class AppSettings {
|
|||||||
final bool checkForUpdates; // Check for updates on app start
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji
|
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -32,8 +35,11 @@ class AppSettings {
|
|||||||
this.checkForUpdates = true, // Default: enabled
|
this.checkForUpdates = true, // Default: enabled
|
||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.convertLyricsToRomaji = false, // Default: keep original Japanese
|
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -49,8 +55,11 @@ class AppSettings {
|
|||||||
bool? checkForUpdates,
|
bool? checkForUpdates,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
bool? convertLyricsToRomaji,
|
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
|
bool? askQualityBeforeDownload,
|
||||||
|
String? spotifyClientId,
|
||||||
|
String? spotifyClientSecret,
|
||||||
|
bool? useCustomSpotifyCredentials,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -65,8 +74,11 @@ class AppSettings {
|
|||||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji,
|
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'list',
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
|
useCustomSpotifyCredentials:
|
||||||
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -37,6 +41,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'checkForUpdates': instance.checkForUpdates,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
|
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import 'dart:io';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -13,6 +11,10 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('DownloadQueue');
|
||||||
|
final _historyLog = AppLogger('DownloadHistory');
|
||||||
|
|
||||||
// Download History Item model
|
// Download History Item model
|
||||||
class DownloadHistoryItem {
|
class DownloadHistoryItem {
|
||||||
@@ -95,8 +97,16 @@ class DownloadHistoryItem {
|
|||||||
// Download History State
|
// Download History State
|
||||||
class DownloadHistoryState {
|
class DownloadHistoryState {
|
||||||
final List<DownloadHistoryItem> items;
|
final List<DownloadHistoryItem> items;
|
||||||
|
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||||
|
|
||||||
const DownloadHistoryState({this.items = const []});
|
DownloadHistoryState({this.items = const []})
|
||||||
|
: _downloadedSpotifyIds = items
|
||||||
|
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
|
.map((item) => item.spotifyId!)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
/// Check if a track has been downloaded (by Spotify ID)
|
||||||
|
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
|
||||||
|
|
||||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||||
return DownloadHistoryState(items: items ?? this.items);
|
return DownloadHistoryState(items: items ?? this.items);
|
||||||
@@ -112,7 +122,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
DownloadHistoryState build() {
|
DownloadHistoryState build() {
|
||||||
// Load history from storage on init
|
// Load history from storage on init
|
||||||
_loadFromStorageSync();
|
_loadFromStorageSync();
|
||||||
return const DownloadHistoryState();
|
return DownloadHistoryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||||
@@ -132,12 +142,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
print('[DownloadHistory] Loaded ${items.length} items from storage');
|
_historyLog.i('Loaded ${items.length} items from storage');
|
||||||
} else {
|
} else {
|
||||||
print('[DownloadHistory] No history found in storage');
|
_historyLog.d('No history found in storage');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadHistory] Failed to load history: $e');
|
_historyLog.e('Failed to load history: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,9 +156,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonList = state.items.map((e) => e.toJson()).toList();
|
final jsonList = state.items.map((e) => e.toJson()).toList();
|
||||||
await prefs.setString(_storageKey, jsonEncode(jsonList));
|
await prefs.setString(_storageKey, jsonEncode(jsonList));
|
||||||
print('[DownloadHistory] Saved ${state.items.length} items to storage');
|
_historyLog.d('Saved ${state.items.length} items to storage');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadHistory] Failed to save history: $e');
|
_historyLog.e('Failed to save history: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +179,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
_saveToStorage();
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove item from history by Spotify ID
|
||||||
|
void removeBySpotifyId(String spotifyId) {
|
||||||
|
state = state.copyWith(
|
||||||
|
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
|
||||||
|
);
|
||||||
|
_saveToStorage();
|
||||||
|
_historyLog.d('Removed item with spotifyId: $spotifyId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get history item by Spotify ID
|
||||||
|
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
|
||||||
|
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
state = const DownloadHistoryState();
|
state = DownloadHistoryState();
|
||||||
_saveToStorage();
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,54 +262,99 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
int _downloadCount = 0; // Counter for connection cleanup
|
int _downloadCount = 0; // Counter for connection cleanup
|
||||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||||
|
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||||
|
int _completedInSession = 0; // Track completed downloads in current session
|
||||||
|
int _failedInSession = 0; // Track failed downloads in current session
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadQueueState build() {
|
DownloadQueueState build() {
|
||||||
// Initialize output directory asynchronously
|
// Cleanup timer when provider is disposed
|
||||||
|
ref.onDispose(() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_progressTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize output directory and load persisted queue asynchronously
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
|
await _loadQueueFromStorage();
|
||||||
});
|
});
|
||||||
return const DownloadQueueState();
|
return const DownloadQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startProgressPolling(String itemId) {
|
/// Load persisted queue from storage (for app restart recovery)
|
||||||
_progressTimer?.cancel();
|
Future<void> _loadQueueFromStorage() async {
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
if (_isLoaded) return;
|
||||||
try {
|
_isLoaded = true;
|
||||||
final progress = await PlatformBridge.getDownloadProgress();
|
|
||||||
final bytesReceived = progress['bytes_received'] as int? ?? 0;
|
|
||||||
final bytesTotal = progress['bytes_total'] as int? ?? 0;
|
|
||||||
final isDownloading = progress['is_downloading'] as bool? ?? false;
|
|
||||||
|
|
||||||
if (isDownloading && bytesTotal > 0) {
|
try {
|
||||||
final percentage = bytesReceived / bytesTotal;
|
final prefs = await SharedPreferences.getInstance();
|
||||||
updateProgress(itemId, percentage);
|
final jsonStr = prefs.getString(_queueStorageKey);
|
||||||
|
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||||
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
|
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
// Update notification with progress
|
// Reset downloading items to queued (they were interrupted)
|
||||||
final currentItem = state.currentDownload;
|
final restoredItems = items.map((item) {
|
||||||
if (currentItem != null) {
|
if (item.status == DownloadStatus.downloading) {
|
||||||
_notificationService.showDownloadProgress(
|
return item.copyWith(status: DownloadStatus.queued, progress: 0);
|
||||||
trackName: currentItem.track.name,
|
|
||||||
artistName: currentItem.track.artistName,
|
|
||||||
progress: bytesReceived,
|
|
||||||
total: bytesTotal,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return item;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
// Log progress
|
// Only restore queued/downloading items (not completed/failed/skipped)
|
||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final pendingItems = restoredItems.where((item) =>
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
item.status == DownloadStatus.queued
|
||||||
print('[DownloadQueue] Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
).toList();
|
||||||
|
|
||||||
|
if (pendingItems.isNotEmpty) {
|
||||||
|
state = state.copyWith(items: pendingItems);
|
||||||
|
_log.i('Restored ${pendingItems.length} pending items from storage');
|
||||||
|
|
||||||
|
// Auto-resume queue processing
|
||||||
|
Future.microtask(() => _processQueue());
|
||||||
|
} else {
|
||||||
|
_log.d('No pending items to restore');
|
||||||
|
// Clear storage since nothing to restore
|
||||||
|
await prefs.remove(_queueStorageKey);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else {
|
||||||
// Ignore polling errors
|
_log.d('No queue found in storage');
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
_log.e('Failed to load queue from storage: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start multi-progress polling for concurrent downloads
|
/// Save current queue to storage (only pending items)
|
||||||
|
Future<void> _saveQueueToStorage() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Only persist queued and downloading items
|
||||||
|
final pendingItems = state.items.where((item) =>
|
||||||
|
item.status == DownloadStatus.queued ||
|
||||||
|
item.status == DownloadStatus.downloading
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
if (pendingItems.isEmpty) {
|
||||||
|
// Clear storage if no pending items
|
||||||
|
await prefs.remove(_queueStorageKey);
|
||||||
|
_log.d('Cleared queue storage (no pending items)');
|
||||||
|
} else {
|
||||||
|
final jsonList = pendingItems.map((e) => e.toJson()).toList();
|
||||||
|
await prefs.setString(_queueStorageKey, jsonEncode(jsonList));
|
||||||
|
_log.d('Saved ${pendingItems.length} pending items to storage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to save queue to storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||||
void _startMultiProgressPolling() {
|
void _startMultiProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||||
@@ -293,12 +362,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
bool hasFinalizingItem = false;
|
||||||
|
String? finalizingTrackName;
|
||||||
|
String? finalizingArtistName;
|
||||||
|
|
||||||
for (final entry in items.entries) {
|
for (final entry in items.entries) {
|
||||||
final itemId = entry.key;
|
final itemId = entry.key;
|
||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||||
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
|
// Check if status is "finalizing" (embedding metadata)
|
||||||
|
// Only trust finalizing status if bytesTotal > 0 (download actually happened)
|
||||||
|
if (status == 'finalizing' && bytesTotal > 0) {
|
||||||
|
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||||
|
|
||||||
|
// Track finalizing item for notification
|
||||||
|
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||||
|
if (currentItem != null) {
|
||||||
|
hasFinalizingItem = true;
|
||||||
|
finalizingTrackName = currentItem.track.name;
|
||||||
|
finalizingArtistName = currentItem.track.artistName;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isDownloading && bytesTotal > 0) {
|
if (isDownloading && bytesTotal > 0) {
|
||||||
final percentage = bytesReceived / bytesTotal;
|
final percentage = bytesReceived / bytesTotal;
|
||||||
@@ -307,26 +396,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Log progress for each item
|
// Log progress for each item
|
||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
print('[DownloadQueue] Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update notification with first active download
|
// Show finalizing notification if any item is finalizing (takes priority)
|
||||||
|
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||||
|
_notificationService.showDownloadFinalizing(
|
||||||
|
trackName: finalizingTrackName,
|
||||||
|
artistName: finalizingArtistName ?? '',
|
||||||
|
);
|
||||||
|
return; // Don't show download progress notification
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update notification with active downloads
|
||||||
if (items.isNotEmpty) {
|
if (items.isNotEmpty) {
|
||||||
final firstEntry = items.entries.first;
|
final firstEntry = items.entries.first;
|
||||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find the item to get track info
|
// Find downloading items (not finalizing)
|
||||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||||
if (downloadingItems.isNotEmpty) {
|
if (downloadingItems.isNotEmpty) {
|
||||||
|
// Show single track name if only 1 download, otherwise show count
|
||||||
|
final trackName = downloadingItems.length == 1
|
||||||
|
? downloadingItems.first.track.name
|
||||||
|
: '${downloadingItems.length} downloads';
|
||||||
|
final artistName = downloadingItems.length == 1
|
||||||
|
? downloadingItems.first.track.artistName
|
||||||
|
: 'Downloading...';
|
||||||
|
|
||||||
_notificationService.showDownloadProgress(
|
_notificationService.showDownloadProgress(
|
||||||
trackName: '${downloadingItems.length} downloads',
|
trackName: trackName,
|
||||||
artistName: 'Downloading...',
|
artistName: artistName,
|
||||||
progress: bytesReceived,
|
progress: bytesReceived,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update foreground service notification (Android)
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
PlatformBridge.updateDownloadServiceProgress(
|
||||||
|
trackName: downloadingItems.first.track.name,
|
||||||
|
artistName: downloadingItems.first.track.artistName,
|
||||||
|
progress: bytesReceived,
|
||||||
|
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||||
|
queueCount: state.queuedCount,
|
||||||
|
).catchError((_) {}); // Ignore errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -424,7 +541,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final dir = Directory(fullPath);
|
final dir = Directory(fullPath);
|
||||||
if (!await dir.exists()) {
|
if (!await dir.exists()) {
|
||||||
await dir.create(recursive: true);
|
await dir.create(recursive: true);
|
||||||
print('[DownloadQueue] Created folder: $fullPath');
|
_log.d('Created folder: $fullPath');
|
||||||
}
|
}
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
@@ -442,7 +559,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String addToQueue(Track track, String service) {
|
String addToQueue(Track track, String service, {String? qualityOverride}) {
|
||||||
// Sync settings before adding to queue
|
// Sync settings before adding to queue
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
@@ -453,9 +570,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
track: track,
|
track: track,
|
||||||
service: service,
|
service: service,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
qualityOverride: qualityOverride,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, item]);
|
state = state.copyWith(items: [...state.items, item]);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
// Run in microtask to not block UI
|
// Run in microtask to not block UI
|
||||||
@@ -465,7 +584,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addMultipleToQueue(List<Track> tracks, String service) {
|
void addMultipleToQueue(List<Track> tracks, String service, {String? qualityOverride}) {
|
||||||
// Sync settings before adding to queue
|
// Sync settings before adding to queue
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
@@ -477,10 +596,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
track: track,
|
track: track,
|
||||||
service: service,
|
service: service,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
qualityOverride: qualityOverride,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, ...newItems]);
|
state = state.copyWith(items: [...state.items, ...newItems]);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
// Run in microtask to not block UI
|
// Run in microtask to not block UI
|
||||||
@@ -502,6 +623,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
|
||||||
|
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
|
||||||
|
if (status == DownloadStatus.completed ||
|
||||||
|
status == DownloadStatus.failed ||
|
||||||
|
status == DownloadStatus.skipped) {
|
||||||
|
_saveQueueToStorage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress) {
|
void updateProgress(String id, double progress) {
|
||||||
@@ -520,10 +648,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
state = state.copyWith(items: [], isPaused: false);
|
state = state.copyWith(items: [], isPaused: false);
|
||||||
|
_saveQueueToStorage(); // Clear persisted queue
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause the download queue
|
/// Pause the download queue
|
||||||
@@ -531,7 +661,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (state.isProcessing && !state.isPaused) {
|
if (state.isProcessing && !state.isPaused) {
|
||||||
state = state.copyWith(isPaused: true);
|
state = state.copyWith(isPaused: true);
|
||||||
_notificationService.cancelDownloadNotification();
|
_notificationService.cancelDownloadNotification();
|
||||||
print('[DownloadQueue] Queue paused');
|
_log.i('Queue paused');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +669,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
void resumeQueue() {
|
void resumeQueue() {
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
state = state.copyWith(isPaused: false);
|
state = state.copyWith(isPaused: false);
|
||||||
print('[DownloadQueue] Queue resumed');
|
_log.i('Queue resumed');
|
||||||
// If there are still queued items, continue processing
|
// If there are still queued items, continue processing
|
||||||
if (state.queuedCount > 0 && !state.isProcessing) {
|
if (state.queuedCount > 0 && !state.isProcessing) {
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
@@ -565,6 +695,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return item;
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
// Start processing if not already
|
// Start processing if not already
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
@@ -576,6 +707,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
void removeItem(String id) {
|
void removeItem(String id) {
|
||||||
final items = state.items.where((item) => item.id != id).toList();
|
final items = state.items.where((item) => item.id != id).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
_saveQueueToStorage(); // Persist queue
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||||
@@ -594,14 +726,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
await response.pipe(sink);
|
await response.pipe(sink);
|
||||||
await sink.close();
|
await sink.close();
|
||||||
print('[DownloadQueue] Cover downloaded to: $coverPath');
|
_log.d('Cover downloaded to: $coverPath');
|
||||||
} else {
|
} else {
|
||||||
print('[DownloadQueue] Failed to download cover: HTTP ${response.statusCode}');
|
_log.w('Failed to download cover: HTTP ${response.statusCode}');
|
||||||
coverPath = null;
|
coverPath = null;
|
||||||
}
|
}
|
||||||
httpClient.close();
|
httpClient.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadQueue] Failed to download cover: $e');
|
_log.e('Failed to download cover: $e');
|
||||||
coverPath = null;
|
coverPath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,25 +743,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
|
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
|
||||||
// FFmpeg can embed cover art to FLAC
|
// FFmpeg can embed cover art to FLAC
|
||||||
if (coverPath != null && await File(coverPath).exists()) {
|
if (coverPath != null && await File(coverPath).exists()) {
|
||||||
final tempOutput = '$flacPath.tmp';
|
final result = await FFmpegService.embedCover(flacPath, coverPath);
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
if (result != null) {
|
||||||
final returnCode = await session.getReturnCode();
|
_log.d('Cover embedded via FFmpeg');
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
|
||||||
// Replace original with temp
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
print('[DownloadQueue] Cover embedded via FFmpeg');
|
|
||||||
} else {
|
} else {
|
||||||
// Try alternative method using metaflac-style embedding
|
_log.w('FFmpeg cover embed failed');
|
||||||
print('[DownloadQueue] FFmpeg cover embed failed, trying alternative...');
|
|
||||||
// Clean up temp file if exists
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cover file
|
// Clean up cover file
|
||||||
@@ -638,7 +757,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadQueue] Failed to embed metadata: $e');
|
_log.e('Failed to embed metadata: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,20 +765,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (state.isProcessing) return; // Prevent multiple concurrent processing
|
if (state.isProcessing) return; // Prevent multiple concurrent processing
|
||||||
|
|
||||||
state = state.copyWith(isProcessing: true);
|
state = state.copyWith(isProcessing: true);
|
||||||
print('[DownloadQueue] Starting queue processing...');
|
_log.i('Starting queue processing...');
|
||||||
|
|
||||||
// Track total items at start for notification
|
// Track total items at start for notification
|
||||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
||||||
|
_completedInSession = 0;
|
||||||
|
_failedInSession = 0;
|
||||||
|
|
||||||
|
// Start foreground service to keep downloads running in background (Android only)
|
||||||
|
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||||
|
final firstItem = state.items.firstWhere(
|
||||||
|
(item) => item.status == DownloadStatus.queued,
|
||||||
|
orElse: () => state.items.first,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await PlatformBridge.startDownloadService(
|
||||||
|
trackName: firstItem.track.name,
|
||||||
|
artistName: firstItem.track.artistName,
|
||||||
|
queueCount: _totalQueuedAtStart,
|
||||||
|
);
|
||||||
|
_log.d('Foreground service started');
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to start foreground service: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure output directory is initialized before processing
|
// Ensure output directory is initialized before processing
|
||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
print('[DownloadQueue] Output dir empty, initializing...');
|
_log.d('Output dir empty, initializing...');
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still empty, use fallback
|
// If still empty, use fallback
|
||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
print('[DownloadQueue] Using fallback directory...');
|
_log.d('Using fallback directory...');
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
@@ -668,8 +807,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(outputDir: musicDir.path);
|
state = state.copyWith(outputDir: musicDir.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[DownloadQueue] Output directory: ${state.outputDir}');
|
_log.d('Output directory: ${state.outputDir}');
|
||||||
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
|
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||||
|
|
||||||
// Use parallel processing if concurrentDownloads > 1
|
// Use parallel processing if concurrentDownloads > 1
|
||||||
if (state.concurrentDownloads > 1) {
|
if (state.concurrentDownloads > 1) {
|
||||||
@@ -680,37 +819,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
|
|
||||||
|
// Stop foreground service (Android only)
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
try {
|
||||||
|
await PlatformBridge.stopDownloadService();
|
||||||
|
_log.d('Foreground service stopped');
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to stop foreground service: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Final cleanup after queue finishes
|
// Final cleanup after queue finishes
|
||||||
if (_downloadCount > 0) {
|
if (_downloadCount > 0) {
|
||||||
print('[DownloadQueue] Final connection cleanup...');
|
_log.d('Final connection cleanup...');
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupConnections();
|
await PlatformBridge.cleanupConnections();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadQueue] Final cleanup failed: $e');
|
_log.e('Final cleanup failed: $e');
|
||||||
}
|
}
|
||||||
_downloadCount = 0;
|
_downloadCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show queue completion notification
|
// Show queue completion notification
|
||||||
final completedCount = state.completedCount;
|
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
||||||
final failedCount = state.failedCount;
|
|
||||||
if (_totalQueuedAtStart > 0) {
|
if (_totalQueuedAtStart > 0) {
|
||||||
await _notificationService.showQueueComplete(
|
await _notificationService.showQueueComplete(
|
||||||
completedCount: completedCount,
|
completedCount: _completedInSession,
|
||||||
failedCount: failedCount,
|
failedCount: _failedInSession,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[DownloadQueue] Queue processing finished');
|
_log.i('Queue processing finished');
|
||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sequential download processing (original behavior)
|
/// Sequential download processing (uses multi-progress system with single item)
|
||||||
Future<void> _processQueueSequential() async {
|
Future<void> _processQueueSequential() async {
|
||||||
|
// Start multi-progress polling (works for both sequential and parallel)
|
||||||
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if paused
|
// Check if paused
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
print('[DownloadQueue] Queue is paused, waiting...');
|
_log.d('Queue is paused, waiting...');
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -726,12 +877,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (nextItem.id.isEmpty) {
|
if (nextItem.id.isEmpty) {
|
||||||
print('[DownloadQueue] No more items to process');
|
_log.d('No more items to process');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _downloadSingleItem(nextItem);
|
await _downloadSingleItem(nextItem);
|
||||||
|
|
||||||
|
// Clear item progress after download completes
|
||||||
|
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop polling when queue is done
|
||||||
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parallel download processing with worker pool
|
/// Parallel download processing with worker pool
|
||||||
@@ -739,13 +896,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final maxConcurrent = state.concurrentDownloads;
|
final maxConcurrent = state.concurrentDownloads;
|
||||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||||
|
|
||||||
// Start multi-progress polling for concurrent downloads
|
// Start multi-progress polling (shared with sequential mode)
|
||||||
_startMultiProgressPolling();
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if paused - don't start new downloads but let active ones finish
|
// Check if paused - don't start new downloads but let active ones finish
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
print('[DownloadQueue] Queue is paused, waiting for active downloads...');
|
_log.d('Queue is paused, waiting for active downloads...');
|
||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
await Future.any(activeDownloads.values);
|
await Future.any(activeDownloads.values);
|
||||||
} else {
|
} else {
|
||||||
@@ -758,7 +915,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
||||||
|
|
||||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||||
print('[DownloadQueue] No more items to process');
|
_log.d('No more items to process');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,7 +934,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeDownloads[item.id] = future;
|
activeDownloads[item.id] = future;
|
||||||
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
_log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for at least one download to complete before checking for more
|
// Wait for at least one download to complete before checking for more
|
||||||
@@ -790,18 +947,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
await Future.wait(activeDownloads.values);
|
await Future.wait(activeDownloads.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop polling when queue is done
|
||||||
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a single item (used by both sequential and parallel processing)
|
/// Download a single item (used by both sequential and parallel processing)
|
||||||
Future<void> _downloadSingleItem(DownloadItem item) async {
|
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||||
print('[DownloadQueue] Processing: ${item.track.name} by ${item.track.artistName}');
|
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||||
print('[DownloadQueue] Cover URL: ${item.track.coverUrl}');
|
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||||
|
|
||||||
// Only set currentDownload for sequential mode (for progress polling)
|
// Set currentDownload for UI reference
|
||||||
if (state.concurrentDownloads == 1) {
|
state = state.copyWith(currentDownload: item);
|
||||||
state = state.copyWith(currentDownload: item);
|
|
||||||
_startProgressPolling(item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
@@ -810,12 +967,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
|
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
|
||||||
|
|
||||||
|
// Use quality override if set, otherwise use default from settings
|
||||||
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
|
|
||||||
Map<String, dynamic> result;
|
Map<String, dynamic> result;
|
||||||
|
|
||||||
if (state.autoFallback) {
|
if (state.autoFallback) {
|
||||||
print('[DownloadQueue] Using auto-fallback mode');
|
_log.d('Using auto-fallback mode');
|
||||||
print('[DownloadQueue] Quality: ${state.audioQuality}');
|
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
||||||
print('[DownloadQueue] Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
isrc: item.track.isrc ?? '',
|
isrc: item.track.isrc ?? '',
|
||||||
spotifyId: item.track.id,
|
spotifyId: item.track.id,
|
||||||
@@ -826,13 +986,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: state.audioQuality,
|
quality: quality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: item.track.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: item.track.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
durationMs: item.track.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
@@ -846,49 +1006,97 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: state.audioQuality,
|
quality: quality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: item.track.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: item.track.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
durationMs: item.track.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop progress polling for this item (sequential mode only)
|
_log.d('Result: $result');
|
||||||
if (state.concurrentDownloads == 1) {
|
|
||||||
_stopProgressPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
print('[DownloadQueue] Result: $result');
|
// Check if item was cancelled while downloading
|
||||||
|
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
||||||
|
if (currentItem.status == DownloadStatus.skipped) {
|
||||||
|
_log.i('Download was cancelled, skipping result processing');
|
||||||
|
// Delete the downloaded file if it exists
|
||||||
|
final filePath = result['file_path'] as String?;
|
||||||
|
if (filePath != null && result['success'] == true) {
|
||||||
|
try {
|
||||||
|
final file = File(filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
_log.d('Deleted cancelled download file: $filePath');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to delete cancelled file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result['success'] == true) {
|
if (result['success'] == true) {
|
||||||
var filePath = result['file_path'] as String?;
|
var filePath = result['file_path'] as String?;
|
||||||
print('[DownloadQueue] Download success, file: $filePath');
|
_log.i('Download success, file: $filePath');
|
||||||
|
|
||||||
|
// Get actual quality from response (if available)
|
||||||
|
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||||
|
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||||
|
String actualQuality = quality; // Default to requested quality
|
||||||
|
|
||||||
|
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||||
|
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||||
|
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||||
|
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1)
|
||||||
|
: '?';
|
||||||
|
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
||||||
|
_log.i('Actual quality: $actualQuality');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
print('[DownloadQueue] Converting M4A to FLAC...');
|
_log.d('Converting M4A to FLAC...');
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||||
if (flacPath != null) {
|
if (flacPath != null) {
|
||||||
filePath = flacPath;
|
filePath = flacPath;
|
||||||
print('[DownloadQueue] Converted to: $flacPath');
|
_log.d('Converted to: $flacPath');
|
||||||
|
|
||||||
// After conversion, embed metadata and cover to the new FLAC file
|
// After conversion, embed metadata and cover to the new FLAC file
|
||||||
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
|
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||||
try {
|
try {
|
||||||
await _embedMetadataAndCover(
|
await _embedMetadataAndCover(
|
||||||
flacPath,
|
flacPath,
|
||||||
item.track,
|
item.track,
|
||||||
);
|
);
|
||||||
print('[DownloadQueue] Metadata and cover embedded successfully');
|
_log.d('Metadata and cover embedded successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
|
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check again if cancelled before updating status and adding to history
|
||||||
|
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
||||||
|
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||||
|
_log.i('Download was cancelled during finalization, cleaning up');
|
||||||
|
// Delete the downloaded file
|
||||||
|
if (filePath != null) {
|
||||||
|
try {
|
||||||
|
final file = File(filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
_log.d('Deleted cancelled download file: $filePath');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to delete cancelled file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.completed,
|
DownloadStatus.completed,
|
||||||
@@ -896,11 +1104,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Increment completed counter
|
||||||
|
_completedInSession++;
|
||||||
|
|
||||||
// Show completion notification for this track
|
// Show completion notification for this track
|
||||||
await _notificationService.showDownloadComplete(
|
await _notificationService.showDownloadComplete(
|
||||||
trackName: item.track.name,
|
trackName: item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: item.track.artistName,
|
||||||
completedCount: state.completedCount,
|
completedCount: _completedInSession,
|
||||||
totalCount: _totalQueuedAtStart,
|
totalCount: _totalQueuedAtStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -923,7 +1134,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
discNumber: item.track.discNumber,
|
discNumber: item.track.discNumber,
|
||||||
duration: item.track.duration,
|
duration: item.track.duration,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
quality: state.audioQuality,
|
quality: actualQuality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -932,35 +1143,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||||
print('[DownloadQueue] Download failed: $errorMsg');
|
_log.e('Download failed: $errorMsg');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
);
|
);
|
||||||
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment download counter and cleanup connections periodically
|
// Increment download counter and cleanup connections periodically
|
||||||
_downloadCount++;
|
_downloadCount++;
|
||||||
if (_downloadCount % _cleanupInterval == 0) {
|
if (_downloadCount % _cleanupInterval == 0) {
|
||||||
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
|
_log.d('Cleaning up idle connections (after $_downloadCount downloads)...');
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupConnections();
|
await PlatformBridge.cleanupConnections();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DownloadQueue] Connection cleanup failed: $e');
|
_log.e('Connection cleanup failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (state.concurrentDownloads == 1) {
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
_stopProgressPolling();
|
|
||||||
}
|
|
||||||
print('[DownloadQueue] Exception: $e');
|
|
||||||
print('[DownloadQueue] StackTrace: $stackTrace');
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: e.toString(),
|
error: e.toString(),
|
||||||
);
|
);
|
||||||
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
// Apply Spotify credentials to Go backend on load
|
||||||
|
_applySpotifyCredentials();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply current Spotify credentials to Go backend
|
||||||
|
Future<void> _applySpotifyCredentials() async {
|
||||||
|
// Only apply custom credentials if enabled and both fields are set
|
||||||
|
if (state.useCustomSpotifyCredentials &&
|
||||||
|
state.spotifyClientId.isNotEmpty &&
|
||||||
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
|
state.spotifyClientId,
|
||||||
|
state.spotifyClientSecret,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Clear to use default
|
||||||
|
await PlatformBridge.setSpotifyCredentials('', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
state = state.copyWith(defaultService: service);
|
state = state.copyWith(defaultService: service);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -89,15 +108,49 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConvertLyricsToRomaji(bool enabled) {
|
|
||||||
state = state.copyWith(convertLyricsToRomaji: enabled);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setHistoryViewMode(String mode) {
|
void setHistoryViewMode(String mode) {
|
||||||
state = state.copyWith(historyViewMode: mode);
|
state = state.copyWith(historyViewMode: mode);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientId(String clientId) {
|
||||||
|
state = state.copyWith(spotifyClientId: clientId);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientSecret(String clientSecret) {
|
||||||
|
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: clientId,
|
||||||
|
spotifyClientSecret: clientSecret,
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSpotifyCredentials() {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: '',
|
||||||
|
spotifyClientSecret: '',
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUseCustomSpotifyCredentials(bool enabled) {
|
||||||
|
state = state.copyWith(useCustomSpotifyCredentials: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -6,51 +6,60 @@ class TrackState {
|
|||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? albumId;
|
||||||
final String? albumName;
|
final String? albumName;
|
||||||
final String? playlistName;
|
final String? playlistName;
|
||||||
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
final TrackState? previousState; // For back navigation
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
|
final bool hasSearchText; // For back button handling
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.albumId,
|
||||||
this.albumName,
|
this.albumName,
|
||||||
this.playlistName,
|
this.playlistName,
|
||||||
|
this.artistId,
|
||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
this.previousState,
|
this.searchArtists,
|
||||||
|
this.hasSearchText = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get canGoBack => previousState != null;
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
|
|
||||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
|
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
|
String? albumId,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
String? artistId,
|
||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
TrackState? previousState,
|
List<SearchArtist>? searchArtists,
|
||||||
bool clearPreviousState = false,
|
bool? hasSearchText,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
albumName: albumName ?? this.albumName,
|
albumName: albumName ?? this.albumName,
|
||||||
playlistName: playlistName ?? this.playlistName,
|
playlistName: playlistName ?? this.playlistName,
|
||||||
|
artistId: artistId ?? this.artistId,
|
||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,31 +85,50 @@ class ArtistAlbum {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents an artist in search results
|
||||||
|
class SearchArtist {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? imageUrl;
|
||||||
|
final int followers;
|
||||||
|
final int popularity;
|
||||||
|
|
||||||
|
const SearchArtist({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.followers,
|
||||||
|
required this.popularity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
/// Request ID to track and cancel outdated requests
|
||||||
|
int _currentRequestId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url) async {
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
// Save current state for back navigation (only if we have content or it's empty)
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
final savedState = state.hasContent ? TrackState(
|
|
||||||
tracks: state.tracks,
|
|
||||||
albumName: state.albumName,
|
|
||||||
playlistName: state.playlistName,
|
|
||||||
artistName: state.artistName,
|
|
||||||
coverUrl: state.coverUrl,
|
|
||||||
artistAlbums: state.artistAlbums,
|
|
||||||
previousState: state.previousState,
|
|
||||||
) : const TrackState(); // Empty state for back to home
|
|
||||||
|
|
||||||
state = TrackState(isLoading: true, previousState: savedState);
|
Future<void> fetchFromUrl(String url) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during fetch
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
@@ -109,7 +137,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
@@ -118,9 +145,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
albumId: parsed['id'] as String?,
|
||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
@@ -132,7 +159,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
@@ -141,42 +167,46 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [], // No tracks for artist view
|
tracks: [], // No tracks for artist view
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
artistId: artistInfo['id'] as String?,
|
||||||
artistName: artistInfo['name'] as String?,
|
artistName: artistInfo['name'] as String?,
|
||||||
coverUrl: artistInfo['images'] as String?,
|
coverUrl: artistInfo['images'] as String?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query) async {
|
||||||
// Save current state for back navigation
|
// Increment request ID to cancel any pending requests
|
||||||
final savedState = state.hasContent ? TrackState(
|
final requestId = ++_currentRequestId;
|
||||||
tracks: state.tracks,
|
|
||||||
albumName: state.albumName,
|
|
||||||
playlistName: state.playlistName,
|
|
||||||
artistName: state.artistName,
|
|
||||||
coverUrl: state.coverUrl,
|
|
||||||
artistAlbums: state.artistAlbums,
|
|
||||||
previousState: state.previousState,
|
|
||||||
) : const TrackState();
|
|
||||||
|
|
||||||
state = TrackState(isLoading: true, previousState: savedState);
|
// Preserve hasSearchText during search
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
|
searchArtists: artists,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
previousState: savedState,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,52 +252,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Go back to previous state (if available)
|
/// Set search text state for back button handling
|
||||||
bool goBack() {
|
void setSearchText(bool hasText) {
|
||||||
if (state.previousState != null) {
|
state = state.copyWith(hasSearchText: hasText);
|
||||||
state = state.previousState!;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch album from artist view - saves current artist state for back navigation
|
|
||||||
Future<void> fetchAlbumFromArtist(String albumId) async {
|
|
||||||
// Save current artist state before fetching album
|
|
||||||
final savedState = TrackState(
|
|
||||||
artistName: state.artistName,
|
|
||||||
coverUrl: state.coverUrl,
|
|
||||||
artistAlbums: state.artistAlbums,
|
|
||||||
previousState: state.previousState, // Keep the chain
|
|
||||||
);
|
|
||||||
|
|
||||||
state = TrackState(
|
|
||||||
isLoading: true,
|
|
||||||
previousState: savedState,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final url = 'https://open.spotify.com/album/$albumId';
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
|
||||||
|
|
||||||
state = TrackState(
|
|
||||||
tracks: tracks,
|
|
||||||
isLoading: false,
|
|
||||||
albumName: albumInfo['name'] as String?,
|
|
||||||
coverUrl: albumInfo['images'] as String?,
|
|
||||||
previousState: savedState,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
state = TrackState(
|
|
||||||
isLoading: false,
|
|
||||||
error: e.toString(),
|
|
||||||
previousState: savedState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
@@ -279,7 +266,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -295,7 +282,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -313,6 +300,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
artists: data['artists'] as String? ?? '',
|
artists: data['artists'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
|
||||||
|
return SearchArtist(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
imageUrl: data['images'] as String?,
|
||||||
|
followers: data['followers'] as int? ?? 0,
|
||||||
|
popularity: data['popularity'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||||
|
|||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for album tracks
|
||||||
|
class _AlbumCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<Track>? get(String albumId) {
|
||||||
|
final entry = _cache[albumId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(albumId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String albumId, List<Track> tracks) {
|
||||||
|
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<Track> tracks;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.tracks, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album detail screen with Material Expressive 3 design
|
||||||
|
class AlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumId;
|
||||||
|
final String albumName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track>? tracks; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const AlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumId,
|
||||||
|
required this.albumName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||||
|
List<Track>? _tracks;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.tracks > cache > fetch
|
||||||
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
|
if (_tracks == null) {
|
||||||
|
_fetchTracks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchTracks() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
|
return Track(
|
||||||
|
id: data['spotify_id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
artistName: data['artists'] as String? ?? '',
|
||||||
|
albumName: data['album_name'] as String? ?? '',
|
||||||
|
albumArtist: data['album_artist'] as String?,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
isrc: data['isrc'] as String?,
|
||||||
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
|
trackNumber: data['track_number'] as int?,
|
||||||
|
discNumber: data['disc_number'] as int?,
|
||||||
|
releaseDate: data['release_date'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.albumName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (tracks.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tracks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context) {
|
||||||
|
final tracks = _tracks;
|
||||||
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (trackName != null) ...[
|
||||||
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
],
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, 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: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _AlbumTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _AlbumTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for artist discography
|
||||||
|
class _ArtistCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<ArtistAlbum>? get(String artistId) {
|
||||||
|
final entry = _cache[artistId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(artistId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String artistId, List<ArtistAlbum> albums) {
|
||||||
|
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<ArtistAlbum> albums;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.albums, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist screen with Material Expressive 3 design - shows discography
|
||||||
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
|
final String artistId;
|
||||||
|
final String artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<ArtistAlbum>? albums; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const ArtistScreen({
|
||||||
|
super.key,
|
||||||
|
required this.artistId,
|
||||||
|
required this.artistName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.albums,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
|
bool _isLoadingDiscography = false;
|
||||||
|
List<ArtistAlbum>? _albums;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.albums > cache > fetch
|
||||||
|
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
||||||
|
if (_albums == null) {
|
||||||
|
_fetchDiscography();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchDiscography() async {
|
||||||
|
setState(() => _isLoadingDiscography = true);
|
||||||
|
try {
|
||||||
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_ArtistCache.set(widget.artistId, albums);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_albums = albums;
|
||||||
|
_isLoadingDiscography = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingDiscography = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
return ArtistAlbum(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
|
artists: data['artists'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final albums = _albums ?? [];
|
||||||
|
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||||
|
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||||
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoadingDiscography)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
|
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
||||||
|
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
||||||
|
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_albums != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 210,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _navigateToAlbum(album),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: album.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
||||||
|
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToAlbum(ArtistAlbum album) {
|
||||||
|
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
||||||
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (context) => AlbumScreen(
|
||||||
|
albumId: album.id,
|
||||||
|
albumName: album.name,
|
||||||
|
coverUrl: album.coverUrl,
|
||||||
|
// tracks: null - will be fetched in AlbumScreen
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,7 +219,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, _) => Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
|||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
import 'package:spotiflac_android/services/update_checker.dart';
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('MainShell');
|
||||||
|
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
@@ -24,6 +27,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
|
DateTime? _lastBackPress; // For double-tap to exit
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,15 +44,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
// Check for pending URL that was received before listener was ready
|
// Check for pending URL that was received before listener was ready
|
||||||
final pendingUrl = ShareIntentService().consumePendingUrl();
|
final pendingUrl = ShareIntentService().consumePendingUrl();
|
||||||
if (pendingUrl != null) {
|
if (pendingUrl != null) {
|
||||||
print('[MainShell] Processing pending shared URL: $pendingUrl');
|
_log.d('Processing pending shared URL: $pendingUrl');
|
||||||
_handleSharedUrl(pendingUrl);
|
_handleSharedUrl(pendingUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for future shared URLs
|
// Listen for future shared URLs with error handling
|
||||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||||
print('[MainShell] Received shared URL from stream: $url');
|
(url) {
|
||||||
_handleSharedUrl(url);
|
_log.d('Received shared URL from stream: $url');
|
||||||
});
|
_handleSharedUrl(url);
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_log.e('Share stream error: $error');
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedUrl(String url) {
|
void _handleSharedUrl(String url) {
|
||||||
@@ -111,24 +121,48 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _showExitDialog() async {
|
/// Handle back press with double-tap to exit
|
||||||
return await showDialog<bool>(
|
void _handleBackPress() {
|
||||||
context: context,
|
final trackState = ref.read(trackProvider);
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Exit App'),
|
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||||
content: const Text('Are you sure you want to exit?'),
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
actions: [
|
if (isKeyboardVisible) {
|
||||||
TextButton(
|
FocusScope.of(context).unfocus();
|
||||||
onPressed: () => Navigator.pop(context, false),
|
return;
|
||||||
child: const Text('No'),
|
}
|
||||||
),
|
|
||||||
TextButton(
|
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||||
onPressed: () => Navigator.pop(context, true),
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
child: const Text('Yes'),
|
ref.read(trackProvider.notifier).clear();
|
||||||
),
|
return;
|
||||||
],
|
}
|
||||||
),
|
|
||||||
) ?? false;
|
// If not on Home tab, go to Home tab first
|
||||||
|
if (_currentIndex != 0) {
|
||||||
|
_onNavTap(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loading, ignore back press
|
||||||
|
if (trackState.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-tap to exit
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||||
|
SystemNavigator.pop();
|
||||||
|
} else {
|
||||||
|
_lastBackPress = now;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Press back again to exit'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -136,28 +170,29 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackState = ref.watch(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
|
// Determine if we can pop (for predictive back animation)
|
||||||
|
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||||
|
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||||
|
final canPop = _currentIndex == 0 &&
|
||||||
|
!trackState.hasSearchText &&
|
||||||
|
!trackState.hasContent &&
|
||||||
|
!trackState.isLoading &&
|
||||||
|
!isKeyboardVisible;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: canPop,
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
if (didPop) return;
|
if (didPop) {
|
||||||
|
// System handled the pop - this means predictive back completed
|
||||||
// If on Search tab and can go back in track history, go back
|
// We need to handle double-tap to exit here
|
||||||
if (_currentIndex == 0 && trackState.canGoBack) {
|
|
||||||
ref.read(trackProvider.notifier).goBack();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not on Search tab, go to Search tab first
|
// Handle back press manually when canPop is false
|
||||||
if (_currentIndex != 0) {
|
_handleBackPress();
|
||||||
_onNavTap(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already at root, show exit dialog
|
|
||||||
final shouldPop = await _showExitDialog();
|
|
||||||
if (shouldPop && context.mounted) {
|
|
||||||
SystemNavigator.pop();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: PageView(
|
body: PageView(
|
||||||
@@ -174,11 +209,14 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
onDestinationSelected: _onNavTap,
|
onDestinationSelected: _onNavTap,
|
||||||
animationDuration: const Duration(milliseconds: 200),
|
animationDuration: const Duration(milliseconds: 200),
|
||||||
|
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||||
destinations: [
|
destinations: [
|
||||||
const NavigationDestination(
|
const NavigationDestination(
|
||||||
icon: Icon(Icons.search_outlined),
|
icon: Icon(Icons.home_outlined),
|
||||||
selectedIcon: Icon(Icons.search),
|
selectedIcon: Icon(Icons.home),
|
||||||
label: 'Search',
|
label: 'Home',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
|
||||||
|
/// Playlist detail screen with Material Expressive 3 design
|
||||||
|
class PlaylistScreen extends ConsumerWidget {
|
||||||
|
final String playlistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track> tracks;
|
||||||
|
|
||||||
|
const PlaylistScreen({
|
||||||
|
super.key,
|
||||||
|
required this.playlistName,
|
||||||
|
this.coverUrl,
|
||||||
|
required this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, ref, colorScheme),
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, ref, colorScheme),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (coverUrl != null)
|
||||||
|
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context, ref),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _PlaylistTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, ref, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context, WidgetRef ref) {
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (trackName != null) ...[
|
||||||
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
],
|
||||||
|
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, 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: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _PlaylistTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _PlaylistTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,18 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
case DownloadStatus.finalizing:
|
||||||
|
return SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
case DownloadStatus.completed:
|
case DownloadStatus.completed:
|
||||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||||
case DownloadStatus.failed:
|
case DownloadStatus.failed:
|
||||||
|
|||||||
@@ -16,20 +16,41 @@ class QueueTab extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _QueueTabState extends ConsumerState<QueueTab> {
|
class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
final Map<String, bool> _fileExistsCache = {};
|
final Map<String, bool> _fileExistsCache = {};
|
||||||
|
final Set<String> _pendingChecks = {}; // Track pending async checks
|
||||||
|
static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak
|
||||||
|
|
||||||
|
/// Check if file exists - returns true optimistically while checking
|
||||||
|
/// This prevents the "red flash" on app start
|
||||||
bool _checkFileExists(String? filePath) {
|
bool _checkFileExists(String? filePath) {
|
||||||
if (filePath == null) return false;
|
if (filePath == null) return false;
|
||||||
|
|
||||||
|
// If already cached, return cached value
|
||||||
if (_fileExistsCache.containsKey(filePath)) {
|
if (_fileExistsCache.containsKey(filePath)) {
|
||||||
return _fileExistsCache[filePath]!;
|
return _fileExistsCache[filePath]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If check is pending, return true optimistically (assume file exists)
|
||||||
|
if (_pendingChecks.contains(filePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit cache size - remove oldest entry if full
|
||||||
|
if (_fileExistsCache.length >= _maxCacheSize) {
|
||||||
|
_fileExistsCache.remove(_fileExistsCache.keys.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as pending and start async check
|
||||||
|
_pendingChecks.add(filePath);
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
final exists = await File(filePath).exists();
|
final exists = await File(filePath).exists();
|
||||||
|
_pendingChecks.remove(filePath);
|
||||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||||
setState(() => _fileExistsCache[filePath] = exists);
|
setState(() => _fileExistsCache[filePath] = exists);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_fileExistsCache[filePath] = false;
|
|
||||||
return false;
|
// Return true optimistically while checking
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFile(String filePath) async {
|
Future<void> _openFile(String filePath) async {
|
||||||
@@ -69,8 +90,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final queueState = ref.watch(downloadQueueProvider);
|
// Use select() to only rebuild when specific fields change
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||||
|
final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing));
|
||||||
|
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||||
|
final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
|
final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount));
|
||||||
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -78,7 +104,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar - Simplified for performance
|
// Collapsing App Bar - Simplified for performance
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 100,
|
expandedHeight: 130,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -86,12 +112,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.4,
|
expandedTitleScale: 1.3,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'History',
|
'History',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
@@ -100,7 +126,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Pause/Resume controls - only show when multiple items or paused
|
// Pause/Resume controls - only show when multiple items or paused
|
||||||
if ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused))
|
if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
@@ -113,14 +139,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: queueState.isPaused
|
color: isPaused
|
||||||
? colorScheme.errorContainer
|
? colorScheme.errorContainer
|
||||||
: colorScheme.primaryContainer,
|
: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
queueState.isPaused ? Icons.pause : Icons.downloading,
|
isPaused ? Icons.pause : Icons.downloading,
|
||||||
color: queueState.isPaused
|
color: isPaused
|
||||||
? colorScheme.onErrorContainer
|
? colorScheme.onErrorContainer
|
||||||
: colorScheme.onPrimaryContainer,
|
: colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
@@ -129,9 +155,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// Status text - simplified
|
// Status text - simplified
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
queueState.isPaused
|
isPaused
|
||||||
? 'Paused'
|
? 'Paused'
|
||||||
: '${queueState.completedCount}/${queueState.items.length}',
|
: '$completedCount/${queueItems.length}',
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -140,7 +166,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// Pause/Resume button
|
// Pause/Resume button
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
||||||
child: Text(queueState.isPaused ? 'Resume' : 'Pause'),
|
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -150,34 +176,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Queue header
|
// Queue header
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Text('Downloading (${queueState.items.length})',
|
child: Text('Downloading (${queueItems.length})',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Queue list
|
// Queue list with keys for efficient updates
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverList(delegate: SliverChildBuilderDelegate(
|
SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildQueueItem(context, queueState.items[index], colorScheme),
|
(context, index) {
|
||||||
childCount: queueState.items.length,
|
final item = queueItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildQueueItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: queueItems.length,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// History section header - show count only
|
// History section header - show count only
|
||||||
if (historyState.items.isNotEmpty && queueState.items.isEmpty)
|
if (historyItems.isNotEmpty && queueItems.isEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Text('${historyState.items.length} ${historyState.items.length == 1 ? 'track' : 'tracks'}',
|
child: Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History section header when queue has items (show "Downloaded" label)
|
// History section header when queue has items (show "Downloaded" label)
|
||||||
if (historyState.items.isNotEmpty && queueState.items.isNotEmpty)
|
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
@@ -186,8 +218,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History - Grid or List based on setting
|
// History - Grid or List based on setting (with keys)
|
||||||
if (historyState.items.isNotEmpty)
|
if (historyItems.isNotEmpty)
|
||||||
historyViewMode == 'grid'
|
historyViewMode == 'grid'
|
||||||
? SliverPadding(
|
? SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@@ -199,18 +231,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
childAspectRatio: 0.75,
|
childAspectRatio: 0.75,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildHistoryGridItem(context, historyState.items[index], colorScheme),
|
(context, index) {
|
||||||
childCount: historyState.items.length,
|
final item = historyItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildHistoryGridItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: historyItems.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: SliverList(delegate: SliverChildBuilderDelegate(
|
: SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme),
|
(context, index) {
|
||||||
childCount: historyState.items.length,
|
final item = historyItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildHistoryItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: historyItems.length,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Empty state when both queue and history are empty
|
// Empty state when both queue and history are empty
|
||||||
if (queueState.items.isEmpty && historyState.items.isEmpty)
|
if (queueItems.isEmpty && historyItems.isEmpty)
|
||||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
||||||
else
|
else
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
@@ -380,6 +424,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case DownloadStatus.finalizing:
|
||||||
|
// Finalizing: Show spinner with edit icon (embedding metadata)
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
case DownloadStatus.completed:
|
case DownloadStatus.completed:
|
||||||
// Completed: Show play button and check icon
|
// Completed: Show play button and check icon
|
||||||
final fileExists = _checkFileExists(item.filePath);
|
final fileExists = _checkFileExists(item.filePath);
|
||||||
@@ -495,6 +558,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Quality badge (top-left)
|
||||||
|
if (item.quality != null && item.quality!.contains('bit'))
|
||||||
|
Positioned(
|
||||||
|
left: 4,
|
||||||
|
top: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.tertiary
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.quality!.split('/').first, // Just show "24-bit" or "16-bit"
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.onTertiary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// Play button overlay
|
// Play button overlay
|
||||||
if (fileExists)
|
if (fileExists)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -614,11 +702,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Row(
|
||||||
dateStr,
|
children: [
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
Text(
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
dateStr,
|
||||||
),
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Quality badge
|
||||||
|
if (item.quality != null && item.quality!.contains('bit')) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.tertiaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.quality!,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: item.quality!.startsWith('24')
|
||||||
|
? colorScheme.onTertiaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class AboutPage extends StatelessWidget {
|
class AboutPage extends StatelessWidget {
|
||||||
const AboutPage({super.key});
|
const AboutPage({super.key});
|
||||||
@@ -10,115 +12,131 @@ class AboutPage extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(
|
||||||
final maxHeight = 120 + topPadding;
|
icon: const Icon(Icons.arrow_back),
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
onPressed: () => Navigator.pop(context),
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
),
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
flexibleSpace: LayoutBuilder(
|
||||||
return FlexibleSpaceBar(
|
builder: (context, constraints) {
|
||||||
expandedTitleScale: 1.0,
|
final maxHeight = 120 + topPadding;
|
||||||
titlePadding: EdgeInsets.zero,
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
title: SafeArea(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
child: Container(
|
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||||
alignment: Alignment.bottomLeft,
|
// When expanded (expandRatio=1): left=24 for normal padding
|
||||||
padding: EdgeInsets.only(
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
return FlexibleSpaceBar(
|
||||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
expandedTitleScale: 1.0,
|
||||||
),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
child: Text('About',
|
title: Text(
|
||||||
style: TextStyle(
|
'About',
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
color: colorScheme.onSurface,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// App info card
|
// App header card with logo and description
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: Card(
|
child: _AppHeaderCard(),
|
||||||
elevation: 0,
|
|
||||||
color: colorScheme.surfaceContainerHigh,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(children: [
|
|
||||||
Container(
|
|
||||||
width: 56, height: 56,
|
|
||||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
||||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// GitHub section
|
// Contributors section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
|
const SliverToBoxAdapter(
|
||||||
SliverList(delegate: SliverChildListDelegate([
|
child: SettingsSectionHeader(title: 'Contributors'),
|
||||||
ListTile(
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: Text('${AppInfo.appName} Mobile'),
|
|
||||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
|
||||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
|
||||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: Text('Original ${AppInfo.appName}'),
|
|
||||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
|
||||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
|
||||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
|
||||||
),
|
|
||||||
])),
|
|
||||||
|
|
||||||
// Credits section
|
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: SettingsGroup(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
children: [
|
||||||
child: Column(children: [
|
_ContributorItem(
|
||||||
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
|
name: AppInfo.mobileAuthor,
|
||||||
const SizedBox(height: 12),
|
description: 'Mobile version developer',
|
||||||
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
|
githubUsername: AppInfo.mobileAuthor,
|
||||||
]),
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: AppInfo.originalAuthor,
|
||||||
|
description: 'Creator of the original SpotiFLAC',
|
||||||
|
githubUsername: AppInfo.originalAuthor,
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Links section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Links'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.phone_android,
|
||||||
|
title: 'Mobile source code',
|
||||||
|
subtitle: 'github.com/${AppInfo.githubRepo}',
|
||||||
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.computer,
|
||||||
|
title: 'PC source code',
|
||||||
|
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
||||||
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
title: 'Report an issue',
|
||||||
|
subtitle: 'Report any problems you encounter',
|
||||||
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.lightbulb_outline,
|
||||||
|
title: 'Feature request',
|
||||||
|
subtitle: 'Suggest new features for the app',
|
||||||
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// App info section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'App'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
title: 'Version',
|
||||||
|
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -126,42 +144,215 @@ class AboutPage extends StatelessWidget {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(child: Text(AppInfo.copyright,
|
child: Center(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
child: Text(
|
||||||
|
AppInfo.copyright,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _launchUrl(String url) async {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
// Use inAppBrowserView for reliable URL opening with app chooser
|
||||||
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppHeaderCard extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final cardColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHighest;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// App logo
|
||||||
|
Container(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// App name
|
||||||
|
Text(
|
||||||
|
AppInfo.appName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Version badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'v${AppInfo.version}',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Description
|
||||||
|
Text(
|
||||||
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _launchUrl(String url) async {
|
|
||||||
final uri = Uri.parse(url);
|
|
||||||
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _ContributorItem extends StatelessWidget {
|
||||||
final String title;
|
final String name;
|
||||||
const _SectionHeader({required this.title});
|
final String description;
|
||||||
@override
|
final String githubUsername;
|
||||||
Widget build(BuildContext context) => Padding(
|
final bool showDivider;
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
||||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
const _ContributorItem({
|
||||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
required this.name,
|
||||||
);
|
required this.description,
|
||||||
}
|
required this.githubUsername,
|
||||||
|
this.showDivider = false,
|
||||||
|
});
|
||||||
|
|
||||||
class _CreditRow extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
const _CreditRow({required this.label, required this.value});
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
|
||||||
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
return Column(
|
||||||
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
|
mainAxisSize: MainAxisSize.min,
|
||||||
]);
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _launchGitHub(githubUsername),
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// GitHub Avatar
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: 'https://github.com/$githubUsername.png',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Name and description
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// GitHub icon
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 76,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchGitHub(String username) async {
|
||||||
|
final uri = Uri.parse('https://github.com/$username');
|
||||||
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class AppearanceSettingsPage extends ConsumerWidget {
|
class AppearanceSettingsPage extends ConsumerWidget {
|
||||||
const AppearanceSettingsPage({super.key});
|
const AppearanceSettingsPage({super.key});
|
||||||
@@ -13,109 +14,111 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
|
||||||
return FlexibleSpaceBar(
|
|
||||||
expandedTitleScale: 1.0,
|
|
||||||
titlePadding: EdgeInsets.zero,
|
|
||||||
title: SafeArea(
|
|
||||||
child: Container(
|
|
||||||
alignment: Alignment.bottomLeft,
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
|
||||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
|
||||||
),
|
|
||||||
child: Text('Appearance',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Theme section
|
// Theme section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: _ThemeModeSelector(
|
|
||||||
currentMode: themeSettings.themeMode,
|
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Color section
|
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Color')),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SwitchListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
title: const Text('Dynamic Color'),
|
|
||||||
subtitle: const Text('Use colors from your wallpaper'),
|
|
||||||
value: themeSettings.useDynamicColor,
|
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (!themeSettings.useDynamicColor)
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _ColorPicker(
|
child: SettingsGroup(
|
||||||
currentColor: themeSettings.seedColorValue,
|
children: [
|
||||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
_ThemeModeSelector(
|
||||||
|
currentMode: themeSettings.themeMode,
|
||||||
|
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Layout section
|
// Color section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Layout')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: SettingsGroup(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: [
|
||||||
child: _HistoryViewSelector(
|
SettingsSwitchItem(
|
||||||
currentMode: settings.historyViewMode,
|
icon: Icons.auto_awesome,
|
||||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
title: 'Dynamic Color',
|
||||||
|
subtitle: 'Use colors from your wallpaper',
|
||||||
|
value: themeSettings.useDynamicColor,
|
||||||
|
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||||
|
showDivider: !themeSettings.useDynamicColor,
|
||||||
|
),
|
||||||
|
if (!themeSettings.useDynamicColor)
|
||||||
|
_ColorPicker(
|
||||||
|
currentColor: themeSettings.seedColorValue,
|
||||||
|
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Fill remaining for scroll
|
// Layout section
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
||||||
],
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_HistoryViewSelector(
|
||||||
|
currentMode: settings.historyViewMode,
|
||||||
|
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fill remaining for scroll
|
||||||
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
/// Optimized app bar title with animation
|
||||||
|
class _AppBarTitle extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
const _SectionHeader({required this.title});
|
final double topPadding;
|
||||||
|
|
||||||
|
const _AppBarTitle({required this.title, required this.topPadding});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
return LayoutBuilder(
|
||||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
builder: (context, constraints) {
|
||||||
);
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ThemeModeSelector extends StatelessWidget {
|
class _ThemeModeSelector extends StatelessWidget {
|
||||||
@@ -125,21 +128,15 @@ class _ThemeModeSelector extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
return Padding(
|
||||||
return Card(
|
padding: const EdgeInsets.all(12),
|
||||||
elevation: 0,
|
child: Row(children: [
|
||||||
color: colorScheme.surfaceContainerHigh,
|
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
const SizedBox(width: 8),
|
||||||
child: Padding(
|
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
||||||
padding: const EdgeInsets.all(8),
|
const SizedBox(width: 8),
|
||||||
child: Row(children: [
|
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
||||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
]),
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,22 +151,40 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Unselected chips need contrast with card background
|
||||||
|
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||||
|
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Container(
|
||||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
border: !isDark && !isSelected
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||||
child: Column(children: [
|
: null,
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
),
|
||||||
const SizedBox(height: 6),
|
child: Material(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
color: Colors.transparent,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
child: InkWell(
|
||||||
]),
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
child: Column(children: [
|
||||||
|
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(label, style: TextStyle(fontSize: 12,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -191,9 +206,9 @@ class _ColorPicker extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
|
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
||||||
final isSelected = color.toARGB32() == currentColor;
|
final isSelected = color.toARGB32() == currentColor;
|
||||||
@@ -224,26 +239,21 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Card(
|
return Padding(
|
||||||
elevation: 0,
|
padding: const EdgeInsets.all(12),
|
||||||
color: colorScheme.surfaceContainerHigh,
|
child: Column(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(8),
|
Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
children: [
|
),
|
||||||
Padding(
|
Row(children: [
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
|
||||||
child: Text('History View', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
const SizedBox(width: 8),
|
||||||
),
|
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
|
||||||
Row(children: [
|
]),
|
||||||
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
|
],
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -259,22 +269,38 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Unselected chips need contrast with card background
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Container(
|
||||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
border: !isDark && !isSelected
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||||
child: Column(children: [
|
: null,
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
),
|
||||||
const SizedBox(height: 6),
|
child: Material(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
color: Colors.transparent,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
child: InkWell(
|
||||||
]),
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
child: Column(children: [
|
||||||
|
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(label, style: TextStyle(fontSize: 12,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class DownloadSettingsPage extends ConsumerWidget {
|
class DownloadSettingsPage extends ConsumerWidget {
|
||||||
const DownloadSettingsPage({super.key});
|
const DownloadSettingsPage({super.key});
|
||||||
@@ -12,106 +13,124 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
titlePadding: EdgeInsets.zero,
|
return FlexibleSpaceBar(
|
||||||
title: SafeArea(
|
expandedTitleScale: 1.0,
|
||||||
child: Container(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
alignment: Alignment.bottomLeft,
|
title: Text(
|
||||||
padding: EdgeInsets.only(
|
'Download',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text('Download',
|
color: colorScheme.onSurface,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: SettingsGroup(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: [
|
||||||
child: _ServiceSelector(
|
_ServiceSelector(
|
||||||
currentService: settings.defaultService,
|
currentService: settings.defaultService,
|
||||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Quality section
|
// Quality section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
|
||||||
SliverList(delegate: SliverChildListDelegate([
|
SliverToBoxAdapter(
|
||||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS',
|
child: SettingsGroup(
|
||||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
children: [
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')),
|
SettingsSwitchItem(
|
||||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES',
|
icon: Icons.tune,
|
||||||
isSelected: settings.audioQuality == 'HI_RES',
|
title: 'Ask Before Download',
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES')),
|
subtitle: 'Choose quality for each download',
|
||||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', value: 'HI_RES_LOSSLESS',
|
value: settings.askQualityBeforeDownload,
|
||||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')),
|
),
|
||||||
])),
|
if (!settings.askQualityBeforeDownload) ...[
|
||||||
|
_QualityOption(
|
||||||
|
title: 'FLAC Lossless',
|
||||||
|
subtitle: '16-bit / 44.1kHz',
|
||||||
|
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||||
|
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC',
|
||||||
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES',
|
||||||
|
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC Max',
|
||||||
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||||
|
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// File settings section
|
// File settings section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')),
|
||||||
SliverList(delegate: SliverChildListDelegate([
|
SliverToBoxAdapter(
|
||||||
ListTile(
|
child: SettingsGroup(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
children: [
|
||||||
leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant),
|
SettingsItem(
|
||||||
title: const Text('Filename Format'),
|
icon: Icons.text_fields,
|
||||||
subtitle: Text(settings.filenameFormat),
|
title: 'Filename Format',
|
||||||
trailing: const Icon(Icons.chevron_right),
|
subtitle: settings.filenameFormat,
|
||||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.folder_outlined,
|
||||||
|
title: 'Download Directory',
|
||||||
|
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
|
||||||
|
onTap: () => _pickDirectory(ref),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.create_new_folder_outlined,
|
||||||
|
title: 'Folder Organization',
|
||||||
|
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
|
||||||
|
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ListTile(
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: const Text('Download Directory'),
|
|
||||||
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => _pickDirectory(ref),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: Icon(Icons.create_new_folder_outlined, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: const Text('Folder Organization'),
|
|
||||||
subtitle: Text(_getFolderOrganizationLabel(settings.folderOrganization)),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
|
|
||||||
),
|
|
||||||
])),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,13 +169,13 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
String _getFolderOrganizationLabel(String value) {
|
String _getFolderOrganizationLabel(String value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'artist':
|
case 'artist':
|
||||||
return 'By Artist (Artist/Track.flac)';
|
return 'By Artist';
|
||||||
case 'album':
|
case 'album':
|
||||||
return 'By Album (Album/Track.flac)';
|
return 'By Album';
|
||||||
case 'artist_album':
|
case 'artist_album':
|
||||||
return 'By Artist & Album (Artist/Album/Track.flac)';
|
return 'By Artist & Album';
|
||||||
default:
|
default:
|
||||||
return 'None (all in one folder)';
|
return 'None';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,17 +234,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
const _SectionHeader({required this.title});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
||||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ServiceSelector extends StatelessWidget {
|
class _ServiceSelector extends StatelessWidget {
|
||||||
final String currentService;
|
final String currentService;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
@@ -233,21 +241,15 @@ class _ServiceSelector extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
return Padding(
|
||||||
return Card(
|
padding: const EdgeInsets.all(12),
|
||||||
elevation: 0,
|
child: Row(children: [
|
||||||
color: colorScheme.surfaceContainerHigh,
|
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
const SizedBox(width: 8),
|
||||||
child: Padding(
|
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
||||||
padding: const EdgeInsets.all(8),
|
const SizedBox(width: 8),
|
||||||
child: Row(children: [
|
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
||||||
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
]),
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,9 +264,15 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -288,20 +296,49 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
class _QualityOption extends StatelessWidget {
|
class _QualityOption extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String value;
|
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.onTap});
|
final bool showDivider;
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return ListTile(
|
return Column(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
mainAxisSize: MainAxisSize.min,
|
||||||
title: Text(title),
|
children: [
|
||||||
subtitle: Text(subtitle),
|
InkWell(
|
||||||
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
|
onTap: onTap,
|
||||||
onTap: onTap,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isSelected
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class OptionsSettingsPage extends ConsumerWidget {
|
class OptionsSettingsPage extends ConsumerWidget {
|
||||||
const OptionsSettingsPage({super.key});
|
const OptionsSettingsPage({super.key});
|
||||||
@@ -12,127 +14,155 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
titlePadding: EdgeInsets.zero,
|
return FlexibleSpaceBar(
|
||||||
title: SafeArea(
|
expandedTitleScale: 1.0,
|
||||||
child: Container(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
alignment: Alignment.bottomLeft,
|
title: Text(
|
||||||
padding: EdgeInsets.only(
|
'Options',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text('Options',
|
color: colorScheme.onSurface,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Download options section
|
// Download options section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Download')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
|
||||||
SliverList(delegate: SliverChildListDelegate([
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: const Text('Auto Fallback'),
|
|
||||||
subtitle: const Text('Try other services if download fails'),
|
|
||||||
value: settings.autoFallback,
|
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: const Text('Embed Lyrics'),
|
|
||||||
subtitle: const Text('Embed synced lyrics into FLAC files'),
|
|
||||||
value: settings.embedLyrics,
|
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant),
|
|
||||||
title: const Text('Max Quality Cover'),
|
|
||||||
subtitle: const Text('Download highest resolution cover art'),
|
|
||||||
value: settings.maxQualityCover,
|
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
|
|
||||||
),
|
|
||||||
])),
|
|
||||||
|
|
||||||
// Performance section
|
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _ConcurrentDownloadsSelector(
|
child: SettingsGroup(
|
||||||
currentValue: settings.concurrentDownloads,
|
children: [
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.sync,
|
||||||
|
title: 'Auto Fallback',
|
||||||
|
subtitle: 'Try other services if download fails',
|
||||||
|
value: settings.autoFallback,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.lyrics,
|
||||||
|
title: 'Embed Lyrics',
|
||||||
|
subtitle: 'Embed synced lyrics into FLAC files',
|
||||||
|
value: settings.embedLyrics,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.image,
|
||||||
|
title: 'Max Quality Cover',
|
||||||
|
subtitle: 'Download highest resolution cover art',
|
||||||
|
value: settings.maxQualityCover,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Lyrics section
|
// Performance section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Lyrics')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Performance')),
|
||||||
SliverList(delegate: SliverChildListDelegate([
|
SliverToBoxAdapter(
|
||||||
SwitchListTile(
|
child: SettingsGroup(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
children: [
|
||||||
secondary: Icon(Icons.translate, color: colorScheme.onSurfaceVariant),
|
_ConcurrentDownloadsItem(
|
||||||
title: const Text('Convert Japanese to Romaji'),
|
currentValue: settings.concurrentDownloads,
|
||||||
subtitle: const Text('Auto-convert Hiragana/Katakana lyrics'),
|
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
|
||||||
value: settings.convertLyricsToRomaji,
|
),
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v),
|
],
|
||||||
),
|
),
|
||||||
])),
|
),
|
||||||
|
|
||||||
// App section
|
// App section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'App')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SwitchListTile(
|
child: SettingsGroup(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
children: [
|
||||||
secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant),
|
SettingsSwitchItem(
|
||||||
title: const Text('Check for Updates'),
|
icon: Icons.system_update,
|
||||||
subtitle: const Text('Notify when new version is available'),
|
title: 'Check for Updates',
|
||||||
value: settings.checkForUpdates,
|
subtitle: 'Notify when new version is available',
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
value: settings.checkForUpdates,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Spotify API section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.key,
|
||||||
|
title: 'Custom Credentials',
|
||||||
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
|
: 'Not configured',
|
||||||
|
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
|
trailing: settings.spotifyClientId.isNotEmpty
|
||||||
|
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||||
|
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||||
|
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||||
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.toggle_on,
|
||||||
|
title: 'Use Custom Credentials',
|
||||||
|
subtitle: settings.useCustomSpotifyCredentials
|
||||||
|
? 'Using your credentials'
|
||||||
|
: 'Using default credentials',
|
||||||
|
value: settings.useCustomSpotifyCredentials,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Data section
|
// Data section
|
||||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Data')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: ListTile(
|
child: SettingsGroup(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
children: [
|
||||||
leading: Icon(Icons.delete_forever, color: colorScheme.error),
|
SettingsItem(
|
||||||
title: const Text('Clear Download History'),
|
icon: Icons.delete_forever,
|
||||||
subtitle: const Text('Remove all downloaded tracks from history'),
|
title: 'Clear Download History',
|
||||||
onTap: () => _showClearHistoryDialog(context, ref, colorScheme),
|
subtitle: 'Remove all downloaded tracks from history',
|
||||||
|
onTap: () => _showClearHistoryDialog(context, ref, colorScheme),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,37 +191,153 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showSpotifyCredentialsDialog(BuildContext context, WidgetRef ref, AppSettings settings) {
|
||||||
|
final clientIdController = TextEditingController(text: settings.spotifyClientId);
|
||||||
|
final clientSecretController = TextEditingController(text: settings.spotifyClientSecret);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
|
||||||
|
child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'Use your own credentials to avoid rate limiting.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: TextField(
|
||||||
|
controller: clientIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client ID',
|
||||||
|
hintText: 'Enter Spotify Client ID',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerLow,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: TextField(
|
||||||
|
controller: clientSecretController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client Secret',
|
||||||
|
hintText: 'Enter Spotify Client Secret',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerLow,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(settingsProvider.notifier).clearSpotifyCredentials();
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Credentials cleared')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final clientId = clientIdController.text.trim();
|
||||||
|
final clientSecret = clientSecretController.text.trim();
|
||||||
|
|
||||||
|
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
|
||||||
|
ref.read(settingsProvider.notifier).setSpotifyCredentials(clientId, clientSecret);
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Credentials saved')),
|
||||||
|
);
|
||||||
|
} else if (clientId.isEmpty && clientSecret.isEmpty) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please fill both Client ID and Secret')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||||
final String title;
|
|
||||||
const _SectionHeader({required this.title});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
||||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConcurrentDownloadsSelector extends StatelessWidget {
|
|
||||||
final int currentValue;
|
final int currentValue;
|
||||||
final ValueChanged<int> onChanged;
|
final ValueChanged<int> onChanged;
|
||||||
const _ConcurrentDownloadsSelector({required this.currentValue, required this.onChanged});
|
const _ConcurrentDownloadsItem({required this.currentValue, required this.onChanged});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
|
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const Text('Concurrent Downloads'),
|
Text('Concurrent Downloads', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
|
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
])),
|
])),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -223,9 +369,15 @@ class _ConcurrentChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
|
|||||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class SettingsTab extends ConsumerWidget {
|
class SettingsTab extends ConsumerWidget {
|
||||||
const SettingsTab({super.key});
|
const SettingsTab({super.key});
|
||||||
@@ -15,9 +16,9 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar - Simplified for performance
|
// Collapsing App Bar
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 100,
|
expandedHeight: 130,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -25,12 +26,12 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.4,
|
expandedTitleScale: 1.3,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Settings',
|
'Settings',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
@@ -38,35 +39,50 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Menu items
|
// First group: Appearance & Download
|
||||||
SliverList(delegate: SliverChildListDelegate([
|
SliverToBoxAdapter(
|
||||||
_SettingsMenuItem(
|
child: SettingsGroup(
|
||||||
icon: Icons.palette_outlined,
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
title: 'Appearance',
|
children: [
|
||||||
subtitle: 'Theme, colors, display',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
icon: Icons.palette_outlined,
|
||||||
|
title: 'Appearance',
|
||||||
|
subtitle: 'Theme, colors, display',
|
||||||
|
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.download_outlined,
|
||||||
|
title: 'Download',
|
||||||
|
subtitle: 'Service, quality, filename format',
|
||||||
|
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.tune_outlined,
|
||||||
|
title: 'Options',
|
||||||
|
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||||
|
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_SettingsMenuItem(
|
),
|
||||||
icon: Icons.download_outlined,
|
|
||||||
title: 'Download',
|
|
||||||
subtitle: 'Service, quality, filename format',
|
|
||||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
|
||||||
),
|
|
||||||
_SettingsMenuItem(
|
|
||||||
icon: Icons.tune_outlined,
|
|
||||||
title: 'Options',
|
|
||||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
|
||||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
|
||||||
),
|
|
||||||
_SettingsMenuItem(
|
|
||||||
icon: Icons.info_outline,
|
|
||||||
title: 'About',
|
|
||||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
|
||||||
onTap: () => _navigateTo(context, const AboutPage()),
|
|
||||||
),
|
|
||||||
])),
|
|
||||||
|
|
||||||
// Fill remaining space to enable scroll
|
// Second group: About
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
title: 'About',
|
||||||
|
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
||||||
|
onTap: () => _navigateTo(context, const AboutPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fill remaining space
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -76,37 +92,3 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsMenuItem extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
||||||
child: Row(children: [
|
|
||||||
Container(
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
])),
|
|
||||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(AppInfo.appName),
|
Text(AppInfo.appName),
|
||||||
],
|
],
|
||||||
@@ -392,7 +392,19 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
title: const Text('Select Quality'),
|
title: const Text('Select Quality'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(AppInfo.appName),
|
Text(AppInfo.appName),
|
||||||
],
|
],
|
||||||
@@ -389,7 +389,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
|||||||
title: const Text('Select Quality'),
|
title: const Text('Select Quality'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
|
||||||
|
|||||||
@@ -87,10 +87,43 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
PermissionStatus status;
|
PermissionStatus status;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
|
// Android 13+: Use audio permission
|
||||||
status = await Permission.audio.request();
|
status = await Permission.audio.request();
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
status = await Permission.manageExternalStorage.request();
|
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
|
||||||
|
// This opens system settings, not a dialog
|
||||||
|
status = await Permission.manageExternalStorage.status;
|
||||||
|
if (!status.isGranted) {
|
||||||
|
// Show explanation dialog first
|
||||||
|
if (mounted) {
|
||||||
|
final shouldOpen = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Storage Access Required'),
|
||||||
|
content: const Text(
|
||||||
|
'Android 11+ requires "All files access" permission to save music files.\n\n'
|
||||||
|
'Please enable "Allow access to manage all files" in the next screen.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldOpen == true) {
|
||||||
|
status = await Permission.manageExternalStorage.request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Android 10 and below: Use legacy storage permission
|
||||||
status = await Permission.storage.request();
|
status = await Permission.storage.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
@@ -46,6 +47,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_fileExists = exists;
|
_fileExists = exists;
|
||||||
_fileSize = size;
|
_fileSize = size;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load lyrics if file exists (embedded lyrics are instant)
|
||||||
|
if (exists) {
|
||||||
|
_fetchLyrics();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +197,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl!,
|
imageUrl: item.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, _) => Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.music_note,
|
Icons.music_note,
|
||||||
@@ -358,22 +364,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||||
if (item.spotifyId == null) return;
|
if (item.spotifyId == null) return;
|
||||||
|
|
||||||
final url = 'https://open.spotify.com/track/${item.spotifyId}';
|
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||||
|
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to open in Spotify app first, fallback to browser
|
// Try to open in Spotify app first using URI scheme
|
||||||
final uri = Uri.parse('spotify:track:${item.spotifyId}');
|
final launched = await launchUrl(
|
||||||
// ignore: deprecated_member_use
|
spotifyUri,
|
||||||
if (await canLaunchUrl(uri)) {
|
mode: LaunchMode.externalApplication,
|
||||||
await launchUrl(uri);
|
);
|
||||||
} else {
|
|
||||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
if (!launched) {
|
||||||
|
// Fallback to web URL which will redirect to app if installed
|
||||||
|
await launchUrl(
|
||||||
|
Uri.parse(webUrl),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
// If URI scheme fails, try web URL
|
||||||
_copyToClipboard(context, url);
|
try {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await launchUrl(
|
||||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
Uri.parse(webUrl),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Last resort: copy to clipboard
|
||||||
|
if (context.mounted) {
|
||||||
|
_copyToClipboard(context, webUrl);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,6 +413,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_MetadataItem('Disc number', item.discNumber.toString()),
|
_MetadataItem('Disc number', item.discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||||
|
if (item.quality != null && item.quality!.contains('bit'))
|
||||||
|
_MetadataItem('Audio quality', item.quality!),
|
||||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
||||||
_MetadataItem('Release date', item.releaseDate!),
|
_MetadataItem('Release date', item.releaseDate!),
|
||||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
if (item.isrc != null && item.isrc!.isNotEmpty)
|
||||||
@@ -739,6 +763,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
item.spotifyId ?? '',
|
item.spotifyId ?? '',
|
||||||
item.trackName,
|
item.trackName,
|
||||||
item.artistName,
|
item.artistName,
|
||||||
|
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -854,7 +879,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
title: const Text('Share'),
|
title: const Text('Share'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
// TODO: Implement share
|
_shareFile(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -926,6 +951,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _shareFile(BuildContext context) async {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('File not found')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [XFile(item.filePath)],
|
||||||
|
text: '${item.trackName} - ${item.artistName}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _formatFullDate(DateTime date) {
|
String _formatFullDate(DateTime date) {
|
||||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'dart:io';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('ApkDownloader');
|
||||||
|
|
||||||
typedef ProgressCallback = void Function(int received, int total);
|
typedef ProgressCallback = void Function(int received, int total);
|
||||||
|
|
||||||
@@ -11,13 +14,22 @@ class ApkDownloader {
|
|||||||
required String version,
|
required String version,
|
||||||
ProgressCallback? onProgress,
|
ProgressCallback? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Validate URL for security
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || uri.scheme != 'https') {
|
||||||
|
_log.e('Refusing to download from invalid or non-HTTPS URL');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = http.Client();
|
||||||
|
IOSink? sink;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final client = http.Client();
|
final request = http.Request('GET', uri);
|
||||||
final request = http.Request('GET', Uri.parse(url));
|
|
||||||
final response = await client.send(request);
|
final response = await client.send(request);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
print('[ApkDownloader] Failed to download: ${response.statusCode}');
|
_log.e('Failed to download: ${response.statusCode}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +38,7 @@ class ApkDownloader {
|
|||||||
// Get download directory
|
// Get download directory
|
||||||
final dir = await getExternalStorageDirectory();
|
final dir = await getExternalStorageDirectory();
|
||||||
if (dir == null) {
|
if (dir == null) {
|
||||||
print('[ApkDownloader] Could not get storage directory');
|
_log.e('Could not get storage directory');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +50,7 @@ class ApkDownloader {
|
|||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
final sink = file.openWrite();
|
sink = file.openWrite();
|
||||||
int received = 0;
|
int received = 0;
|
||||||
|
|
||||||
await for (final chunk in response.stream) {
|
await for (final chunk in response.stream) {
|
||||||
@@ -47,23 +59,24 @@ class ApkDownloader {
|
|||||||
onProgress?.call(received, contentLength);
|
onProgress?.call(received, contentLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sink.close();
|
await sink.flush();
|
||||||
client.close();
|
_log.i('Downloaded to: $filePath');
|
||||||
|
|
||||||
print('[ApkDownloader] Downloaded to: $filePath');
|
|
||||||
return filePath;
|
return filePath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ApkDownloader] Error: $e');
|
_log.e('Error: $e');
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
await sink?.close();
|
||||||
|
client.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> installApk(String filePath) async {
|
static Future<void> installApk(String filePath) async {
|
||||||
try {
|
try {
|
||||||
final result = await OpenFilex.open(filePath);
|
final result = await OpenFilex.open(filePath);
|
||||||
print('[ApkDownloader] Open result: ${result.type} - ${result.message}');
|
_log.i('Open result: ${result.type} - ${result.message}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ApkDownloader] Install error: $e');
|
_log.e('Install error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
/// FFmpeg service for audio conversion and remuxing
|
/// FFmpeg service for audio conversion and remuxing
|
||||||
|
/// Uses native MethodChannel to call FFmpegKit from local AAR
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
|
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
|
||||||
|
|
||||||
|
/// Execute FFmpeg command and return result
|
||||||
|
static Future<FFmpegResult> _execute(String command) async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod('execute', {'command': command});
|
||||||
|
final map = Map<String, dynamic>.from(result);
|
||||||
|
return FFmpegResult(
|
||||||
|
success: map['success'] as bool,
|
||||||
|
returnCode: map['returnCode'] as int,
|
||||||
|
output: map['output'] as String,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('FFmpeg execute error: $e');
|
||||||
|
return FFmpegResult(success: false, returnCode: -1, output: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert M4A (DASH segments) to FLAC
|
/// Convert M4A (DASH segments) to FLAC
|
||||||
/// Returns the output file path on success, null on failure
|
/// Returns the output file path on success, null on failure
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
@@ -13,10 +34,9 @@ class FFmpegService {
|
|||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final result = await _execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (result.success) {
|
||||||
// Delete original M4A file
|
// Delete original M4A file
|
||||||
try {
|
try {
|
||||||
await File(inputPath).delete();
|
await File(inputPath).delete();
|
||||||
@@ -24,12 +44,7 @@ class FFmpegService {
|
|||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log error for debugging
|
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||||
final logs = await session.getLogs();
|
|
||||||
for (final log in logs) {
|
|
||||||
print('[FFmpeg] ${log.getMessage()}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +66,13 @@ class FFmpegService {
|
|||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final result = await _execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (result.success) {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,22 +103,21 @@ class FFmpegService {
|
|||||||
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final result = await _execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (result.success) {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute('-version');
|
final version = await _channel.invokeMethod('getVersion');
|
||||||
final returnCode = await session.getReturnCode();
|
return version != null && version.toString().isNotEmpty;
|
||||||
return ReturnCode.isSuccess(returnCode);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -112,11 +126,55 @@ class FFmpegService {
|
|||||||
/// Get FFmpeg version info
|
/// Get FFmpeg version info
|
||||||
static Future<String?> getVersion() async {
|
static Future<String?> getVersion() async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute('-version');
|
final version = await _channel.invokeMethod('getVersion');
|
||||||
final output = await session.getOutput();
|
return version as String?;
|
||||||
return output;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
// Replace original with temp
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after cover embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of FFmpeg command execution
|
||||||
|
class FFmpegResult {
|
||||||
|
final bool success;
|
||||||
|
final int returnCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
FFmpegResult({
|
||||||
|
required this.success,
|
||||||
|
required this.returnCode,
|
||||||
|
required this.output,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,49 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> showDownloadFinalizing({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
priority: Priority.low,
|
||||||
|
showProgress: true,
|
||||||
|
maxProgress: 100,
|
||||||
|
progress: 100,
|
||||||
|
indeterminate: false,
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
onlyAlertOnce: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
downloadProgressId,
|
||||||
|
'Finalizing $trackName',
|
||||||
|
'$artistName • Embedding metadata...',
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> showDownloadComplete({
|
Future<void> showDownloadComplete({
|
||||||
required String trackName,
|
required String trackName,
|
||||||
required String artistName,
|
required String artistName,
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search Spotify for tracks and artists
|
||||||
|
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||||
|
'query': query,
|
||||||
|
'track_limit': trackLimit,
|
||||||
|
'artist_limit': artistLimit,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check track availability on streaming services
|
/// Check track availability on streaming services
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
@@ -50,12 +60,12 @@ class PlatformBridge {
|
|||||||
String quality = 'LOSSLESS',
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
bool convertLyricsToRomaji = false,
|
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String? itemId,
|
String? itemId,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -71,12 +81,12 @@ class PlatformBridge {
|
|||||||
'quality': quality,
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
'item_id': itemId ?? '',
|
'item_id': itemId ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||||
@@ -97,13 +107,13 @@ class PlatformBridge {
|
|||||||
String quality = 'LOSSLESS',
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
bool convertLyricsToRomaji = false,
|
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'tidal',
|
String preferredService = 'tidal',
|
||||||
String? itemId,
|
String? itemId,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -119,12 +129,12 @@ class PlatformBridge {
|
|||||||
'quality': quality,
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
'item_id': itemId ?? '',
|
'item_id': itemId ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||||
@@ -204,15 +214,18 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get lyrics in LRC format
|
/// Get lyrics in LRC format
|
||||||
|
/// First tries to extract from embedded file, then falls back to internet
|
||||||
static Future<String> getLyricsLRC(
|
static Future<String> getLyricsLRC(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String trackName,
|
String trackName,
|
||||||
String artistName,
|
String artistName, {
|
||||||
) async {
|
String? filePath,
|
||||||
|
}) async {
|
||||||
final result = await _channel.invokeMethod('getLyricsLRC', {
|
final result = await _channel.invokeMethod('getLyricsLRC', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'track_name': trackName,
|
'track_name': trackName,
|
||||||
'artist_name': artistName,
|
'artist_name': artistName,
|
||||||
|
'file_path': filePath ?? '',
|
||||||
});
|
});
|
||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
@@ -234,4 +247,54 @@ class PlatformBridge {
|
|||||||
static Future<void> cleanupConnections() async {
|
static Future<void> cleanupConnections() async {
|
||||||
await _channel.invokeMethod('cleanupConnections');
|
await _channel.invokeMethod('cleanupConnections');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start foreground download service to keep downloads running in background
|
||||||
|
static Future<void> startDownloadService({
|
||||||
|
String trackName = '',
|
||||||
|
String artistName = '',
|
||||||
|
int queueCount = 0,
|
||||||
|
}) async {
|
||||||
|
await _channel.invokeMethod('startDownloadService', {
|
||||||
|
'track_name': trackName,
|
||||||
|
'artist_name': artistName,
|
||||||
|
'queue_count': queueCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop foreground download service
|
||||||
|
static Future<void> stopDownloadService() async {
|
||||||
|
await _channel.invokeMethod('stopDownloadService');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update download service notification progress
|
||||||
|
static Future<void> updateDownloadServiceProgress({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
required int progress,
|
||||||
|
required int total,
|
||||||
|
required int queueCount,
|
||||||
|
}) async {
|
||||||
|
await _channel.invokeMethod('updateDownloadServiceProgress', {
|
||||||
|
'track_name': trackName,
|
||||||
|
'artist_name': artistName,
|
||||||
|
'progress': progress,
|
||||||
|
'total': total,
|
||||||
|
'queue_count': queueCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if download service is running
|
||||||
|
static Future<bool> isDownloadServiceRunning() async {
|
||||||
|
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
||||||
|
return result as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set custom Spotify API credentials
|
||||||
|
/// Pass empty strings to use default credentials
|
||||||
|
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||||
|
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||||
|
'client_id': clientId,
|
||||||
|
'client_secret': clientSecret,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('ShareIntent');
|
||||||
|
|
||||||
/// Service to handle incoming share intents from other apps (e.g., Spotify)
|
/// Service to handle incoming share intents from other apps (e.g., Spotify)
|
||||||
class ShareIntentService {
|
class ShareIntentService {
|
||||||
@@ -30,7 +33,7 @@ class ShareIntentService {
|
|||||||
// Listen to media sharing coming from outside the app while the app is in memory
|
// Listen to media sharing coming from outside the app while the app is in memory
|
||||||
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
|
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||||
_handleSharedMedia,
|
_handleSharedMedia,
|
||||||
onError: (err) => print('[ShareIntent] Error: $err'),
|
onError: (err) => _log.e('Error: $err'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the media sharing coming from outside the app while the app is closed
|
// Get the media sharing coming from outside the app while the app is closed
|
||||||
@@ -49,7 +52,7 @@ class ShareIntentService {
|
|||||||
|
|
||||||
final url = _extractSpotifyUrl(textToCheck);
|
final url = _extractSpotifyUrl(textToCheck);
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
print('[ShareIntent] Received Spotify URL: $url (initial: $isInitial)');
|
_log.i('Received Spotify URL: $url (initial: $isInitial)');
|
||||||
if (isInitial) {
|
if (isInitial) {
|
||||||
// Store for later - listener might not be ready yet
|
// Store for later - listener might not be ready yet
|
||||||
_pendingUrl = url;
|
_pendingUrl = url;
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('UpdateChecker');
|
||||||
|
|
||||||
class UpdateInfo {
|
class UpdateInfo {
|
||||||
final String version;
|
final String version;
|
||||||
final String changelog;
|
final String changelog;
|
||||||
final String downloadUrl;
|
final String downloadUrl;
|
||||||
final String? apkDownloadUrl; // Direct APK download URL
|
final String? apkDownloadUrl;
|
||||||
final DateTime publishedAt;
|
final DateTime publishedAt;
|
||||||
|
|
||||||
const UpdateInfo({
|
const UpdateInfo({
|
||||||
@@ -22,20 +25,16 @@ class UpdateInfo {
|
|||||||
class UpdateChecker {
|
class UpdateChecker {
|
||||||
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||||
|
|
||||||
/// Get device CPU architecture
|
|
||||||
static Future<String> _getDeviceArch() async {
|
static Future<String> _getDeviceArch() async {
|
||||||
if (!Platform.isAndroid) return 'unknown';
|
if (!Platform.isAndroid) return 'unknown';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read CPU info from /proc/cpuinfo
|
|
||||||
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
||||||
|
|
||||||
// Check for 64-bit indicators
|
|
||||||
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
||||||
return 'arm64';
|
return 'arm64';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check architecture from uname
|
|
||||||
final result = await Process.run('uname', ['-m']);
|
final result = await Process.run('uname', ['-m']);
|
||||||
final arch = result.stdout.toString().trim().toLowerCase();
|
final arch = result.stdout.toString().trim().toLowerCase();
|
||||||
|
|
||||||
@@ -49,14 +48,13 @@ class UpdateChecker {
|
|||||||
return 'x86';
|
return 'x86';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'arm64'; // Default to arm64 for modern devices
|
return 'arm64';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[UpdateChecker] Error detecting arch: $e');
|
_log.e('Error detecting arch: $e');
|
||||||
return 'arm64'; // Default fallback
|
return 'arm64';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check for updates from GitHub releases
|
|
||||||
static Future<UpdateInfo?> checkForUpdate() async {
|
static Future<UpdateInfo?> checkForUpdate() async {
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
@@ -65,7 +63,7 @@ class UpdateChecker {
|
|||||||
).timeout(const Duration(seconds: 10));
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
print('[UpdateChecker] GitHub API returned ${response.statusCode}');
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,18 +72,16 @@ class UpdateChecker {
|
|||||||
final latestVersion = tagName.replaceFirst('v', '');
|
final latestVersion = tagName.replaceFirst('v', '');
|
||||||
|
|
||||||
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||||
print('[UpdateChecker] No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get changelog from release body
|
|
||||||
final body = data['body'] as String? ?? 'No changelog available';
|
final body = data['body'] as String? ?? 'No changelog available';
|
||||||
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||||
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
||||||
|
|
||||||
// Find APK download URL from assets based on device architecture
|
|
||||||
final deviceArch = await _getDeviceArch();
|
final deviceArch = await _getDeviceArch();
|
||||||
print('[UpdateChecker] Device architecture: $deviceArch');
|
_log.d('Device architecture: $deviceArch');
|
||||||
|
|
||||||
String? arm64Url;
|
String? arm64Url;
|
||||||
String? arm32Url;
|
String? arm32Url;
|
||||||
@@ -96,6 +92,12 @@ class UpdateChecker {
|
|||||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||||
if (name.endsWith('.apk')) {
|
if (name.endsWith('.apk')) {
|
||||||
final downloadUrl = asset['browser_download_url'] as String?;
|
final downloadUrl = asset['browser_download_url'] as String?;
|
||||||
|
// Only accept HTTPS URLs for security
|
||||||
|
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
|
||||||
|
if (uri == null || uri.scheme != 'https') {
|
||||||
|
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (name.contains('arm64') || name.contains('v8a')) {
|
if (name.contains('arm64') || name.contains('v8a')) {
|
||||||
arm64Url = downloadUrl;
|
arm64Url = downloadUrl;
|
||||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||||
@@ -106,7 +108,6 @@ class UpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select APK based on device architecture
|
|
||||||
String? apkUrl;
|
String? apkUrl;
|
||||||
if (deviceArch == 'arm64') {
|
if (deviceArch == 'arm64') {
|
||||||
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
||||||
@@ -116,7 +117,7 @@ class UpdateChecker {
|
|||||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[UpdateChecker] Update available: $latestVersion, APK URL: $apkUrl');
|
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
|
||||||
|
|
||||||
return UpdateInfo(
|
return UpdateInfo(
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
@@ -126,18 +127,19 @@ class UpdateChecker {
|
|||||||
publishedAt: publishedAt,
|
publishedAt: publishedAt,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[UpdateChecker] Error checking for updates: $e');
|
_log.e('Error checking for updates: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare version strings (e.g., "1.1.1" vs "1.1.0")
|
|
||||||
static bool _isNewerVersion(String latest, String current) {
|
static bool _isNewerVersion(String latest, String current) {
|
||||||
try {
|
try {
|
||||||
final latestParts = latest.split('.').map(int.parse).toList();
|
final latestBase = latest.split('-').first;
|
||||||
final currentParts = current.split('.').map(int.parse).toList();
|
final currentBase = current.split('-').first;
|
||||||
|
|
||||||
|
final latestParts = latestBase.split('.').map(int.parse).toList();
|
||||||
|
final currentParts = currentBase.split('.').map(int.parse).toList();
|
||||||
|
|
||||||
// Pad with zeros if needed
|
|
||||||
while (latestParts.length < 3) {
|
while (latestParts.length < 3) {
|
||||||
latestParts.add(0);
|
latestParts.add(0);
|
||||||
}
|
}
|
||||||
@@ -149,8 +151,15 @@ class UpdateChecker {
|
|||||||
if (latestParts[i] > currentParts[i]) return true;
|
if (latestParts[i] > currentParts[i]) return true;
|
||||||
if (latestParts[i] < currentParts[i]) return false;
|
if (latestParts[i] < currentParts[i]) return false;
|
||||||
}
|
}
|
||||||
return false; // Same version
|
|
||||||
|
final latestHasSuffix = latest.contains('-');
|
||||||
|
final currentHasSuffix = current.contains('-');
|
||||||
|
|
||||||
|
if (!latestHasSuffix && currentHasSuffix) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_log.e('Error comparing versions: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
// Use dynamic colors from wallpaper (Android 12+)
|
// Use dynamic colors from wallpaper (Android 12+)
|
||||||
lightScheme = lightDynamic;
|
lightScheme = lightDynamic;
|
||||||
darkScheme = darkDynamic;
|
darkScheme = darkDynamic;
|
||||||
debugPrint('Using dynamic color from wallpaper');
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to seed color
|
// Fallback to seed color
|
||||||
final seedColor = themeSettings.seedColor;
|
final seedColor = themeSettings.seedColor;
|
||||||
@@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build themes
|
// Build themes
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
/// Global logger instance for the app
|
||||||
|
/// Uses pretty printer in debug mode for readable output
|
||||||
|
final log = Logger(
|
||||||
|
printer: PrettyPrinter(
|
||||||
|
methodCount: 0,
|
||||||
|
errorMethodCount: 5,
|
||||||
|
lineLength: 80,
|
||||||
|
colors: true,
|
||||||
|
printEmojis: false,
|
||||||
|
dateTimeFormat: DateTimeFormat.none,
|
||||||
|
),
|
||||||
|
level: Level.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Logger with class/tag prefix for better traceability
|
||||||
|
class AppLogger {
|
||||||
|
final String _tag;
|
||||||
|
|
||||||
|
AppLogger(this._tag);
|
||||||
|
|
||||||
|
void d(String message) => log.d('[$_tag] $message');
|
||||||
|
void i(String message) => log.i('[$_tag] $message');
|
||||||
|
void w(String message) => log.w('[$_tag] $message');
|
||||||
|
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
||||||
|
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A grouped settings card that connects items together like Android Settings
|
||||||
|
/// Items are connected with no gap between them, only separated when changing groups
|
||||||
|
class SettingsGroup extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
|
||||||
|
const SettingsGroup({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.margin,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Use a more contrasting color for cards
|
||||||
|
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
|
||||||
|
// So we add a slight white overlay to make it more visible
|
||||||
|
// In light mode with dynamic color, we add a slight black overlay for the same reason
|
||||||
|
final cardColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single settings item that can be used inside SettingsGroup
|
||||||
|
class SettingsItem extends StatelessWidget {
|
||||||
|
final IconData? icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget? trailing;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const SettingsItem({
|
||||||
|
super.key,
|
||||||
|
this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.trailing,
|
||||||
|
this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
trailing!,
|
||||||
|
] else if (onTap != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: icon != null ? 56 : 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A switch settings item for SettingsGroup
|
||||||
|
class SettingsSwitchItem extends StatelessWidget {
|
||||||
|
final IconData? icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool>? onChanged;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const SettingsSwitchItem({
|
||||||
|
super.key,
|
||||||
|
this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.value,
|
||||||
|
this.onChanged,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onChanged != null ? () => onChanged!(!value) : null,
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Switch(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: icon != null ? 56 : 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section header for settings groups
|
||||||
|
class SettingsSectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const SettingsSectionHeader({super.key, required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
|
// Cancel progress notification first
|
||||||
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
await notificationService.showUpdateDownloadComplete(
|
await notificationService.showUpdateDownloadComplete(
|
||||||
version: widget.updateInfo.version,
|
version: widget.updateInfo.version,
|
||||||
);
|
);
|
||||||
@@ -80,6 +83,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
// Open APK for installation
|
// Open APK for installation
|
||||||
await ApkDownloader.installApk(filePath);
|
await ApkDownloader.installApk(filePath);
|
||||||
} else {
|
} else {
|
||||||
|
// Cancel progress notification first
|
||||||
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
await notificationService.showUpdateDownloadFailed();
|
await notificationService.showUpdateDownloadFailed();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -98,129 +104,202 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return AlertDialog(
|
return Dialog(
|
||||||
title: Row(
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
children: [
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
Icon(Icons.system_update, color: colorScheme.primary),
|
child: Padding(
|
||||||
const SizedBox(width: 12),
|
padding: const EdgeInsets.all(24),
|
||||||
const Text('Update Available'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Version info
|
// Header with icon
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Version badge
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: isDark
|
||||||
borderRadius: BorderRadius.circular(12),
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
|
||||||
'v${AppInfo.version}',
|
const SizedBox(width: 12),
|
||||||
style: TextStyle(color: colorScheme.onPrimaryContainer),
|
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 8),
|
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
|
||||||
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'v${widget.updateInfo.version}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Changelog header
|
// Download progress (when downloading)
|
||||||
Text(
|
if (_isDownloading) ...[
|
||||||
'What\'s New:',
|
Container(
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
padding: const EdgeInsets.all(16),
|
||||||
fontWeight: FontWeight.bold,
|
decoration: BoxDecoration(
|
||||||
),
|
color: isDark
|
||||||
),
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
const SizedBox(height: 8),
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
// Changelog content (scrollable) - hide when downloading
|
),
|
||||||
if (!_isDownloading)
|
child: Column(
|
||||||
Flexible(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Container(
|
children: [
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
Row(
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: colorScheme.surfaceContainerHighest,
|
SizedBox(
|
||||||
borderRadius: BorderRadius.circular(8),
|
width: 20, height: 20,
|
||||||
),
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
padding: const EdgeInsets.all(12),
|
const SizedBox(width: 12),
|
||||||
child: Text(
|
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
_formatChangelog(widget.updateInfo.changelog),
|
],
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _progress,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
// Changelog section
|
||||||
|
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 180),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
_formatChangelog(widget.updateInfo.changelog),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Download progress
|
|
||||||
if (_isDownloading) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
LinearProgressIndicator(value: _progress),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_statusText,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
if (_isDownloading)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _downloadAndInstall,
|
||||||
|
icon: const Icon(Icons.download_rounded, size: 20),
|
||||||
|
label: const Text('Download & Install'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onDisableUpdates();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onDismiss();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: _isDownloading
|
|
||||||
? [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
// Don't remind again button
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
widget.onDisableUpdates();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Don\'t remind',
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Later button
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
widget.onDismiss();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: const Text('Later'),
|
|
||||||
),
|
|
||||||
// Download button
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _downloadAndInstall,
|
|
||||||
child: const Text('Install'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format changelog - clean up markdown and extract relevant content
|
/// Format changelog - clean up markdown and extract relevant content
|
||||||
String _formatChangelog(String changelog) {
|
String _formatChangelog(String changelog) {
|
||||||
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
|
|
||||||
var content = changelog;
|
var content = changelog;
|
||||||
|
|
||||||
// Find content after "What's New" header
|
// Find content after "What's New" header
|
||||||
@@ -238,19 +317,18 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
// Process line by line for better formatting
|
// Process line by line for better formatting
|
||||||
final lines = content.split('\n');
|
final lines = content.split('\n');
|
||||||
final formattedLines = <String>[];
|
final formattedLines = <String>[];
|
||||||
String? currentSection;
|
|
||||||
|
|
||||||
for (var line in lines) {
|
for (var line in lines) {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line.isEmpty) continue;
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
// Check if it's a section header (### Added, ### Fixed, etc.)
|
// Check if it's a section header
|
||||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||||
if (sectionMatch != null) {
|
if (sectionMatch != null) {
|
||||||
currentSection = sectionMatch.group(1)?.trim();
|
final section = sectionMatch.group(1)?.trim();
|
||||||
if (currentSection != null && currentSection.isNotEmpty) {
|
if (section != null && section.isNotEmpty) {
|
||||||
if (formattedLines.isNotEmpty) formattedLines.add('');
|
if (formattedLines.isNotEmpty) formattedLines.add('');
|
||||||
formattedLines.add('$currentSection:');
|
formattedLines.add(section);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -259,36 +337,23 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||||
if (listMatch != null) {
|
if (listMatch != null) {
|
||||||
var itemText = listMatch.group(1) ?? '';
|
var itemText = listMatch.group(1) ?? '';
|
||||||
// Remove bold markdown
|
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||||
itemText = itemText.replaceAllMapped(
|
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
|
||||||
RegExp(r'\*\*([^*]+)\*\*'),
|
|
||||||
(m) => m.group(1) ?? ''
|
|
||||||
);
|
|
||||||
// Remove code markdown
|
|
||||||
itemText = itemText.replaceAllMapped(
|
|
||||||
RegExp(r'`([^`]+)`'),
|
|
||||||
(m) => m.group(1) ?? ''
|
|
||||||
);
|
|
||||||
formattedLines.add('• $itemText');
|
formattedLines.add('• $itemText');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a sub-item (indented list)
|
// Check if it's a sub-item
|
||||||
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||||
if (subListMatch != null) {
|
if (subListMatch != null) {
|
||||||
var itemText = subListMatch.group(1) ?? '';
|
var itemText = subListMatch.group(1) ?? '';
|
||||||
itemText = itemText.replaceAllMapped(
|
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||||
RegExp(r'\*\*([^*]+)\*\*'),
|
|
||||||
(m) => m.group(1) ?? ''
|
|
||||||
);
|
|
||||||
formattedLines.add(' - $itemText');
|
formattedLines.add(' - $itemText');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var formatted = formattedLines.join('\n').trim();
|
var formatted = formattedLines.join('\n').trim();
|
||||||
|
|
||||||
// Limit length
|
|
||||||
if (formatted.length > 2000) {
|
if (formatted.length > 2000) {
|
||||||
formatted = '${formatted.substring(0, 2000)}...';
|
formatted = '${formatted.substring(0, 2000)}...';
|
||||||
}
|
}
|
||||||
@@ -297,6 +362,44 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _VersionChip extends StatelessWidget {
|
||||||
|
final String version;
|
||||||
|
final String label;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final bool isNew;
|
||||||
|
|
||||||
|
const _VersionChip({
|
||||||
|
required this.version,
|
||||||
|
required this.label,
|
||||||
|
required this.colorScheme,
|
||||||
|
this.isNew = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'v$version',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Show update dialog
|
/// Show update dialog
|
||||||
Future<void> showUpdateDialog(
|
Future<void> showUpdateDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
|
|||||||
@@ -5,26 +5,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "85.0.0"
|
version: "91.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.6.0"
|
version: "8.4.1"
|
||||||
analyzer_buffer:
|
analyzer_buffer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer_buffer
|
name: analyzer_buffer
|
||||||
sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43
|
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.10"
|
version: "0.1.11"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -61,18 +61,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
|
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "4.0.3"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_config
|
name: build_config
|
||||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.2.0"
|
||||||
build_daemon:
|
build_daemon:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,30 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.1"
|
version: "4.1.1"
|
||||||
build_resolvers:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_resolvers
|
|
||||||
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.1"
|
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
|
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.10.4"
|
||||||
build_runner_core:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_runner_core
|
|
||||||
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.2.1"
|
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -245,10 +229,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.3"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -313,22 +297,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
ffmpeg_kit_flutter_new_audio:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: ffmpeg_kit_flutter_new_audio
|
|
||||||
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.0"
|
|
||||||
ffmpeg_kit_flutter_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: ffmpeg_kit_flutter_platform_interface
|
|
||||||
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.1"
|
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -386,26 +354,34 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "18.0.1"
|
version: "19.5.0"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "6.0.0"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.0"
|
version: "9.1.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -576,6 +552,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
logger:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.2"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -620,10 +604,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mockito
|
name: mockito
|
||||||
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99"
|
sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.5.0"
|
version: "5.6.1"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -876,18 +860,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.4"
|
version: "12.0.1"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: share_plus_platform_interface
|
name: share_plus_platform_interface
|
||||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.2"
|
version: "6.1.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -985,18 +969,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3"
|
sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "4.1.1"
|
||||||
source_helper:
|
source_helper:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_helper
|
name: source_helper
|
||||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.7"
|
version: "1.3.8"
|
||||||
source_map_stack_trace:
|
source_map_stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1149,14 +1133,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.1"
|
version: "0.10.1"
|
||||||
timing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: timing
|
|
||||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.5.5+22
|
version: 2.0.7+37
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -46,21 +46,22 @@ dependencies:
|
|||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^10.1.4
|
share_plus: ^12.0.1
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg for audio conversion (audio-only version - much smaller)
|
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
||||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^18.0.1
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
build_runner: ^2.4.15
|
build_runner: ^2.10.4
|
||||||
riverpod_generator: ^4.0.0
|
riverpod_generator: ^4.0.0
|
||||||
json_serializable: ^6.11.2
|
json_serializable: ^6.11.2
|
||||||
flutter_launcher_icons: ^0.14.3
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: spotiflac_android
|
||||||
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 2.0.7+37
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.10.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# State Management
|
||||||
|
flutter_riverpod: ^3.1.0
|
||||||
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^17.0.1
|
||||||
|
|
||||||
|
# Storage & Persistence
|
||||||
|
shared_preferences: ^2.5.3
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
|
# HTTP & Network
|
||||||
|
http: ^1.4.0
|
||||||
|
dio: ^5.8.0
|
||||||
|
|
||||||
|
# UI Components
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
cached_network_image: ^3.4.1
|
||||||
|
flutter_svg: ^2.1.0
|
||||||
|
|
||||||
|
# Material Expressive 3 / Dynamic Color
|
||||||
|
dynamic_color: ^1.7.0
|
||||||
|
material_color_utilities: ^0.11.1
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
|
# File Picker
|
||||||
|
file_picker: ^10.3.0
|
||||||
|
|
||||||
|
# JSON Serialization
|
||||||
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
url_launcher: ^6.3.1
|
||||||
|
device_info_plus: ^12.3.0
|
||||||
|
share_plus: ^12.0.1
|
||||||
|
receive_sharing_intent: ^1.8.1
|
||||||
|
logger: ^2.5.0
|
||||||
|
|
||||||
|
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
||||||
|
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||||
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
build_runner: ^2.10.4
|
||||||
|
riverpod_generator: ^4.0.0
|
||||||
|
json_serializable: ^6.11.2
|
||||||
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: "icon.png"
|
||||||
|
adaptive_icon_background: "#1a1a2e"
|
||||||
|
adaptive_icon_foreground: "icon.png"
|
||||||
|
ios_content_mode: scaleAspectFill
|
||||||
|
remove_alpha_ios: true
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/images/
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
|
||||||
- Default: Sequential (1 at a time) for stability
|
|
||||||
- Options: 1, 2, or 3 concurrent downloads
|
|
||||||
- Warning about potential rate limiting from streaming services
|
|
||||||
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
|
|
||||||
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
|
||||||
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
|
||||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
|
||||||
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
|
||||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
|
||||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
|
||||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Updated version to 1.1.0
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
|
||||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
|
||||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
|
||||||
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
|
|
||||||
- Added shared `http.Transport` with connection pooling in `httputil.go`
|
|
||||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
|
||||||
|
|
||||||
## [1.0.5] - Previous Release
|
|
||||||
- Material Expressive 3 UI
|
|
||||||
- Dynamic color support
|
|
||||||
- Swipe navigation with PageView
|
|
||||||
- Settings as bottom navigation tab
|
|
||||||
- APK size optimization
|
|
||||||