mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-18 14:24:53 +02:00
Merge dev into main: v3.6.7 release
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## [3.6.7] - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
- "Advanced Filename Templates" - new placeholders for custom track/disc formatting and date patterns
|
||||
- `{track_raw}` and `{disc_raw}` - unpadded raw numbers
|
||||
- `{track:N}` and `{disc:N}` - zero-padded to N digits (e.g. `{track:02}` → `01`)
|
||||
- `{date}` - full release date from metadata
|
||||
- `{date:%Y-%m-%d}` - date formatting with strftime patterns
|
||||
- "Show advanced tags" toggle in Settings > Download > Filename Format to reveal these placeholders
|
||||
- Low-RAM / ARM32-only device profiling - detects constrained devices at startup and reduces image cache (120 items / 24 MiB) and disables overscroll effects for smoother performance
|
||||
- Responsive selection bar on artist screen - switches to compact stacked layout on narrow screens (< 430dp) or large text scale (> 1.15x)
|
||||
- Quality picker dialog before downloading individual tracks from artist screen (when "Ask quality before download" is enabled)
|
||||
- Project website with GitHub Pages deployment workflow
|
||||
- Mobile burger menu navigation for all site pages
|
||||
- Go filename template test suite
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed ICU plural syntax errors in DE, ES, PT, RU translations - incorrect `=1` clause was causing missing plural forms
|
||||
- Fixed featured-artist regex incorrectly splitting on `&` character (e.g. "Simon & Garfunkel" was being split) - removed `&` from separator pattern
|
||||
- Fixed `{date}` placeholder not working in filename templates - release date was not being passed to the template builder across all providers (Amazon, Qobuz, Tidal, YouTube, extensions)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved Go backend metadata handling - filename builder now supports fallback metadata keys and automatic type conversion for more robust template rendering
|
||||
- Extension providers now pass full metadata set to filename builder (track, disc, year, date, release_date)
|
||||
- Updated translations: added filename advanced tags strings (EN, ID), regenerated all locale dart files
|
||||
- Updated app screenshot assets
|
||||
|
||||
---
|
||||
|
||||
## [3.6.6] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
|
||||
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
|
||||
- Collapsible "Artist Name Filters" section in download settings UI
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
|
||||
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
|
||||
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
|
||||
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
|
||||
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
|
||||
- Fixed Track Metadata screen showing scan date instead of file date for local library items
|
||||
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
|
||||
- Updated translations from Crowdin (all 14 languages)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.5] - 2026-02-10
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -441,6 +441,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
var outputPath string
|
||||
|
||||
+190
-44
@@ -43,6 +43,7 @@ type OggQuality struct {
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Duration int
|
||||
Bitrate int // estimated bitrate in bps
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -664,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
|
||||
file.Seek(audioStart, io.SeekStart)
|
||||
|
||||
// Find first valid MP3 frame sync
|
||||
frameHeader := make([]byte, 4)
|
||||
for i := 0; i < 10000; i++ { // Search first 10KB
|
||||
var frameStart int64 = -1
|
||||
for i := 0; i < 10000; i++ {
|
||||
if _, err := io.ReadFull(file, frameHeader); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
|
||||
version := (frameHeader[1] >> 3) & 0x03
|
||||
layer := (frameHeader[1] >> 1) & 0x03
|
||||
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
|
||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||
|
||||
sampleRates := [][]int{
|
||||
{11025, 12000, 8000},
|
||||
{0, 0, 0},
|
||||
{22050, 24000, 16000},
|
||||
{44100, 48000, 32000},
|
||||
}
|
||||
if version < 4 && sampleRateIdx < 3 {
|
||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||
}
|
||||
|
||||
if version == 3 && layer == 1 {
|
||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||
if bitrateIdx < 16 {
|
||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||
}
|
||||
}
|
||||
|
||||
quality.BitDepth = 16
|
||||
|
||||
if quality.Bitrate > 0 {
|
||||
audioSize := fileSize - audioStart - 128
|
||||
if audioSize > 0 {
|
||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||
}
|
||||
}
|
||||
|
||||
pos, _ := file.Seek(0, io.SeekCurrent)
|
||||
frameStart = pos - 4
|
||||
break
|
||||
}
|
||||
|
||||
file.Seek(-3, io.SeekCurrent)
|
||||
}
|
||||
|
||||
if frameStart < 0 {
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
version := (frameHeader[1] >> 3) & 0x03
|
||||
layer := (frameHeader[1] >> 1) & 0x03
|
||||
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
|
||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||
channelMode := (frameHeader[3] >> 6) & 0x03
|
||||
|
||||
// Sample rate tables: [version][index]
|
||||
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
|
||||
sampleRates := [][]int{
|
||||
{11025, 12000, 8000},
|
||||
{0, 0, 0},
|
||||
{22050, 24000, 16000},
|
||||
{44100, 48000, 32000},
|
||||
}
|
||||
if version < 4 && sampleRateIdx < 3 {
|
||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||
}
|
||||
|
||||
// Bitrate tables for all MPEG versions and layers
|
||||
// MPEG1 Layer III
|
||||
if version == 3 && layer == 1 {
|
||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||
if bitrateIdx < 16 {
|
||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||
}
|
||||
}
|
||||
// MPEG2/2.5 Layer III
|
||||
if (version == 0 || version == 2) && layer == 1 {
|
||||
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
||||
if bitrateIdx < 16 {
|
||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||
}
|
||||
}
|
||||
|
||||
// Determine samples per frame for duration calculation
|
||||
samplesPerFrame := 1152 // MPEG1 Layer III
|
||||
if version == 0 || version == 2 {
|
||||
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
||||
}
|
||||
|
||||
// Try to read Xing/VBRI header from the first frame for VBR info
|
||||
// Xing header offset depends on MPEG version and channel mode
|
||||
var xingOffset int
|
||||
if version == 3 { // MPEG1
|
||||
if channelMode == 3 { // Mono
|
||||
xingOffset = 17
|
||||
} else {
|
||||
xingOffset = 32
|
||||
}
|
||||
} else { // MPEG2/2.5
|
||||
if channelMode == 3 {
|
||||
xingOffset = 9
|
||||
} else {
|
||||
xingOffset = 17
|
||||
}
|
||||
}
|
||||
|
||||
// Read enough of the first frame to find Xing/VBRI header
|
||||
xingBuf := make([]byte, 200)
|
||||
file.Seek(frameStart+4, io.SeekStart)
|
||||
n, _ := io.ReadFull(file, xingBuf)
|
||||
xingBuf = xingBuf[:n]
|
||||
|
||||
vbrFrames := 0
|
||||
vbrBytes := int64(0)
|
||||
isVBR := false
|
||||
|
||||
// Check for Xing/Info header
|
||||
if xingOffset+8 <= n {
|
||||
tag := string(xingBuf[xingOffset : xingOffset+4])
|
||||
if tag == "Xing" || tag == "Info" {
|
||||
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
|
||||
off := xingOffset + 8
|
||||
if flags&0x01 != 0 && off+4 <= n { // Frames flag
|
||||
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
|
||||
off += 4
|
||||
}
|
||||
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
|
||||
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
|
||||
}
|
||||
if vbrFrames > 0 {
|
||||
isVBR = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for VBRI header (always at offset 32 from frame start + 4)
|
||||
if !isVBR && 36+26 <= n {
|
||||
if string(xingBuf[32:36]) == "VBRI" {
|
||||
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
||||
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
|
||||
if vbrFrames > 0 {
|
||||
isVBR = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
||||
// Accurate duration from total frames
|
||||
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
||||
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
||||
|
||||
// Accurate average bitrate
|
||||
if vbrBytes > 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
||||
} else if quality.Duration > 0 {
|
||||
audioSize := fileSize - audioStart
|
||||
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
} else if quality.Bitrate > 0 {
|
||||
// CBR fallback: estimate duration from file size and frame bitrate
|
||||
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
||||
if audioSize > 0 {
|
||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||
}
|
||||
}
|
||||
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
@@ -981,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
defer file.Close()
|
||||
|
||||
quality := &OggQuality{}
|
||||
isOpus := false
|
||||
|
||||
packets, err := collectOggPackets(file, 5, 10)
|
||||
if err != nil && len(packets) == 0 {
|
||||
@@ -997,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if streamType == oggStreamOpus {
|
||||
isOpus = true
|
||||
isOpus := streamType == oggStreamOpus
|
||||
var preSkip int
|
||||
|
||||
if isOpus {
|
||||
for _, pkt := range packets {
|
||||
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||
if quality.SampleRate == 0 {
|
||||
quality.SampleRate = 48000
|
||||
}
|
||||
quality.BitDepth = 16
|
||||
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1013,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
for _, pkt := range packets {
|
||||
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||
quality.BitDepth = 16
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read granule position from the last Ogg page for accurate duration
|
||||
stat, err := file.Stat()
|
||||
if err == nil {
|
||||
// Very rough duration estimate based on file size
|
||||
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
|
||||
avgBitrate := 128000
|
||||
if !isOpus {
|
||||
avgBitrate = 160000
|
||||
if err != nil {
|
||||
return quality, nil
|
||||
}
|
||||
fileSize := stat.Size()
|
||||
|
||||
granule := readLastOggGranulePosition(file, fileSize)
|
||||
if granule > 0 {
|
||||
if isOpus {
|
||||
// Opus always uses 48kHz granule position internally
|
||||
totalSamples := granule - int64(preSkip)
|
||||
if totalSamples > 0 {
|
||||
quality.Duration = int(totalSamples / 48000)
|
||||
}
|
||||
} else if quality.SampleRate > 0 {
|
||||
quality.Duration = int(granule / int64(quality.SampleRate))
|
||||
}
|
||||
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate))
|
||||
}
|
||||
|
||||
// Calculate average bitrate from file size and actual duration
|
||||
if quality.Duration > 0 {
|
||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
// readLastOggGranulePosition seeks to the end of the file and scans backwards
|
||||
// to find the last Ogg page, then reads its granule position (bytes 6-13).
|
||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
// Read the last chunk of the file to find the last OggS sync
|
||||
searchSize := int64(65536)
|
||||
if searchSize > fileSize {
|
||||
searchSize = fileSize
|
||||
}
|
||||
|
||||
buf := make([]byte, searchSize)
|
||||
offset := fileSize - searchSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
n, err := file.ReadAt(buf, offset)
|
||||
if err != nil && n == 0 {
|
||||
return 0
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
// Scan backwards for "OggS" magic
|
||||
lastPageOffset := -1
|
||||
for i := n - 4; i >= 0; i-- {
|
||||
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
|
||||
lastPageOffset = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lastPageOffset < 0 || lastPageOffset+14 > n {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
|
||||
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ID3v1 Genre List
|
||||
// =============================================================================
|
||||
|
||||
+102
-29
@@ -213,6 +213,9 @@ type DownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
@@ -260,6 +263,21 @@ func buildDownloadSuccessResponse(
|
||||
isrc = req.ISRC
|
||||
}
|
||||
|
||||
genre := result.Genre
|
||||
if genre == "" {
|
||||
genre = req.Genre
|
||||
}
|
||||
|
||||
label := result.Label
|
||||
if label == "" {
|
||||
label = req.Label
|
||||
}
|
||||
|
||||
copyright := result.Copyright
|
||||
if copyright == "" {
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
@@ -277,14 +295,85 @@ func buildDownloadSuccessResponse(
|
||||
DiscNumber: discNumber,
|
||||
ISRC: isrc,
|
||||
CoverURL: req.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipQualityProbe(filePath string) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return true
|
||||
}
|
||||
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
|
||||
if strings.Contains(path, "://") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(result.FilePath)
|
||||
if shouldSkipQualityProbe(path) {
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
quality, qErr := GetAudioQuality(path)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
return
|
||||
}
|
||||
|
||||
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
|
||||
}
|
||||
|
||||
func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deezerClient := GetDeezerClient()
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
if err != nil || extMeta == nil {
|
||||
if err != nil {
|
||||
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
@@ -303,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
@@ -390,11 +481,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
quality, qErr := GetAudioQuality(actualPath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
result.FilePath = actualPath
|
||||
enrichResultQualityFromFile(&result)
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
@@ -407,14 +495,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
quality, qErr := GetAudioQuality(result.FilePath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
enrichResultQualityFromFile(&result)
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
@@ -488,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||
preferredService := req.Service
|
||||
if preferredService == "" {
|
||||
@@ -585,11 +668,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
if err == nil {
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
quality, qErr := GetAudioQuality(actualPath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
result.FilePath = actualPath
|
||||
enrichResultQualityFromFile(&result)
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
@@ -602,14 +682,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
quality, qErr := GetAudioQuality(result.FilePath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
enrichResultQualityFromFile(&result)
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
|
||||
@@ -1136,8 +1136,13 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
}
|
||||
|
||||
|
||||
+234
-29
@@ -3,28 +3,35 @@ package gobackend
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
var (
|
||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
multiUnderscore = regexp.MustCompile(`_+`)
|
||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||
)
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
multiUnderscore := regexp.MustCompile(`_+`)
|
||||
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
}
|
||||
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
}
|
||||
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -32,45 +39,120 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
}
|
||||
|
||||
result := template
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{year}": getString(metadata, "year"),
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
|
||||
result := replaceFormattedNumberPlaceholders(template, metadata)
|
||||
result = replaceDateFormatPlaceholders(result, metadata)
|
||||
|
||||
dateValue := getDateValue(metadata)
|
||||
yearValue := getString(metadata, "year")
|
||||
if yearValue == "" {
|
||||
yearValue = extractYear(dateValue)
|
||||
}
|
||||
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||
"{year}": yearValue,
|
||||
"{date}": dateValue,
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||
}
|
||||
|
||||
for placeholder, value := range placeholders {
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
number := getInt(metadata, parts[1])
|
||||
width, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatNumberWithWidth(number, width)
|
||||
})
|
||||
}
|
||||
|
||||
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatDateWithPattern(getDateValue(metadata), parts[1])
|
||||
})
|
||||
}
|
||||
|
||||
func getDateValue(metadata map[string]interface{}) string {
|
||||
date := getString(metadata, "date")
|
||||
if date != "" {
|
||||
return date
|
||||
}
|
||||
|
||||
releaseDate := getString(metadata, "release_date")
|
||||
if releaseDate != "" {
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
return getString(metadata, "year")
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case float64:
|
||||
return strconv.Itoa(int(value))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
candidateKeys := []string{key}
|
||||
switch key {
|
||||
case "track":
|
||||
candidateKeys = append(candidateKeys, "track_number")
|
||||
case "disc":
|
||||
candidateKeys = append(candidateKeys, "disc_number")
|
||||
}
|
||||
|
||||
for _, candidate := range candidateKeys {
|
||||
if v, ok := m[candidate]; ok {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(n))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -88,6 +170,129 @@ func formatDiscNumber(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
func formatRawNumber(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
func formatNumberWithWidth(n int, width int) string {
|
||||
if n <= 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if width <= 1 {
|
||||
return formatRawNumber(n)
|
||||
}
|
||||
return fmt.Sprintf("%0*d", width, n)
|
||||
}
|
||||
|
||||
func formatDateWithPattern(rawDate string, strftimePattern string) string {
|
||||
if rawDate == "" || strftimePattern == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsedDate, ok := parseMetadataDate(rawDate)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
goLayout := convertStrftimeToGoLayout(strftimePattern)
|
||||
if goLayout == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsedDate.Format(goLayout)
|
||||
}
|
||||
|
||||
func parseMetadataDate(rawDate string) (time.Time, bool) {
|
||||
clean := strings.TrimSpace(rawDate)
|
||||
if clean == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
"2006-01",
|
||||
"2006",
|
||||
"2006/01/02",
|
||||
"2006/01",
|
||||
"2006.01.02",
|
||||
"2006.01",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, clean)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
if len(clean) >= 10 {
|
||||
parsed, err := time.Parse("2006-01-02", clean[:10])
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
yearMatch := yearPattern.FindString(clean)
|
||||
if yearMatch == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
year, err := strconv.Atoi(yearMatch)
|
||||
if err != nil || year <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
|
||||
func convertStrftimeToGoLayout(pattern string) string {
|
||||
if pattern == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < len(pattern); i++ {
|
||||
ch := pattern[i]
|
||||
if ch != '%' {
|
||||
builder.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
if i+1 >= len(pattern) {
|
||||
builder.WriteByte('%')
|
||||
break
|
||||
}
|
||||
|
||||
i++
|
||||
switch pattern[i] {
|
||||
case 'Y':
|
||||
builder.WriteString("2006")
|
||||
case 'y':
|
||||
builder.WriteString("06")
|
||||
case 'm':
|
||||
builder.WriteString("01")
|
||||
case 'd':
|
||||
builder.WriteString("02")
|
||||
case 'b':
|
||||
builder.WriteString("Jan")
|
||||
case 'B':
|
||||
builder.WriteString("January")
|
||||
case '%':
|
||||
builder.WriteByte('%')
|
||||
default:
|
||||
builder.WriteByte('%')
|
||||
builder.WriteByte(pattern[i])
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func extractYear(date string) string {
|
||||
if len(date) >= 4 {
|
||||
return date[:4]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"album": "Album Name",
|
||||
"track": 1,
|
||||
"disc": 2,
|
||||
"year": "2025",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {track} - {track_raw} - d{disc} - d{disc_raw} - {title}",
|
||||
metadata,
|
||||
)
|
||||
|
||||
expected := "Artist Name - 01 - 1 - d2 - d2 - Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"track": 0,
|
||||
"disc": 0,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{track_raw}-{disc_raw}-{title}", metadata)
|
||||
expected := "--Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"track": 3,
|
||||
"disc": 2,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{track:1}-{track:02}-{disc:03}", metadata)
|
||||
expected := "3-03-002"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"release_date": "2024-03-09",
|
||||
"track_number": 7,
|
||||
"disc_number": 1,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {track:02} - {title} - {date:%Y-%m-%d} - {year}",
|
||||
metadata,
|
||||
)
|
||||
expected := "Artist Name - 07 - Song Name - 2024-03-09 - 2024"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"date": "2019",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{date:%Y}-{date:%m}-{date:%d}", metadata)
|
||||
expected := "2019-01-01"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.7
|
||||
toolchain go1.26.0
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
@@ -10,8 +10,8 @@ require (
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
||||
golang.org/x/net v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -20,10 +20,10 @@ require (
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -30,20 +30,34 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
|
||||
@@ -1180,6 +1180,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
var outputPath string
|
||||
|
||||
@@ -1609,6 +1609,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
|
||||
@@ -500,6 +500,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
@@ -46,6 +46,11 @@ post_install do |installer|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
|
||||
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
|
||||
definitions << 'PERMISSION_NOTIFICATIONS=1'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,18 +83,12 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadTrack":
|
||||
case "downloadByStrategy":
|
||||
let requestJson = call.arguments as! String
|
||||
let response = GobackendDownloadTrack(requestJson, &error)
|
||||
let response = GobackendDownloadByStrategy(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadWithFallback":
|
||||
let requestJson = call.arguments as! String
|
||||
let response = GobackendDownloadWithFallback(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
|
||||
case "getDownloadProgress":
|
||||
let response = GobackendGetDownloadProgress()
|
||||
return response
|
||||
@@ -209,6 +203,41 @@ import Gobackend // Import Go framework
|
||||
case "cleanupConnections":
|
||||
GobackendCleanupConnections()
|
||||
return nil
|
||||
|
||||
case "downloadCoverToFile":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let coverURL = args["cover_url"] as! String
|
||||
let outputPath = args["output_path"] as! String
|
||||
let maxQuality = args["max_quality"] as? Bool ?? true
|
||||
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "extractCoverToFile":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let audioPath = args["audio_path"] as! String
|
||||
let outputPath = args["output_path"] as! String
|
||||
GobackendExtractCoverToFile(audioPath, outputPath, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "fetchAndSaveLyrics":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let trackName = args["track_name"] as! String
|
||||
let artistName = args["artist_name"] as! String
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||
let outputPath = args["output_path"] as! String
|
||||
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "reEnrichFile":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let requestJson = args["request_json"] as? String ?? "{}"
|
||||
let response = GobackendReEnrichFile(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "readFileMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -479,12 +508,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadWithExtensions":
|
||||
let requestJson = call.arguments as! String
|
||||
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "enrichTrackWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
@@ -492,6 +515,12 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadWithExtensions":
|
||||
let requestJson = call.arguments as! String
|
||||
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "removeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
||||
+19
-15
@@ -10,9 +10,13 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
final _routerProvider = Provider<GoRouter>((ref) {
|
||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
||||
|
||||
final isFirstLaunch = ref.watch(
|
||||
settingsProvider.select((s) => s.isFirstLaunch),
|
||||
);
|
||||
final hasCompletedTutorial = ref.watch(
|
||||
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||
);
|
||||
|
||||
// Determine initial location based on app state
|
||||
String initialLocation;
|
||||
if (isFirstLaunch) {
|
||||
@@ -22,18 +26,12 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
} else {
|
||||
initialLocation = '/';
|
||||
}
|
||||
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const MainShell(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/setup',
|
||||
builder: (context, state) => const SetupScreen(),
|
||||
),
|
||||
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
|
||||
GoRoute(
|
||||
path: '/tutorial',
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
@@ -43,13 +41,18 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
});
|
||||
|
||||
class SpotiFLACApp extends ConsumerWidget {
|
||||
const SpotiFLACApp({super.key});
|
||||
final bool disableOverscrollEffects;
|
||||
|
||||
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(_routerProvider);
|
||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||
|
||||
final scrollBehavior = disableOverscrollEffects
|
||||
? const MaterialScrollBehavior().copyWith(overscroll: false)
|
||||
: null;
|
||||
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
if (localeString.contains('_')) {
|
||||
@@ -59,7 +62,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return DynamicColorWrapper(
|
||||
builder: (lightTheme, darkTheme, themeMode) {
|
||||
return MaterialApp.router(
|
||||
@@ -68,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
themeMode: themeMode,
|
||||
scrollBehavior: scrollBehavior,
|
||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
routerConfig: router,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.6.5';
|
||||
static const String buildNumber = '79';
|
||||
static const String version = '3.6.7';
|
||||
static const String buildNumber = '81';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -2152,6 +2152,18 @@ abstract class AppLocalizations {
|
||||
/// **'{artist} - {title}'**
|
||||
String filenameHint(Object artist, Object title);
|
||||
|
||||
/// Toggle label for showing advanced filename tags
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show advanced tags'**
|
||||
String get filenameShowAdvancedTags;
|
||||
|
||||
/// Description for advanced filename tag toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable formatted tags for track padding and date patterns'**
|
||||
String get filenameShowAdvancedTagsDescription;
|
||||
|
||||
/// Setting title - folder structure
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
+246
-232
File diff suppressed because it is too large
Load Diff
@@ -1182,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,62 +13,62 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get appDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
String get navHome => 'Accueil';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
String get navLibrary => 'Bibliothèques';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
String get navHistory => 'Historique';
|
||||
|
||||
@override
|
||||
String get navSettings => 'Settings';
|
||||
String get navSettings => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => 'Magasin';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
String get homeTitle => 'Accueil';
|
||||
|
||||
@override
|
||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
||||
String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...';
|
||||
|
||||
@override
|
||||
String homeSearchHintExtension(String extensionName) {
|
||||
return 'Search with $extensionName...';
|
||||
return 'Rechercher avec $extensionName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => 'Récent';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
String get historyTitle => 'Historique';
|
||||
|
||||
@override
|
||||
String historyDownloading(int count) {
|
||||
return 'Downloading ($count)';
|
||||
return 'Téléchargement ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get historyDownloaded => 'Downloaded';
|
||||
String get historyDownloaded => 'Téléchargé';
|
||||
|
||||
@override
|
||||
String get historyFilterAll => 'All';
|
||||
String get historyFilterAll => 'Tous';
|
||||
|
||||
@override
|
||||
String get historyFilterAlbums => 'Albums';
|
||||
|
||||
@override
|
||||
String get historyFilterSingles => 'Singles';
|
||||
String get historyFilterSingles => 'Titres';
|
||||
|
||||
@override
|
||||
String historyTracksCount(int count) {
|
||||
@@ -93,36 +93,37 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get historyNoDownloads => 'No download history';
|
||||
String get historyNoDownloads => 'Pas d\'historique de téléchargement';
|
||||
|
||||
@override
|
||||
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
|
||||
String get historyNoDownloadsSubtitle =>
|
||||
'Les pistes téléchargées apparaîtront ici';
|
||||
|
||||
@override
|
||||
String get historyNoAlbums => 'No album downloads';
|
||||
String get historyNoAlbums => 'Pas de téléchargement d\'album';
|
||||
|
||||
@override
|
||||
String get historyNoAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
'Téléchargez plusieurs titres d\'un album pour les voir ici';
|
||||
|
||||
@override
|
||||
String get historyNoSingles => 'No single downloads';
|
||||
String get historyNoSingles => 'Pas de téléchargements uniques';
|
||||
|
||||
@override
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
'Les téléchargements de pistes uniques apparaîtront ici';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
String get historySearchHint => 'Historique de recherche...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
String get settingsTitle => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get settingsDownload => 'Download';
|
||||
String get settingsDownload => 'Télécharger';
|
||||
|
||||
@override
|
||||
String get settingsAppearance => 'Appearance';
|
||||
String get settingsAppearance => 'Apparence';
|
||||
|
||||
@override
|
||||
String get settingsOptions => 'Options';
|
||||
@@ -131,51 +132,54 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get settingsExtensions => 'Extensions';
|
||||
|
||||
@override
|
||||
String get settingsAbout => 'About';
|
||||
String get settingsAbout => 'À propos';
|
||||
|
||||
@override
|
||||
String get downloadTitle => 'Download';
|
||||
String get downloadTitle => 'Télécharger';
|
||||
|
||||
@override
|
||||
String get downloadLocation => 'Download Location';
|
||||
String get downloadLocation => 'Télécharger Localisation';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle => 'Choose where to save files';
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choisissez où enregistrer des fichiers';
|
||||
|
||||
@override
|
||||
String get downloadLocationDefault => 'Default location';
|
||||
String get downloadLocationDefault => 'Localisation par défaut';
|
||||
|
||||
@override
|
||||
String get downloadDefaultService => 'Default Service';
|
||||
String get downloadDefaultService => 'Service par défaut';
|
||||
|
||||
@override
|
||||
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
|
||||
String get downloadDefaultServiceSubtitle =>
|
||||
'Service utilisé pour les téléchargements';
|
||||
|
||||
@override
|
||||
String get downloadDefaultQuality => 'Default Quality';
|
||||
String get downloadDefaultQuality => 'Qualité par défaut';
|
||||
|
||||
@override
|
||||
String get downloadAskQuality => 'Ask Quality Before Download';
|
||||
String get downloadAskQuality =>
|
||||
'Demandez La Qualité Avant Le Téléchargement';
|
||||
|
||||
@override
|
||||
String get downloadAskQualitySubtitle =>
|
||||
'Show quality picker for each download';
|
||||
'Afficher le sélecteur de qualité pour chaque téléchargement';
|
||||
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
String get downloadFilenameFormat => 'Nom du fichier';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
String get downloadFolderOrganization => 'Organisation du dossier';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSingles => 'Separate Singles';
|
||||
String get downloadSeparateSingles => 'Titres séparés';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesSubtitle =>
|
||||
'Put single tracks in a separate folder';
|
||||
'Mettre des pistes uniques dans un dossier séparé';
|
||||
|
||||
@override
|
||||
String get qualityBest => 'Best Available';
|
||||
String get qualityBest => 'Meilleur Disponible';
|
||||
|
||||
@override
|
||||
String get qualityFlac => 'FLAC';
|
||||
@@ -187,69 +191,71 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get quality128 => '128 kbps';
|
||||
|
||||
@override
|
||||
String get appearanceTitle => 'Appearance';
|
||||
String get appearanceTitle => 'Apparence';
|
||||
|
||||
@override
|
||||
String get appearanceTheme => 'Theme';
|
||||
String get appearanceTheme => 'Thème';
|
||||
|
||||
@override
|
||||
String get appearanceThemeSystem => 'System';
|
||||
String get appearanceThemeSystem => 'Système';
|
||||
|
||||
@override
|
||||
String get appearanceThemeLight => 'Light';
|
||||
String get appearanceThemeLight => 'Clair';
|
||||
|
||||
@override
|
||||
String get appearanceThemeDark => 'Dark';
|
||||
String get appearanceThemeDark => 'Sombre';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColor => 'Dynamic Color';
|
||||
String get appearanceDynamicColor => 'Couleur dynamique';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
||||
String get appearanceDynamicColorSubtitle =>
|
||||
'Utilisez les couleurs de votre fond d\'écran';
|
||||
|
||||
@override
|
||||
String get appearanceAccentColor => 'Accent Color';
|
||||
String get appearanceAccentColor => 'Couleur d\'accent';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryView => 'History View';
|
||||
String get appearanceHistoryView => 'Historique Vue';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryViewList => 'List';
|
||||
String get appearanceHistoryViewList => '';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryViewGrid => 'Grid';
|
||||
String get appearanceHistoryViewGrid => 'Grille';
|
||||
|
||||
@override
|
||||
String get optionsTitle => 'Options';
|
||||
|
||||
@override
|
||||
String get optionsSearchSource => 'Search Source';
|
||||
String get optionsSearchSource => 'Recherche Source';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProvider => 'Primary Provider';
|
||||
String get optionsPrimaryProvider => 'Fournisseur principal';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service utilisé lors de la recherche par nom de piste.';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
return 'Using extension: $extensionName';
|
||||
return 'Utilisation de l\'extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
'Essayez d\'autres services si le téléchargement échoue';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
String get optionsUseExtensionProviders =>
|
||||
'Utiliser des fournisseurs d\'extension';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
@@ -376,16 +382,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionsUninstall => 'Uninstall';
|
||||
String get extensionsUninstall => 'Désinstaller';
|
||||
|
||||
@override
|
||||
String get extensionsSetAsSearch => 'Set as Search Provider';
|
||||
String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Extension Store';
|
||||
String get storeTitle => 'Magasin d\'extension';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Search extensions...';
|
||||
String get storeSearch => 'Recherche d\'extensions...';
|
||||
|
||||
@override
|
||||
String get storeInstall => 'Install';
|
||||
@@ -567,7 +573,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackMetadataDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get trackMetadataQuality => 'Quality';
|
||||
String get trackMetadataQuality => '';
|
||||
|
||||
@override
|
||||
String get trackMetadataPath => 'File Path';
|
||||
@@ -579,38 +585,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackMetadataService => 'Service';
|
||||
|
||||
@override
|
||||
String get trackMetadataPlay => 'Play';
|
||||
String get trackMetadataPlay => 'Jouer';
|
||||
|
||||
@override
|
||||
String get trackMetadataShare => 'Share';
|
||||
String get trackMetadataShare => 'Partager';
|
||||
|
||||
@override
|
||||
String get trackMetadataDelete => 'Delete';
|
||||
String get trackMetadataDelete => 'Supprimer';
|
||||
|
||||
@override
|
||||
String get trackMetadataRedownload => 'Re-download';
|
||||
String get trackMetadataRedownload => 'Re-télécharger';
|
||||
|
||||
@override
|
||||
String get trackMetadataOpenFolder => 'Open Folder';
|
||||
String get trackMetadataOpenFolder => 'Dossier ouvert';
|
||||
|
||||
@override
|
||||
String get setupTitle => 'Welcome to SpotiFLAC';
|
||||
String get setupTitle => 'Bienvenue chez SpotiFLAC';
|
||||
|
||||
@override
|
||||
String get setupSubtitle => 'Let\'s get you started';
|
||||
String get setupSubtitle => 'On va commencer';
|
||||
|
||||
@override
|
||||
String get setupStoragePermission => 'Storage Permission';
|
||||
String get setupStoragePermission => 'Permission de stockage';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionSubtitle =>
|
||||
'Required to save downloaded files';
|
||||
'Requis pour enregistrer les fichiers téléchargés';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionGranted => 'Permission granted';
|
||||
String get setupStoragePermissionGranted => 'Permission accordée';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionDenied => 'Permission denied';
|
||||
String get setupStoragePermissionDenied => 'Permission refusée';
|
||||
|
||||
@override
|
||||
String get setupGrantPermission => 'Grant Permission';
|
||||
@@ -735,14 +741,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'Get notified when downloads complete or require attention.';
|
||||
|
||||
@override
|
||||
String get setupFolderSelected => 'Download Folder Selected!';
|
||||
String get setupFolderSelected => 'Dossier de téléchargement sélectionné!';
|
||||
|
||||
@override
|
||||
String get setupFolderChoose => 'Choose Download Folder';
|
||||
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
|
||||
|
||||
@override
|
||||
String get setupFolderDescription =>
|
||||
'Select a folder where your downloaded music will be saved.';
|
||||
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
|
||||
|
||||
@override
|
||||
String get setupChangeFolder => 'Change Folder';
|
||||
@@ -1182,6 +1188,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
|
||||
@@ -1182,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
|
||||
+113
-112
@@ -349,7 +349,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Ekstensi';
|
||||
@@ -1188,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Organisasi Folder';
|
||||
|
||||
@@ -1941,27 +1948,26 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Gunakan Album Artist untuk folder';
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Folder artis memakai Album Artist jika tersedia';
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Folder artis hanya memakai Track Artist';
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Nama artis lengkap dipakai untuk folder';
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Simpan Format';
|
||||
@@ -2200,10 +2206,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'Belum ada item terbaru';
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Tampilkan Semua Download';
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
@@ -2312,10 +2318,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Penyimpanan & Cache';
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
@@ -2590,221 +2596,219 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Metadata, cover art, dan lirik otomatis tertanam';
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Mencari Musik';
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Atau ketik nama lagu, artis, atau album untuk mencari';
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Mendukung lagu, album, playlist, dan halaman artis';
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Unduh seluruh album atau playlist dengan satu ketukan';
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'Lihat progres unduhan dan antrian di tab Perpustakaan';
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Ekstensi';
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Tambahkan provider unduhan atau sumber pencarian baru';
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Ubah lokasi unduhan dan organisasi folder';
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Atur kualitas audio dan preferensi format default';
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'CONTOH';
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Pindai Ulang Penuh';
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle =>
|
||||
'Pindai ulang semua file, abaikan cache';
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Hapus entri riwayat untuk file yang tidak ada lagi';
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone =>
|
||||
'Tidak ada entri unduhan tidak valid';
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Penyimpanan & Cache';
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Ringkasan cache';
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimasi penggunaan cache: $size';
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Data Cache';
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Perawatan';
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'Direktori cache aplikasi';
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'Respons HTTP, data WebView, dan data sementara aplikasi.';
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Direktori sementara';
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'File sementara dari proses download dan konversi audio.';
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cache gambar cover';
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Cache cover library';
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Cache feed Explore';
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Cache pencocokan lagu';
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'Tidak ada data cache';
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size dalam $count file';
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2814,126 +2818,123 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entri';
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Berhasil dibersihkan: $target';
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Bersihkan cache?';
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Bersihkan semua cache';
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Segarkan statistik';
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Simpan Cover Art';
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle =>
|
||||
'Simpan cover album sebagai file .jpg';
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Ambil dan simpan lirik sebagai file .lrc';
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Tanamkan ulang metadata tanpa mengunduh ulang';
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Cari metadata dari internet dan tanamkan ke file';
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art disimpan ke $fileName';
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'Tidak ada sumber cover art';
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lirik disimpan ke $fileName';
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Gagal menanamkan metadata via FFmpeg';
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Gagal: $error';
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Konversi Format';
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Konversi Audio';
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Format Tujuan';
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
@@ -2941,17 +2942,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Mengkonversi audio...';
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Berhasil dikonversi ke $format';
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Konversi gagal';
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -1176,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'フォルダ構成';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get appDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
@@ -34,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
||||
String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
|
||||
|
||||
@override
|
||||
String homeSearchHintExtension(String extensionName) {
|
||||
return 'Search with $extensionName...';
|
||||
return '$extensionName에서 검색';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => '최근 기록';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
String get historyTitle => '기록';
|
||||
|
||||
@override
|
||||
String historyDownloading(int count) {
|
||||
return 'Downloading ($count)';
|
||||
return '다운로드 중... $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get historyDownloaded => 'Downloaded';
|
||||
String get historyDownloaded => '다운로드 목록';
|
||||
|
||||
@override
|
||||
String get historyFilterAll => 'All';
|
||||
@@ -75,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
other: '${count}tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
@@ -245,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
@@ -1182,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
|
||||
@@ -1182,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+248
-175
@@ -19,7 +19,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get navHome => 'Главная';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
String get navLibrary => 'Библиотека';
|
||||
|
||||
@override
|
||||
String get navHistory => 'История';
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
'Поиск Spotify устареет 3 марта 2026 года из-за изменений Spotify API. Пожалуйста, перейдите на Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Расширения';
|
||||
@@ -486,7 +486,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
'Создатель I Don\'t Have Spotify (IDHS). Резервный резолвер ссылки';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
@@ -507,7 +507,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
'Потоковая передача Tidal Hi-Res FLAC. Ключевая часть lossless головоломки!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
@@ -712,7 +712,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
'iCloud Drive не поддерживается. Пожалуйста, используйте папку Документы.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
|
||||
@@ -975,7 +975,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
return '\"$trackName\" уже есть в вашей библиотеке';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1209,6 +1209,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Организация папок';
|
||||
|
||||
@@ -1918,33 +1925,35 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle =>
|
||||
'Opus 320 кбит/с (конвертировать из FLAC)';
|
||||
|
||||
@override
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
String get qualityLossyOpusSubtitle =>
|
||||
'Opus 128 кбит/с (конвертировать из FLAC)';
|
||||
|
||||
@override
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
String get enableLossyOption => 'Включить опцию Lossy';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
String get enableLossyOptionSubtitleOn => 'Доступно качество с потерями';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
'Скачивать FLAC и конвертировать в MP3 320 кбит/с';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
String get lossyFormat => 'Формат с потерями';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
String get lossyFormatDescription => 'Выберите Lossy формат для конвертации';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
String get lossyFormatMp3Subtitle => '320Кбит/с, лучшая совместимость';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
'128кбит/с, лучшее качество при меньших размерах';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1952,7 +1961,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||
@@ -1967,7 +1976,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Структура папок альбома';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Использовать исполнителя альбома для папок';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
@@ -1975,7 +1985,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Папки исполнителя используют только трек исполнителя';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
@@ -2069,37 +2090,37 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Вы уверены, что хотите очистить все загрузки?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
String get queueExportFailed => 'Экспорт';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
'Сбой при экспорте загрузок в файл TXT';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
String get queueExportFailedClear => 'Не удалось очистить';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
String get queueExportFailedError => 'Не удалось экспортировать загрузки';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed => 'Автоэкспорт неудачных загрузок';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
'Автоматическое сохранение неудачных загрузок в TXT файл';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
String get settingsDownloadNetwork => 'Сеть для скачивания';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
'Выберите, какую сеть использовать для скачивания. Когда установлено значение только WiFi — скачивания через мобильную сеть будут приостановлены.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Нет загрузок в очереди';
|
||||
@@ -2231,10 +2252,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get recentTypePlaylist => 'Плейлист';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
String get recentEmpty => 'Нет недавних элементов';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
String get recentShowAllDownloads => 'Показать все загрузки';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
@@ -2314,234 +2335,254 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Не удалось получить некоторые альбомы';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
String get sectionStorageAccess => 'Доступ к хранилищу';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
String get allFilesAccess => 'Доступ ко всем файлам';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
String get allFilesAccessEnabledSubtitle => 'Можно записать в любую папку';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
String get allFilesAccessDisabledSubtitle =>
|
||||
'Ограничено только папками медиа';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
'Включите, если вы сталкиваетесь с ошибками записи при сохранении в пользовательские папки. Android 13+ по умолчанию ограничивает доступ к определенным папкам.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
'В разрешении отказано. Пожалуйста, включите функцию «Доступ ко всем файлам» в настройках системы.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
'Доступ ко всем файлам отключен. Приложение будет использовать ограниченный доступ к хранилищу.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
String get settingsLocalLibrary => 'Локальная библиотека';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
String get settingsLocalLibrarySubtitle =>
|
||||
'Сканировать и обнаружить дубликаты';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
String get settingsCache => 'Хранилище и кэш';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
String get settingsCacheSubtitle => 'Просмотреть размер и очистить кэш';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
String get libraryTitle => 'Локальная библиотека';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
String get libraryStatus => 'Статус Библиотеки';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
String get libraryScanSettings => 'Настройки сканирования';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
String get libraryEnableLocalLibrary => 'Включить локальную библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
'Сканировать и отслеживать вашу существующую музыку';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
String get libraryFolder => 'Папка библиотеки';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
String get libraryFolderHint => 'Нажмите, чтобы выбрать папку';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
String get libraryShowDuplicateIndicator => 'Показать индикатор дубликатов';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
'Показать при поиске существующих треков';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
String get libraryActions => 'Действия';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
String get libraryScan => 'Сканировать библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
String get libraryScanSubtitle => 'Сканировать аудио файлы';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
String get libraryScanSelectFolderFirst => 'Сначала выберите папку';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
String get libraryCleanupMissingFiles => 'Очистка отсутствующих файлов';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
'Удалить записи для файлов, которых больше не существует';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
String get libraryClear => 'Очистить библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
String get libraryClearSubtitle => 'Удалить все сканированные треки';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
String get libraryClearConfirmTitle => 'Очистить библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
'Это удалит все сканированные треки из вашей библиотеки. Ваши фактические файлы не будут удалены.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
String get libraryAbout => 'О локальной библиотеке';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
'Сканирует существующую коллекцию музыки для обнаружения дубликатов при загрузке. Поддерживает форматы FLAC, M4A, MP3, Opus и OGG. Метаданные читаются из тегов файлов, если доступны.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
return 'Последнее сканирование: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
String get libraryLastScannedNever => 'Никогда';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
String get libraryScanning => 'Сканирование...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
return '$progress% из $total файлов';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
String get libraryInLibrary => 'В библиотеке';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'отсутствующих файлов',
|
||||
many: 'отсутствующих файлов',
|
||||
few: 'трека',
|
||||
one: 'отсутствующий файл',
|
||||
);
|
||||
return 'Удалено $count $_temp0 в библиотеке';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
String get libraryCleared => 'Библиотека очищена';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
String get libraryStorageAccessRequired => 'Требуется доступ к хранилищу';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
'SpotiFLAC требуется доступ к хранилищу для сканирования вашей библиотеки музыки. Пожалуйста, предоставьте разрешение в настройках.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
String get libraryFolderNotExist => 'Выбранной папки не существует';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
String get librarySourceDownloaded => 'Скачанные';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
String get librarySourceLocal => 'Локальные';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
String get libraryFilterAll => 'Все';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
String get libraryFilterDownloaded => 'Скачанные';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
String get libraryFilterLocal => 'Локальные';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
String get libraryFilterTitle => 'Фильтры';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
String get libraryFilterReset => 'Сброс';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
String get libraryFilterApply => 'Применить';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
String get libraryFilterSource => 'Источник';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
String get libraryFilterQuality => 'Качество';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24 бит)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
String get libraryFilterQualityCD => 'CD (16 бит)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
String get libraryFilterQualityLossy => 'С потерями';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
String get libraryFilterFormat => 'Формат';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
String get libraryFilterDate => 'Дата добавления';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
String get libraryFilterDateToday => 'Сегодня';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
String get libraryFilterDateWeek => 'На этой неделе';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
String get libraryFilterDateMonth => 'В этом месяце';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
String get libraryFilterDateYear => 'В этом году';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
String get libraryFilterSort => 'Сортировка';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
String get libraryFilterSortLatest => 'Последние';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
String get libraryFilterSortOldest => 'Старые';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
return '$count фильтр(-ов) активно';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
String get timeJustNow => 'Только что';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
other: '$count минут',
|
||||
many: '$count минут',
|
||||
few: '$count минуты',
|
||||
one: '$count минуту',
|
||||
);
|
||||
return '$_temp0';
|
||||
return '$_temp0 назад';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2549,160 +2590,186 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
other: '$count часов',
|
||||
many: '$count часов',
|
||||
few: '$count часа',
|
||||
one: '$count час',
|
||||
);
|
||||
return '$_temp0';
|
||||
return '$_temp0 назад';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
String get storageSwitchTitle => 'Сменить режим хранения';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
String get storageSwitchToSafTitle => 'Переключиться на SAF хранилище?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
String get storageSwitchToAppTitle => 'Переключиться хранилище приложения?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
'Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
'Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
String get storageSwitchExistingDownloads => 'Существующие загрузки';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0 в $mode хранилище';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
String get storageSwitchNewDownloads => 'Новые загрузки';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
return 'Будет сохранено в: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
String get storageSwitchContinue => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
String get storageSwitchSelectFolder => 'Выберите папку SAF';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
String get storageAppStorage => 'Хранилище приложения';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
String get storageSafStorage => 'Хранилище SAF';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
return 'Хранилище: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
String get storageStatsTitle => 'Статистика хранилища';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0 в хранилище приложения';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0 в вашей папке в SAF';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
String get storageModeInfo => 'Ваши файлы хранятся в нескольких местах';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Добро пожаловать в SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
'Давайте научимся скачивать свою любимую музыку в качестве без потерь. В этом кратком руководстве мы покажем вам основы.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
'Автоматическое встраивание метаданных, обложек и текстов песен';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
String get tutorialSearchTitle => 'Поиск музыки';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
'Есть два простых способа найти музыку, которую вы хотите скачать.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
'Вставьте ссылку Spotify или Deezer прямо в поле поиска';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
'Или введите название песни, исполнителя или альбом для поиска';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
'Поддержка треков, альбомов, плейлистов и страниц исполнителей';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
String get tutorialDownloadTitle => 'Скачивание музыки';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
'Скачивание музыки просто и быстро. Вот как это работает.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
'Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
'Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
'Скачать все альбомы или плейлисты одним нажатием';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
String get tutorialLibraryTitle => 'Ваша библиотека';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
'Вся скачанная музыка организована во вкладке Библиотека.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
'Просмотр прогресса загрузки и очереди на вкладке Библиотека';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
'Нажмите на любой трек, чтобы воспроизвести его с помощью вашего музыкального плеера';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
'Переключение между списком и сеткой для лучшего просмотра';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
String get tutorialExtensionsTitle => 'Расширения';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
'Расширьте возможности приложения с расширениями от сообщества.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
@@ -2710,14 +2777,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
'Добавить новых поставщиков загрузок или поиска';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
String get tutorialSettingsTitle => 'Настройте приложение под себя';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
@@ -2725,27 +2792,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
'Изменить местоположение и организацию папок для скачивания';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
'Настройте качество и формата аудиофайла по умолчанию';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
String get tutorialSettingsTip3 => 'Настроить тему и внешний вид приложения';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
'Всё готово! Начните загружать любимую музыку прямо сейчас.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
String get libraryForceFullScan => 'Полное сканирование';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
String get libraryForceFullScanSubtitle =>
|
||||
'Пересканировать все файлы, игнорировать кэш';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
@@ -2763,10 +2831,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
String get cacheTitle => 'Хранилище и кэш';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
String get cacheSummaryTitle => 'Просмотр кэша';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
@@ -2778,13 +2846,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
String get cacheSectionStorage => 'Кэшированные данные';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
String get cacheAppDirectory => 'Папка кэша приложения';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
@@ -2830,11 +2898,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
String get cacheNoData => 'Нет кэшированных данных';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
return '$size в $count файлах';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2849,11 +2917,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
return 'Очищено: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
String get cacheClearConfirmTitle => 'Очистить кэш?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
@@ -2861,17 +2929,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
String get cacheClearAllConfirmTitle => 'Очистить весь кэш?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
'Это очистит все категории кэша на этой странице. Скачанные музыкальные файлы не будут удалены.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
String get cacheClearAll => 'Очистить весь кэш';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
String get cacheCleanupUnused => 'Очистка неиспользуемых данных';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
@@ -2883,19 +2951,23 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
String get cacheRefreshStats => 'Обновить статистику';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
String get trackSaveCoverArt => 'Сохранить обложку';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
String get trackSaveCoverArtSubtitle => 'Сохранить обложку как файл .jpg';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
String get trackSaveLyrics => 'Сохранить текст (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Получить и сохранить текст песни в формате .lrc';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
@@ -2912,36 +2984,37 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
String get trackEditMetadata => 'Редактировать метаданные';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
return 'Обложка сохранена в $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
String get trackCoverNoSource => 'Нет доступных источников обложки';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
return 'Текст песни сохранен в $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Ошибка встраивания метаданных FFmpeg';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
return 'Ошибка: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1189,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Klasör Organizasyonu';
|
||||
|
||||
|
||||
+3733
-2140
File diff suppressed because it is too large
Load Diff
+1246
-234
File diff suppressed because it is too large
Load Diff
@@ -874,6 +874,14 @@
|
||||
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
|
||||
"filenameHint": "{artist} - {title}",
|
||||
"@filenameHint": {"description": "Default filename format hint"},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
|
||||
"folderOrganization": "Folder Organization",
|
||||
"@folderOrganization": {"description": "Setting title - folder structure"},
|
||||
|
||||
+1276
-11
File diff suppressed because it is too large
Load Diff
+1107
-95
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+856
-221
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1041
-29
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1316
-51
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+55
-5
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -11,19 +12,68 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
_configureImageCache();
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
|
||||
runApp(
|
||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _configureImageCache() {
|
||||
Future<_RuntimeProfile> _resolveRuntimeProfile() async {
|
||||
const defaults = _RuntimeProfile(
|
||||
imageCacheMaximumSize: 240,
|
||||
imageCacheMaximumSizeBytes: 60 << 20,
|
||||
disableOverscrollEffects: false,
|
||||
);
|
||||
|
||||
if (!Platform.isAndroid) return defaults;
|
||||
|
||||
try {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final isArm32Only = androidInfo.supported64BitAbis.isEmpty;
|
||||
final isLowRamDevice =
|
||||
androidInfo.isLowRamDevice || androidInfo.physicalRamSize <= 2500;
|
||||
|
||||
if (!isArm32Only && !isLowRamDevice) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return _RuntimeProfile(
|
||||
imageCacheMaximumSize: 120,
|
||||
imageCacheMaximumSizeBytes: 24 << 20,
|
||||
disableOverscrollEffects: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to resolve runtime profile: $e');
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
void _configureImageCache(_RuntimeProfile runtimeProfile) {
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
// Keep memory cache bounded so cover-heavy pages don't retain too many
|
||||
// full-resolution images simultaneously.
|
||||
imageCache.maximumSize = 240;
|
||||
imageCache.maximumSizeBytes = 60 << 20; // 60 MiB
|
||||
imageCache.maximumSize = runtimeProfile.imageCacheMaximumSize;
|
||||
imageCache.maximumSizeBytes = runtimeProfile.imageCacheMaximumSizeBytes;
|
||||
}
|
||||
|
||||
class _RuntimeProfile {
|
||||
final int imageCacheMaximumSize;
|
||||
final int imageCacheMaximumSizeBytes;
|
||||
final bool disableOverscrollEffects;
|
||||
|
||||
const _RuntimeProfile({
|
||||
required this.imageCacheMaximumSize,
|
||||
required this.imageCacheMaximumSizeBytes,
|
||||
required this.disableOverscrollEffects,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
|
||||
+34
-16
@@ -21,6 +21,7 @@ class AppSettings {
|
||||
final String folderOrganization;
|
||||
final bool useAlbumArtistForFolders;
|
||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||
final bool filterContributingArtistsInAlbumArtist;
|
||||
final String historyViewMode;
|
||||
final String historyFilterMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
@@ -36,18 +37,24 @@ class AppSettings {
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final String
|
||||
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
|
||||
// Local Library Settings
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
final String localLibraryPath; // Path to scan for audio files
|
||||
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
|
||||
final bool
|
||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
|
||||
// Tutorial/Onboarding
|
||||
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
final bool
|
||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -67,6 +74,7 @@ class AppSettings {
|
||||
this.folderOrganization = 'none',
|
||||
this.useAlbumArtistForFolders = true,
|
||||
this.usePrimaryArtistOnly = false,
|
||||
this.filterContributingArtistsInAlbumArtist = false,
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.askQualityBeforeDownload = true,
|
||||
@@ -112,6 +120,7 @@ class AppSettings {
|
||||
String? folderOrganization,
|
||||
bool? useAlbumArtistForFolders,
|
||||
bool? usePrimaryArtistOnly,
|
||||
bool? filterContributingArtistsInAlbumArtist,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
@@ -157,18 +166,25 @@ class AppSettings {
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
useAlbumArtistForFolders:
|
||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly:
|
||||
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
filterContributingArtistsInAlbumArtist ??
|
||||
this.filterContributingArtistsInAlbumArtist,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
askQualityBeforeDownload:
|
||||
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
useCustomSpotifyCredentials:
|
||||
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
||||
useExtensionProviders:
|
||||
useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
@@ -176,12 +192,14 @@ class AppSettings {
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
autoExportFailedDownloads:
|
||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||
// Local Library
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
localLibraryShowDuplicates:
|
||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
@@ -72,6 +74,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'filterContributingArtistsInAlbumArtist':
|
||||
instance.filterContributingArtistsInAlbumArtist,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
|
||||
@@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
|
||||
final hasFilePath = item.filePath.trim().isNotEmpty;
|
||||
final hasSafFileName =
|
||||
item.safFileName != null && item.safFileName!.trim().isNotEmpty;
|
||||
if (!hasFilePath && !hasSafFileName) {
|
||||
continue;
|
||||
}
|
||||
candidateIndexes.add(i);
|
||||
@@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
for (var c = 0; c < candidateIndexes.length; c++) {
|
||||
final i = candidateIndexes[c];
|
||||
final item = items[i];
|
||||
final rawPath = item.filePath.trim();
|
||||
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
||||
|
||||
final exists = await fileExists(item.filePath);
|
||||
if (exists) {
|
||||
final verified = item.copyWith(
|
||||
safRepaired: true,
|
||||
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
|
||||
);
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
} else {
|
||||
final fallbackName =
|
||||
item.safFileName ?? _fileNameFromUri(item.filePath);
|
||||
if (fallbackName.isEmpty) {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
if (isDirectSafUri) {
|
||||
final exists = await fileExists(rawPath);
|
||||
if (exists) {
|
||||
final verified = item.copyWith(
|
||||
safRepaired: true,
|
||||
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
|
||||
);
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = resolved['uri'] as String? ?? '';
|
||||
if (newUri.isEmpty) continue;
|
||||
var fallbackName = (item.safFileName ?? '').trim();
|
||||
if (fallbackName.isEmpty && isDirectSafUri) {
|
||||
fallbackName = _fileNameFromUri(rawPath);
|
||||
}
|
||||
if (fallbackName.isEmpty) {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir:
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safRepaired: true,
|
||||
);
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = (resolved['uri'] as String? ?? '').trim();
|
||||
if (newUri.isEmpty) continue;
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
repairedCount++;
|
||||
await _db.upsert(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir:
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safRepaired: true,
|
||||
);
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
repairedCount++;
|
||||
await _db.upsert(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
|
||||
if ((c + 1) % _safRepairBatchSize == 0) {
|
||||
@@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
existing = state.getByIsrc(item.isrc!);
|
||||
}
|
||||
|
||||
final mergedItem = existing == null
|
||||
? item
|
||||
: item.copyWith(
|
||||
genre:
|
||||
_normalizeOptionalString(item.genre) ??
|
||||
_normalizeOptionalString(existing.genre),
|
||||
label:
|
||||
_normalizeOptionalString(item.label) ??
|
||||
_normalizeOptionalString(existing.label),
|
||||
copyright:
|
||||
_normalizeOptionalString(item.copyright) ??
|
||||
_normalizeOptionalString(existing.copyright),
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
final updatedItems = state.items
|
||||
.where((i) => i.id != existing!.id)
|
||||
.toList();
|
||||
updatedItems.insert(0, item);
|
||||
updatedItems.insert(0, mergedItem);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
|
||||
} else {
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||
state = state.copyWith(items: [mergedItem, ...state.items]);
|
||||
_historyLog.d('Added new history entry: ${mergedItem.trackName}');
|
||||
}
|
||||
|
||||
_db.upsert(item.toJson()).catchError((e) {
|
||||
_db.upsert(mergedItem.toJson()).catchError((e) {
|
||||
_historyLog.e('Failed to save to database: $e');
|
||||
});
|
||||
}
|
||||
@@ -1173,11 +1197,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool useAlbumArtistForFolders = true,
|
||||
bool usePrimaryArtistOnly = false,
|
||||
bool filterContributingArtistsInAlbumArtist = false,
|
||||
}) async {
|
||||
String baseDir = state.outputDir;
|
||||
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
|
||||
var folderArtist = useAlbumArtistForFolders
|
||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||
? normalizedAlbumArtist ?? track.artistName
|
||||
: track.artistName;
|
||||
if (useAlbumArtistForFolders &&
|
||||
filterContributingArtistsInAlbumArtist &&
|
||||
normalizedAlbumArtist != null) {
|
||||
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||
}
|
||||
if (usePrimaryArtistOnly) {
|
||||
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||
}
|
||||
@@ -1273,7 +1304,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
static final _featuredArtistPattern = RegExp(
|
||||
r'\s*[,;&]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
||||
r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -1285,6 +1316,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return artist;
|
||||
}
|
||||
|
||||
String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
|
||||
var albumArtist =
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
if (settings.filterContributingArtistsInAlbumArtist) {
|
||||
albumArtist = _extractPrimaryArtist(albumArtist);
|
||||
}
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
bool _isSafMode(AppSettings settings) {
|
||||
return Platform.isAndroid &&
|
||||
settings.storageMode == 'saf' &&
|
||||
@@ -1309,10 +1349,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool useAlbumArtistForFolders = true,
|
||||
bool usePrimaryArtistOnly = false,
|
||||
bool filterContributingArtistsInAlbumArtist = false,
|
||||
}) async {
|
||||
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
|
||||
var folderArtist = useAlbumArtistForFolders
|
||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||
? normalizedAlbumArtist ?? track.artistName
|
||||
: track.artistName;
|
||||
if (useAlbumArtistForFolders &&
|
||||
filterContributingArtistsInAlbumArtist &&
|
||||
normalizedAlbumArtist != null) {
|
||||
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||
}
|
||||
if (usePrimaryArtistOnly) {
|
||||
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||
}
|
||||
@@ -1728,6 +1775,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
|
||||
track,
|
||||
settings,
|
||||
);
|
||||
|
||||
if (!settings.useExtensionProviders) return;
|
||||
|
||||
@@ -1742,8 +1793,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'title': track.name,
|
||||
'artist': track.artistName,
|
||||
'album': track.albumName,
|
||||
'album_artist':
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName,
|
||||
'album_artist': resolvedAlbumArtist,
|
||||
'track_number': track.trackNumber ?? 1,
|
||||
'disc_number': track.discNumber ?? 1,
|
||||
'isrc': track.isrc ?? '',
|
||||
@@ -1803,7 +1853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Track _buildTrackForMetadataEmbedding(
|
||||
Track baseTrack,
|
||||
Map<String, dynamic> backendResult,
|
||||
String? normalizedAlbumArtist,
|
||||
String resolvedAlbumArtist,
|
||||
) {
|
||||
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
|
||||
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
|
||||
@@ -1826,7 +1876,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
name: baseTrack.name,
|
||||
artistName: baseTrack.artistName,
|
||||
albumName: backendAlbum ?? baseTrack.albumName,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
albumArtist: resolvedAlbumArtist,
|
||||
coverUrl: baseTrack.coverUrl,
|
||||
duration: baseTrack.duration,
|
||||
isrc: baseTrack.isrc,
|
||||
@@ -1890,16 +1940,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'ALBUM': track.albumName,
|
||||
};
|
||||
|
||||
final albumArtist =
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
metadata['TRACK'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
metadata['DISC'] = track.discNumber.toString();
|
||||
}
|
||||
@@ -2033,16 +2082,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'ALBUM': track.albumName,
|
||||
};
|
||||
|
||||
final albumArtist =
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
metadata['TRACK'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
metadata['DISC'] = track.discNumber.toString();
|
||||
}
|
||||
@@ -2198,15 +2246,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'ALBUM': track.albumName,
|
||||
};
|
||||
|
||||
final albumArtist =
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
}
|
||||
|
||||
@@ -2442,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
||||
} else if (!isValidIosWritablePath(state.outputDir)) {
|
||||
// Check for other invalid paths (like container root without Documents/)
|
||||
_log.w(
|
||||
@@ -2451,6 +2499,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final correctedPath = await validateOrFixIosPath(state.outputDir);
|
||||
_log.i('Corrected path: $correctedPath');
|
||||
state = state.copyWith(outputDir: correctedPath);
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(correctedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2717,8 +2766,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||
|
||||
final normalizedAlbumArtist = _normalizeOptionalString(
|
||||
trackToDownload.albumArtist,
|
||||
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
|
||||
trackToDownload,
|
||||
settings,
|
||||
);
|
||||
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
@@ -2731,6 +2781,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
)
|
||||
: '';
|
||||
String? appOutputDir;
|
||||
@@ -2743,6 +2795,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
);
|
||||
var effectiveOutputDir = initialOutputDir;
|
||||
var effectiveSafMode = isSafMode;
|
||||
@@ -2759,6 +2813,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'track': trackToDownload.trackNumber ?? 0,
|
||||
'disc': trackToDownload.discNumber ?? 0,
|
||||
'year': _extractYear(trackToDownload.releaseDate) ?? '',
|
||||
'date': trackToDownload.releaseDate ?? '',
|
||||
});
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
safBaseName = sanitized;
|
||||
@@ -2768,6 +2823,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
String? genre;
|
||||
String? label;
|
||||
String? copyright;
|
||||
|
||||
String? deezerTrackId = trackToDownload.deezerId;
|
||||
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
|
||||
@@ -2845,9 +2901,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
(trackToDownload.isrc == null && deezerIsrc != null) ||
|
||||
(!_isValidISRC(trackToDownload.isrc ?? '') &&
|
||||
deezerIsrc != null) ||
|
||||
(trackToDownload.trackNumber == null &&
|
||||
deezerTrackNum != null) ||
|
||||
(trackToDownload.discNumber == null && deezerDiscNum != null);
|
||||
((trackToDownload.trackNumber == null ||
|
||||
trackToDownload.trackNumber! <= 0) &&
|
||||
deezerTrackNum != null &&
|
||||
deezerTrackNum > 0) ||
|
||||
((trackToDownload.discNumber == null ||
|
||||
trackToDownload.discNumber! <= 0) &&
|
||||
deezerDiscNum != null &&
|
||||
deezerDiscNum > 0);
|
||||
|
||||
if (needsEnrich) {
|
||||
trackToDownload = Track(
|
||||
@@ -2861,8 +2922,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
|
||||
? deezerIsrc
|
||||
: trackToDownload.isrc,
|
||||
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
|
||||
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
|
||||
trackNumber:
|
||||
(trackToDownload.trackNumber != null &&
|
||||
trackToDownload.trackNumber! > 0)
|
||||
? trackToDownload.trackNumber
|
||||
: deezerTrackNum,
|
||||
discNumber:
|
||||
(trackToDownload.discNumber != null &&
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber
|
||||
: deezerDiscNum,
|
||||
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
|
||||
deezerId: deezerTrackId,
|
||||
availability: trackToDownload.availability,
|
||||
@@ -2889,8 +2958,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (extendedMetadata != null) {
|
||||
genre = extendedMetadata['genre'];
|
||||
label = extendedMetadata['label'];
|
||||
copyright = extendedMetadata['copyright'];
|
||||
if (genre != null && genre.isNotEmpty) {
|
||||
_log.d('Extended metadata - Genre: $genre, Label: $label');
|
||||
_log.d(
|
||||
'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -2937,6 +3009,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
_log.d('Output dir: $outputDir');
|
||||
|
||||
final normalizedTrackNumber =
|
||||
(trackToDownload.trackNumber != null &&
|
||||
trackToDownload.trackNumber! > 0)
|
||||
? trackToDownload.trackNumber!
|
||||
: 1;
|
||||
final normalizedDiscNumber =
|
||||
(trackToDownload.discNumber != null &&
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber!
|
||||
: 1;
|
||||
|
||||
final payload = DownloadRequestPayload(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
service: item.service,
|
||||
@@ -2944,7 +3027,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName,
|
||||
albumArtist: resolvedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl ?? '',
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
@@ -2952,14 +3035,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Keep prior behavior: non-YouTube paths were implicitly true.
|
||||
embedLyrics: isYouTube ? settings.embedLyrics : true,
|
||||
embedMaxQualityCover: settings.maxQualityCover,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
trackNumber: normalizedTrackNumber,
|
||||
discNumber: normalizedDiscNumber,
|
||||
releaseDate: trackToDownload.releaseDate ?? '',
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
source: trackToDownload.source ?? '',
|
||||
genre: genre ?? '',
|
||||
label: label ?? '',
|
||||
copyright: copyright ?? '',
|
||||
deezerId: deezerTrackId ?? '',
|
||||
lyricsMode: settings.lyricsMode,
|
||||
storageMode: storageMode,
|
||||
@@ -2992,6 +3076,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
);
|
||||
final fallbackResult = await runDownload(
|
||||
useSaf: false,
|
||||
@@ -3329,7 +3415,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
normalizedAlbumArtist,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
@@ -3493,7 +3579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
normalizedAlbumArtist,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
@@ -3553,7 +3639,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
normalizedAlbumArtist,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
@@ -3613,7 +3699,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
normalizedAlbumArtist,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
@@ -3650,7 +3736,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
normalizedAlbumArtist,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
@@ -3748,6 +3834,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF downloads should end with content URI. If we still have a
|
||||
// transient FD path, recover URI from SAF metadata to keep history
|
||||
// dedup/exclusion stable.
|
||||
if (effectiveSafMode &&
|
||||
filePath != null &&
|
||||
filePath.isNotEmpty &&
|
||||
!isContentUri(filePath) &&
|
||||
settings.downloadTreeUri.isNotEmpty) {
|
||||
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
|
||||
if (fallbackName.isNotEmpty) {
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
|
||||
final resolvedRelativeDir =
|
||||
(resolved['relative_dir'] as String? ?? '').trim();
|
||||
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
|
||||
_log.w('Recovered SAF URI from transient path: $filePath');
|
||||
filePath = resolvedUri;
|
||||
finalSafFileName = fallbackName;
|
||||
if (resolvedRelativeDir.isNotEmpty) {
|
||||
effectiveOutputDir = resolvedRelativeDir;
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF URI recovery failed: $e');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'SAF download returned non-URI path without filename metadata: $filePath',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
@@ -3840,13 +3967,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
final effectiveGenre =
|
||||
_normalizeOptionalString(backendGenre) ??
|
||||
_normalizeOptionalString(genre) ??
|
||||
_normalizeOptionalString(existingInHistory?.genre);
|
||||
final effectiveLabel =
|
||||
_normalizeOptionalString(backendLabel) ??
|
||||
_normalizeOptionalString(label) ??
|
||||
_normalizeOptionalString(existingInHistory?.label);
|
||||
final effectiveCopyright =
|
||||
_normalizeOptionalString(backendCopyright) ??
|
||||
_normalizeOptionalString(copyright) ??
|
||||
_normalizeOptionalString(existingInHistory?.copyright);
|
||||
|
||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||
|
||||
final historyAlbumArtist =
|
||||
(normalizedAlbumArtist != null &&
|
||||
normalizedAlbumArtist != trackToDownload.artistName)
|
||||
? normalizedAlbumArtist
|
||||
resolvedAlbumArtist != trackToDownload.artistName
|
||||
? resolvedAlbumArtist
|
||||
: null;
|
||||
|
||||
final isMp3 = filePath.endsWith('.mp3');
|
||||
@@ -3899,9 +4037,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
quality: actualQuality,
|
||||
bitDepth: historyBitDepth,
|
||||
sampleRate: historySampleRate,
|
||||
genre: backendGenre,
|
||||
label: backendLabel,
|
||||
copyright: backendCopyright,
|
||||
genre: effectiveGenre,
|
||||
label: effectiveLabel,
|
||||
copyright: effectiveCopyright,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -116,6 +118,7 @@ class LocalLibraryState {
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
Timer? _progressTimer;
|
||||
bool _isLoaded = false;
|
||||
@@ -180,6 +183,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Set<String> _buildPathMatchKeys(String? filePath) {
|
||||
final raw = filePath?.trim() ?? '';
|
||||
if (raw.isEmpty) return const {};
|
||||
|
||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
|
||||
final keys = <String>{cleaned};
|
||||
|
||||
void addNormalized(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
keys.add(trimmed);
|
||||
keys.add(trimmed.toLowerCase());
|
||||
if (trimmed.contains('\\')) {
|
||||
final slash = trimmed.replaceAll('\\', '/');
|
||||
keys.add(slash);
|
||||
keys.add(slash.toLowerCase());
|
||||
}
|
||||
if (trimmed.contains('%')) {
|
||||
try {
|
||||
final decoded = Uri.decodeFull(trimmed);
|
||||
keys.add(decoded);
|
||||
keys.add(decoded.toLowerCase());
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
addNormalized(cleaned);
|
||||
|
||||
if (cleaned.startsWith('content://')) {
|
||||
try {
|
||||
final uri = Uri.parse(cleaned);
|
||||
addNormalized(uri.toString());
|
||||
addNormalized(uri.replace(query: null, fragment: null).toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
|
||||
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final candidateKeys = _buildPathMatchKeys(filePath);
|
||||
for (final key in candidateKeys) {
|
||||
if (downloadedPathKeys.contains(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
@@ -202,6 +257,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
await _showScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: 0,
|
||||
totalFiles: 0,
|
||||
currentFile: null,
|
||||
);
|
||||
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
@@ -217,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
try {
|
||||
final isSaf = folderPath.startsWith('content://');
|
||||
|
||||
// Get all file paths from download history to exclude them
|
||||
// Get all file paths from download history to exclude them.
|
||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
||||
// been flushed to SQLite yet.
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
final inMemoryHistoryPaths = ref
|
||||
.read(downloadHistoryProvider)
|
||||
.items
|
||||
.map((item) => item.filePath)
|
||||
.where((path) => path.isNotEmpty);
|
||||
final allHistoryPaths = <String>{
|
||||
...downloadedPaths,
|
||||
...inMemoryHistoryPaths,
|
||||
};
|
||||
final downloadedPathKeys = <String>{};
|
||||
for (final path in allHistoryPaths) {
|
||||
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
|
||||
}
|
||||
_log.i(
|
||||
'Excluding ${downloadedPaths.length} downloaded files from library scan',
|
||||
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
|
||||
'(${downloadedPathKeys.length} path keys)',
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
@@ -230,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
for (final json in results) {
|
||||
final filePath = json['filePath'] as String?;
|
||||
// Skip files that are already in download history
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
@@ -275,6 +353,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: items.length,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
@@ -308,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -344,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
final filePath = map['filePath'] as String?;
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
@@ -399,10 +483,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, '
|
||||
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
|
||||
);
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: items.length,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||
await _showScanFailedNotification(e.toString());
|
||||
} finally {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
@@ -441,6 +531,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: currentFile,
|
||||
);
|
||||
}
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
@@ -473,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
_stopProgressPolling();
|
||||
await _showScanCancelledNotification();
|
||||
}
|
||||
|
||||
Future<void> _showScanProgressNotification({
|
||||
required double progress,
|
||||
required int scannedFiles,
|
||||
required int totalFiles,
|
||||
required String? currentFile,
|
||||
}) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanProgress(
|
||||
progress: progress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: _shortenFileForNotification(currentFile),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan progress notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanCompleteNotification({
|
||||
required int totalTracks,
|
||||
required int excludedDownloadedCount,
|
||||
required int errorCount,
|
||||
}) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanComplete(
|
||||
totalTracks: totalTracks,
|
||||
excludedDownloadedCount: excludedDownloadedCount,
|
||||
errorCount: errorCount,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan complete notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanFailedNotification(String message) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanFailed(message);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan failure notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanCancelledNotification() async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanCancelled();
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan cancelled notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String? _shortenFileForNotification(String? path) {
|
||||
final raw = path?.trim() ?? '';
|
||||
if (raw.isEmpty) return null;
|
||||
|
||||
var decoded = raw;
|
||||
try {
|
||||
decoded = Uri.decodeFull(raw);
|
||||
} catch (_) {}
|
||||
|
||||
final slashIdx = decoded.lastIndexOf('/');
|
||||
final backslashIdx = decoded.lastIndexOf('\\');
|
||||
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
|
||||
if (cut >= 0 && cut < decoded.length - 1) {
|
||||
return decoded.substring(cut + 1);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
|
||||
@@ -236,6 +236,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
|
||||
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
|
||||
+170
-54
@@ -490,6 +490,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
0,
|
||||
(sum, a) => sum + a.totalTracks,
|
||||
);
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
final compactLayout =
|
||||
MediaQuery.sizeOf(context).width < 430 || textScale > 1.15;
|
||||
|
||||
return Positioned(
|
||||
left: 0,
|
||||
@@ -510,53 +513,145 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: compactLayout
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadSelectedAlbums(
|
||||
context,
|
||||
selectedAlbums,
|
||||
)
|
||||
: null,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
context.l10n.discographyDownloadSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadSelectedAlbums(
|
||||
context,
|
||||
selectedAlbums,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownloadSelected),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadSelectedAlbums(context, selectedAlbums)
|
||||
: null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownloadSelected),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1427,15 +1522,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
void enqueue(String service, {String? quality}) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
onSelect: (quality, service) {
|
||||
if (!mounted) return;
|
||||
enqueue(service, quality: quality);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue(settings.defaultService);
|
||||
}
|
||||
|
||||
Widget _buildAlbumSection(
|
||||
@@ -1468,7 +1579,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final album = albums[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.id),
|
||||
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
|
||||
child: _buildAlbumCard(
|
||||
album,
|
||||
colorScheme,
|
||||
tileSize: tileSize,
|
||||
sectionHeight: sectionHeight,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1601,9 +1717,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Flexible(
|
||||
child: Text(
|
||||
album.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
||||
|
||||
// For lossy formats, use bitrate
|
||||
if (first.bitrate != null && first.bitrate! > 0) {
|
||||
final fmt = first.format?.toUpperCase() ?? '';
|
||||
final firstBitrate = first.bitrate;
|
||||
for (final track in tracks) {
|
||||
if (track.bitrate != firstBitrate) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return '$fmt ${firstBitrate}kbps'.trim();
|
||||
}
|
||||
|
||||
// For lossless formats, use bit depth / sample rate
|
||||
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
|
||||
|
||||
final firstQuality =
|
||||
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
||||
|
||||
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
|
||||
|
||||
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
||||
String? quality;
|
||||
if (item.bitDepth != null && item.sampleRate != null) {
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
// Lossy format with bitrate
|
||||
final fmt = item.format?.toUpperCase() ?? '';
|
||||
quality = '$fmt ${item.bitrate}kbps'.trim();
|
||||
} else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) {
|
||||
// Lossless format with actual bit depth
|
||||
quality =
|
||||
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||
}
|
||||
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
String? _localQualityLabel(LocalLibraryItem item) {
|
||||
if (item.bitDepth == null || item.sampleRate == null) {
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
return '${item.bitrate}kbps';
|
||||
}
|
||||
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
|
||||
return null;
|
||||
}
|
||||
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||
|
||||
@@ -25,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasAllFilesAccess = false;
|
||||
bool _artistFolderFiltersExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -363,7 +364,53 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseAlbumArtistForFolders(value),
|
||||
showDivider: false,
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.filter_alt_outlined,
|
||||
title: 'Artist Name Filters',
|
||||
subtitle: _getArtistFolderFilterSubtitle(
|
||||
context,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterAlbumArtistContributors:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
),
|
||||
trailing: Icon(
|
||||
_artistFolderFiltersExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_artistFolderFiltersExpanded =
|
||||
!_artistFolderFiltersExpanded;
|
||||
});
|
||||
},
|
||||
showDivider: !_artistFolderFiltersExpanded,
|
||||
),
|
||||
if (_artistFolderFiltersExpanded)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.person_outline,
|
||||
title: context.l10n.downloadUsePrimaryArtistOnly,
|
||||
subtitle: settings.usePrimaryArtistOnly
|
||||
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
|
||||
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
|
||||
value: settings.usePrimaryArtistOnly,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsePrimaryArtistOnly(value),
|
||||
),
|
||||
if (_artistFolderFiltersExpanded)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.group_remove_outlined,
|
||||
title: 'Filter contributing artists in Album Artist',
|
||||
subtitle: settings.filterContributingArtistsInAlbumArtist
|
||||
? 'Album Artist metadata uses primary artist only'
|
||||
: 'Keep full Album Artist metadata value',
|
||||
value: settings.filterContributingArtistsInAlbumArtist,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilterContributingArtistsInAlbumArtist(value),
|
||||
showDivider: false,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.person_outline,
|
||||
@@ -585,14 +632,28 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final tags = [
|
||||
final basicTags = [
|
||||
'{artist}',
|
||||
'{title}',
|
||||
'{album}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
];
|
||||
final advancedTags = [
|
||||
'{track_raw}',
|
||||
'{track:02}',
|
||||
'{track:1}',
|
||||
'{date:%Y}',
|
||||
'{date:%Y-%m-%d}',
|
||||
'{disc_raw}',
|
||||
'{disc:02}',
|
||||
];
|
||||
var showAdvancedTags = RegExp(
|
||||
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(current);
|
||||
|
||||
void insertTag(String tag) {
|
||||
final text = controller.text;
|
||||
@@ -624,130 +685,164 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
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: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Customize how your files are named.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'Tap to insert tag:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: tags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Customize how your files are named.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
Text(
|
||||
'Tap to insert tag:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: basicTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilenameFormat(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: showAdvancedTags,
|
||||
onChanged: (value) =>
|
||||
setModalState(() => showAdvancedTags = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||
subtitle: Text(
|
||||
context.l10n.filenameShowAdvancedTagsDescription,
|
||||
),
|
||||
),
|
||||
if (showAdvancedTags) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: advancedTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilenameFormat(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -937,7 +1032,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
|
||||
content: Text(
|
||||
validation.errorReason ??
|
||||
context.l10n.setupIcloudNotSupported,
|
||||
),
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
@@ -1000,6 +1098,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getArtistFolderFilterSubtitle(
|
||||
BuildContext context, {
|
||||
required bool usePrimaryArtistOnly,
|
||||
required bool filterAlbumArtistContributors,
|
||||
}) {
|
||||
final statuses = <String>[
|
||||
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
|
||||
filterAlbumArtistContributors
|
||||
? 'Album Artist metadata: Primary only'
|
||||
: 'Album Artist metadata: Full',
|
||||
];
|
||||
return statuses.join(' | ');
|
||||
}
|
||||
|
||||
String _getLyricsModeLabel(BuildContext context, String mode) {
|
||||
switch (mode) {
|
||||
case 'external':
|
||||
@@ -1456,9 +1568,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: unselectedColor,
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
|
||||
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
Future<void> _checkInitialPermissions() async {
|
||||
if (Platform.isIOS) {
|
||||
final notificationStatus = await Permission.notification.status;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storagePermissionGranted = true;
|
||||
_notificationPermissionGranted = true;
|
||||
_notificationPermissionGranted =
|
||||
notificationStatus.isGranted || notificationStatus.isProvisional;
|
||||
});
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
if (Platform.isIOS) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted || status.isProvisional) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
await _showPermissionDeniedDialog('Notification');
|
||||
}
|
||||
} else if (_androidSdkVersion >= 33) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
|
||||
@@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
|
||||
int? get sampleRate =>
|
||||
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
|
||||
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
|
||||
|
||||
String get _filePath =>
|
||||
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||
@@ -424,8 +425,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
|
||||
DateTime get _addedAt =>
|
||||
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
|
||||
DateTime get _addedAt {
|
||||
if (_isLocalItem) {
|
||||
// Use file modification time if available, otherwise fall back to scannedAt
|
||||
final modTime = _localLibraryItem!.fileModTime;
|
||||
if (modTime != null && modTime > 0) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(modTime);
|
||||
}
|
||||
return _localLibraryItem!.scannedAt;
|
||||
}
|
||||
return _downloadItem!.downloadedAt;
|
||||
}
|
||||
|
||||
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
|
||||
|
||||
String get cleanFilePath {
|
||||
@@ -433,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||
}
|
||||
|
||||
String _formatPathForDisplay(String pathOrUri) {
|
||||
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
|
||||
return pathOrUri;
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(pathOrUri);
|
||||
final segments = uri.pathSegments;
|
||||
String? documentId;
|
||||
|
||||
final documentIndex = segments.indexOf('document');
|
||||
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
|
||||
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
|
||||
}
|
||||
|
||||
if (documentId == null || documentId.isEmpty) {
|
||||
final treeIndex = segments.indexOf('tree');
|
||||
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
|
||||
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (documentId == null || documentId.isEmpty) return pathOrUri;
|
||||
|
||||
final separatorIndex = documentId.indexOf(':');
|
||||
if (separatorIndex <= 0) return documentId;
|
||||
|
||||
final volumeId = documentId.substring(0, separatorIndex);
|
||||
final relativePath = documentId
|
||||
.substring(separatorIndex + 1)
|
||||
.replaceAll('\\', '/');
|
||||
|
||||
if (volumeId.toLowerCase() == 'primary') {
|
||||
if (relativePath.isEmpty) return '/storage/emulated/0';
|
||||
return '/storage/emulated/0/$relativePath';
|
||||
}
|
||||
|
||||
if (relativePath.isEmpty) return volumeId;
|
||||
return 'SD Card/$relativePath';
|
||||
} catch (_) {
|
||||
return pathOrUri;
|
||||
}
|
||||
}
|
||||
|
||||
void _markMetadataChanged() {
|
||||
_hasMetadataChanges = true;
|
||||
}
|
||||
@@ -913,7 +968,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||
// Determine audio quality string - prefer stored quality from download
|
||||
String? audioQualityStr;
|
||||
final fileName = _filePath.split('/').last;
|
||||
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
|
||||
final fileExt = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: '';
|
||||
@@ -921,8 +976,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
// Use stored quality from download history if available
|
||||
if (_quality != null && _quality!.isNotEmpty) {
|
||||
audioQualityStr = _quality;
|
||||
} else if (bitDepth != null && sampleRate != null) {
|
||||
// Fallback for FLAC files without stored quality
|
||||
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
|
||||
// Lossy local file with bitrate info
|
||||
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
|
||||
audioQualityStr = '$fmt ${_localBitrate}kbps';
|
||||
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
|
||||
// Lossless file with actual bit depth (FLAC, ALAC)
|
||||
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
||||
} else {
|
||||
@@ -1031,7 +1090,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool fileExists,
|
||||
int? fileSize,
|
||||
) {
|
||||
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
|
||||
final displayFilePath = _formatPathForDisplay(cleanFilePath);
|
||||
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
|
||||
final fileExtension = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: 'Unknown';
|
||||
@@ -1128,7 +1188,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (bitDepth != null && sampleRate != null)
|
||||
else if (_isLocalItem &&
|
||||
_localBitrate != null &&
|
||||
_localBitrate! > 0 &&
|
||||
(fileExtension == 'MP3' ||
|
||||
fileExtension == 'OPUS' ||
|
||||
fileExtension == 'OGG'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_localBitrate}kbps',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (bitDepth != null &&
|
||||
bitDepth! > 0 &&
|
||||
sampleRate != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
@@ -1194,7 +1280,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
cleanFilePath,
|
||||
displayFilePath,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -23,6 +23,7 @@ class LocalLibraryItem {
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
|
||||
final String? genre;
|
||||
final String? format; // flac, mp3, opus, m4a
|
||||
|
||||
@@ -43,6 +44,7 @@ class LocalLibraryItem {
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.genre,
|
||||
this.format,
|
||||
});
|
||||
@@ -64,6 +66,7 @@ class LocalLibraryItem {
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'bitrate': bitrate,
|
||||
'genre': genre,
|
||||
'format': format,
|
||||
};
|
||||
@@ -86,6 +89,7 @@ class LocalLibraryItem {
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: json['bitDepth'] as int?,
|
||||
sampleRate: json['sampleRate'] as int?,
|
||||
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||
genre: json['genre'] as String?,
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
@@ -115,7 +119,7 @@ class LibraryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 3, // Bumped version for file_mod_time migration
|
||||
version: 4, // Bumped version for bitrate column
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
@@ -142,6 +146,7 @@ class LibraryDatabase {
|
||||
release_date TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bitrate INTEGER,
|
||||
genre TEXT,
|
||||
format TEXT
|
||||
)
|
||||
@@ -169,6 +174,12 @@ class LibraryDatabase {
|
||||
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
||||
_log.i('Added file_mod_time column for incremental scanning');
|
||||
}
|
||||
|
||||
if (oldVersion < 4) {
|
||||
// Add bitrate column for lossy format quality info
|
||||
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||
_log.i('Added bitrate column for lossy format quality');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
@@ -189,6 +200,7 @@ class LibraryDatabase {
|
||||
'release_date': json['releaseDate'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'bitrate': json['bitrate'],
|
||||
'genre': json['genre'],
|
||||
'format': json['format'],
|
||||
};
|
||||
@@ -212,6 +224,7 @@ class LibraryDatabase {
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'bitrate': row['bitrate'],
|
||||
'genre': row['genre'],
|
||||
'format': row['format'],
|
||||
};
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
bool _notificationPermissionRequested = false;
|
||||
|
||||
static const int downloadProgressId = 1;
|
||||
static const int updateDownloadId = 2;
|
||||
static const int libraryScanId = 3;
|
||||
static const String channelId = 'download_progress';
|
||||
static const String channelName = 'Download Progress';
|
||||
static const String channelDescription = 'Shows download progress for tracks';
|
||||
static const String libraryChannelId = 'library_scan';
|
||||
static const String libraryChannelName = 'Library Scan';
|
||||
static const String libraryChannelDescription =
|
||||
'Shows local library scan progress';
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
@@ -33,24 +45,86 @@ class NotificationService {
|
||||
await _notifications.initialize(settings: initSettings);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
description: channelDescription,
|
||||
importance: Importance.low,
|
||||
showBadge: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
),
|
||||
);
|
||||
final androidImpl = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
await androidImpl?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
description: channelDescription,
|
||||
importance: Importance.low,
|
||||
showBadge: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
),
|
||||
);
|
||||
await androidImpl?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
libraryChannelId,
|
||||
libraryChannelName,
|
||||
description: libraryChannelDescription,
|
||||
importance: Importance.low,
|
||||
showBadge: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<bool> _ensureNotificationPermission() async {
|
||||
if (!Platform.isIOS) return true;
|
||||
|
||||
final status = await Permission.notification.status;
|
||||
if (status.isGranted || status.isProvisional) return true;
|
||||
|
||||
if (_notificationPermissionRequested ||
|
||||
status.isPermanentlyDenied ||
|
||||
status.isRestricted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_notificationPermissionRequested = true;
|
||||
final requested = await Permission.notification.request();
|
||||
return requested.isGranted || requested.isProvisional;
|
||||
}
|
||||
|
||||
Future<void> _showSafely({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required NotificationDetails details,
|
||||
}) async {
|
||||
if (!await _ensureNotificationPermission()) return;
|
||||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
final isNotificationsNotAllowed =
|
||||
Platform.isIOS &&
|
||||
(e.code == 'Error 1' ||
|
||||
(e.message?.contains('UNErrorDomain error 1') ?? false) ||
|
||||
e.toString().contains('UNErrorDomain error 1'));
|
||||
|
||||
if (isNotificationsNotAllowed) {
|
||||
debugPrint(
|
||||
'iOS notifications not allowed; skipping local notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showDownloadProgress({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
@@ -60,7 +134,7 @@ class NotificationService {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
|
||||
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
@@ -89,11 +163,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: 'Downloading $trackName',
|
||||
body: '$artistName • $percentage%',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,11 +206,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: 'Finalizing $trackName',
|
||||
body: '$artistName • Embedding metadata...',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,11 +256,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: title,
|
||||
body: '$trackName - $artistName',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,11 +296,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: title,
|
||||
body: '$completedCount tracks downloaded successfully',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,6 +308,175 @@ class NotificationService {
|
||||
await _notifications.cancel(id: downloadProgressId);
|
||||
}
|
||||
|
||||
Future<void> showLibraryScanProgress({
|
||||
required double progress,
|
||||
required int scannedFiles,
|
||||
required int totalFiles,
|
||||
String? currentFile,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final clampedProgress = progress.clamp(0.0, 100.0);
|
||||
final percentage = clampedProgress.round();
|
||||
final progressBody = totalFiles > 0
|
||||
? '$scannedFiles/$totalFiles files • $percentage%'
|
||||
: '$scannedFiles files scanned • $percentage%';
|
||||
final body = (currentFile != null && currentFile.isNotEmpty)
|
||||
? '$progressBody\n$currentFile'
|
||||
: progressBody;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
libraryChannelId,
|
||||
libraryChannelName,
|
||||
channelDescription: libraryChannelDescription,
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
showProgress: true,
|
||||
maxProgress: 100,
|
||||
progress: percentage,
|
||||
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 _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Scanning local library',
|
||||
body: body,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showLibraryScanComplete({
|
||||
required int totalTracks,
|
||||
int excludedDownloadedCount = 0,
|
||||
int errorCount = 0,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final extras = <String>[];
|
||||
if (excludedDownloadedCount > 0) {
|
||||
extras.add('$excludedDownloadedCount excluded');
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
extras.add('$errorCount errors');
|
||||
}
|
||||
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
libraryChannelId,
|
||||
libraryChannelName,
|
||||
channelDescription: libraryChannelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
playSound: false,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Library scan complete',
|
||||
body: '$totalTracks tracks indexed$suffix',
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showLibraryScanFailed(String message) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
libraryChannelId,
|
||||
libraryChannelName,
|
||||
channelDescription: libraryChannelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
playSound: false,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Library scan failed',
|
||||
body: message,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showLibraryScanCancelled() async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
libraryChannelId,
|
||||
libraryChannelName,
|
||||
channelDescription: libraryChannelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
autoCancel: true,
|
||||
playSound: false,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Library scan cancelled',
|
||||
body: 'Scan stopped before completion.',
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelLibraryScanNotification() async {
|
||||
await _notifications.cancel(id: libraryScanId);
|
||||
}
|
||||
|
||||
Future<void> showUpdateDownloadProgress({
|
||||
required String version,
|
||||
required int received,
|
||||
@@ -244,7 +487,7 @@ class NotificationService {
|
||||
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
|
||||
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
@@ -273,11 +516,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: updateDownloadId,
|
||||
title: 'Downloading SpotiFLAC v$version',
|
||||
body: '$receivedMB / $totalMB MB • $percentage%',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,11 +549,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: updateDownloadId,
|
||||
title: 'Update Ready',
|
||||
body: 'SpotiFLAC v$version downloaded. Tap to install.',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,11 +581,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: updateDownloadId,
|
||||
title: 'Update Failed',
|
||||
body: 'Could not download update. Try again later.',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@ class PlatformBridge {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
@@ -509,6 +507,7 @@ class PlatformBridge {
|
||||
return {
|
||||
'genre': data['genre'] as String? ?? '',
|
||||
'label': data['label'] as String? ?? '',
|
||||
'copyright': data['copyright'] as String? ?? '',
|
||||
};
|
||||
} catch (e) {
|
||||
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
|
||||
@@ -719,8 +718,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
static Future<void> cleanupExtensions() async {
|
||||
_log.d('cleanupExtensions');
|
||||
await _channel.invokeMethod('cleanupExtensions');
|
||||
@@ -1130,5 +1127,4 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
// ==================== YOUTUBE / COBALT ====================
|
||||
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
|
||||
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
|
||||
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _iosLegacyRelativeDocumentsPattern = RegExp(
|
||||
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Checks if a path is a valid writable directory on iOS.
|
||||
/// Returns false if:
|
||||
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
|
||||
bool isValidIosWritablePath(String path) {
|
||||
if (!Platform.isIOS) return true;
|
||||
if (path.isEmpty) return false;
|
||||
if (!path.startsWith('/')) return false;
|
||||
|
||||
// Check if it's the container root (without Documents/, tmp/, etc.)
|
||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
|
||||
|
||||
/// Validates and potentially corrects an iOS path.
|
||||
/// Returns a valid Documents subdirectory path if the input is invalid.
|
||||
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
|
||||
Future<String> validateOrFixIosPath(
|
||||
String path, {
|
||||
String subfolder = 'SpotiFLAC',
|
||||
}) async {
|
||||
if (!Platform.isIOS) return path;
|
||||
|
||||
if (isValidIosWritablePath(path)) {
|
||||
return path;
|
||||
final trimmed = path.trim();
|
||||
if (isValidIosWritablePath(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final candidates = <String>[];
|
||||
|
||||
if (trimmed.isNotEmpty) {
|
||||
candidates.add(trimmed);
|
||||
}
|
||||
|
||||
// Some pickers can return absolute iOS paths without the leading slash.
|
||||
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
|
||||
candidates.add('/$trimmed');
|
||||
}
|
||||
|
||||
// Recover legacy relative iOS path format:
|
||||
// Data/Application/<UUID>/Documents/<subdir>
|
||||
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
|
||||
trimmed,
|
||||
);
|
||||
if (legacyRelativeMatch != null) {
|
||||
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
|
||||
final normalizedSuffix = suffix.startsWith('/')
|
||||
? suffix.substring(1)
|
||||
: suffix;
|
||||
candidates.add(
|
||||
normalizedSuffix.isEmpty
|
||||
? docDir.path
|
||||
: '${docDir.path}/$normalizedSuffix',
|
||||
);
|
||||
}
|
||||
|
||||
// Generic salvage for relative paths containing `Documents/...`.
|
||||
if (!trimmed.startsWith('/')) {
|
||||
final documentsMarker = 'Documents/';
|
||||
final index = trimmed.indexOf(documentsMarker);
|
||||
if (index >= 0) {
|
||||
final suffix = trimmed.substring(index + documentsMarker.length).trim();
|
||||
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
|
||||
}
|
||||
}
|
||||
|
||||
for (final candidate in candidates) {
|
||||
if (isValidIosWritablePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to app Documents directory
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${dir.path}/$subfolder');
|
||||
final musicDir = Directory('${docDir.path}/$subfolder');
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
@@ -96,11 +153,20 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason:
|
||||
'Invalid path format. Please choose a local folder from Files.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's the container root
|
||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
|
||||
errorReason:
|
||||
'Cannot write to app container root. Please choose a subfolder like Documents.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,7 +176,8 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
path.contains('com~apple~CloudDocs')) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
|
||||
errorReason:
|
||||
'iCloud Drive is not supported. Please choose a local folder.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +192,8 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
if (remainingPath.isEmpty || remainingPath == '/') {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
|
||||
errorReason:
|
||||
'Cannot write to app container root. Please use the default folder or choose a different location.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.6.5+79
|
||||
version: 3.6.7+81
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -42,7 +42,7 @@ dependencies:
|
||||
|
||||
# Material Expressive 3 / Dynamic Color
|
||||
dynamic_color: ^1.7.0
|
||||
material_color_utilities: ^0.11.1
|
||||
material_color_utilities: ">=0.11.1 <0.14.0"
|
||||
|
||||
# Permissions
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Downloads - SpotiFLAC Mobile</title>
|
||||
<meta name="description" content="Download the latest version of SpotiFLAC Mobile. Changelog and release history included.">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<link rel="icon" href="icon.png" type="image/png">
|
||||
|
||||
<!-- Google Sans Flex -->
|
||||
<style>
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 600; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-600-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 700; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-700-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 800; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-800-normal.woff2) format('woff2'); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ── M3 AMOLED surface ramp ── */
|
||||
:root {
|
||||
--green: #1DB954;
|
||||
--green-dim: #1aa34a;
|
||||
--bg: #0a0a0a;
|
||||
--bg-card: #1a1a1a;
|
||||
--bg-card-hover: #222222;
|
||||
--surface: #121212;
|
||||
--text: #e8e8e8;
|
||||
--text-dim: #999;
|
||||
--max-w: 900px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--green); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(18,18,18,.78);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: var(--max-w); margin: auto;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px; height: 64px;
|
||||
}
|
||||
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; color: var(--text); }
|
||||
.nav-brand img { width: 32px; height: 32px; border-radius: 50%; }
|
||||
.nav-links { display: flex; gap: 24px; list-style: none; }
|
||||
.nav-links a { color: var(--text-dim); font-size: .9rem; transition: color .2s; }
|
||||
.nav-links a:hover { color: var(--text); text-decoration: none; }
|
||||
.nav-links a.active { color: var(--text); font-weight: 600; }
|
||||
.nav-links .nav-icon { display: flex; align-items: center; opacity: .6; transition: opacity .2s; margin-left: -12px; }
|
||||
.nav-links .nav-icon:hover { opacity: 1; }
|
||||
.nav-links .nav-icon svg { width: 24px; height: 24px; fill: currentColor; }
|
||||
.nav-links .nav-divider { width: 1px; height: 20px; background: rgba(255,255,255,.15); margin-left: -4px; }
|
||||
|
||||
/* ── PAGE HEADER ── */
|
||||
.page-header {
|
||||
padding: 100px 24px 40px; text-align: center;
|
||||
}
|
||||
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
|
||||
.page-header p { color: var(--text-dim); font-size: 1rem; }
|
||||
|
||||
/* ── LATEST HERO ── */
|
||||
.latest-hero {
|
||||
max-width: var(--max-w); margin: 0 auto; padding: 0 24px 40px;
|
||||
}
|
||||
.latest-card {
|
||||
background: var(--bg-card-hover); border-radius: 20px;
|
||||
padding: 32px; position: relative; overflow: hidden;
|
||||
}
|
||||
.latest-header {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;
|
||||
}
|
||||
.latest-tag { font-size: 1.6rem; font-weight: 800; }
|
||||
.latest-badge {
|
||||
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||
padding: 4px 12px; border-radius: 999px;
|
||||
background: var(--green); color: #000;
|
||||
}
|
||||
.latest-date { font-size: .85rem; color: var(--text-dim); margin-left: auto; }
|
||||
.latest-assets {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px; margin-bottom: 24px;
|
||||
}
|
||||
.latest-asset {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 18px; border-radius: 16px;
|
||||
background: rgba(29,185,84,.08);
|
||||
color: var(--text); transition: background .2s; text-decoration: none;
|
||||
}
|
||||
.latest-asset:hover { background: rgba(29,185,84,.15); text-decoration: none; }
|
||||
.latest-asset-icon { color: var(--green); flex-shrink: 0; }
|
||||
.latest-asset-info { min-width: 0; }
|
||||
.latest-asset-name { font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.latest-asset-meta { font-size: .75rem; color: var(--text-dim); }
|
||||
.latest-changelog-toggle {
|
||||
background: var(--bg-card); border: none; border-radius: 16px;
|
||||
color: var(--text-dim); padding: 10px 16px; font-size: .85rem;
|
||||
cursor: pointer; transition: background .2s; width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
.latest-changelog-toggle:hover { background: var(--surface); color: var(--text); }
|
||||
.latest-changelog {
|
||||
display: none; margin-top: 16px; padding-top: 16px;
|
||||
border-top: 1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.latest-changelog.show { display: block; }
|
||||
|
||||
/* ── OLDER RELEASES ── */
|
||||
.older-section {
|
||||
max-width: var(--max-w); margin: 0 auto; padding: 40px 24px 80px;
|
||||
}
|
||||
.older-title {
|
||||
font-size: 1.1rem; font-weight: 600; color: var(--text-dim);
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── RELEASE CARDS ── */
|
||||
.release-card {
|
||||
background: var(--bg-card); border-radius: 16px;
|
||||
margin-bottom: 8px; transition: background .2s;
|
||||
}
|
||||
.release-card:hover { background: var(--bg-card-hover); }
|
||||
.release-summary {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 16px 20px; cursor: pointer; list-style: none;
|
||||
}
|
||||
.release-summary::-webkit-details-marker { display: none; }
|
||||
.release-tag { font-size: 1rem; font-weight: 700; }
|
||||
.release-badge {
|
||||
font-size: .65rem; font-weight: 700; text-transform: uppercase;
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
}
|
||||
.release-badge-pre { background: #f59e0b; color: #000; }
|
||||
.release-date { font-size: .8rem; color: var(--text-dim); margin-left: auto; }
|
||||
.release-expand { color: var(--text-dim); font-size: .8rem; transition: transform .2s; }
|
||||
details[open] .release-expand { transform: rotate(180deg); }
|
||||
.release-detail { padding: 0 20px 20px; }
|
||||
.release-assets { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||
.release-asset {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 12px; font-size: .82rem; font-weight: 500;
|
||||
background: rgba(29,185,84,.08);
|
||||
color: var(--green); transition: background .2s; text-decoration: none;
|
||||
}
|
||||
.release-asset:hover { background: rgba(29,185,84,.15); text-decoration: none; }
|
||||
.release-asset-size { color: var(--text-dim); font-size: .72rem; }
|
||||
|
||||
/* ── CHANGELOG BODY ── */
|
||||
.release-body {
|
||||
font-size: .85rem; color: var(--text-dim); line-height: 1.7;
|
||||
max-height: 400px; overflow-y: auto;
|
||||
scrollbar-width: thin; scrollbar-color: #333 transparent;
|
||||
}
|
||||
.release-body h1, .release-body h2, .release-body h3 {
|
||||
color: var(--text); font-size: .95rem; margin: 16px 0 8px;
|
||||
}
|
||||
.release-body h1:first-child, .release-body h2:first-child, .release-body h3:first-child { margin-top: 0; }
|
||||
.release-body ul { padding-left: 20px; margin: 4px 0; }
|
||||
.release-body li { margin: 4px 0; }
|
||||
.release-body code { background: var(--bg-card-hover); padding: 2px 6px; border-radius: 4px; font-size: .8rem; }
|
||||
.release-body a { color: var(--green); }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
background: var(--surface);
|
||||
padding: 40px 24px; text-align: center;
|
||||
}
|
||||
.footer-inner { max-width: var(--max-w); margin: auto; }
|
||||
.footer-links { display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.footer-links a { color: var(--text-dim); font-size: .9rem; }
|
||||
.footer-links a:hover { color: var(--text); }
|
||||
.footer-copy { color: #555; font-size: .8rem; }
|
||||
|
||||
/* ── LOADING ── */
|
||||
.loading { text-align: center; color: var(--text-dim); padding: 60px 0; }
|
||||
.loading-spinner {
|
||||
width: 32px; height: 32px; margin: 0 auto 12px;
|
||||
border: 3px solid var(--surface); border-top-color: var(--green);
|
||||
border-radius: 50%; animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.all-releases-link {
|
||||
display: block; text-align: center; padding: 24px;
|
||||
color: var(--text-dim); font-size: .9rem;
|
||||
}
|
||||
|
||||
/* ── MOBILE MENU ── */
|
||||
.nav-burger {
|
||||
display: none; width: 40px; height: 40px; border-radius: 12px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.nav-burger .bar {
|
||||
display: block; width: 20px; height: 2px; background: var(--text);
|
||||
border-radius: 2px; transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .2s;
|
||||
position: absolute; left: 10px;
|
||||
}
|
||||
.nav-burger .bar:nth-child(1) { top: 12px; }
|
||||
.nav-burger .bar:nth-child(2) { top: 19px; }
|
||||
.nav-burger .bar:nth-child(3) { top: 26px; }
|
||||
.nav-burger.active .bar:nth-child(1) { top: 19px; transform: rotate(45deg); }
|
||||
.nav-burger.active .bar:nth-child(2) { opacity: 0; }
|
||||
.nav-burger.active .bar:nth-child(3) { top: 19px; transform: rotate(-45deg); }
|
||||
.mobile-overlay {
|
||||
position: fixed; top: 64px; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,.5); z-index: 98;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
.mobile-menu {
|
||||
position: fixed; top: 64px; left: 0; right: 0;
|
||||
background: rgba(18,18,18,.95); padding: 8px 16px 16px; z-index: 99;
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
transform: translateY(-8px); opacity: 0; pointer-events: none;
|
||||
transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-menu.open { transform: translateY(0); opacity: 1; pointer-events: auto; }
|
||||
.mobile-menu a {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 12px;
|
||||
color: var(--text-dim); font-size: .95rem; font-weight: 500;
|
||||
transition: background .2s; opacity: 0; transform: translateY(-6px);
|
||||
}
|
||||
.mobile-menu.open a {
|
||||
opacity: 1; transform: translateY(0);
|
||||
transition: background .2s, opacity .3s cubic-bezier(.4,0,.2,1), transform .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-menu.open a:nth-child(1) { transition-delay: .03s; }
|
||||
.mobile-menu.open a:nth-child(2) { transition-delay: .06s; }
|
||||
.mobile-menu.open a:nth-child(3) { transition-delay: .09s; }
|
||||
.mobile-menu.open a:nth-child(4) { transition-delay: .12s; }
|
||||
.mobile-menu.open a:nth-child(5) { transition-delay: .15s; }
|
||||
.mobile-menu a:hover { background: var(--bg-card); color: var(--text); text-decoration: none; }
|
||||
.mobile-menu a.active { color: var(--text); font-weight: 600; background: var(--bg-card); }
|
||||
.mobile-menu .mobile-divider {
|
||||
height: 1px; background: rgba(255,255,255,.06); margin: 4px 0;
|
||||
opacity: 0; transition: opacity .3s .15s;
|
||||
}
|
||||
.mobile-menu.open .mobile-divider { opacity: 1; }
|
||||
.mobile-menu .mobile-icons {
|
||||
display: flex; gap: 8px; padding: 8px 16px 0;
|
||||
opacity: 0; transform: translateY(-6px);
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1) .18s, transform .3s cubic-bezier(.4,0,.2,1) .18s;
|
||||
}
|
||||
.mobile-menu.open .mobile-icons { opacity: 1; transform: translateY(0); }
|
||||
.mobile-menu .mobile-icons a {
|
||||
padding: 10px; border-radius: 12px; background: var(--bg-card);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 1; transform: none;
|
||||
}
|
||||
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
|
||||
/* ── MOBILE ── */
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-burger { display: flex; }
|
||||
.page-header { padding: 80px 16px 32px; }
|
||||
.latest-hero { padding: 0 16px 32px; }
|
||||
.latest-card { padding: 20px; }
|
||||
.latest-header { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
.latest-date { margin-left: 0; }
|
||||
.latest-assets { grid-template-columns: 1fr; }
|
||||
.older-section { padding: 32px 16px 60px; }
|
||||
.release-summary { flex-direction: row; gap: 8px; }
|
||||
.release-date { margin-left: auto; }
|
||||
}
|
||||
|
||||
.icon-svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a class="nav-brand" href="index.html">
|
||||
<img src="icon.png" alt="SpotiFLAC">
|
||||
SpotiFLAC
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="index.html#features">Features</a></li>
|
||||
<li><a href="downloads.html" class="active">Downloads</a></li>
|
||||
<li><a href="index.html#faq">FAQ</a></li>
|
||||
<li><a href="partners.html">Partners</a></li>
|
||||
<li><a href="https://zarz.moe/docs" target="_blank">Docs</a></li>
|
||||
<li class="nav-divider"></li>
|
||||
<li><a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" class="nav-icon" aria-label="GitHub"><svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.38c.6.12.82-.26.82-.57L9 20.86c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22l-.01 3.29c0 .31.21.69.82.57A12 12 0 0 0 12 .3"/></svg></a></li>
|
||||
<li><a href="https://t.me/spotiflac" target="_blank" class="nav-icon" aria-label="Telegram"><svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg></a></li>
|
||||
</ul>
|
||||
<button class="nav-burger" onclick="toggleMenu()" aria-label="Menu">
|
||||
<span class="bar"></span><span class="bar"></span><span class="bar"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
||||
<div class="mobile-menu" id="mobileMenu">
|
||||
<a href="index.html#features">Features</a>
|
||||
<a href="downloads.html" class="active">Downloads</a>
|
||||
<a href="index.html#faq">FAQ</a>
|
||||
<a href="partners.html">Partners</a>
|
||||
<a href="https://zarz.moe/docs" target="_blank">Docs</a>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-icons">
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/spotiflac" target="_blank" aria-label="Telegram">
|
||||
<svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Downloads</h1>
|
||||
<p>Latest releases with changelog and direct download links.</p>
|
||||
</div>
|
||||
|
||||
<div class="latest-hero" id="latest-hero">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
Loading latest release...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="older-section" id="older-section" style="display:none">
|
||||
<div class="older-title">Previous Releases</div>
|
||||
<div id="older-releases"></div>
|
||||
<a class="all-releases-link" href="https://github.com/zarzet/SpotiFLAC-Mobile/releases" target="_blank">
|
||||
View all releases on GitHub →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="https://zarz.moe/docs" target="_blank">Documentation</a>
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank">GitHub</a>
|
||||
<a href="https://t.me/spotiflac" target="_blank">Telegram</a>
|
||||
<a href="https://ko-fi.com/zarzet" target="_blank">Support / Ko-fi</a>
|
||||
</div>
|
||||
<p class="footer-copy">SpotiFLAC is for educational and private use only. Not affiliated with any streaming service.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(async () => {
|
||||
const REPO = 'zarzet/SpotiFLAC-Mobile';
|
||||
const latestEl = document.getElementById('latest-hero');
|
||||
const olderEl = document.getElementById('older-releases');
|
||||
const olderSection = document.getElementById('older-section');
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=10`);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const releases = await res.json();
|
||||
if (!releases.length) { latestEl.innerHTML = '<p style="text-align:center;color:#999;padding:40px">No releases found.</p>'; return; }
|
||||
|
||||
// Latest release
|
||||
const latest = releases[0];
|
||||
const latestDate = fmtDate(latest.published_at);
|
||||
const latestBody = md(latest.body || '');
|
||||
const latestAssets = (latest.assets || []).filter(a => !a.name.endsWith('.sha256'));
|
||||
|
||||
latestEl.innerHTML = `
|
||||
<div class="latest-card">
|
||||
<div class="latest-header">
|
||||
<span class="latest-tag">${latest.tag_name}</span>
|
||||
<span class="latest-badge">${latest.prerelease ? 'Pre-release' : 'Latest Release'}</span>
|
||||
<span class="latest-date">${latestDate}</span>
|
||||
</div>
|
||||
<div class="latest-assets">
|
||||
${latestAssets.map(a => `
|
||||
<a class="latest-asset" href="${a.browser_download_url}">
|
||||
<svg class="latest-asset-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
<div class="latest-asset-info">
|
||||
<div class="latest-asset-name">${a.name}</div>
|
||||
<div class="latest-asset-meta">${fmtSize(a.size)} · ${fmtCount(a.download_count)} downloads</div>
|
||||
</div>
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
${latestBody ? `
|
||||
<button class="latest-changelog-toggle" onclick="this.nextElementSibling.classList.toggle('show'); this.textContent = this.nextElementSibling.classList.contains('show') ? 'Hide changelog' : 'Show changelog'">
|
||||
Show changelog
|
||||
</button>
|
||||
<div class="latest-changelog">
|
||||
<div class="release-body">${latestBody}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Older releases
|
||||
const older = releases.slice(1);
|
||||
if (older.length) {
|
||||
olderSection.style.display = '';
|
||||
olderEl.innerHTML = older.map(r => {
|
||||
const date = fmtDate(r.published_at);
|
||||
const body = md(r.body || '');
|
||||
const assets = (r.assets || []).filter(a => !a.name.endsWith('.sha256'));
|
||||
|
||||
return `
|
||||
<details class="release-card">
|
||||
<summary class="release-summary">
|
||||
<span class="release-tag">${r.tag_name}</span>
|
||||
${r.prerelease ? '<span class="release-badge release-badge-pre">Pre-release</span>' : ''}
|
||||
<span class="release-date">${date}</span>
|
||||
<span class="release-expand">▼</span>
|
||||
</summary>
|
||||
<div class="release-detail">
|
||||
${assets.length ? `
|
||||
<div class="release-assets">
|
||||
${assets.map(a => `
|
||||
<a class="release-asset" href="${a.browser_download_url}" target="_blank">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
|
||||
${a.name}
|
||||
<span class="release-asset-size">${fmtSize(a.size)}</span>
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
${body ? `<div class="release-body">${body}</div>` : ''}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
latestEl.innerHTML = `<p style="text-align:center;color:#999;padding:40px">Failed to load releases. <a href="https://github.com/${REPO}/releases" target="_blank">View on GitHub</a></p>`;
|
||||
}
|
||||
|
||||
function fmtDate(d) { return new Date(d).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' }); }
|
||||
function fmtSize(b) { return b < 1048576 ? (b/1024).toFixed(1)+' KB' : (b/1048576).toFixed(1)+' MB'; }
|
||||
function fmtCount(n) { return n >= 1000 ? (n/1000).toFixed(1)+'k' : n; }
|
||||
function md(s) {
|
||||
return s
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank">$1</a>')
|
||||
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n{2,}/g, '<br>')
|
||||
.replace(/(^<ul>)|(<\/ul>$)/g, '')
|
||||
.replace(/(<li>[\s\S]*?<\/li>)(?=\s*<h|$|\s*<br>)/g, '<ul>$1</ul>');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function toggleMenu() {
|
||||
document.getElementById('mobileMenu').classList.toggle('open');
|
||||
document.getElementById('mobileOverlay').classList.toggle('open');
|
||||
document.querySelector('.nav-burger').classList.toggle('active');
|
||||
}
|
||||
document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
||||
if (e.target.closest('a')) toggleMenu();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 539 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 811 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
+465
@@ -0,0 +1,465 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SpotiFLAC Mobile - Lossless Music Downloader</title>
|
||||
<meta name="description" content="Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music. No account required. Available on Android & iOS.">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="SpotiFLAC Mobile">
|
||||
<meta property="og:description" content="Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music. No account required.">
|
||||
<meta property="og:image" content="icon.png">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<link rel="icon" href="icon.png" type="image/png">
|
||||
|
||||
<!-- Google Sans Flex -->
|
||||
<style>
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 600; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-600-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 700; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-700-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 800; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-800-normal.woff2) format('woff2'); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ── M3 AMOLED surface ramp ── */
|
||||
:root {
|
||||
--green: #1DB954;
|
||||
--green-dim: #1aa34a;
|
||||
--bg: #0a0a0a; /* surfaceContainerLow */
|
||||
--bg-card: #1a1a1a; /* surfaceContainerHigh */
|
||||
--bg-card-hover: #222222; /* surfaceContainerHighest */
|
||||
--surface: #121212; /* surfaceContainer */
|
||||
--text: #e8e8e8; /* onSurface */
|
||||
--text-dim: #999; /* onSurfaceVariant */
|
||||
--max-w: 1100px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--green); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(18,18,18,.78);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: var(--max-w); margin: auto;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px; height: 64px;
|
||||
}
|
||||
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; color: var(--text); }
|
||||
.nav-brand img { width: 32px; height: 32px; border-radius: 50%; }
|
||||
.nav-links { display: flex; gap: 24px; list-style: none; }
|
||||
.nav-links a { color: var(--text-dim); font-size: .9rem; transition: color .2s; }
|
||||
.nav-links a:hover { color: var(--text); text-decoration: none; }
|
||||
.nav-links a.active { color: var(--text); font-weight: 600; }
|
||||
.nav-links .nav-icon { display: flex; align-items: center; opacity: .6; transition: opacity .2s; margin-left: -12px; }
|
||||
.nav-links .nav-icon:hover { opacity: 1; }
|
||||
.nav-links .nav-icon svg { width: 24px; height: 24px; fill: currentColor; }
|
||||
.nav-links .nav-divider { width: 1px; height: 20px; background: rgba(255,255,255,.15); margin-left: -4px; }
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center; padding: 100px 24px 60px;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(29,185,84,.05) 0%, transparent 50%);
|
||||
}
|
||||
.hero h1 { font-size: clamp(2.2rem, 5vw, 3.5rem); font-weight: 800; letter-spacing: -1px; margin-bottom: 12px; }
|
||||
.hero h1 span { color: var(--green); }
|
||||
.hero p { font-size: 1.15rem; color: var(--text-dim); max-width: 520px; margin-bottom: 8px; }
|
||||
.hero-badges { display: flex; gap: 8px; justify-content: center; margin: 16px 0 32px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 16px; border-radius: 999px;
|
||||
font-size: .8rem; font-weight: 600;
|
||||
background: var(--surface); color: var(--text-dim);
|
||||
}
|
||||
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 12px 28px; border-radius: 16px;
|
||||
font-size: .95rem; font-weight: 600;
|
||||
transition: background .2s; cursor: pointer; border: none;
|
||||
}
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-dim); text-decoration: none; }
|
||||
.btn-secondary { background: var(--bg-card); color: var(--text); }
|
||||
.btn-secondary:hover { background: var(--bg-card-hover); text-decoration: none; }
|
||||
|
||||
/* ── SECTIONS ── */
|
||||
section { padding: 80px 24px; }
|
||||
.section-inner { max-width: var(--max-w); margin: auto; }
|
||||
.section-title { font-size: 1.8rem; font-weight: 700; text-align: center; margin-bottom: 12px; }
|
||||
.section-sub { text-align: center; color: var(--text-dim); max-width: 560px; margin: 0 auto 48px; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--bg-card); border-radius: 20px;
|
||||
padding: 28px 24px; transition: background .2s;
|
||||
}
|
||||
.feature-card:hover { background: var(--bg-card-hover); }
|
||||
.feature-icon {
|
||||
width: 40px; height: 40px; border-radius: 12px;
|
||||
background: rgba(29,185,84,.12); color: var(--green);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 16px; font-size: 1.2rem;
|
||||
}
|
||||
.feature-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
|
||||
.feature-card p { color: var(--text-dim); font-size: .9rem; }
|
||||
|
||||
/* ── FAQ ── */
|
||||
.faq-list { max-width: 700px; margin: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.faq-item {
|
||||
background: var(--bg-card); border-radius: 16px;
|
||||
}
|
||||
.faq-item summary {
|
||||
cursor: pointer; font-weight: 600; font-size: 1rem;
|
||||
list-style: none; display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.faq-item summary::-webkit-details-marker { display: none; }
|
||||
.faq-item summary::after { content: "+"; font-size: 1.4rem; color: var(--text-dim); transition: transform .2s; }
|
||||
.faq-item[open] summary::after { content: "\2212"; }
|
||||
.faq-item .faq-answer { padding: 0 20px 18px; color: var(--text-dim); font-size: .92rem; line-height: 1.7; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
background: var(--surface);
|
||||
padding: 40px 24px; text-align: center;
|
||||
}
|
||||
.footer-inner { max-width: var(--max-w); margin: auto; }
|
||||
.footer-links { display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.footer-links a { color: var(--text-dim); font-size: .9rem; }
|
||||
.footer-links a:hover { color: var(--text); }
|
||||
.footer-copy { color: #555; font-size: .8rem; }
|
||||
|
||||
/* ── MOBILE MENU ── */
|
||||
.nav-burger {
|
||||
display: none; width: 40px; height: 40px; border-radius: 12px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.nav-burger .bar {
|
||||
display: block; width: 20px; height: 2px; background: var(--text);
|
||||
border-radius: 2px; transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .2s;
|
||||
position: absolute; left: 10px;
|
||||
}
|
||||
.nav-burger .bar:nth-child(1) { top: 12px; }
|
||||
.nav-burger .bar:nth-child(2) { top: 19px; }
|
||||
.nav-burger .bar:nth-child(3) { top: 26px; }
|
||||
.nav-burger.active .bar:nth-child(1) { top: 19px; transform: rotate(45deg); }
|
||||
.nav-burger.active .bar:nth-child(2) { opacity: 0; }
|
||||
.nav-burger.active .bar:nth-child(3) { top: 19px; transform: rotate(-45deg); }
|
||||
.mobile-overlay {
|
||||
position: fixed; top: 64px; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,.5); z-index: 98;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
.mobile-menu {
|
||||
position: fixed; top: 64px; left: 0; right: 0;
|
||||
background: rgba(18,18,18,.95); padding: 8px 16px 16px; z-index: 99;
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
transform: translateY(-8px); opacity: 0; pointer-events: none;
|
||||
transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-menu.open { transform: translateY(0); opacity: 1; pointer-events: auto; }
|
||||
.mobile-menu a {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 12px;
|
||||
color: var(--text-dim); font-size: .95rem; font-weight: 500;
|
||||
transition: background .2s; opacity: 0; transform: translateY(-6px);
|
||||
}
|
||||
.mobile-menu.open a {
|
||||
opacity: 1; transform: translateY(0);
|
||||
transition: background .2s, opacity .3s cubic-bezier(.4,0,.2,1), transform .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-menu.open a:nth-child(1) { transition-delay: .03s; }
|
||||
.mobile-menu.open a:nth-child(2) { transition-delay: .06s; }
|
||||
.mobile-menu.open a:nth-child(3) { transition-delay: .09s; }
|
||||
.mobile-menu.open a:nth-child(4) { transition-delay: .12s; }
|
||||
.mobile-menu.open a:nth-child(5) { transition-delay: .15s; }
|
||||
.mobile-menu a:hover { background: var(--bg-card); color: var(--text); text-decoration: none; }
|
||||
.mobile-menu a.active { color: var(--text); font-weight: 600; background: var(--bg-card); }
|
||||
.mobile-menu .mobile-divider {
|
||||
height: 1px; background: rgba(255,255,255,.06); margin: 4px 0;
|
||||
opacity: 0; transition: opacity .3s .15s;
|
||||
}
|
||||
.mobile-menu.open .mobile-divider { opacity: 1; }
|
||||
.mobile-menu .mobile-icons {
|
||||
display: flex; gap: 8px; padding: 8px 16px 0;
|
||||
opacity: 0; transform: translateY(-6px);
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1) .18s, transform .3s cubic-bezier(.4,0,.2,1) .18s;
|
||||
}
|
||||
.mobile-menu.open .mobile-icons { opacity: 1; transform: translateY(0); }
|
||||
.mobile-menu .mobile-icons a {
|
||||
padding: 10px; border-radius: 12px; background: var(--bg-card);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 1; transform: none;
|
||||
}
|
||||
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
|
||||
/* ── MOBILE ── */
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-burger { display: flex; }
|
||||
.hero { padding: 80px 16px 40px; }
|
||||
section { padding: 60px 16px; }
|
||||
}
|
||||
|
||||
/* ── HERO MOCKUPS ── */
|
||||
.hero-mockups {
|
||||
display: flex; gap: 20px; justify-content: center; align-items: flex-end;
|
||||
margin-top: 48px; perspective: 800px;
|
||||
}
|
||||
.phone-frame {
|
||||
width: 180px; border-radius: 28px; overflow: hidden;
|
||||
border: 3px solid #333; background: #000;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||
transition: transform .3s;
|
||||
}
|
||||
.phone-frame:hover { transform: translateY(-4px); }
|
||||
.phone-frame img { width: 100%; display: block; }
|
||||
.phone-frame.phone-center {
|
||||
width: 210px;
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,.6), 0 0 40px rgba(29,185,84,.1);
|
||||
}
|
||||
.phone-frame.phone-side { opacity: .7; }
|
||||
@media (max-width: 640px) {
|
||||
.hero-mockups { gap: 10px; margin-top: 32px; }
|
||||
.phone-frame { width: 120px; border-radius: 20px; border-width: 2px; }
|
||||
.phone-frame.phone-center { width: 150px; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.phone-frame.phone-side { display: none; }
|
||||
.phone-frame.phone-center { width: 200px; }
|
||||
}
|
||||
|
||||
/* ── SVG ICONS ── */
|
||||
.icon-svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a class="nav-brand" href="#">
|
||||
<img src="icon.png" alt="SpotiFLAC">
|
||||
SpotiFLAC
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="downloads.html">Downloads</a></li>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
<li><a href="partners.html">Partners</a></li>
|
||||
<li><a href="https://zarz.moe/docs" target="_blank">Docs</a></li>
|
||||
<li class="nav-divider"></li>
|
||||
<li><a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" class="nav-icon" aria-label="GitHub"><svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.38c.6.12.82-.26.82-.57L9 20.86c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22l-.01 3.29c0 .31.21.69.82.57A12 12 0 0 0 12 .3"/></svg></a></li>
|
||||
<li><a href="https://t.me/spotiflac" target="_blank" class="nav-icon" aria-label="Telegram"><svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg></a></li>
|
||||
</ul>
|
||||
<button class="nav-burger" onclick="toggleMenu()" aria-label="Menu">
|
||||
<span class="bar"></span><span class="bar"></span><span class="bar"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
||||
<div class="mobile-menu" id="mobileMenu">
|
||||
<a href="#features">Features</a>
|
||||
<a href="downloads.html">Downloads</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="partners.html">Partners</a>
|
||||
<a href="https://zarz.moe/docs" target="_blank">Docs</a>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-icons">
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/spotiflac" target="_blank" aria-label="Telegram">
|
||||
<svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<h1>Spoti<span>FLAC</span> Mobile</h1>
|
||||
<p>Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.</p>
|
||||
<div class="hero-badges">
|
||||
<span class="badge">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path d="M6 18c0 .55.45 1 1 1h1v3.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5V19h2v3.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5V19h1c.55 0 1-.45 1-1V7H6v11zM3.5 7C2.67 7 2 7.67 2 8.5v7c0 .83.67 1.5 1.5 1.5S5 16.33 5 15.5v-7C5 7.67 4.33 7 3.5 7zm17 0c-.83 0-1.5.67-1.5 1.5v7c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5v-7c0-.83-.67-1.5-1.5-1.5zm-4.97-5.84l1.3-1.3c.2-.2.2-.51 0-.71-.2-.2-.51-.2-.71 0l-1.48 1.48A5.84 5.84 0 0012 0c-.96 0-1.86.23-2.66.63L7.85.15c-.2-.2-.51-.2-.71 0-.2.2-.2.51 0 .71l1.31 1.31A5.983 5.983 0 006 6h12c0-2.21-1.2-4.15-2.97-5.18-.25-.14-.4-.24-.5-.36v-.3zM10 4H9V3h1v1zm5 0h-1V3h1v1z"/></svg>
|
||||
Android 7.0+
|
||||
</span>
|
||||
<span class="badge">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
|
||||
iOS 14.0+
|
||||
</span>
|
||||
<span class="badge">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
Open Source
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="downloads.html">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="#000" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
|
||||
Download
|
||||
</a>
|
||||
<a class="btn btn-secondary" href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="hero-mockups">
|
||||
<div class="phone-frame phone-side"><img src="images/2.jpg" alt="Search" loading="lazy"></div>
|
||||
<div class="phone-frame phone-center"><img src="images/1.jpg" alt="Home screen" loading="lazy"></div>
|
||||
<div class="phone-frame phone-side"><img src="images/3.jpg" alt="History" loading="lazy"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features">
|
||||
<div class="section-inner">
|
||||
<h2 class="section-title">Features</h2>
|
||||
<p class="section-sub">Everything you need to build a high-quality music library on your phone.</p>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
</div>
|
||||
<h3>True Lossless FLAC</h3>
|
||||
<p>Download in up to 24-bit/192kHz quality. No transcoding, no quality loss. Pure studio-grade audio files.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<h3>Multiple Providers</h3>
|
||||
<p>Download from Tidal, Qobuz, Amazon Music, and more. Automatic fallback if a source is unavailable.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5a2.5 2.5 0 00-5 0V5H4c-1.1 0-2 .9-2 2v3.8h1.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5a2.5 2.5 0 000-5z"/></svg>
|
||||
</div>
|
||||
<h3>Extensions</h3>
|
||||
<p>Community-built extensions add new music sources and features. Install from the built-in Store with one tap.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
</div>
|
||||
<h3>Search by Link or Name</h3>
|
||||
<p>Paste a Spotify, Tidal, Qobuz, or Deezer link. Or just search by song name — it handles the rest.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zm-6-1l-4-4.8h3V5h2v4.2h3L14 14z"/></svg>
|
||||
</div>
|
||||
<h3>Batch & Playlist Download</h3>
|
||||
<p>Download entire albums and playlists at once. Smart queue management with concurrent downloads.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg class="icon-svg" viewBox="0 0 24 24"><path fill="currentColor" d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM5 15h14v3H5z"/></svg>
|
||||
</div>
|
||||
<h3>Rich Metadata</h3>
|
||||
<p>Full metadata embedding — album art, lyrics, genre, label, copyright, and more. All embedded in the FLAC file.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- FAQ -->
|
||||
<section id="faq">
|
||||
<div class="section-inner">
|
||||
<h2 class="section-title">FAQ</h2>
|
||||
<p class="section-sub">Common questions about SpotiFLAC Mobile.</p>
|
||||
<div class="faq-list">
|
||||
<details class="faq-item">
|
||||
<summary>Why is my download failing with "Song not found"?</summary>
|
||||
<div class="faq-answer">The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.</div>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Why are some tracks downloading in lower quality?</summary>
|
||||
<div class="faq-answer">Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.</div>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Can I download entire playlists?</summary>
|
||||
<div class="faq-answer">Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.</div>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Why do I need to grant storage permission?</summary>
|
||||
<div class="faq-answer">The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.</div>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Is this app safe?</summary>
|
||||
<div class="faq-answer">Yes, the app is fully open source. You can verify the code yourself on GitHub. Each release is scanned with VirusTotal.</div>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Download not working in my country?</summary>
|
||||
<div class="faq-answer">Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.</div>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>How do I create my own extension?</summary>
|
||||
<div class="faq-answer">Check out the <a href="https://zarz.moe/docs" target="_blank">Extension Development Guide</a> for complete documentation on building custom extensions.</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/releases" target="_blank">Download</a>
|
||||
<a href="https://zarz.moe/docs" target="_blank">Documentation</a>
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank">GitHub</a>
|
||||
<a href="https://github.com/afkarxyz/SpotiFLAC" target="_blank">Desktop Version</a>
|
||||
<a href="https://t.me/spotiflac" target="_blank">Telegram Channel</a>
|
||||
<a href="https://t.me/spotiflac_chat" target="_blank">Community</a>
|
||||
<a href="https://ko-fi.com/zarzet" target="_blank">Support / Ko-fi</a>
|
||||
<a href="https://crowdin.com/project/spotiflac-mobile" target="_blank">Help Translate</a>
|
||||
</div>
|
||||
<p class="footer-copy">SpotiFLAC is for educational and private use only. Not affiliated with any streaming service.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
<script>
|
||||
function toggleMenu() {
|
||||
document.getElementById('mobileMenu').classList.toggle('open');
|
||||
document.getElementById('mobileOverlay').classList.toggle('open');
|
||||
document.querySelector('.nav-burger').classList.toggle('active');
|
||||
}
|
||||
document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
||||
if (e.target.closest('a')) toggleMenu();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,516 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Partners & Services - SpotiFLAC Mobile</title>
|
||||
<meta name="description" content="The APIs and services that power SpotiFLAC Mobile. Giving credit to the platforms that make lossless music downloads possible.">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<link rel="icon" href="icon.png" type="image/png">
|
||||
|
||||
<!-- Google Sans Flex -->
|
||||
<style>
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 600; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-600-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 700; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-700-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 800; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-800-normal.woff2) format('woff2'); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ── M3 AMOLED surface ramp ── */
|
||||
:root {
|
||||
--green: #1DB954;
|
||||
--green-dim: #1aa34a;
|
||||
--bg: #0a0a0a;
|
||||
--bg-card: #1a1a1a;
|
||||
--bg-card-hover: #222222;
|
||||
--surface: #121212;
|
||||
--text: #e8e8e8;
|
||||
--text-dim: #999;
|
||||
--max-w: 1100px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--green); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(18,18,18,.78);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: var(--max-w); margin: auto;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px; height: 64px;
|
||||
}
|
||||
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; color: var(--text); }
|
||||
.nav-brand img { width: 32px; height: 32px; border-radius: 50%; }
|
||||
.nav-links { display: flex; gap: 24px; list-style: none; }
|
||||
.nav-links a { color: var(--text-dim); font-size: .9rem; transition: color .2s; }
|
||||
.nav-links a:hover { color: var(--text); text-decoration: none; }
|
||||
.nav-links a.active { color: var(--text); font-weight: 600; }
|
||||
.nav-links .nav-icon { display: flex; align-items: center; opacity: .6; transition: opacity .2s; margin-left: -12px; }
|
||||
.nav-links .nav-icon:hover { opacity: 1; }
|
||||
.nav-links .nav-icon svg { width: 24px; height: 24px; fill: currentColor; }
|
||||
.nav-links .nav-divider { width: 1px; height: 20px; background: rgba(255,255,255,.15); margin-left: -4px; }
|
||||
|
||||
/* ── PAGE HEADER ── */
|
||||
.page-header {
|
||||
padding: 100px 24px 40px; text-align: center;
|
||||
}
|
||||
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
|
||||
.page-header p { color: var(--text-dim); font-size: 1rem; max-width: 560px; margin: 0 auto; }
|
||||
|
||||
/* ── SECTIONS ── */
|
||||
section { padding: 40px 24px 60px; }
|
||||
.section-inner { max-width: var(--max-w); margin: auto; }
|
||||
.section-label {
|
||||
font-size: .85rem; font-weight: 600;
|
||||
color: var(--green); margin-bottom: 8px;
|
||||
}
|
||||
.section-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.section-sub { color: var(--text-dim); font-size: .95rem; margin-bottom: 32px; max-width: 600px; }
|
||||
|
||||
/* ── INFRA CARDS ── */
|
||||
.infra-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.infra-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex; align-items: flex-start; gap: 16px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.infra-card:hover { background: var(--bg-card-hover); }
|
||||
.infra-icon {
|
||||
width: 48px; height: 48px; border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.infra-icon svg { width: 24px; height: 24px; fill: currentColor; }
|
||||
.infra-info { flex: 1; min-width: 0; }
|
||||
.infra-name { font-size: 1.05rem; font-weight: 700; margin-bottom: 4px; }
|
||||
.infra-desc { color: var(--text-dim); font-size: .88rem; line-height: 1.6; margin-bottom: 10px; }
|
||||
.infra-link {
|
||||
font-size: .82rem; font-weight: 600; color: var(--text-dim);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
transition: color .2s;
|
||||
}
|
||||
.infra-link:hover { color: var(--text); text-decoration: none; }
|
||||
.infra-link svg { width: 13px; height: 13px; fill: currentColor; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
background: var(--surface);
|
||||
padding: 40px 24px; text-align: center;
|
||||
}
|
||||
.footer-inner { max-width: var(--max-w); margin: auto; }
|
||||
.footer-links { display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.footer-links a { color: var(--text-dim); font-size: .9rem; }
|
||||
.footer-links a:hover { color: var(--text); }
|
||||
.footer-copy { color: #555; font-size: .8rem; }
|
||||
|
||||
/* ── DISCLAIMER ── */
|
||||
.disclaimer {
|
||||
max-width: var(--max-w); margin: 0 auto; padding: 0 24px 60px;
|
||||
text-align: center;
|
||||
}
|
||||
.disclaimer p {
|
||||
color: #555; font-size: .8rem; line-height: 1.6;
|
||||
max-width: 600px; margin: 0 auto;
|
||||
padding: 20px; border-radius: 16px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── MOBILE MENU ── */
|
||||
.nav-burger {
|
||||
display: none; width: 40px; height: 40px; border-radius: 12px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.nav-burger .bar {
|
||||
display: block; width: 20px; height: 2px; background: var(--text);
|
||||
border-radius: 2px; transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .2s;
|
||||
position: absolute; left: 10px;
|
||||
}
|
||||
.nav-burger .bar:nth-child(1) { top: 12px; }
|
||||
.nav-burger .bar:nth-child(2) { top: 19px; }
|
||||
.nav-burger .bar:nth-child(3) { top: 26px; }
|
||||
.nav-burger.active .bar:nth-child(1) { top: 19px; transform: rotate(45deg); }
|
||||
.nav-burger.active .bar:nth-child(2) { opacity: 0; }
|
||||
.nav-burger.active .bar:nth-child(3) { top: 19px; transform: rotate(-45deg); }
|
||||
.mobile-overlay {
|
||||
position: fixed; top: 64px; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,.5); z-index: 98;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
.mobile-menu {
|
||||
position: fixed; top: 64px; left: 0; right: 0;
|
||||
background: rgba(18,18,18,.95); padding: 8px 16px 16px; z-index: 99;
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
transform: translateY(-8px); opacity: 0; pointer-events: none;
|
||||
transition: transform .3s cubic-bezier(.4,0,.2,1), opacity .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-menu.open { transform: translateY(0); opacity: 1; pointer-events: auto; }
|
||||
.mobile-menu a {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 12px;
|
||||
color: var(--text-dim); font-size: .95rem; font-weight: 500;
|
||||
transition: background .2s; opacity: 0; transform: translateY(-6px);
|
||||
}
|
||||
.mobile-menu.open a {
|
||||
opacity: 1; transform: translateY(0);
|
||||
transition: background .2s, opacity .3s cubic-bezier(.4,0,.2,1), transform .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.mobile-menu.open a:nth-child(1) { transition-delay: .03s; }
|
||||
.mobile-menu.open a:nth-child(2) { transition-delay: .06s; }
|
||||
.mobile-menu.open a:nth-child(3) { transition-delay: .09s; }
|
||||
.mobile-menu.open a:nth-child(4) { transition-delay: .12s; }
|
||||
.mobile-menu.open a:nth-child(5) { transition-delay: .15s; }
|
||||
.mobile-menu a:hover { background: var(--bg-card); color: var(--text); text-decoration: none; }
|
||||
.mobile-menu a.active { color: var(--text); font-weight: 600; background: var(--bg-card); }
|
||||
.mobile-menu .mobile-divider {
|
||||
height: 1px; background: rgba(255,255,255,.06); margin: 4px 0;
|
||||
opacity: 0; transition: opacity .3s .15s;
|
||||
}
|
||||
.mobile-menu.open .mobile-divider { opacity: 1; }
|
||||
.mobile-menu .mobile-icons {
|
||||
display: flex; gap: 8px; padding: 8px 16px 0;
|
||||
opacity: 0; transform: translateY(-6px);
|
||||
transition: opacity .3s cubic-bezier(.4,0,.2,1) .18s, transform .3s cubic-bezier(.4,0,.2,1) .18s;
|
||||
}
|
||||
.mobile-menu.open .mobile-icons { opacity: 1; transform: translateY(0); }
|
||||
.mobile-menu .mobile-icons a {
|
||||
padding: 10px; border-radius: 12px; background: var(--bg-card);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 1; transform: none;
|
||||
}
|
||||
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
|
||||
/* ── MOBILE ── */
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-burger { display: flex; }
|
||||
.page-header { padding: 80px 16px 32px; }
|
||||
section { padding: 32px 16px 48px; }
|
||||
.infra-grid { grid-template-columns: 1fr; }
|
||||
.disclaimer { padding: 0 16px 48px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a class="nav-brand" href="index.html">
|
||||
<img src="icon.png" alt="SpotiFLAC">
|
||||
SpotiFLAC
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="index.html#features">Features</a></li>
|
||||
<li><a href="downloads.html">Downloads</a></li>
|
||||
<li><a href="index.html#faq">FAQ</a></li>
|
||||
<li><a href="partners.html" class="active">Partners</a></li>
|
||||
<li><a href="https://zarz.moe/docs" target="_blank">Docs</a></li>
|
||||
<li class="nav-divider"></li>
|
||||
<li><a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" class="nav-icon" aria-label="GitHub"><svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.38c.6.12.82-.26.82-.57L9 20.86c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22l-.01 3.29c0 .31.21.69.82.57A12 12 0 0 0 12 .3"/></svg></a></li>
|
||||
<li><a href="https://t.me/spotiflac" target="_blank" class="nav-icon" aria-label="Telegram"><svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg></a></li>
|
||||
</ul>
|
||||
<button class="nav-burger" onclick="toggleMenu()" aria-label="Menu">
|
||||
<span class="bar"></span><span class="bar"></span><span class="bar"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
||||
<div class="mobile-menu" id="mobileMenu">
|
||||
<a href="index.html#features">Features</a>
|
||||
<a href="downloads.html">Downloads</a>
|
||||
<a href="index.html#faq">FAQ</a>
|
||||
<a href="partners.html" class="active">Partners</a>
|
||||
<a href="https://zarz.moe/docs" target="_blank">Docs</a>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-icons">
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/spotiflac" target="_blank" aria-label="Telegram">
|
||||
<svg viewBox="0 0 24 24"><path d="M11.94 24c6.6 0 12-5.4 12-12s-5.4-12-12-12-12 5.4-12 12 5.4 12 12 12zm-3.2-8.69l-.37-3.04 8.52-5.18c.38-.23.73.09.45.35l-6.96 6.4-.29 2.97c-.04.35-.48.43-.64.12l-1.64-3.33-3.6-1.17c-.78-.24-.8-.78-.02-1.14l14.04-5.4c.65-.25 1.25.15 1.04.83l-2.39 11.28c-.18.81-.7 1.01-1.42.63l-3.92-2.89-1.89 1.82c-.21.2-.39.38-.65.38l.28-3.06z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Partners & Services</h1>
|
||||
<p>The behind-the-scenes APIs and tools that power SpotiFLAC Mobile. We appreciate every one of them.</p>
|
||||
</div>
|
||||
|
||||
<!-- INFRASTRUCTURE -->
|
||||
<section>
|
||||
<div class="section-inner">
|
||||
<div class="section-label">Infrastructure</div>
|
||||
<h2 class="section-title">APIs & Tools</h2>
|
||||
<p class="section-sub">The services that handle link resolution, lyrics, audio extraction, and more.</p>
|
||||
|
||||
<div class="infra-grid">
|
||||
|
||||
<!-- === TRACK LINKING === -->
|
||||
|
||||
<!-- Odesli / song.link (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(99,102,241,.1); color: #6366f1;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">Odesli / song.link</div>
|
||||
<div class="infra-desc">Cross-platform link resolution. Translates any Spotify, Deezer, or streaming URL into matching Tidal, Qobuz, Amazon, and YouTube IDs — enabling SpotiFLAC to find the best lossless source for every track.</div>
|
||||
<a class="infra-link" href="https://odesli.co" target="_blank">
|
||||
odesli.co
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- I Don't Have Spotify (GitHub) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">I Don't Have Spotify</div>
|
||||
<div class="infra-desc">Fallback link resolution service. When Odesli is rate-limited or unavailable, IDHS provides an alternative way to match Spotify links to Tidal, Qobuz, and other streaming platforms.</div>
|
||||
<a class="infra-link" href="https://github.com/sjdonado/idonthavespotify" target="_blank">
|
||||
sjdonado/idonthavespotify
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LRCLIB (GitHub) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">LRCLIB</div>
|
||||
<div class="infra-desc">Open synced lyrics database. Provides time-stamped lyrics that get embedded directly into downloaded FLAC files, so your music player can display lyrics in sync with the music.</div>
|
||||
<a class="infra-link" href="https://github.com/tranxuanthang/lrclib" target="_blank">
|
||||
tranxuanthang/lrclib
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === TIDAL STREAM APIs === -->
|
||||
|
||||
<!-- hifi-api / Binimum (GitHub) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">hifi-api / Binimum</div>
|
||||
<div class="infra-desc">Primary Tidal lossless stream API. Accepts a track ID and quality parameter, returns hi-res download URLs and DASH manifests. Also deployed at music.binimum.org.</div>
|
||||
<a class="infra-link" href="https://github.com/binimum/hifi-api" target="_blank">
|
||||
binimum/hifi-api
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QQDL (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(244,63,94,.1); color: #f43f5e;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">QQDL</div>
|
||||
<div class="infra-desc">Redundant Tidal API mirror cluster. Operates five parallel endpoints (vogel, maus, hund, katze, wolf) for high-availability lossless track downloads across the API pool.</div>
|
||||
<a class="infra-link" href="https://qqdl.site" target="_blank">
|
||||
qqdl.site
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Squid (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(6,182,212,.1); color: #06b6d4;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">Squid</div>
|
||||
<div class="infra-desc">Dual-purpose download API serving both Tidal and Qobuz streams. Supports multi-region retrieval (US/FR fallback for Qobuz) to maximize track availability across catalogs.</div>
|
||||
<a class="infra-link" href="https://squid.wtf" target="_blank">
|
||||
squid.wtf
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SpotiSaver (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(245,158,11,.1); color: #f59e0b;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">SpotiSaver</div>
|
||||
<div class="infra-desc">Tidal hi-fi download endpoints. Hosts two parallel instances (hifi-one, hifi-two) that provide additional redundancy in the 10-API parallel race pool.</div>
|
||||
<a class="infra-link" href="https://spotisaver.net" target="_blank">
|
||||
spotisaver.net
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === QOBUZ STREAM APIs === -->
|
||||
|
||||
<!-- DabMusic (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(139,92,246,.1); color: #8b5cf6;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">DabMusic</div>
|
||||
<div class="infra-desc">Primary Qobuz lossless stream API. Provides direct download URLs for FLAC audio at up to 24-bit/192kHz quality. Queried in parallel alongside squid.wtf for fastest response.</div>
|
||||
<a class="infra-link" href="https://dabmusic.xyz" target="_blank">
|
||||
dabmusic.xyz
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jumo DL (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(56,189,248,.1); color: #38bdf8;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">Jumo DL</div>
|
||||
<div class="infra-desc">Qobuz final fallback. A Cloudflare Pages worker tried after all standard Qobuz APIs fail, with automatic quality downgrade cascade (hi-res → CD → MP3) to maximize success rate.</div>
|
||||
<a class="infra-link" href="https://jumo-dl.pages.dev" target="_blank">
|
||||
jumo-dl.pages.dev
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === AMAZON === -->
|
||||
|
||||
<!-- AfkarXYZ (GitHub) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">AfkarXYZ</div>
|
||||
<div class="infra-desc">Sole Amazon Music download API with stream decryption support. Also provides a SpotFetch-compatible Spotify metadata proxy used when direct API access is blocked.</div>
|
||||
<a class="infra-link" href="https://github.com/afkarxyz" target="_blank">
|
||||
afkarxyz
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === YOUTUBE AUDIO === -->
|
||||
|
||||
<!-- Cobalt (GitHub) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">Cobalt</div>
|
||||
<div class="infra-desc">Privacy-focused media extraction tool. The core engine behind YouTube Music downloads — accepts a video URL and returns a tunnel URL to the audio stream in opus or mp3 format.</div>
|
||||
<a class="infra-link" href="https://github.com/imputnet/cobalt" target="_blank">
|
||||
imputnet/cobalt
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Qwkuns (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(16,185,129,.1); color: #10b981;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">Qwkuns</div>
|
||||
<div class="infra-desc">Cobalt-compatible API for YouTube audio extraction. Serves as the fallback download engine when the primary SpotubeDL proxy is unavailable, using the standard Cobalt protocol.</div>
|
||||
<a class="infra-link" href="https://qwkuns.me" target="_blank">
|
||||
qwkuns.me
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SpotubeDL (no GitHub — globe) -->
|
||||
<div class="infra-card">
|
||||
<div class="infra-icon" style="background: rgba(244,63,94,.1); color: #f43f5e;">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div class="infra-info">
|
||||
<div class="infra-name">SpotubeDL</div>
|
||||
<div class="infra-desc">Primary YouTube download proxy. Handles authentication to Cobalt download instances and serves as the first-choice engine for YouTube Music audio extraction.</div>
|
||||
<a class="infra-link" href="https://spotubedl.com" target="_blank">
|
||||
spotubedl.com
|
||||
<svg viewBox="0 0 24 24"><path d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DISCLAIMER -->
|
||||
<div class="disclaimer">
|
||||
<p>SpotiFLAC Mobile is not affiliated with, endorsed by, or connected to any of the services listed above. All trademarks and logos belong to their respective owners. This page is meant to acknowledge and appreciate the platforms that make this project possible.</p>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="downloads.html">Downloads</a>
|
||||
<a href="https://zarz.moe/docs" target="_blank">Documentation</a>
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile" target="_blank">GitHub</a>
|
||||
<a href="https://t.me/spotiflac" target="_blank">Telegram</a>
|
||||
<a href="https://ko-fi.com/zarzet" target="_blank">Support / Ko-fi</a>
|
||||
</div>
|
||||
<p class="footer-copy">SpotiFLAC is for educational and private use only. Not affiliated with any streaming service.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function toggleMenu() {
|
||||
document.getElementById('mobileMenu').classList.toggle('open');
|
||||
document.getElementById('mobileOverlay').classList.toggle('open');
|
||||
document.querySelector('.nav-burger').classList.toggle('active');
|
||||
}
|
||||
document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
||||
if (e.target.closest('a')) toggleMenu();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user