mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
feat(metadata): edit release tags and render UPC barcodes
This commit is contained in:
@@ -379,6 +379,16 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||
metadata.Composer = value
|
||||
case "COMMENT":
|
||||
metadata.Comment = value
|
||||
case "ITUNESADVISORY":
|
||||
metadata.Explicit = isTruthyTagValue(value)
|
||||
case "RELEASETYPE":
|
||||
metadata.AlbumType = value
|
||||
case "BARCODE", "UPC":
|
||||
metadata.UPC = value
|
||||
case "COMPILATION":
|
||||
if isTruthyTagValue(value) && metadata.AlbumType == "" {
|
||||
metadata.AlbumType = "compilation"
|
||||
}
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
@@ -428,6 +438,14 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||
addItem("Copyright", metadata.Copyright)
|
||||
addItem("Composer", metadata.Composer)
|
||||
addItem("Comment", metadata.Comment)
|
||||
if metadata.Explicit {
|
||||
addItem("ITUNESADVISORY", "1")
|
||||
}
|
||||
addItem("RELEASETYPE", metadata.AlbumType)
|
||||
addItem("BARCODE", metadata.UPC)
|
||||
if strings.EqualFold(strings.TrimSpace(metadata.AlbumType), "compilation") {
|
||||
addItem("COMPILATION", "1")
|
||||
}
|
||||
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||
@@ -455,6 +473,10 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
"copyright": "COPYRIGHT",
|
||||
"composer": "COMPOSER",
|
||||
"comment": "COMMENT",
|
||||
"explicit": "ITUNESADVISORY",
|
||||
"album_type": "RELEASETYPE",
|
||||
"upc": "BARCODE",
|
||||
"compilation": "COMPILATION",
|
||||
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||
|
||||
@@ -31,6 +31,9 @@ func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||
Copyright: "Copyright",
|
||||
Composer: "Composer",
|
||||
Comment: "Comment",
|
||||
AlbumType: "compilation",
|
||||
Explicit: true,
|
||||
UPC: "4006381333931",
|
||||
ReplayGainTrackGain: "-6.50 dB",
|
||||
ReplayGainTrackPeak: "0.98",
|
||||
ReplayGainAlbumGain: "-5.00 dB",
|
||||
@@ -57,6 +60,9 @@ func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
||||
t.Fatalf("metadata = %#v", readMetadata)
|
||||
}
|
||||
if !readMetadata.Explicit || readMetadata.AlbumType != "compilation" || readMetadata.UPC != "4006381333931" {
|
||||
t.Fatalf("release identity = %#v", readMetadata)
|
||||
}
|
||||
|
||||
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
||||
if err != nil {
|
||||
|
||||
@@ -28,6 +28,9 @@ type AudioMetadata struct {
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
AlbumType string
|
||||
Explicit bool
|
||||
UPC string
|
||||
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
|
||||
ReplayGainTrackGain string
|
||||
ReplayGainTrackPeak string
|
||||
@@ -191,6 +194,14 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = userValue
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(desc)) {
|
||||
case "ITUNESADVISORY":
|
||||
metadata.Explicit = isTruthyTagValue(userValue)
|
||||
case "RELEASETYPE":
|
||||
metadata.AlbumType = userValue
|
||||
case "BARCODE", "UPC":
|
||||
metadata.UPC = userValue
|
||||
}
|
||||
}
|
||||
|
||||
pos += 6 + frameSize
|
||||
@@ -303,6 +314,10 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
metadata.Label = value
|
||||
case "TCOP":
|
||||
metadata.Copyright = value
|
||||
case "TCMP":
|
||||
if isTruthyTagValue(value) && metadata.AlbumType == "" {
|
||||
metadata.AlbumType = "compilation"
|
||||
}
|
||||
case "COMM":
|
||||
if v := extractLangTextFrame(frameData); v != "" {
|
||||
metadata.Comment = v
|
||||
@@ -326,6 +341,12 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
metadata.ReplayGainAlbumGain = userValue
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = userValue
|
||||
case "ITUNESADVISORY":
|
||||
metadata.Explicit = isTruthyTagValue(userValue)
|
||||
case "RELEASETYPE":
|
||||
metadata.AlbumType = userValue
|
||||
case "BARCODE", "UPC":
|
||||
metadata.UPC = userValue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "ITUNESADVISORY":
|
||||
metadata.Explicit = isTruthyTagValue(value)
|
||||
case "RELEASETYPE":
|
||||
metadata.AlbumType = value
|
||||
case "BARCODE", "UPC":
|
||||
metadata.UPC = value
|
||||
case "COMPILATION":
|
||||
if isTruthyTagValue(value) && metadata.AlbumType == "" {
|
||||
metadata.AlbumType = "compilation"
|
||||
}
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
|
||||
@@ -30,6 +30,9 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
id3CommentFrame("USLT", "Lyrics"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
||||
id3UserTextFrame("TXXX", "ITUNESADVISORY", "1"),
|
||||
id3UserTextFrame("TXXX", "RELEASETYPE", "album"),
|
||||
id3UserTextFrame("TXXX", "BARCODE", "4006381333931"),
|
||||
)
|
||||
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2: %v", err)
|
||||
@@ -45,6 +48,9 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
||||
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
||||
}
|
||||
if !meta.Explicit || meta.AlbumType != "album" || meta.UPC != "4006381333931" {
|
||||
t.Fatalf("metadata release identity = %#v", meta)
|
||||
}
|
||||
|
||||
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
||||
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
||||
@@ -201,6 +207,9 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("RELEASETYPE", "single")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("BARCODE", "4006381333931")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AInt8Atom("rtng", 1)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
||||
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
||||
@@ -215,6 +224,9 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
||||
t.Fatalf("M4A metadata = %#v", meta)
|
||||
}
|
||||
if !meta.Explicit || meta.AlbumType != "single" || meta.UPC != "4006381333931" {
|
||||
t.Fatalf("M4A release identity = %#v", meta)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
||||
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
||||
}
|
||||
@@ -381,6 +393,9 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
"TRACKNUMBER=2/9",
|
||||
"DISCNUMBER=1/2",
|
||||
"LYRICS=[00:00.00]Ogg Lyrics",
|
||||
"ITUNESADVISORY=1",
|
||||
"RELEASETYPE=ep",
|
||||
"BARCODE=4006381333931",
|
||||
}
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
||||
for _, entry := range entries {
|
||||
@@ -401,6 +416,9 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
||||
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
||||
}
|
||||
if !meta.Explicit || meta.AlbumType != "ep" || meta.UPC != "4006381333931" {
|
||||
t.Fatalf("Ogg release identity = %#v", meta)
|
||||
}
|
||||
|
||||
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
||||
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
||||
|
||||
@@ -29,6 +29,9 @@ func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
|
||||
result["copyright"] = meta.Copyright
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
result["album_type"] = meta.AlbumType
|
||||
result["explicit"] = meta.Explicit
|
||||
result["upc"] = meta.UPC
|
||||
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
|
||||
@@ -68,6 +71,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
"copyright": "",
|
||||
"composer": "",
|
||||
"comment": "",
|
||||
"album_type": "",
|
||||
"explicit": false,
|
||||
"upc": "",
|
||||
"duration": 0,
|
||||
"format": "",
|
||||
"audio_codec": "",
|
||||
@@ -83,23 +89,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
||||
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
||||
if oggErr == nil && oggMeta != nil {
|
||||
result["title"] = oggMeta.Title
|
||||
result["artist"] = oggMeta.Artist
|
||||
result["album"] = oggMeta.Album
|
||||
result["album_artist"] = oggMeta.AlbumArtist
|
||||
result["date"] = oggMeta.Date
|
||||
if oggMeta.Date == "" {
|
||||
result["date"] = oggMeta.Year
|
||||
}
|
||||
result["track_number"] = oggMeta.TrackNumber
|
||||
result["total_tracks"] = oggMeta.TotalTracks
|
||||
result["disc_number"] = oggMeta.DiscNumber
|
||||
result["total_discs"] = oggMeta.TotalDiscs
|
||||
result["isrc"] = oggMeta.ISRC
|
||||
result["lyrics"] = oggMeta.Lyrics
|
||||
result["genre"] = oggMeta.Genre
|
||||
result["composer"] = oggMeta.Composer
|
||||
result["comment"] = oggMeta.Comment
|
||||
applyAudioMetadataToResult(result, oggMeta)
|
||||
quality, qualityErr := GetOggQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
@@ -130,6 +120,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["copyright"] = metadata.Copyright
|
||||
result["composer"] = metadata.Composer
|
||||
result["comment"] = metadata.Comment
|
||||
result["album_type"] = metadata.AlbumType
|
||||
result["explicit"] = metadata.Explicit
|
||||
result["upc"] = metadata.UPC
|
||||
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
|
||||
|
||||
@@ -36,6 +36,10 @@ type LibraryScanResult struct {
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
AlbumType string `json:"albumType,omitempty"`
|
||||
Explicit bool `json:"explicit,omitempty"`
|
||||
UPC string `json:"upc,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||
}
|
||||
|
||||
@@ -141,6 +141,10 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
result.Comment = metadata.Comment
|
||||
result.AlbumType = metadata.AlbumType
|
||||
result.Explicit = metadata.Explicit
|
||||
result.UPC = metadata.UPC
|
||||
|
||||
quality, err := audioQualityFromParsedFlac(f)
|
||||
if err == nil {
|
||||
@@ -289,20 +293,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
|
||||
@@ -30,6 +30,9 @@ func writeSinglePassTestFlac(t testing.TB, path string, cover []byte) {
|
||||
comments := flacvorbis.New()
|
||||
setComment(comments, "TITLE", "Single Pass")
|
||||
setComment(comments, "ARTIST", "Artist")
|
||||
setComment(comments, "ITUNESADVISORY", "1")
|
||||
setComment(comments, "RELEASETYPE", "album")
|
||||
setComment(comments, "BARCODE", "4006381333931")
|
||||
commentBlock := comments.Marshal()
|
||||
pictureBlock := (&flacpicture.MetadataBlockPicture{
|
||||
PictureType: flacpicture.PictureTypeFrontCover,
|
||||
@@ -63,6 +66,9 @@ func TestScanFLACSinglePassReadsMetadataQualityAndCover(t *testing.T) {
|
||||
if result.TrackName != "Single Pass" || result.ArtistName != "Artist" || result.SampleRate != 44100 || result.BitDepth != 16 || result.Duration != 10 {
|
||||
t.Fatalf("scan result = %#v", result)
|
||||
}
|
||||
if !result.Explicit || result.AlbumType != "album" || result.UPC != "4006381333931" {
|
||||
t.Fatalf("release identity = %#v", result)
|
||||
}
|
||||
if result.CoverPath == "" {
|
||||
t.Fatal("cover was not cached")
|
||||
}
|
||||
|
||||
@@ -391,6 +391,15 @@ func metadataFromParsedFlac(f *flac.File) *Metadata {
|
||||
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
||||
metadata.Composer = getComment(cmt, "COMPOSER")
|
||||
metadata.Comment = getComment(cmt, "COMMENT")
|
||||
metadata.Explicit = isTruthyTagValue(getComment(cmt, "ITUNESADVISORY"))
|
||||
metadata.AlbumType = getComment(cmt, "RELEASETYPE")
|
||||
if metadata.AlbumType == "" && isTruthyTagValue(getComment(cmt, "COMPILATION")) {
|
||||
metadata.AlbumType = "compilation"
|
||||
}
|
||||
metadata.UPC = getComment(cmt, "BARCODE")
|
||||
if metadata.UPC == "" {
|
||||
metadata.UPC = getComment(cmt, "UPC")
|
||||
}
|
||||
|
||||
metadata.ReplayGainTrackGain = getComment(cmt, "REPLAYGAIN_TRACK_GAIN")
|
||||
metadata.ReplayGainTrackPeak = getComment(cmt, "REPLAYGAIN_TRACK_PEAK")
|
||||
|
||||
@@ -73,6 +73,14 @@ func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMet
|
||||
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fileSize)
|
||||
case "disk":
|
||||
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fileSize)
|
||||
case "rtng":
|
||||
if value, valueErr := readM4AByteValue(f, header, fileSize); valueErr == nil {
|
||||
metadata.Explicit = value == 1
|
||||
}
|
||||
case "cpil":
|
||||
if value, valueErr := readM4AByteValue(f, header, fileSize); valueErr == nil && value != 0 && metadata.AlbumType == "" {
|
||||
metadata.AlbumType = "compilation"
|
||||
}
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fileSize)
|
||||
if freeformErr == nil {
|
||||
@@ -105,6 +113,12 @@ func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMet
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
case "ITUNESADVISORY":
|
||||
metadata.Explicit = isTruthyTagValue(value)
|
||||
case "RELEASETYPE":
|
||||
metadata.AlbumType = value
|
||||
case "BARCODE", "UPC":
|
||||
metadata.UPC = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,6 +300,17 @@ func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int,
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
|
||||
}
|
||||
|
||||
func readM4AByteValue(f *os.File, parent atomHeader, fileSize int64) (byte, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return 0, fmt.Errorf("integer payload is empty in %s", parent.typ)
|
||||
}
|
||||
return payload[len(payload)-1], nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -606,6 +606,14 @@ func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []b
|
||||
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
|
||||
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
|
||||
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
|
||||
if meta.Explicit {
|
||||
writeTXXX("ITUNESADVISORY", "1")
|
||||
}
|
||||
writeTXXX("RELEASETYPE", meta.AlbumType)
|
||||
writeTXXX("BARCODE", meta.UPC)
|
||||
if strings.EqualFold(strings.TrimSpace(meta.AlbumType), "compilation") {
|
||||
writeText("TCMP", "1")
|
||||
}
|
||||
|
||||
if len(coverData) > 0 {
|
||||
if strings.TrimSpace(coverMIME) == "" {
|
||||
@@ -802,6 +810,9 @@ func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
|
||||
Copyright: fields["copyright"],
|
||||
Composer: fields["composer"],
|
||||
Comment: fields["comment"],
|
||||
AlbumType: fields["album_type"],
|
||||
Explicit: isTruthyTagValue(fields["explicit"]),
|
||||
UPC: fields["upc"],
|
||||
ReplayGainTrackGain: fields["replaygain_track_gain"],
|
||||
ReplayGainTrackPeak: fields["replaygain_track_peak"],
|
||||
ReplayGainAlbumGain: fields["replaygain_album_gain"],
|
||||
@@ -835,6 +846,11 @@ func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]stri
|
||||
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
|
||||
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
|
||||
meta.Comment = keep("comment", meta.Comment, existing.Comment)
|
||||
meta.AlbumType = keep("album_type", meta.AlbumType, existing.AlbumType)
|
||||
meta.UPC = keep("upc", meta.UPC, existing.UPC)
|
||||
if _, ok := fields["explicit"]; !ok {
|
||||
meta.Explicit = existing.Explicit
|
||||
}
|
||||
meta.Date = keep("date", meta.Date, existing.Date)
|
||||
if _, ok := fields["track_number"]; !ok {
|
||||
meta.TrackNumber = existing.TrackNumber
|
||||
@@ -957,6 +973,10 @@ func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
result.Comment = metadata.Comment
|
||||
result.AlbumType = metadata.AlbumType
|
||||
result.Explicit = metadata.Explicit
|
||||
result.UPC = metadata.UPC
|
||||
}
|
||||
|
||||
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
|
||||
|
||||
@@ -6976,6 +6976,48 @@ abstract class AppLocalizations {
|
||||
/// **'Comment'**
|
||||
String get editMetadataFieldComment;
|
||||
|
||||
/// Label for an album or release type metadata value
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Release Type'**
|
||||
String get trackAlbumType;
|
||||
|
||||
/// Hint for the release type metadata field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album, single, EP, compilation...'**
|
||||
String get editMetadataFieldAlbumTypeHint;
|
||||
|
||||
/// Label for the explicit content metadata field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Explicit'**
|
||||
String get editMetadataFieldExplicit;
|
||||
|
||||
/// Description for the explicit content metadata switch
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mark this track as containing explicit content'**
|
||||
String get editMetadataFieldExplicitHint;
|
||||
|
||||
/// Displayed value when a track is marked explicit
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Explicit'**
|
||||
String get metadataExplicitValue;
|
||||
|
||||
/// Label for the release barcode metadata field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'UPC / Barcode'**
|
||||
String get editMetadataFieldUpc;
|
||||
|
||||
/// Hint for the release barcode metadata field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Numeric UPC, EAN, or GTIN'**
|
||||
String get editMetadataFieldUpcHint;
|
||||
|
||||
/// Expandable section label for advanced metadata fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -4255,6 +4255,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Kommentar';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Erweitert';
|
||||
|
||||
|
||||
@@ -4209,6 +4209,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -4203,6 +4203,29 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -4316,6 +4316,29 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Commentaire';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Avancé';
|
||||
|
||||
|
||||
@@ -4206,6 +4206,29 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Jenis Rilisan';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, singel, EP, kompilasi...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Eksplisit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Tandai lagu ini sebagai konten eksplisit';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Eksplisit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'UPC, EAN, atau GTIN numerik';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -4198,6 +4198,29 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -4087,6 +4087,29 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => '주석';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => '고급';
|
||||
|
||||
|
||||
@@ -4203,6 +4203,29 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -4240,6 +4240,29 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -4239,6 +4239,29 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Yorum';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Gelişmiş';
|
||||
|
||||
|
||||
@@ -4257,6 +4257,29 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get trackAlbumType => 'Release Type';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumTypeHint =>
|
||||
'Album, single, EP, compilation...';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicit => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldExplicitHint =>
|
||||
'Mark this track as containing explicit content';
|
||||
|
||||
@override
|
||||
String get metadataExplicitValue => 'Explicit';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpc => 'UPC / Barcode';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldUpcHint => 'Numeric UPC, EAN, or GTIN';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
|
||||
@@ -5464,6 +5464,34 @@
|
||||
"@editMetadataFieldComment": {
|
||||
"description": "Label for comment field in the edit metadata sheet"
|
||||
},
|
||||
"trackAlbumType": "Release Type",
|
||||
"@trackAlbumType": {
|
||||
"description": "Label for an album or release type metadata value"
|
||||
},
|
||||
"editMetadataFieldAlbumTypeHint": "Album, single, EP, compilation...",
|
||||
"@editMetadataFieldAlbumTypeHint": {
|
||||
"description": "Hint for the release type metadata field"
|
||||
},
|
||||
"editMetadataFieldExplicit": "Explicit",
|
||||
"@editMetadataFieldExplicit": {
|
||||
"description": "Label for the explicit content metadata field"
|
||||
},
|
||||
"editMetadataFieldExplicitHint": "Mark this track as containing explicit content",
|
||||
"@editMetadataFieldExplicitHint": {
|
||||
"description": "Description for the explicit content metadata switch"
|
||||
},
|
||||
"metadataExplicitValue": "Explicit",
|
||||
"@metadataExplicitValue": {
|
||||
"description": "Displayed value when a track is marked explicit"
|
||||
},
|
||||
"editMetadataFieldUpc": "UPC / Barcode",
|
||||
"@editMetadataFieldUpc": {
|
||||
"description": "Label for the release barcode metadata field"
|
||||
},
|
||||
"editMetadataFieldUpcHint": "Numeric UPC, EAN, or GTIN",
|
||||
"@editMetadataFieldUpcHint": {
|
||||
"description": "Hint for the release barcode metadata field"
|
||||
},
|
||||
"editMetadataAdvanced": "Advanced",
|
||||
"@editMetadataAdvanced": {
|
||||
"description": "Expandable section label for advanced metadata fields"
|
||||
|
||||
@@ -5945,6 +5945,13 @@
|
||||
"storeEmptyNoResults": "Tidak ada ekstensi ditemukan",
|
||||
"logIssueTrackNotFoundDescription": "Some tracks could not be found on download services",
|
||||
"editMetadataFieldComment": "Comment",
|
||||
"trackAlbumType": "Jenis Rilisan",
|
||||
"editMetadataFieldAlbumTypeHint": "Album, singel, EP, kompilasi...",
|
||||
"editMetadataFieldExplicit": "Eksplisit",
|
||||
"editMetadataFieldExplicitHint": "Tandai lagu ini sebagai konten eksplisit",
|
||||
"metadataExplicitValue": "Eksplisit",
|
||||
"editMetadataFieldUpc": "UPC / Barcode",
|
||||
"editMetadataFieldUpcHint": "UPC, EAN, atau GTIN numerik",
|
||||
"settingsMetadata": "Metadata",
|
||||
"settingsCache": "Storage & Cache",
|
||||
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi yang diaktifkan dengan kemampuan penyedia unduhan yang tercantum di sini.",
|
||||
|
||||
@@ -41,6 +41,11 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
|
||||
'copyright': val('copyright', copyright),
|
||||
'composer': val('composer', composer),
|
||||
'comment': fileMetadata?['comment']?.toString() ?? '',
|
||||
'album_type': val('album_type', albumType),
|
||||
'explicit': (parseExplicitFlag(fileMetadata?['explicit']) ?? isExplicit)
|
||||
? '1'
|
||||
: '0',
|
||||
'upc': val('upc', upc),
|
||||
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
|
||||
};
|
||||
|
||||
|
||||
@@ -302,6 +302,15 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
|
||||
_buildMetadataGrid(context, colorScheme),
|
||||
|
||||
if (upc case final barcodeValue?) ...[
|
||||
const SizedBox(height: 16),
|
||||
MetadataBarcode(
|
||||
value: barcodeValue,
|
||||
label: context.l10n.editMetadataFieldUpc,
|
||||
onCopy: () => _copyToClipboard(context, barcodeValue),
|
||||
),
|
||||
],
|
||||
|
||||
if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
@@ -455,6 +464,15 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
_MetadataItem(context.l10n.trackCopyright, copyright!),
|
||||
if (composer != null && composer!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.editMetadataFieldComposer, composer!),
|
||||
if (albumType != null)
|
||||
_MetadataItem(context.l10n.trackAlbumType, albumType!),
|
||||
if (isExplicit)
|
||||
_MetadataItem(
|
||||
context.l10n.editMetadataFieldExplicit,
|
||||
context.l10n.metadataExplicitValue,
|
||||
),
|
||||
if (comment != null)
|
||||
_MetadataItem(context.l10n.editMetadataFieldComment, comment!),
|
||||
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
|
||||
|
||||
@@ -504,6 +504,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'label': 'label',
|
||||
'copyright': 'copyright',
|
||||
'composer': 'composer',
|
||||
'comment': 'comment',
|
||||
'album_type': 'album_type',
|
||||
'explicit': 'explicit',
|
||||
'upc': 'upc',
|
||||
'cover': 'cover',
|
||||
};
|
||||
|
||||
@@ -523,6 +527,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
late final TextEditingController _copyrightCtrl;
|
||||
late final TextEditingController _composerCtrl;
|
||||
late final TextEditingController _commentCtrl;
|
||||
late final TextEditingController _albumTypeCtrl;
|
||||
late final TextEditingController _upcCtrl;
|
||||
bool _explicit = false;
|
||||
bool _fetchingMusicBrainz = false;
|
||||
|
||||
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
|
||||
@@ -815,6 +822,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return l10n.editMetadataFieldCopyright;
|
||||
case 'composer':
|
||||
return l10n.editMetadataFieldComposer;
|
||||
case 'comment':
|
||||
return l10n.editMetadataFieldComment;
|
||||
case 'album_type':
|
||||
return l10n.trackAlbumType;
|
||||
case 'explicit':
|
||||
return l10n.editMetadataFieldExplicit;
|
||||
case 'upc':
|
||||
return l10n.editMetadataFieldUpc;
|
||||
case 'cover':
|
||||
return l10n.editMetadataFieldCover;
|
||||
default:
|
||||
@@ -854,6 +869,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return _copyrightCtrl;
|
||||
case 'composer':
|
||||
return _composerCtrl;
|
||||
case 'comment':
|
||||
return _commentCtrl;
|
||||
case 'album_type':
|
||||
return _albumTypeCtrl;
|
||||
case 'upc':
|
||||
return _upcCtrl;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -871,6 +892,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_invalidateAutoFillPreview();
|
||||
_autoFillFields.clear();
|
||||
for (final key in _fieldDefs.keys) {
|
||||
if (key == 'explicit') continue;
|
||||
if (key == 'cover') {
|
||||
if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) {
|
||||
_autoFillFields.add(key);
|
||||
@@ -1036,6 +1058,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
put('label', track['label']);
|
||||
put('copyright', track['copyright']);
|
||||
put('composer', track['composer']);
|
||||
put('comment', track['comment']);
|
||||
put('album_type', track['album_type'] ?? track['albumType']);
|
||||
put('explicit', track['explicit'] ?? track['is_explicit']);
|
||||
put('upc', track['upc'] ?? track['barcode']);
|
||||
}
|
||||
|
||||
int _metadataMatchScore(
|
||||
@@ -1627,7 +1653,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final availableValues = <String, String>{};
|
||||
for (final entry in enriched.entries) {
|
||||
final value = entry.value.trim();
|
||||
if (value.isNotEmpty && value != '0' && value != 'null') {
|
||||
final isExplicitValue = entry.key == 'explicit';
|
||||
if (value.isNotEmpty &&
|
||||
(isExplicitValue || value != '0') &&
|
||||
value != 'null') {
|
||||
availableValues[entry.key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1699,6 +1728,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
for (final key in _autoFillFields) {
|
||||
if (key == 'cover') continue;
|
||||
final value = preview.values[key];
|
||||
if (key == 'explicit' && value != null) {
|
||||
final parsed = parseExplicitFlag(value);
|
||||
if (parsed != null) {
|
||||
_explicit = parsed;
|
||||
filledCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final ctrl = _controllerForKey(key);
|
||||
if (value != null && ctrl != null) {
|
||||
ctrl.text = value;
|
||||
@@ -1788,6 +1825,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
|
||||
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
|
||||
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
|
||||
_albumTypeCtrl = TextEditingController(text: v['album_type'] ?? '');
|
||||
_upcCtrl = TextEditingController(text: v['upc'] ?? '');
|
||||
_explicit = parseExplicitFlag(v['explicit']) == true;
|
||||
_loadCurrentCoverPreview();
|
||||
}
|
||||
|
||||
@@ -1812,6 +1852,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_copyrightCtrl.dispose();
|
||||
_composerCtrl.dispose();
|
||||
_commentCtrl.dispose();
|
||||
_albumTypeCtrl.dispose();
|
||||
_upcCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -1862,6 +1904,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'copyright': _copyrightCtrl.text,
|
||||
'composer': _composerCtrl.text,
|
||||
'comment': _commentCtrl.text,
|
||||
'album_type': _albumTypeCtrl.text,
|
||||
'explicit': _explicit ? '1' : '0',
|
||||
'upc': _upcCtrl.text,
|
||||
'compilation': _albumTypeCtrl.text.trim().toLowerCase() == 'compilation'
|
||||
? '1'
|
||||
: '0',
|
||||
'cover_path': coverPathForSave ?? '',
|
||||
'artist_tag_mode': widget.artistTagMode,
|
||||
};
|
||||
@@ -1929,6 +1977,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'COPYRIGHT': metadata['copyright'] ?? '',
|
||||
'COMPOSER': metadata['composer'] ?? '',
|
||||
'COMMENT': metadata['comment'] ?? '',
|
||||
'ITUNESADVISORY': metadata['explicit'] ?? '',
|
||||
'RELEASETYPE': metadata['album_type'] ?? '',
|
||||
'BARCODE': metadata['upc'] ?? '',
|
||||
'COMPILATION': metadata['compilation'] ?? '',
|
||||
};
|
||||
try {
|
||||
final existingMetadata = await PlatformBridge.readFileMetadata(
|
||||
@@ -2229,6 +2281,29 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
context.l10n.editMetadataFieldComposer,
|
||||
_composerCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.trackAlbumType,
|
||||
_albumTypeCtrl,
|
||||
hint: context.l10n.editMetadataFieldAlbumTypeHint,
|
||||
),
|
||||
_switchField(
|
||||
label: context.l10n.editMetadataFieldExplicit,
|
||||
subtitle:
|
||||
context.l10n.editMetadataFieldExplicitHint,
|
||||
value: _explicit,
|
||||
onChanged: (value) =>
|
||||
setState(() => _explicit = value),
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldUpc,
|
||||
_upcCtrl,
|
||||
hint: context.l10n.editMetadataFieldUpcHint,
|
||||
keyboard: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(18),
|
||||
],
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComment,
|
||||
_commentCtrl,
|
||||
@@ -2788,6 +2863,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
String? hint,
|
||||
TextInputType? keyboard,
|
||||
int maxLines = 1,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
}) {
|
||||
final cs = widget.colorScheme;
|
||||
final radius = BorderRadius.circular(14);
|
||||
@@ -2810,6 +2886,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
cursorColor: cs.primary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
@@ -2841,6 +2918,35 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchField({
|
||||
required String label,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
final cs = widget.colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Material(
|
||||
color: _fieldFill(cs),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SwitchListTile.adaptive(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(label, style: Theme.of(context).textTheme.bodyLarge),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectionField({
|
||||
required String label,
|
||||
required String value,
|
||||
|
||||
@@ -45,6 +45,7 @@ import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/metadata_barcode.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/constants/music_services.dart';
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
@@ -354,6 +355,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
final resolvedLabel = metadata['label']?.toString();
|
||||
final resolvedCopyright = metadata['copyright']?.toString();
|
||||
final resolvedISRC = metadata['isrc']?.toString();
|
||||
final resolvedAlbumType = metadata['album_type']?.toString();
|
||||
final resolvedUPC = (metadata['upc'] ?? metadata['barcode'])?.toString();
|
||||
final resolvedComment = metadata['comment']?.toString();
|
||||
final resolvedExplicit = parseExplicitFlag(metadata['explicit']);
|
||||
final needsTrackNumber =
|
||||
resolvedTrackNumber != null &&
|
||||
resolvedTrackNumber > 0 &&
|
||||
@@ -390,6 +395,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
final fileHasCopyright = present(resolvedCopyright);
|
||||
final fileHasISRC = present(resolvedISRC);
|
||||
final fileHasLabel = present(resolvedLabel);
|
||||
final fileHasAlbumType = present(resolvedAlbumType);
|
||||
final fileHasUPC = present(resolvedUPC);
|
||||
final fileHasComment = present(resolvedComment);
|
||||
final fileHasExplicit = resolvedExplicit == true;
|
||||
final fileHasTrackNumber =
|
||||
resolvedTrackNumber != null && resolvedTrackNumber > 0;
|
||||
final fileHasTotalTracks =
|
||||
@@ -442,6 +451,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
fileHasISRC ||
|
||||
fileHasLabel ||
|
||||
fileHasCopyright ||
|
||||
fileHasAlbumType ||
|
||||
fileHasUPC ||
|
||||
fileHasComment ||
|
||||
fileHasExplicit ||
|
||||
isPlaceholderQualityLabel(_quality)) &&
|
||||
mounted) {
|
||||
setState(() {
|
||||
@@ -467,6 +480,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
if (fileHasISRC) 'isrc': resolvedISRC,
|
||||
if (fileHasLabel) 'label': resolvedLabel,
|
||||
if (fileHasCopyright) 'copyright': resolvedCopyright,
|
||||
if (fileHasAlbumType) 'album_type': resolvedAlbumType,
|
||||
if (fileHasUPC) 'upc': resolvedUPC,
|
||||
if (fileHasComment) 'comment': resolvedComment,
|
||||
'explicit': ?resolvedExplicit,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,6 +297,15 @@ extension _TrackMetadataCover on _TrackMetadataScreenState {
|
||||
String? get composer =>
|
||||
_editedMetadata?['composer']?.toString() ??
|
||||
(_isLocalItem ? _localLibraryItem!.composer : null);
|
||||
String? get albumType =>
|
||||
normalizeOptionalString(_editedMetadata?['album_type']?.toString());
|
||||
String? get upc => normalizeOptionalString(
|
||||
(_editedMetadata?['upc'] ?? _editedMetadata?['barcode'])?.toString(),
|
||||
);
|
||||
String? get comment =>
|
||||
normalizeOptionalString(_editedMetadata?['comment']?.toString());
|
||||
bool get isExplicit =>
|
||||
parseExplicitFlag(_editedMetadata?['explicit']) == true;
|
||||
int? get duration =>
|
||||
readPositiveInt(_editedMetadata?['duration']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||
|
||||
@@ -2040,6 +2040,7 @@ class FFmpegService {
|
||||
// touched (an empty value clears the tag).
|
||||
if (metadata != null) {
|
||||
await _writeM4AFreeformTags(m4aPath, metadata);
|
||||
await _writeM4AReleaseIdentityTags(m4aPath, metadata);
|
||||
}
|
||||
|
||||
_log.d('M4A metadata embedded successfully');
|
||||
@@ -2561,6 +2562,42 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the iTunes atoms that FFmpeg does not reliably map from generic
|
||||
/// metadata keys. This runs after a successful remux, when the container is
|
||||
/// canonical enough for the native editor even if the original was not.
|
||||
static Future<void> _writeM4AReleaseIdentityTags(
|
||||
String m4aPath,
|
||||
Map<String, String> metadata,
|
||||
) async {
|
||||
final fields = <String, String>{};
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
switch (key) {
|
||||
case 'ITUNESADVISORY':
|
||||
fields['explicit'] = entry.value;
|
||||
case 'RELEASETYPE':
|
||||
fields['album_type'] = entry.value;
|
||||
case 'BARCODE':
|
||||
case 'UPC':
|
||||
fields['upc'] = entry.value;
|
||||
case 'COMPILATION':
|
||||
fields['compilation'] = entry.value;
|
||||
}
|
||||
}
|
||||
if (fields.isEmpty) return;
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.editFileMetadata(m4aPath, fields);
|
||||
if (result['error'] != null || result['method'] != 'native_m4a') {
|
||||
_log.w(
|
||||
'Native M4A release identity write was not completed for $m4aPath',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('M4A release identity write failed for $m4aPath: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a CUE+audio file into individual track files using FFmpeg.
|
||||
/// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded.
|
||||
/// [audioPath] is the source audio file (FLAC, WAV, etc.)
|
||||
|
||||
@@ -31,6 +31,10 @@ Map<String, dynamic> normalizeScannedAudioMetadata(
|
||||
normalized['total_discs'] = firstValue('total_discs', 'totalDiscs');
|
||||
normalized['bit_depth'] = firstValue('bit_depth', 'bitDepth');
|
||||
normalized['sample_rate'] = firstValue('sample_rate', 'sampleRate');
|
||||
normalized['album_type'] = firstValue('album_type', 'albumType');
|
||||
normalized['upc'] = firstValue('upc', 'barcode');
|
||||
normalized['comment'] = metadata['comment'];
|
||||
normalized['explicit'] = metadata['explicit'];
|
||||
normalized['audio_codec'] =
|
||||
firstValue('audio_codec', 'audioCodec') ?? metadata['format'];
|
||||
return normalized;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
class RetailBarcodeData {
|
||||
final String value;
|
||||
final String symbology;
|
||||
final String modules;
|
||||
final int quietZoneModules;
|
||||
|
||||
const RetailBarcodeData({
|
||||
required this.value,
|
||||
required this.symbology,
|
||||
required this.modules,
|
||||
required this.quietZoneModules,
|
||||
});
|
||||
}
|
||||
|
||||
const _eanLeftOdd = <String>[
|
||||
'0001101',
|
||||
'0011001',
|
||||
'0010011',
|
||||
'0111101',
|
||||
'0100011',
|
||||
'0110001',
|
||||
'0101111',
|
||||
'0111011',
|
||||
'0110111',
|
||||
'0001011',
|
||||
];
|
||||
|
||||
const _eanLeftEven = <String>[
|
||||
'0100111',
|
||||
'0110011',
|
||||
'0011011',
|
||||
'0100001',
|
||||
'0011101',
|
||||
'0111001',
|
||||
'0000101',
|
||||
'0010001',
|
||||
'0001001',
|
||||
'0010111',
|
||||
];
|
||||
|
||||
const _eanRight = <String>[
|
||||
'1110010',
|
||||
'1100110',
|
||||
'1101100',
|
||||
'1000010',
|
||||
'1011100',
|
||||
'1001110',
|
||||
'1010000',
|
||||
'1000100',
|
||||
'1001000',
|
||||
'1110100',
|
||||
];
|
||||
|
||||
const _ean13Parity = <String>[
|
||||
'LLLLLL',
|
||||
'LLGLGG',
|
||||
'LLGGLG',
|
||||
'LLGGGL',
|
||||
'LGLLGG',
|
||||
'LGGLLG',
|
||||
'LGGGLL',
|
||||
'LGLGLG',
|
||||
'LGLGGL',
|
||||
'LGGLGL',
|
||||
];
|
||||
|
||||
// Code 128 module widths, including start/check/stop symbols. It is used only
|
||||
// as an exact-value fallback when a numeric identifier is not a valid UPC-A or
|
||||
// EAN-13. Valid retail identifiers always use their native symbology.
|
||||
const _code128Patterns = <String>[
|
||||
'212222',
|
||||
'222122',
|
||||
'222221',
|
||||
'121223',
|
||||
'121322',
|
||||
'131222',
|
||||
'122213',
|
||||
'122312',
|
||||
'132212',
|
||||
'221213',
|
||||
'221312',
|
||||
'231212',
|
||||
'112232',
|
||||
'122132',
|
||||
'122231',
|
||||
'113222',
|
||||
'123122',
|
||||
'123221',
|
||||
'223211',
|
||||
'221132',
|
||||
'221231',
|
||||
'213212',
|
||||
'223112',
|
||||
'312131',
|
||||
'311222',
|
||||
'321122',
|
||||
'321221',
|
||||
'312212',
|
||||
'322112',
|
||||
'322211',
|
||||
'212123',
|
||||
'212321',
|
||||
'232121',
|
||||
'111323',
|
||||
'131123',
|
||||
'131321',
|
||||
'112313',
|
||||
'132113',
|
||||
'132311',
|
||||
'211313',
|
||||
'231113',
|
||||
'231311',
|
||||
'112133',
|
||||
'112331',
|
||||
'132131',
|
||||
'113123',
|
||||
'113321',
|
||||
'133121',
|
||||
'313121',
|
||||
'211331',
|
||||
'231131',
|
||||
'213113',
|
||||
'213311',
|
||||
'213131',
|
||||
'311123',
|
||||
'311321',
|
||||
'331121',
|
||||
'312113',
|
||||
'312311',
|
||||
'332111',
|
||||
'314111',
|
||||
'221411',
|
||||
'431111',
|
||||
'111224',
|
||||
'111422',
|
||||
'121124',
|
||||
'121421',
|
||||
'141122',
|
||||
'141221',
|
||||
'112214',
|
||||
'112412',
|
||||
'122114',
|
||||
'122411',
|
||||
'142112',
|
||||
'142211',
|
||||
'241211',
|
||||
'221114',
|
||||
'413111',
|
||||
'241112',
|
||||
'134111',
|
||||
'111242',
|
||||
'121142',
|
||||
'121241',
|
||||
'114212',
|
||||
'124112',
|
||||
'124211',
|
||||
'411212',
|
||||
'421112',
|
||||
'421211',
|
||||
'212141',
|
||||
'214121',
|
||||
'412121',
|
||||
'111143',
|
||||
'111341',
|
||||
'131141',
|
||||
'114113',
|
||||
'114311',
|
||||
'411113',
|
||||
'411311',
|
||||
'113141',
|
||||
'114131',
|
||||
'311141',
|
||||
'411131',
|
||||
'211412',
|
||||
'211214',
|
||||
'211232',
|
||||
'2331112',
|
||||
];
|
||||
|
||||
RetailBarcodeData? encodeRetailBarcode(String rawValue) {
|
||||
final value = rawValue.replaceAll(RegExp(r'[\s-]+'), '');
|
||||
if (value.isEmpty || !RegExp(r'^\d+$').hasMatch(value)) return null;
|
||||
|
||||
if (value.length == 12 && hasValidGtinCheckDigit(value)) {
|
||||
return RetailBarcodeData(
|
||||
value: value,
|
||||
symbology: 'UPC-A',
|
||||
modules: _encodeEan13('0$value'),
|
||||
quietZoneModules: 11,
|
||||
);
|
||||
}
|
||||
if (value.length == 13 && hasValidGtinCheckDigit(value)) {
|
||||
return RetailBarcodeData(
|
||||
value: value,
|
||||
symbology: 'EAN-13',
|
||||
modules: _encodeEan13(value),
|
||||
quietZoneModules: 11,
|
||||
);
|
||||
}
|
||||
|
||||
return RetailBarcodeData(
|
||||
value: value,
|
||||
symbology: 'CODE 128',
|
||||
modules: _encodeCode128B(value),
|
||||
quietZoneModules: 10,
|
||||
);
|
||||
}
|
||||
|
||||
bool hasValidGtinCheckDigit(String value) {
|
||||
if (value.length < 2 || !RegExp(r'^\d+$').hasMatch(value)) return false;
|
||||
var sum = 0;
|
||||
var useThree = true;
|
||||
for (var i = value.length - 2; i >= 0; i--) {
|
||||
final digit = value.codeUnitAt(i) - 48;
|
||||
sum += digit * (useThree ? 3 : 1);
|
||||
useThree = !useThree;
|
||||
}
|
||||
final expected = (10 - (sum % 10)) % 10;
|
||||
return expected == value.codeUnitAt(value.length - 1) - 48;
|
||||
}
|
||||
|
||||
String _encodeEan13(String value) {
|
||||
final first = value.codeUnitAt(0) - 48;
|
||||
final parity = _ean13Parity[first];
|
||||
final out = StringBuffer('101');
|
||||
for (var i = 1; i <= 6; i++) {
|
||||
final digit = value.codeUnitAt(i) - 48;
|
||||
out.write(parity[i - 1] == 'L' ? _eanLeftOdd[digit] : _eanLeftEven[digit]);
|
||||
}
|
||||
out.write('01010');
|
||||
for (var i = 7; i <= 12; i++) {
|
||||
out.write(_eanRight[value.codeUnitAt(i) - 48]);
|
||||
}
|
||||
out.write('101');
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
String _encodeCode128B(String value) {
|
||||
const startB = 104;
|
||||
final codes = <int>[startB];
|
||||
var checksum = startB;
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
final code = value.codeUnitAt(i) - 32;
|
||||
codes.add(code);
|
||||
checksum += code * (i + 1);
|
||||
}
|
||||
codes
|
||||
..add(checksum % 103)
|
||||
..add(106);
|
||||
|
||||
final out = StringBuffer();
|
||||
for (final code in codes) {
|
||||
final widths = _code128Patterns[code];
|
||||
for (var i = 0; i < widths.length; i++) {
|
||||
final width = widths.codeUnitAt(i) - 48;
|
||||
out.write(List.filled(width, i.isEven ? '1' : '0').join());
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/utils/retail_barcode.dart';
|
||||
|
||||
class MetadataBarcode extends StatelessWidget {
|
||||
final String value;
|
||||
final String label;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const MetadataBarcode({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barcode = encodeRetailBarcode(value);
|
||||
if (barcode == null) return const SizedBox.shrink();
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
label: '$label ${barcode.value}',
|
||||
button: onCopy != null,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onCopy,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_2_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
barcode.symbology,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (onCopy != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(10, 12, 10, 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 76,
|
||||
child: CustomPaint(painter: _BarcodePainter(barcode)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
barcode.value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 2.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BarcodePainter extends CustomPainter {
|
||||
final RetailBarcodeData barcode;
|
||||
|
||||
const _BarcodePainter(this.barcode);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final totalModules =
|
||||
barcode.modules.length + (barcode.quietZoneModules * 2);
|
||||
final moduleWidth = size.width / totalModules;
|
||||
final paint = Paint()
|
||||
..color = Colors.black
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = false;
|
||||
|
||||
for (var i = 0; i < barcode.modules.length; i++) {
|
||||
if (barcode.modules.codeUnitAt(i) != 49) continue;
|
||||
final left = (barcode.quietZoneModules + i) * moduleWidth;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(left, 0, moduleWidth + 0.01, size.height),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _BarcodePainter oldDelegate) {
|
||||
return barcode.modules != oldDelegate.barcode.modules ||
|
||||
barcode.quietZoneModules != oldDelegate.barcode.quietZoneModules;
|
||||
}
|
||||
}
|
||||
@@ -1036,6 +1036,10 @@ void main() {
|
||||
'bitDepth': 24,
|
||||
'sampleRate': 96000,
|
||||
'bitrate': 1840,
|
||||
'albumType': 'album',
|
||||
'explicit': true,
|
||||
'upc': '4006381333931',
|
||||
'comment': 'https://listen.tidal.com/album/1',
|
||||
'format': 'flac',
|
||||
});
|
||||
|
||||
@@ -1051,6 +1055,10 @@ void main() {
|
||||
expect(normalized['bit_depth'], 24);
|
||||
expect(normalized['sample_rate'], 96000);
|
||||
expect(normalized['bitrate'], 1840);
|
||||
expect(normalized['album_type'], 'album');
|
||||
expect(normalized['explicit'], isTrue);
|
||||
expect(normalized['upc'], '4006381333931');
|
||||
expect(normalized['comment'], 'https://listen.tidal.com/album/1');
|
||||
expect(normalized['audio_codec'], 'flac');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/utils/retail_barcode.dart';
|
||||
import 'package:spotiflac_android/widgets/metadata_barcode.dart';
|
||||
|
||||
void main() {
|
||||
test('encodes a valid UPC-A with its native retail symbology', () {
|
||||
final barcode = encodeRetailBarcode('036000291452');
|
||||
|
||||
expect(barcode, isNotNull);
|
||||
expect(barcode!.symbology, 'UPC-A');
|
||||
expect(barcode.value, '036000291452');
|
||||
expect(barcode.modules, hasLength(95));
|
||||
expect(barcode.modules, startsWith('101'));
|
||||
expect(barcode.modules, endsWith('101'));
|
||||
});
|
||||
|
||||
test('encodes a valid EAN-13 with its native retail symbology', () {
|
||||
final barcode = encodeRetailBarcode('4006381333931');
|
||||
|
||||
expect(barcode, isNotNull);
|
||||
expect(barcode!.symbology, 'EAN-13');
|
||||
expect(barcode.modules, hasLength(95));
|
||||
expect(hasValidGtinCheckDigit(barcode.value), isTrue);
|
||||
});
|
||||
|
||||
test('uses an exact scannable fallback for a malformed provider value', () {
|
||||
final barcode = encodeRetailBarcode('0012345678901');
|
||||
|
||||
expect(barcode, isNotNull);
|
||||
expect(barcode!.symbology, 'CODE 128');
|
||||
expect(barcode.value, '0012345678901');
|
||||
expect(barcode.modules, isNotEmpty);
|
||||
});
|
||||
|
||||
test('normalizes separators but rejects non-numeric values', () {
|
||||
expect(encodeRetailBarcode('0 36000-29145 2')?.value, '036000291452');
|
||||
expect(encodeRetailBarcode('not-a-upc'), isNull);
|
||||
});
|
||||
|
||||
testWidgets('metadata barcode stays usable in a narrow metadata card', (
|
||||
tester,
|
||||
) async {
|
||||
var copied = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(colorSchemeSeed: Colors.teal),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: MetadataBarcode(
|
||||
value: '036000291452',
|
||||
label: 'UPC / Barcode',
|
||||
onCopy: () => copied = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('UPC-A'), findsOneWidget);
|
||||
expect(find.text('036000291452'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byType(MetadataBarcode));
|
||||
expect(copied, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user