[READ-ONLY] Mirror of https://github.com/probablykasper/mr-tagger. Music file tagging app for Mac, Linux and Windows
audio linux macos music tagger tauri windows
0

Configure Feed

Select the types of activity you want to include in your feed.

Switch indentation to tabs

Kasper (Feb 9, 2023, 5:13 AM +0100) 017c885c ca624ba3

+1153 -1149
+1
package.json
··· 29 29 "vite": "^4.1.1" 30 30 }, 31 31 "prettier": { 32 + "useTabs": true, 32 33 "printWidth": 100, 33 34 "semi": false, 34 35 "singleQuote": true,
+1 -1
src-tauri/rustfmt.toml
··· 1 - tab_spaces = 2 1 + hard_tabs = true
+1 -1
src-tauri/src/build.rs
··· 1 1 fn main() { 2 - tauri_build::build(); 2 + tauri_build::build(); 3 3 }
+159 -159
src-tauri/src/cmd.rs
··· 12 12 13 13 #[derive(Clone, Serialize)] 14 14 pub struct File { 15 - pub path: PathBuf, 16 - pub dirty: bool, 17 - #[serde(skip_serializing)] 18 - pub metadata: Metadata, 15 + pub path: PathBuf, 16 + pub dirty: bool, 17 + #[serde(skip_serializing)] 18 + pub metadata: Metadata, 19 19 } 20 20 21 21 #[derive(Default, Serialize)] 22 22 pub struct App { 23 - pub current_index: usize, 24 - pub files: Vec<File>, 23 + pub current_index: usize, 24 + pub files: Vec<File>, 25 25 } 26 26 impl App { 27 - pub fn current_file(&mut self) -> Result<&mut File, String> { 28 - match self.files.get_mut(self.current_index) { 29 - Some(file) => Ok(file), 30 - None => { 31 - throw!("Error getting open file") 32 - } 33 - } 34 - } 27 + pub fn current_file(&mut self) -> Result<&mut File, String> { 28 + match self.files.get_mut(self.current_index) { 29 + Some(file) => Ok(file), 30 + None => { 31 + throw!("Error getting open file") 32 + } 33 + } 34 + } 35 35 } 36 36 37 37 #[derive(Default)] ··· 41 41 42 42 #[command] 43 43 pub fn error_popup(msg: String, win: tauri::Window) { 44 - println!("Error popup: {}", msg); 45 - thread::spawn(move || { 46 - dialog::message(Some(&win), "Error", msg); 47 - }); 44 + println!("Error popup: {}", msg); 45 + thread::spawn(move || { 46 + dialog::message(Some(&win), "Error", msg); 47 + }); 48 48 } 49 49 50 50 #[command] 51 51 pub fn get_app(app: AppArg<'_>) -> Value { 52 - let app = app.0.lock().unwrap(); 53 - serde_json::to_value(&*app).unwrap() 52 + let app = app.0.lock().unwrap(); 53 + serde_json::to_value(&*app).unwrap() 54 54 } 55 55 56 56 #[command] 57 57 pub fn show(index: usize, app: AppArg<'_>) { 58 - let mut app = app.0.lock().unwrap(); 59 - app.current_index = index; 58 + let mut app = app.0.lock().unwrap(); 59 + app.current_index = index; 60 60 } 61 61 62 62 #[command] 63 63 pub fn close_window(win: tauri::Window) { 64 - let _ = win.close(); 64 + let _ = win.close(); 65 65 } 66 66 67 67 fn id3_split<'a>(s: Option<&'a str>) -> Vec<&'a str> { 68 - match s { 69 - Some(s) => s.split('\u{0}').collect(), 70 - None => vec![], 71 - } 68 + match s { 69 + Some(s) => s.split('\u{0}').collect(), 70 + None => vec![], 71 + } 72 72 } 73 73 74 74 fn get_frame_text<'a>(tag: &'a id3::Tag, id: &str) -> Option<&'a str> { 75 - let frame = tag.get(id)?; 76 - let text = frame.content().text()?; 77 - return Some(text); 75 + let frame = tag.get(id)?; 76 + let text = frame.content().text()?; 77 + return Some(text); 78 78 } 79 79 80 80 fn opt_to_str<'a>(n: Option<impl ToString>) -> String { 81 - match n { 82 - Some(n) => n.to_string(), 83 - None => "".to_string(), 84 - } 81 + match n { 82 + Some(n) => n.to_string(), 83 + None => "".to_string(), 84 + } 85 85 } 86 86 87 87 #[command] 88 88 pub fn get_page(app: AppArg<'_>) -> Option<Value> { 89 - let mut app = app.0.lock().unwrap(); 90 - let file = app.current_file().ok()?; 89 + let mut app = app.0.lock().unwrap(); 90 + let file = app.current_file().ok()?; 91 91 92 - let title = match file.metadata { 93 - Metadata::Id3(ref tag) => tag.title().unwrap_or("").to_string(), 94 - Metadata::Mp4(ref tag) => tag.title().unwrap_or("").to_string(), 95 - Metadata::VorbisComments(ref tag) => tag.title().map(|s| s.to_string()).unwrap_or_default(), 96 - }; 92 + let title = match file.metadata { 93 + Metadata::Id3(ref tag) => tag.title().unwrap_or("").to_string(), 94 + Metadata::Mp4(ref tag) => tag.title().unwrap_or("").to_string(), 95 + Metadata::VorbisComments(ref tag) => tag.title().map(|s| s.to_string()).unwrap_or_default(), 96 + }; 97 97 98 - let artists: Vec<_> = match file.metadata { 99 - Metadata::Id3(ref tag) => id3_split(tag.artist()), 100 - Metadata::Mp4(ref tag) => tag.artists().collect(), 101 - Metadata::VorbisComments(ref tag) => tag.get_all("ARTIST").collect(), 102 - }; 98 + let artists: Vec<_> = match file.metadata { 99 + Metadata::Id3(ref tag) => id3_split(tag.artist()), 100 + Metadata::Mp4(ref tag) => tag.artists().collect(), 101 + Metadata::VorbisComments(ref tag) => tag.get_all("ARTIST").collect(), 102 + }; 103 103 104 - let album = match file.metadata { 105 - Metadata::Id3(ref tag) => tag.album().unwrap_or("").to_string(), 106 - Metadata::Mp4(ref tag) => tag.album().unwrap_or("").to_string(), 107 - Metadata::VorbisComments(ref tag) => tag.album().map(|s| s.to_string()).unwrap_or_default(), 108 - }; 104 + let album = match file.metadata { 105 + Metadata::Id3(ref tag) => tag.album().unwrap_or("").to_string(), 106 + Metadata::Mp4(ref tag) => tag.album().unwrap_or("").to_string(), 107 + Metadata::VorbisComments(ref tag) => tag.album().map(|s| s.to_string()).unwrap_or_default(), 108 + }; 109 109 110 - let album_artists: Vec<_> = match file.metadata { 111 - Metadata::Id3(ref tag) => id3_split(tag.album_artist()), 112 - Metadata::Mp4(ref tag) => tag.album_artists().collect(), 113 - Metadata::VorbisComments(ref tag) => tag.get_all("ALBUMARTIST").collect(), 114 - }; 110 + let album_artists: Vec<_> = match file.metadata { 111 + Metadata::Id3(ref tag) => id3_split(tag.album_artist()), 112 + Metadata::Mp4(ref tag) => tag.album_artists().collect(), 113 + Metadata::VorbisComments(ref tag) => tag.get_all("ALBUMARTIST").collect(), 114 + }; 115 115 116 - let composer = match file.metadata { 117 - Metadata::Id3(ref tag) => id3_split(get_frame_text(&tag, "TCOM")), 118 - Metadata::Mp4(ref tag) => tag.composers().collect(), 119 - Metadata::VorbisComments(ref tag) => tag.get_all("COMPOSER").collect(), 120 - }; 116 + let composer = match file.metadata { 117 + Metadata::Id3(ref tag) => id3_split(get_frame_text(&tag, "TCOM")), 118 + Metadata::Mp4(ref tag) => tag.composers().collect(), 119 + Metadata::VorbisComments(ref tag) => tag.get_all("COMPOSER").collect(), 120 + }; 121 121 122 - let groupings = match file.metadata { 123 - // non-standard iTunes tag, three-byte ID3v2.2 tag is not remapped 124 - Metadata::Id3(ref tag) => id3_split(match get_frame_text(&tag, "GRP1") { 125 - Some(s) => Some(s), 126 - None => get_frame_text(&tag, "GP1"), 127 - }), 128 - Metadata::Mp4(ref tag) => tag.groupings().collect(), 129 - Metadata::VorbisComments(ref tag) => tag.get_all("GROUPING").collect(), 130 - }; 122 + let groupings = match file.metadata { 123 + // non-standard iTunes tag, three-byte ID3v2.2 tag is not remapped 124 + Metadata::Id3(ref tag) => id3_split(match get_frame_text(&tag, "GRP1") { 125 + Some(s) => Some(s), 126 + None => get_frame_text(&tag, "GP1"), 127 + }), 128 + Metadata::Mp4(ref tag) => tag.groupings().collect(), 129 + Metadata::VorbisComments(ref tag) => tag.get_all("GROUPING").collect(), 130 + }; 131 131 132 - let genres = match file.metadata { 133 - Metadata::Id3(ref tag) => id3_split(tag.genre()), 134 - Metadata::Mp4(ref tag) => tag.genres().collect(), 135 - Metadata::VorbisComments(ref tag) => tag.get_all("GENRE").collect(), 136 - }; 132 + let genres = match file.metadata { 133 + Metadata::Id3(ref tag) => id3_split(tag.genre()), 134 + Metadata::Mp4(ref tag) => tag.genres().collect(), 135 + Metadata::VorbisComments(ref tag) => tag.get_all("GENRE").collect(), 136 + }; 137 137 138 - let track_num = match file.metadata { 139 - Metadata::Id3(ref tag) => opt_to_str(tag.track()), 140 - Metadata::Mp4(ref tag) => opt_to_str(tag.track().0), 141 - Metadata::VorbisComments(ref tag) => opt_to_str(tag.track()), 142 - }; 143 - let track_total = match file.metadata { 144 - Metadata::Id3(ref tag) => opt_to_str(tag.total_tracks()), 145 - Metadata::Mp4(ref tag) => opt_to_str(tag.track().1), 146 - Metadata::VorbisComments(ref tag) => opt_to_str(tag.track_total()), 147 - }; 138 + let track_num = match file.metadata { 139 + Metadata::Id3(ref tag) => opt_to_str(tag.track()), 140 + Metadata::Mp4(ref tag) => opt_to_str(tag.track().0), 141 + Metadata::VorbisComments(ref tag) => opt_to_str(tag.track()), 142 + }; 143 + let track_total = match file.metadata { 144 + Metadata::Id3(ref tag) => opt_to_str(tag.total_tracks()), 145 + Metadata::Mp4(ref tag) => opt_to_str(tag.track().1), 146 + Metadata::VorbisComments(ref tag) => opt_to_str(tag.track_total()), 147 + }; 148 148 149 - let disc_num = match file.metadata { 150 - Metadata::Id3(ref tag) => opt_to_str(tag.disc()), 151 - Metadata::Mp4(ref tag) => opt_to_str(tag.disc().0), 152 - Metadata::VorbisComments(ref tag) => opt_to_str(tag.disk()), 153 - }; 154 - let disc_total = match file.metadata { 155 - Metadata::Id3(ref tag) => opt_to_str(tag.total_discs()), 156 - Metadata::Mp4(ref tag) => opt_to_str(tag.disc().1), 157 - Metadata::VorbisComments(ref tag) => opt_to_str(tag.disk_total()), 158 - }; 149 + let disc_num = match file.metadata { 150 + Metadata::Id3(ref tag) => opt_to_str(tag.disc()), 151 + Metadata::Mp4(ref tag) => opt_to_str(tag.disc().0), 152 + Metadata::VorbisComments(ref tag) => opt_to_str(tag.disk()), 153 + }; 154 + let disc_total = match file.metadata { 155 + Metadata::Id3(ref tag) => opt_to_str(tag.total_discs()), 156 + Metadata::Mp4(ref tag) => opt_to_str(tag.disc().1), 157 + Metadata::VorbisComments(ref tag) => opt_to_str(tag.disk_total()), 158 + }; 159 159 160 - let compilation = match file.metadata { 161 - Metadata::Id3(ref tag) => { 162 - // non-standard iTunes tag, three-byte ID3v2.2 tag is not remapped 163 - get_frame_text(&tag, "TCMP") == Some("1") || get_frame_text(&tag, "TCP") == Some("1") 164 - } 165 - Metadata::Mp4(ref tag) => tag.compilation(), 166 - Metadata::VorbisComments(ref tag) => tag.get("COMPILATION") == Some("1"), 167 - }; 160 + let compilation = match file.metadata { 161 + Metadata::Id3(ref tag) => { 162 + // non-standard iTunes tag, three-byte ID3v2.2 tag is not remapped 163 + get_frame_text(&tag, "TCMP") == Some("1") || get_frame_text(&tag, "TCP") == Some("1") 164 + } 165 + Metadata::Mp4(ref tag) => tag.compilation(), 166 + Metadata::VorbisComments(ref tag) => tag.get("COMPILATION") == Some("1"), 167 + }; 168 168 169 - let bpm = match file.metadata { 170 - Metadata::Id3(ref tag) => get_frame_text(&tag, "TBPM").unwrap_or("").to_string(), 171 - Metadata::Mp4(ref tag) => opt_to_str(tag.bpm()), 172 - Metadata::VorbisComments(ref tag) => tag.get("BPM").unwrap_or("").to_string(), 173 - }; 169 + let bpm = match file.metadata { 170 + Metadata::Id3(ref tag) => get_frame_text(&tag, "TBPM").unwrap_or("").to_string(), 171 + Metadata::Mp4(ref tag) => opt_to_str(tag.bpm()), 172 + Metadata::VorbisComments(ref tag) => tag.get("BPM").unwrap_or("").to_string(), 173 + }; 174 174 175 - #[derive(Serialize)] 176 - struct Comment { 177 - text: String, 178 - lang: Option<String>, 179 - description: Option<String>, 180 - } 181 - let comments: Vec<Comment> = match file.metadata { 182 - Metadata::Id3(ref tag) => tag 183 - .comments() 184 - .map(|c| Comment { 185 - text: c.text.clone(), 186 - lang: Some(c.lang.clone()), 187 - description: Some(c.description.clone()), 188 - }) 189 - .collect(), 190 - Metadata::Mp4(ref tag) => tag 191 - .comments() 192 - .map(|c| Comment { 193 - text: c.to_string(), 194 - lang: None, 195 - description: None, 196 - }) 197 - .collect(), 198 - Metadata::VorbisComments(ref tag) => tag 199 - .get_all("COMMENT") 200 - .map(|c| Comment { 201 - text: c.to_string(), 202 - lang: None, 203 - description: None, 204 - }) 205 - .collect(), 206 - }; 175 + #[derive(Serialize)] 176 + struct Comment { 177 + text: String, 178 + lang: Option<String>, 179 + description: Option<String>, 180 + } 181 + let comments: Vec<Comment> = match file.metadata { 182 + Metadata::Id3(ref tag) => tag 183 + .comments() 184 + .map(|c| Comment { 185 + text: c.text.clone(), 186 + lang: Some(c.lang.clone()), 187 + description: Some(c.description.clone()), 188 + }) 189 + .collect(), 190 + Metadata::Mp4(ref tag) => tag 191 + .comments() 192 + .map(|c| Comment { 193 + text: c.to_string(), 194 + lang: None, 195 + description: None, 196 + }) 197 + .collect(), 198 + Metadata::VorbisComments(ref tag) => tag 199 + .get_all("COMMENT") 200 + .map(|c| Comment { 201 + text: c.to_string(), 202 + lang: None, 203 + description: None, 204 + }) 205 + .collect(), 206 + }; 207 207 208 - Some(serde_json::json!({ 209 - "path": file.path.clone(), 210 - "title": title, 211 - "artists": artists, 212 - "album": album, 213 - "album_artists": album_artists, 214 - "composer": composer, 215 - "groupings": groupings, 216 - "genres": genres, 217 - // year 218 - "track_num": track_num, 219 - "track_total": track_total, 220 - "disc_num": disc_num, 221 - "disc_total": disc_total, 222 - "compilation": compilation, 223 - "bpm": bpm, 224 - "comments": comments, 225 - "frames": file.metadata.get_frames(), 226 - })) 208 + Some(serde_json::json!({ 209 + "path": file.path.clone(), 210 + "title": title, 211 + "artists": artists, 212 + "album": album, 213 + "album_artists": album_artists, 214 + "composer": composer, 215 + "groupings": groupings, 216 + "genres": genres, 217 + // year 218 + "track_num": track_num, 219 + "track_total": track_total, 220 + "disc_num": disc_num, 221 + "disc_total": disc_total, 222 + "compilation": compilation, 223 + "bpm": bpm, 224 + "comments": comments, 225 + "frames": file.metadata.get_frames(), 226 + })) 227 227 }
+103 -103
src-tauri/src/files.rs
··· 8 8 use tauri::command; 9 9 10 10 fn open_file(path: &PathBuf) -> Result<fs::File, String> { 11 - match fs::File::open(&path) { 12 - Ok(f) => Ok(f), 13 - Err(e) => throw!("Error opening file {}: {}", path.to_string_lossy(), e), 14 - } 11 + match fs::File::open(&path) { 12 + Ok(f) => Ok(f), 13 + Err(e) => throw!("Error opening file {}: {}", path.to_string_lossy(), e), 14 + } 15 15 } 16 16 17 17 fn get_metadata(path: &PathBuf) -> Result<Metadata, String> { 18 - let ext = path.extension().unwrap_or_default().to_string_lossy(); 19 - let path_str = path.to_string_lossy(); 20 - let metadata = match ext.as_ref() { 21 - // ID3 22 - "mp3" | "aiff" => { 23 - let tag = match id3::Tag::read_from_path(&path) { 24 - Ok(tag) => tag, 25 - Err(e) => match e.kind { 26 - id3::ErrorKind::NoTag => id3::Tag::default(), 27 - _ => throw!("Error reading tag for file {}: {}", path_str, e.description), 28 - }, 29 - }; 30 - Metadata::Id3(tag) 31 - } 32 - // iTunes-style 33 - "m4a" | "mp4" | "m4p" | "m4b" | "m4r" | "m4v" => { 34 - let tag = match mp4ameta::Tag::read_from_path(&path) { 35 - Ok(tag) => tag, 36 - Err(e) => match e.kind { 37 - mp4ameta::ErrorKind::NoTag => mp4ameta::Tag::default(), 38 - _ => throw!("Error reading tag for file {}: {}", path_str, e.description), 39 - }, 40 - }; 41 - Metadata::Mp4(tag) 42 - } 43 - "opus" => { 44 - let mut file = open_file(&path)?; 45 - let mut opus = match lofty::ogg::OpusFile::read_from( 46 - &mut file, 47 - lofty::ParseOptions::new().read_properties(false), 48 - ) { 49 - Ok(f) => f, 50 - Err(e) => throw!("Error reading tag for file {}: {}", path_str, e), 51 - }; 52 - Metadata::VorbisComments(opus.remove_vorbis_comments()) 53 - } 54 - _ => throw!("Unsupported file type"), 55 - }; 56 - Ok(metadata) 18 + let ext = path.extension().unwrap_or_default().to_string_lossy(); 19 + let path_str = path.to_string_lossy(); 20 + let metadata = match ext.as_ref() { 21 + // ID3 22 + "mp3" | "aiff" => { 23 + let tag = match id3::Tag::read_from_path(&path) { 24 + Ok(tag) => tag, 25 + Err(e) => match e.kind { 26 + id3::ErrorKind::NoTag => id3::Tag::default(), 27 + _ => throw!("Error reading tag for file {}: {}", path_str, e.description), 28 + }, 29 + }; 30 + Metadata::Id3(tag) 31 + } 32 + // iTunes-style 33 + "m4a" | "mp4" | "m4p" | "m4b" | "m4r" | "m4v" => { 34 + let tag = match mp4ameta::Tag::read_from_path(&path) { 35 + Ok(tag) => tag, 36 + Err(e) => match e.kind { 37 + mp4ameta::ErrorKind::NoTag => mp4ameta::Tag::default(), 38 + _ => throw!("Error reading tag for file {}: {}", path_str, e.description), 39 + }, 40 + }; 41 + Metadata::Mp4(tag) 42 + } 43 + "opus" => { 44 + let mut file = open_file(&path)?; 45 + let mut opus = match lofty::ogg::OpusFile::read_from( 46 + &mut file, 47 + lofty::ParseOptions::new().read_properties(false), 48 + ) { 49 + Ok(f) => f, 50 + Err(e) => throw!("Error reading tag for file {}: {}", path_str, e), 51 + }; 52 + Metadata::VorbisComments(opus.remove_vorbis_comments()) 53 + } 54 + _ => throw!("Unsupported file type"), 55 + }; 56 + Ok(metadata) 57 57 } 58 58 59 59 #[command] 60 60 pub async fn open_files(paths: Vec<PathBuf>, app: AppArg<'_>) -> Result<(), String> { 61 - let mut app = app.0.lock().unwrap(); 62 - let initial_len = app.files.len(); 63 - for path in paths { 64 - let is_duplicate = app.files.iter().any(|f| f.path == path); 65 - if !is_duplicate { 66 - let metadata = get_metadata(&path)?; 67 - app.files.push(File { 68 - path: path.clone(), 69 - dirty: false, 70 - metadata: metadata.clone(), 71 - }); 72 - } 73 - } 74 - if initial_len == 0 && app.files.len() >= 1 { 75 - app.current_index = app.files.len() - 1; 76 - } 77 - Ok(()) 61 + let mut app = app.0.lock().unwrap(); 62 + let initial_len = app.files.len(); 63 + for path in paths { 64 + let is_duplicate = app.files.iter().any(|f| f.path == path); 65 + if !is_duplicate { 66 + let metadata = get_metadata(&path)?; 67 + app.files.push(File { 68 + path: path.clone(), 69 + dirty: false, 70 + metadata: metadata.clone(), 71 + }); 72 + } 73 + } 74 + if initial_len == 0 && app.files.len() >= 1 { 75 + app.current_index = app.files.len() - 1; 76 + } 77 + Ok(()) 78 78 } 79 79 80 80 #[command] 81 81 pub async fn close_file(index: usize, app: AppArg<'_>) -> Result<(), String> { 82 - let mut app = app.0.lock().unwrap(); 83 - app.files.remove(index); 84 - if app.current_index >= index && index >= 1 { 85 - app.current_index -= 1; 86 - } 87 - Ok(()) 82 + let mut app = app.0.lock().unwrap(); 83 + app.files.remove(index); 84 + if app.current_index >= index && index >= 1 { 85 + app.current_index -= 1; 86 + } 87 + Ok(()) 88 88 } 89 89 90 90 #[command] 91 91 pub async fn save_file(index: usize, save_as: bool, app: AppArg<'_>) -> Result<(), String> { 92 - let mut app = app.0.lock().unwrap(); 93 - let mut file = app.files.get_mut(index).unwrap(); 94 - if save_as { 95 - let ext = file.path.extension().unwrap_or_default().to_string_lossy(); 96 - let (sender, receiver) = std::sync::mpsc::channel(); 97 - dialog::FileDialogBuilder::new() 98 - .set_file_name("report.kryp") 99 - .add_filter("Audio/Video file", &[&ext]) 100 - .save_file(move |p| { 101 - sender.send(p).unwrap(); 102 - }); 103 - let new_path = match receiver.recv().unwrap_or_default() { 104 - Some(file_path) => file_path, 105 - None => return Ok(()), 106 - }; 107 - match fs::copy(&file.path, &new_path) { 108 - Ok(_) => {} 109 - Err(e) => throw!("Error copying file: {}", e), 110 - } 111 - file.path = new_path; 112 - } 113 - match file.metadata { 114 - Metadata::Id3(ref tag) => match tag.write_to_path(&file.path, id3::Version::Id3v24) { 115 - Ok(_) => {} 116 - Err(e) => throw!("Error saving file: {}", e.description), 117 - }, 118 - Metadata::Mp4(ref tag) => match tag.write_to_path(&file.path) { 119 - Ok(_) => {} 120 - Err(e) => throw!("Error saving file: {}", e.description), 121 - }, 122 - Metadata::VorbisComments(ref tag) => match tag.save_to_path(&file.path) { 123 - Ok(_) => {} 124 - Err(e) => throw!("Error saving file: {}", e.to_string()), 125 - }, 126 - } 127 - file.dirty = false; 128 - Ok(()) 92 + let mut app = app.0.lock().unwrap(); 93 + let mut file = app.files.get_mut(index).unwrap(); 94 + if save_as { 95 + let ext = file.path.extension().unwrap_or_default().to_string_lossy(); 96 + let (sender, receiver) = std::sync::mpsc::channel(); 97 + dialog::FileDialogBuilder::new() 98 + .set_file_name("report.kryp") 99 + .add_filter("Audio/Video file", &[&ext]) 100 + .save_file(move |p| { 101 + sender.send(p).unwrap(); 102 + }); 103 + let new_path = match receiver.recv().unwrap_or_default() { 104 + Some(file_path) => file_path, 105 + None => return Ok(()), 106 + }; 107 + match fs::copy(&file.path, &new_path) { 108 + Ok(_) => {} 109 + Err(e) => throw!("Error copying file: {}", e), 110 + } 111 + file.path = new_path; 112 + } 113 + match file.metadata { 114 + Metadata::Id3(ref tag) => match tag.write_to_path(&file.path, id3::Version::Id3v24) { 115 + Ok(_) => {} 116 + Err(e) => throw!("Error saving file: {}", e.description), 117 + }, 118 + Metadata::Mp4(ref tag) => match tag.write_to_path(&file.path) { 119 + Ok(_) => {} 120 + Err(e) => throw!("Error saving file: {}", e.description), 121 + }, 122 + Metadata::VorbisComments(ref tag) => match tag.save_to_path(&file.path) { 123 + Ok(_) => {} 124 + Err(e) => throw!("Error saving file: {}", e.to_string()), 125 + }, 126 + } 127 + file.dirty = false; 128 + Ok(()) 129 129 }
+80 -80
src-tauri/src/frames.rs
··· 3 3 4 4 #[derive(Clone)] 5 5 pub enum Metadata { 6 - Id3(id3::Tag), 7 - Mp4(mp4ameta::Tag), 8 - VorbisComments(lofty::ogg::VorbisComments), 6 + Id3(id3::Tag), 7 + Mp4(mp4ameta::Tag), 8 + VorbisComments(lofty::ogg::VorbisComments), 9 9 } 10 10 impl Metadata { 11 - pub fn get_frames(&self) -> Vec<Frame> { 12 - match self { 13 - Self::Id3(id3) => get_id3_frames(id3), 14 - Self::Mp4(mp4) => get_mp4_frames(mp4), 15 - Self::VorbisComments(vorbis_comments) => get_vorbis_comments_frames(vorbis_comments), 16 - } 17 - } 11 + pub fn get_frames(&self) -> Vec<Frame> { 12 + match self { 13 + Self::Id3(id3) => get_id3_frames(id3), 14 + Self::Mp4(mp4) => get_mp4_frames(mp4), 15 + Self::VorbisComments(vorbis_comments) => get_vorbis_comments_frames(vorbis_comments), 16 + } 17 + } 18 18 } 19 19 20 20 #[derive(Serialize)] 21 21 pub enum Frame { 22 - Text { id: String, value: String }, 22 + Text { id: String, value: String }, 23 23 } 24 24 25 25 fn get_id3_frames(tag: &id3::Tag) -> Vec<Frame> { 26 - let mut frames = Vec::new(); 27 - for frame in tag.frames() { 28 - match frame.content() { 29 - id3::Content::Text(s) => { 30 - frames.push(Frame::Text { 31 - id: frame.id().to_string(), 32 - value: s.to_string(), 33 - }); 34 - } 35 - // !TODO 36 - id3::Content::ExtendedText(_) => {} 37 - // !TODO 38 - id3::Content::Link(_) => {} 39 - // !TODO 40 - id3::Content::ExtendedLink(_) => {} 41 - // !TODO 42 - id3::Content::Comment(_) => {} 43 - // !TODO 44 - id3::Content::Lyrics(_) => {} 45 - // !TODO 46 - id3::Content::SynchronisedLyrics(_) => {} 47 - // !TODO 48 - id3::Content::Picture(_) => {} 49 - // !TODO 50 - id3::Content::EncapsulatedObject(_) => {} 51 - // !TODO 52 - id3::Content::Unknown(_) => {} 53 - // !TODO 54 - id3::Content::Popularimeter(_) => {} 55 - // !TODO 56 - id3::Content::Chapter(_) => {} 57 - // !TODO 58 - id3::Content::MpegLocationLookupTable(_) => {} 26 + let mut frames = Vec::new(); 27 + for frame in tag.frames() { 28 + match frame.content() { 29 + id3::Content::Text(s) => { 30 + frames.push(Frame::Text { 31 + id: frame.id().to_string(), 32 + value: s.to_string(), 33 + }); 34 + } 35 + // !TODO 36 + id3::Content::ExtendedText(_) => {} 37 + // !TODO 38 + id3::Content::Link(_) => {} 39 + // !TODO 40 + id3::Content::ExtendedLink(_) => {} 41 + // !TODO 42 + id3::Content::Comment(_) => {} 43 + // !TODO 44 + id3::Content::Lyrics(_) => {} 45 + // !TODO 46 + id3::Content::SynchronisedLyrics(_) => {} 47 + // !TODO 48 + id3::Content::Picture(_) => {} 49 + // !TODO 50 + id3::Content::EncapsulatedObject(_) => {} 51 + // !TODO 52 + id3::Content::Unknown(_) => {} 53 + // !TODO 54 + id3::Content::Popularimeter(_) => {} 55 + // !TODO 56 + id3::Content::Chapter(_) => {} 57 + // !TODO 58 + id3::Content::MpegLocationLookupTable(_) => {} 59 59 60 - _ => {} 61 - } 62 - } 63 - frames 60 + _ => {} 61 + } 62 + } 63 + frames 64 64 } 65 65 66 66 fn get_mp4_frames(tag: &mp4ameta::Tag) -> Vec<Frame> { 67 - let mut frames = Vec::new(); 68 - for (id, data) in tag.data() { 69 - match data { 70 - // !TODO 71 - mp4ameta::Data::Reserved(_) => {} 72 - mp4ameta::Data::Utf8(s) => { 73 - frames.push(Frame::Text { 74 - id: id.to_string(), 75 - value: s.to_string(), 76 - }); 77 - } 78 - // !TODO 79 - mp4ameta::Data::Utf16(_) => {} 80 - // !TODO 81 - mp4ameta::Data::Jpeg(_) => {} 82 - // !TODO 83 - mp4ameta::Data::Png(_) => {} 84 - // !TODO 85 - mp4ameta::Data::BeSigned(_) => {} 86 - // !TODO 87 - mp4ameta::Data::Bmp(_) => {} 88 - } 89 - } 90 - frames 67 + let mut frames = Vec::new(); 68 + for (id, data) in tag.data() { 69 + match data { 70 + // !TODO 71 + mp4ameta::Data::Reserved(_) => {} 72 + mp4ameta::Data::Utf8(s) => { 73 + frames.push(Frame::Text { 74 + id: id.to_string(), 75 + value: s.to_string(), 76 + }); 77 + } 78 + // !TODO 79 + mp4ameta::Data::Utf16(_) => {} 80 + // !TODO 81 + mp4ameta::Data::Jpeg(_) => {} 82 + // !TODO 83 + mp4ameta::Data::Png(_) => {} 84 + // !TODO 85 + mp4ameta::Data::BeSigned(_) => {} 86 + // !TODO 87 + mp4ameta::Data::Bmp(_) => {} 88 + } 89 + } 90 + frames 91 91 } 92 92 93 93 fn get_vorbis_comments_frames(tag: &VorbisComments) -> Vec<Frame> { 94 - let mut frames = Vec::new(); 95 - for (key, value) in tag.items() { 96 - frames.push(Frame::Text { 97 - id: key.to_string(), 98 - value: value.to_string(), 99 - }); 100 - } 101 - frames 94 + let mut frames = Vec::new(); 95 + for (key, value) in tag.items() { 96 + frames.push(Frame::Text { 97 + id: key.to_string(), 98 + value: value.to_string(), 99 + }); 100 + } 101 + frames 102 102 }
+243 -241
src-tauri/src/image.rs
··· 11 11 12 12 #[derive(Serialize)] 13 13 pub struct Image { 14 - index: usize, 15 - total_images: usize, 16 - data: String, 17 - mime_type: String, 18 - description: Option<String>, 19 - picture_type: Option<String>, 14 + index: usize, 15 + total_images: usize, 16 + data: String, 17 + mime_type: String, 18 + description: Option<String>, 19 + picture_type: Option<String>, 20 20 } 21 21 22 22 #[command] 23 23 pub fn get_image(index: Option<usize>, app: AppArg<'_>) -> Result<Option<Image>, String> { 24 - let mut app = app.0.lock().unwrap(); 25 - let file = app.current_file()?; 26 - let index = match index { 27 - Some(index) => index, 28 - None => match file.metadata { 29 - Metadata::Id3(ref tag) => { 30 - let mut index = match tag.pictures().next() { 31 - Some(_pic) => 0, 32 - None => return Ok(None), 33 - }; 34 - for (i, current_pic) in tag.pictures().enumerate() { 35 - if current_pic.picture_type == id3::frame::PictureType::CoverFront { 36 - index = i; 37 - break; 38 - } 39 - } 40 - index 41 - } 42 - Metadata::Mp4(ref tag) => match tag.artwork() { 43 - Some(_artwork) => 0, 44 - None => return Ok(None), 45 - }, 46 - Metadata::VorbisComments(ref tag) => { 47 - if tag.pictures().len() == 0 { 48 - return Ok(None); 49 - } 50 - 0 51 - } 52 - }, 53 - }; 54 - let image_option = match file.metadata { 55 - Metadata::Id3(ref tag) => match tag.pictures().nth(index) { 56 - Some(pic) => Some(Image { 57 - index, 58 - total_images: tag.pictures().count(), 59 - data: base64::encode(&pic.data), 60 - mime_type: pic.mime_type.clone(), 61 - description: Some(pic.description.clone()), 62 - picture_type: Some(pic.picture_type.to_string()), 63 - }), 64 - None => None, 65 - }, 66 - Metadata::Mp4(ref tag) => match tag.artworks().nth(index) { 67 - Some(artwork) => Some(Image { 68 - index, 69 - total_images: tag.artworks().count(), 70 - data: base64::encode(&artwork.data), 71 - mime_type: match artwork.fmt { 72 - mp4ameta::ImgFmt::Bmp => "image/bmp".to_string(), 73 - mp4ameta::ImgFmt::Jpeg => "image/jpeg".to_string(), 74 - mp4ameta::ImgFmt::Png => "image/png".to_string(), 75 - }, 76 - description: None, 77 - picture_type: None, 78 - }), 79 - None => None, 80 - }, 81 - Metadata::VorbisComments(ref tag) => match tag.pictures().get(index) { 82 - Some((pic, _info)) => Some(Image { 83 - index, 84 - total_images: tag.pictures().len(), 85 - data: base64::encode(pic.data()), 86 - mime_type: match pic.mime_type() { 87 - lofty::MimeType::Png => "image/png".to_string(), 88 - lofty::MimeType::Jpeg => "image/jpeg".to_string(), 89 - lofty::MimeType::Tiff => "image/tiff".to_string(), 90 - lofty::MimeType::Bmp => "image/bmp".to_string(), 91 - lofty::MimeType::Gif => "image/gif".to_string(), 92 - lofty::MimeType::Unknown(unknown) => throw!("Unknown picture type {unknown}"), 93 - lofty::MimeType::None => throw!("No picture type"), 94 - _ => throw!("Unsupported picture type"), 95 - }, 96 - description: pic.description().map(|s| s.to_string()), 97 - picture_type: Some(match pic.pic_type() { 98 - lofty::PictureType::Other => "Other".to_string(), 99 - lofty::PictureType::Icon => "Icon".to_string(), 100 - lofty::PictureType::OtherIcon => "Other icon".to_string(), 101 - lofty::PictureType::CoverFront => "Front cover".to_string(), 102 - lofty::PictureType::CoverBack => "Back cover".to_string(), 103 - lofty::PictureType::Leaflet => "Leaflet".to_string(), 104 - lofty::PictureType::Media => "Media".to_string(), 105 - lofty::PictureType::LeadArtist => "Lead artist".to_string(), 106 - lofty::PictureType::Artist => "Artist".to_string(), 107 - lofty::PictureType::Conductor => "Conductor".to_string(), 108 - lofty::PictureType::Band => "Band".to_string(), 109 - lofty::PictureType::Composer => "Composer".to_string(), 110 - lofty::PictureType::Lyricist => "Lyricist".to_string(), 111 - lofty::PictureType::RecordingLocation => "Recording location".to_string(), 112 - lofty::PictureType::DuringRecording => "During recording".to_string(), 113 - lofty::PictureType::DuringPerformance => "During performance".to_string(), 114 - lofty::PictureType::ScreenCapture => "Screen capture".to_string(), 115 - lofty::PictureType::BrightFish => "Bright fish".to_string(), 116 - lofty::PictureType::Illustration => "Illustration".to_string(), 117 - lofty::PictureType::BandLogo => "Band logo".to_string(), 118 - lofty::PictureType::PublisherLogo => "Publisher logo".to_string(), 119 - lofty::PictureType::Undefined(u) => throw!("Undefined type {u}"), 120 - _ => throw!("Unsupported picture type {:?}", pic.pic_type()), 121 - }), 122 - }), 123 - None => None, 124 - }, 125 - }; 126 - Ok(image_option) 24 + let mut app = app.0.lock().unwrap(); 25 + let file = app.current_file()?; 26 + let index = match index { 27 + Some(index) => index, 28 + None => match file.metadata { 29 + Metadata::Id3(ref tag) => { 30 + let mut index = match tag.pictures().next() { 31 + Some(_pic) => 0, 32 + None => return Ok(None), 33 + }; 34 + for (i, current_pic) in tag.pictures().enumerate() { 35 + if current_pic.picture_type == id3::frame::PictureType::CoverFront { 36 + index = i; 37 + break; 38 + } 39 + } 40 + index 41 + } 42 + Metadata::Mp4(ref tag) => match tag.artwork() { 43 + Some(_artwork) => 0, 44 + None => return Ok(None), 45 + }, 46 + Metadata::VorbisComments(ref tag) => { 47 + if tag.pictures().len() == 0 { 48 + return Ok(None); 49 + } 50 + 0 51 + } 52 + }, 53 + }; 54 + let image_option = match file.metadata { 55 + Metadata::Id3(ref tag) => match tag.pictures().nth(index) { 56 + Some(pic) => Some(Image { 57 + index, 58 + total_images: tag.pictures().count(), 59 + data: base64::encode(&pic.data), 60 + mime_type: pic.mime_type.clone(), 61 + description: Some(pic.description.clone()), 62 + picture_type: Some(pic.picture_type.to_string()), 63 + }), 64 + None => None, 65 + }, 66 + Metadata::Mp4(ref tag) => match tag.artworks().nth(index) { 67 + Some(artwork) => Some(Image { 68 + index, 69 + total_images: tag.artworks().count(), 70 + data: base64::encode(&artwork.data), 71 + mime_type: match artwork.fmt { 72 + mp4ameta::ImgFmt::Bmp => "image/bmp".to_string(), 73 + mp4ameta::ImgFmt::Jpeg => "image/jpeg".to_string(), 74 + mp4ameta::ImgFmt::Png => "image/png".to_string(), 75 + }, 76 + description: None, 77 + picture_type: None, 78 + }), 79 + None => None, 80 + }, 81 + Metadata::VorbisComments(ref tag) => match tag.pictures().get(index) { 82 + Some((pic, _info)) => Some(Image { 83 + index, 84 + total_images: tag.pictures().len(), 85 + data: base64::encode(pic.data()), 86 + mime_type: match pic.mime_type() { 87 + lofty::MimeType::Png => "image/png".to_string(), 88 + lofty::MimeType::Jpeg => "image/jpeg".to_string(), 89 + lofty::MimeType::Tiff => "image/tiff".to_string(), 90 + lofty::MimeType::Bmp => "image/bmp".to_string(), 91 + lofty::MimeType::Gif => "image/gif".to_string(), 92 + lofty::MimeType::Unknown(unknown) => throw!("Unknown picture type {unknown}"), 93 + lofty::MimeType::None => throw!("No picture type"), 94 + _ => throw!("Unsupported picture type"), 95 + }, 96 + description: pic.description().map(|s| s.to_string()), 97 + picture_type: Some(match pic.pic_type() { 98 + lofty::PictureType::Other => "Other".to_string(), 99 + lofty::PictureType::Icon => "Icon".to_string(), 100 + lofty::PictureType::OtherIcon => "Other icon".to_string(), 101 + lofty::PictureType::CoverFront => "Front cover".to_string(), 102 + lofty::PictureType::CoverBack => "Back cover".to_string(), 103 + lofty::PictureType::Leaflet => "Leaflet".to_string(), 104 + lofty::PictureType::Media => "Media".to_string(), 105 + lofty::PictureType::LeadArtist => "Lead artist".to_string(), 106 + lofty::PictureType::Artist => "Artist".to_string(), 107 + lofty::PictureType::Conductor => "Conductor".to_string(), 108 + lofty::PictureType::Band => "Band".to_string(), 109 + lofty::PictureType::Composer => "Composer".to_string(), 110 + lofty::PictureType::Lyricist => "Lyricist".to_string(), 111 + lofty::PictureType::RecordingLocation => "Recording location".to_string(), 112 + lofty::PictureType::DuringRecording => "During recording".to_string(), 113 + lofty::PictureType::DuringPerformance => "During performance".to_string(), 114 + lofty::PictureType::ScreenCapture => "Screen capture".to_string(), 115 + lofty::PictureType::BrightFish => "Bright fish".to_string(), 116 + lofty::PictureType::Illustration => "Illustration".to_string(), 117 + lofty::PictureType::BandLogo => "Band logo".to_string(), 118 + lofty::PictureType::PublisherLogo => "Publisher logo".to_string(), 119 + lofty::PictureType::Undefined(u) => throw!("Undefined type {u}"), 120 + _ => throw!("Unsupported picture type {:?}", pic.pic_type()), 121 + }), 122 + }), 123 + None => None, 124 + }, 125 + }; 126 + Ok(image_option) 127 127 } 128 128 129 129 #[command] 130 130 pub fn remove_image(index: usize, app: AppArg<'_>) -> Result<(), String> { 131 - let mut app = app.0.lock().unwrap(); 132 - let file = app.current_file().unwrap(); 133 - match file.metadata { 134 - Metadata::Id3(ref mut tag) => { 135 - let mut pic_frames: Vec<_> = tag 136 - .frames() 137 - .filter(|frame| frame.content().picture().is_some()) 138 - .map(|frame| frame.clone()) 139 - .collect(); 140 - pic_frames.remove(index); 141 - tag.remove_all_pictures(); 142 - for pic_frame in pic_frames { 143 - tag.add_frame(pic_frame); 144 - } 145 - } 146 - Metadata::Mp4(ref mut tag) => { 147 - let mut artworks: Vec<_> = tag.take_artworks().collect(); 148 - artworks.remove(index); 149 - tag.set_artworks(artworks); 150 - } 151 - Metadata::VorbisComments(ref mut tag) => { 152 - tag.remove_picture(index); 153 - } 154 - } 155 - file.dirty = true; 156 - Ok(()) 131 + let mut app = app.0.lock().unwrap(); 132 + let file = app.current_file().unwrap(); 133 + match file.metadata { 134 + Metadata::Id3(ref mut tag) => { 135 + let mut pic_frames: Vec<_> = tag 136 + .frames() 137 + .filter(|frame| frame.content().picture().is_some()) 138 + .map(|frame| frame.clone()) 139 + .collect(); 140 + pic_frames.remove(index); 141 + tag.remove_all_pictures(); 142 + for pic_frame in pic_frames { 143 + tag.add_frame(pic_frame); 144 + } 145 + } 146 + Metadata::Mp4(ref mut tag) => { 147 + let mut artworks: Vec<_> = tag.take_artworks().collect(); 148 + artworks.remove(index); 149 + tag.set_artworks(artworks); 150 + } 151 + Metadata::VorbisComments(ref mut tag) => { 152 + tag.remove_picture(index); 153 + } 154 + } 155 + file.dirty = true; 156 + Ok(()) 157 157 } 158 158 159 159 enum MimeType { 160 - Png, 161 - Jpeg, 160 + Png, 161 + Jpeg, 162 162 } 163 163 164 164 #[command] 165 165 pub fn set_image(index: usize, path: PathBuf, app: AppArg<'_>) -> Result<(), String> { 166 - let mut app = app.0.lock().unwrap(); 167 - let file = app.current_file().unwrap(); 168 - let new_bytes = match fs::read(&path) { 169 - Ok(b) => b, 170 - Err(e) => throw!("Error reading that file: {}", e), 171 - }; 172 - let ext = path.extension().unwrap_or_default().to_string_lossy(); 173 - let mime_type = match ext.as_ref() { 174 - "jpg" | "jpeg" => MimeType::Jpeg, 175 - "png" => MimeType::Png, 176 - ext => throw!("Unsupported file type: {}", ext), 177 - }; 178 - match file.metadata { 179 - Metadata::Id3(ref mut tag) => { 180 - let mut pic_frames: Vec<_> = tag 181 - .frames() 182 - .filter(|frame| frame.content().picture().is_some()) 183 - .map(|frame| frame.clone()) 184 - .collect(); 185 - let mime_type = match ext.as_ref() { 186 - "jpg" | "jpeg" => "image/jpeg".to_string(), 187 - "png" => "image/png".to_string(), 188 - ext => throw!("Unsupported file type: {}", ext), 189 - }; 190 - let mut new_pic = id3::frame::Picture { 191 - mime_type, 192 - picture_type: id3::frame::PictureType::Other, 193 - description: "".to_string(), 194 - data: new_bytes, 195 - }; 196 - match pic_frames.get_mut(index) { 197 - Some(old_frame) => { 198 - let old_pic = old_frame.content().picture().unwrap(); 199 - new_pic.picture_type = old_pic.picture_type; 200 - new_pic.description = old_pic.description.clone(); 201 - let new_frame = id3::Frame::with_content("APIC", id3::Content::Picture(new_pic)); 202 - *old_frame = new_frame; 203 - } 204 - None => { 205 - if index == pic_frames.len() { 206 - let new_frame = id3::Frame::with_content("APIC", id3::Content::Picture(new_pic)); 207 - pic_frames.insert(index, new_frame); 208 - } else { 209 - throw!("Index out of range"); 210 - } 211 - } 212 - } 213 - tag.remove_all_pictures(); 214 - for pic_frame in pic_frames { 215 - tag.add_frame(pic_frame); 216 - } 217 - } 218 - Metadata::Mp4(ref mut tag) => { 219 - let mut artworks: Vec<_> = tag.take_artworks().collect(); 220 - let new_artwork = mp4ameta::Img { 221 - fmt: match ext.as_ref() { 222 - "jpg" | "jpeg" => mp4ameta::ImgFmt::Jpeg, 223 - "png" => mp4ameta::ImgFmt::Png, 224 - "bmp" => mp4ameta::ImgFmt::Bmp, 225 - ext => throw!("Unsupported file type: {}", ext), 226 - }, 227 - data: new_bytes, 228 - }; 229 - match artworks.get_mut(index) { 230 - Some(artwork) => { 231 - *artwork = new_artwork; 232 - } 233 - None => { 234 - if index == artworks.len() { 235 - artworks.push(new_artwork); 236 - } else { 237 - throw!("Index out of range"); 238 - } 239 - } 240 - } 241 - tag.set_artworks(artworks); 242 - } 243 - Metadata::VorbisComments(ref mut tag) => { 244 - if index <= tag.pictures().len() { 245 - let info_result = match mime_type { 246 - MimeType::Png => lofty::PictureInformation::from_png(&new_bytes), 247 - MimeType::Jpeg => lofty::PictureInformation::from_jpeg(&new_bytes), 248 - }; 249 - let info = match info_result { 250 - Ok(info) => info, 251 - Err(e) => throw!("Error reading picture info: {}", e), 252 - }; 253 - let mut byte_cursor = std::io::Cursor::new(new_bytes); 254 - let pic = match lofty::Picture::from_reader(&mut byte_cursor) { 255 - Ok(mut pic) => { 256 - pic.set_pic_type(lofty::PictureType::Other); 257 - pic 258 - } 259 - Err(e) => throw!("Error reading picture: {}", e), 260 - }; 261 - // this is safe because set_picture appends if out of bounds: 262 - tag.set_picture(index, pic, info); 263 - } else { 264 - throw!("Index out of range"); 265 - } 266 - } 267 - } 268 - file.dirty = true; 269 - Ok(()) 166 + let mut app = app.0.lock().unwrap(); 167 + let file = app.current_file().unwrap(); 168 + let new_bytes = match fs::read(&path) { 169 + Ok(b) => b, 170 + Err(e) => throw!("Error reading that file: {}", e), 171 + }; 172 + let ext = path.extension().unwrap_or_default().to_string_lossy(); 173 + let mime_type = match ext.as_ref() { 174 + "jpg" | "jpeg" => MimeType::Jpeg, 175 + "png" => MimeType::Png, 176 + ext => throw!("Unsupported file type: {}", ext), 177 + }; 178 + match file.metadata { 179 + Metadata::Id3(ref mut tag) => { 180 + let mut pic_frames: Vec<_> = tag 181 + .frames() 182 + .filter(|frame| frame.content().picture().is_some()) 183 + .map(|frame| frame.clone()) 184 + .collect(); 185 + let mime_type = match ext.as_ref() { 186 + "jpg" | "jpeg" => "image/jpeg".to_string(), 187 + "png" => "image/png".to_string(), 188 + ext => throw!("Unsupported file type: {}", ext), 189 + }; 190 + let mut new_pic = id3::frame::Picture { 191 + mime_type, 192 + picture_type: id3::frame::PictureType::Other, 193 + description: "".to_string(), 194 + data: new_bytes, 195 + }; 196 + match pic_frames.get_mut(index) { 197 + Some(old_frame) => { 198 + let old_pic = old_frame.content().picture().unwrap(); 199 + new_pic.picture_type = old_pic.picture_type; 200 + new_pic.description = old_pic.description.clone(); 201 + let new_frame = 202 + id3::Frame::with_content("APIC", id3::Content::Picture(new_pic)); 203 + *old_frame = new_frame; 204 + } 205 + None => { 206 + if index == pic_frames.len() { 207 + let new_frame = 208 + id3::Frame::with_content("APIC", id3::Content::Picture(new_pic)); 209 + pic_frames.insert(index, new_frame); 210 + } else { 211 + throw!("Index out of range"); 212 + } 213 + } 214 + } 215 + tag.remove_all_pictures(); 216 + for pic_frame in pic_frames { 217 + tag.add_frame(pic_frame); 218 + } 219 + } 220 + Metadata::Mp4(ref mut tag) => { 221 + let mut artworks: Vec<_> = tag.take_artworks().collect(); 222 + let new_artwork = mp4ameta::Img { 223 + fmt: match ext.as_ref() { 224 + "jpg" | "jpeg" => mp4ameta::ImgFmt::Jpeg, 225 + "png" => mp4ameta::ImgFmt::Png, 226 + "bmp" => mp4ameta::ImgFmt::Bmp, 227 + ext => throw!("Unsupported file type: {}", ext), 228 + }, 229 + data: new_bytes, 230 + }; 231 + match artworks.get_mut(index) { 232 + Some(artwork) => { 233 + *artwork = new_artwork; 234 + } 235 + None => { 236 + if index == artworks.len() { 237 + artworks.push(new_artwork); 238 + } else { 239 + throw!("Index out of range"); 240 + } 241 + } 242 + } 243 + tag.set_artworks(artworks); 244 + } 245 + Metadata::VorbisComments(ref mut tag) => { 246 + if index <= tag.pictures().len() { 247 + let info_result = match mime_type { 248 + MimeType::Png => lofty::PictureInformation::from_png(&new_bytes), 249 + MimeType::Jpeg => lofty::PictureInformation::from_jpeg(&new_bytes), 250 + }; 251 + let info = match info_result { 252 + Ok(info) => info, 253 + Err(e) => throw!("Error reading picture info: {}", e), 254 + }; 255 + let mut byte_cursor = std::io::Cursor::new(new_bytes); 256 + let pic = match lofty::Picture::from_reader(&mut byte_cursor) { 257 + Ok(mut pic) => { 258 + pic.set_pic_type(lofty::PictureType::Other); 259 + pic 260 + } 261 + Err(e) => throw!("Error reading picture: {}", e), 262 + }; 263 + // this is safe because set_picture appends if out of bounds: 264 + tag.set_picture(index, pic, info); 265 + } else { 266 + throw!("Index out of range"); 267 + } 268 + } 269 + } 270 + file.dirty = true; 271 + Ok(()) 270 272 }
+144 -143
src-tauri/src/main.rs
··· 1 1 #![cfg_attr( 2 - all(not(debug_assertions), target_os = "windows"), 3 - windows_subsystem = "windows" 2 + all(not(debug_assertions), target_os = "windows"), 3 + windows_subsystem = "windows" 4 4 )] 5 5 6 6 use crate::cmd::AppArg; 7 7 use std::thread; 8 8 use tauri::api::{dialog, shell}; 9 9 use tauri::{ 10 - AboutMetadata, AppHandle, CustomMenuItem, Manager, Menu, MenuEntry, MenuItem, Submenu, 11 - WindowBuilder, WindowUrl, 10 + AboutMetadata, AppHandle, CustomMenuItem, Manager, Menu, MenuEntry, MenuItem, Submenu, 11 + WindowBuilder, WindowUrl, 12 12 }; 13 13 14 14 mod cmd; ··· 24 24 } 25 25 26 26 fn main() { 27 - let ctx = tauri::generate_context!(); 28 - let app = tauri::Builder::default() 29 - .invoke_handler(tauri::generate_handler![ 30 - cmd::error_popup, 31 - cmd::get_app, 32 - cmd::show, 33 - cmd::close_window, 34 - cmd::get_page, 35 - files::open_files, 36 - files::close_file, 37 - files::save_file, 38 - image::get_image, 39 - image::remove_image, 40 - image::set_image, 41 - ]) 42 - .setup(|app| { 43 - let _ = WindowBuilder::new(app, "main", WindowUrl::default()) 44 - .title("Mr Tagger") 45 - .resizable(true) 46 - .decorations(true) 47 - .always_on_top(false) 48 - .inner_size(800.0, 550.0) 49 - .min_inner_size(400.0, 200.0) 50 - .skip_taskbar(false) 51 - .build() 52 - .expect("Unable to create window"); 53 - Ok(()) 54 - }) 55 - .manage(cmd::AppState(Default::default())) 56 - .menu(Menu::with_items([ 57 - #[cfg(target_os = "macos")] 58 - MenuEntry::Submenu(Submenu::new( 59 - &ctx.package_info().name, 60 - Menu::with_items([ 61 - MenuItem::About(ctx.package_info().name.clone(), AboutMetadata::default()).into(), 62 - MenuItem::Separator.into(), 63 - MenuItem::Services.into(), 64 - MenuItem::Separator.into(), 65 - MenuItem::Hide.into(), 66 - MenuItem::HideOthers.into(), 67 - MenuItem::ShowAll.into(), 68 - MenuItem::Separator.into(), 69 - MenuItem::Quit.into(), 70 - ]), 71 - )), 72 - MenuEntry::Submenu(Submenu::new( 73 - "File", 74 - Menu::with_items([ 75 - CustomMenuItem::new("Open...", "Open...") 76 - .accelerator("cmdOrControl+O") 77 - .into(), 78 - MenuItem::Separator.into(), 79 - CustomMenuItem::new("Close", "Close") 80 - .accelerator("cmdOrControl+W") 81 - .into(), 82 - CustomMenuItem::new("Save", "Save") 83 - .accelerator("cmdOrControl+S") 84 - .into(), 85 - CustomMenuItem::new("Save As...", "Save As...") 86 - .accelerator("shift+cmdOrControl+S") 87 - .into(), 88 - ]), 89 - )), 90 - MenuEntry::Submenu(Submenu::new( 91 - "Edit", 92 - Menu::with_items([ 93 - MenuItem::Undo.into(), 94 - MenuItem::Redo.into(), 95 - MenuItem::Separator.into(), 96 - MenuItem::Cut.into(), 97 - MenuItem::Copy.into(), 98 - MenuItem::Paste.into(), 99 - #[cfg(not(target_os = "macos"))] 100 - MenuItem::Separator.into(), 101 - MenuItem::SelectAll.into(), 102 - ]), 103 - )), 104 - MenuEntry::Submenu(Submenu::new( 105 - "View", 106 - Menu::with_items([MenuItem::EnterFullScreen.into()]), 107 - )), 108 - MenuEntry::Submenu(Submenu::new( 109 - "Window", 110 - Menu::with_items([MenuItem::Minimize.into(), MenuItem::Zoom.into()]), 111 - )), 112 - // You should always have a Help menu on macOS because it will automatically 113 - // show a menu search field 114 - MenuEntry::Submenu(Submenu::new( 115 - "Help", 116 - Menu::with_items([CustomMenuItem::new("Learn More", "Learn More").into()]), 117 - )), 118 - ])) 119 - .on_menu_event(|event| { 120 - let event_name = event.menu_item_id(); 121 - event.window().emit("menu", event_name).unwrap(); 122 - match event_name { 123 - "Learn More" => { 124 - let link = "https://github.com/probablykasper/mr-tagger".to_string(); 125 - shell::open(&event.window().shell_scope(), link, None).unwrap(); 126 - } 127 - _ => {} 128 - } 129 - }) 130 - .build(ctx) 131 - .expect("error while running tauri app"); 132 - app.run(|_app_handle, e| match e { 133 - tauri::RunEvent::WindowEvent { label, event, .. } => match event { 134 - tauri::WindowEvent::CloseRequested { api, .. } => { 135 - if label == "main" && is_dirty(&_app_handle) { 136 - api.prevent_close(); 137 - handle_close_requested(_app_handle.clone()) 138 - } 139 - } 140 - _ => {} 141 - }, 142 - _ => {} 143 - }); 27 + let ctx = tauri::generate_context!(); 28 + let app = tauri::Builder::default() 29 + .invoke_handler(tauri::generate_handler![ 30 + cmd::error_popup, 31 + cmd::get_app, 32 + cmd::show, 33 + cmd::close_window, 34 + cmd::get_page, 35 + files::open_files, 36 + files::close_file, 37 + files::save_file, 38 + image::get_image, 39 + image::remove_image, 40 + image::set_image, 41 + ]) 42 + .setup(|app| { 43 + let _ = WindowBuilder::new(app, "main", WindowUrl::default()) 44 + .title("Mr Tagger") 45 + .resizable(true) 46 + .decorations(true) 47 + .always_on_top(false) 48 + .inner_size(800.0, 550.0) 49 + .min_inner_size(400.0, 200.0) 50 + .skip_taskbar(false) 51 + .build() 52 + .expect("Unable to create window"); 53 + Ok(()) 54 + }) 55 + .manage(cmd::AppState(Default::default())) 56 + .menu(Menu::with_items([ 57 + #[cfg(target_os = "macos")] 58 + MenuEntry::Submenu(Submenu::new( 59 + &ctx.package_info().name, 60 + Menu::with_items([ 61 + MenuItem::About(ctx.package_info().name.clone(), AboutMetadata::default()) 62 + .into(), 63 + MenuItem::Separator.into(), 64 + MenuItem::Services.into(), 65 + MenuItem::Separator.into(), 66 + MenuItem::Hide.into(), 67 + MenuItem::HideOthers.into(), 68 + MenuItem::ShowAll.into(), 69 + MenuItem::Separator.into(), 70 + MenuItem::Quit.into(), 71 + ]), 72 + )), 73 + MenuEntry::Submenu(Submenu::new( 74 + "File", 75 + Menu::with_items([ 76 + CustomMenuItem::new("Open...", "Open...") 77 + .accelerator("cmdOrControl+O") 78 + .into(), 79 + MenuItem::Separator.into(), 80 + CustomMenuItem::new("Close", "Close") 81 + .accelerator("cmdOrControl+W") 82 + .into(), 83 + CustomMenuItem::new("Save", "Save") 84 + .accelerator("cmdOrControl+S") 85 + .into(), 86 + CustomMenuItem::new("Save As...", "Save As...") 87 + .accelerator("shift+cmdOrControl+S") 88 + .into(), 89 + ]), 90 + )), 91 + MenuEntry::Submenu(Submenu::new( 92 + "Edit", 93 + Menu::with_items([ 94 + MenuItem::Undo.into(), 95 + MenuItem::Redo.into(), 96 + MenuItem::Separator.into(), 97 + MenuItem::Cut.into(), 98 + MenuItem::Copy.into(), 99 + MenuItem::Paste.into(), 100 + #[cfg(not(target_os = "macos"))] 101 + MenuItem::Separator.into(), 102 + MenuItem::SelectAll.into(), 103 + ]), 104 + )), 105 + MenuEntry::Submenu(Submenu::new( 106 + "View", 107 + Menu::with_items([MenuItem::EnterFullScreen.into()]), 108 + )), 109 + MenuEntry::Submenu(Submenu::new( 110 + "Window", 111 + Menu::with_items([MenuItem::Minimize.into(), MenuItem::Zoom.into()]), 112 + )), 113 + // You should always have a Help menu on macOS because it will automatically 114 + // show a menu search field 115 + MenuEntry::Submenu(Submenu::new( 116 + "Help", 117 + Menu::with_items([CustomMenuItem::new("Learn More", "Learn More").into()]), 118 + )), 119 + ])) 120 + .on_menu_event(|event| { 121 + let event_name = event.menu_item_id(); 122 + event.window().emit("menu", event_name).unwrap(); 123 + match event_name { 124 + "Learn More" => { 125 + let link = "https://github.com/probablykasper/mr-tagger".to_string(); 126 + shell::open(&event.window().shell_scope(), link, None).unwrap(); 127 + } 128 + _ => {} 129 + } 130 + }) 131 + .build(ctx) 132 + .expect("error while running tauri app"); 133 + app.run(|_app_handle, e| match e { 134 + tauri::RunEvent::WindowEvent { label, event, .. } => match event { 135 + tauri::WindowEvent::CloseRequested { api, .. } => { 136 + if label == "main" && is_dirty(&_app_handle) { 137 + api.prevent_close(); 138 + handle_close_requested(_app_handle.clone()) 139 + } 140 + } 141 + _ => {} 142 + }, 143 + _ => {} 144 + }); 144 145 } 145 146 146 147 fn is_dirty(app: &AppHandle) -> bool { 147 - let app: AppArg<'_> = app.state(); 148 - let app = app.0.lock().unwrap(); 149 - for file in &app.files { 150 - if file.dirty { 151 - return true; 152 - } 153 - } 154 - false 148 + let app: AppArg<'_> = app.state(); 149 + let app = app.0.lock().unwrap(); 150 + for file in &app.files { 151 + if file.dirty { 152 + return true; 153 + } 154 + } 155 + false 155 156 } 156 157 157 158 fn handle_close_requested(app_handle: AppHandle) { 158 - thread::spawn(move || { 159 - let w = app_handle.get_window("main").unwrap(); 160 - dialog::ask( 161 - Some(&w), 162 - "You have unsaved changes. Close without saving?", 163 - "", 164 - move |res| { 165 - let w = app_handle.get_window("main").unwrap(); 166 - if res == true { 167 - w.close().unwrap(); 168 - } 169 - }, 170 - ); 171 - }); 159 + thread::spawn(move || { 160 + let w = app_handle.get_window("main").unwrap(); 161 + dialog::ask( 162 + Some(&w), 163 + "You have unsaved changes. Close without saving?", 164 + "", 165 + move |res| { 166 + let w = app_handle.get_window("main").unwrap(); 167 + if res == true { 168 + w.close().unwrap(); 169 + } 170 + }, 171 + ); 172 + }); 172 173 }
+151 -151
src/App.svelte
··· 1 1 <script lang="ts"> 2 - import { dialog, event } from '@tauri-apps/api' 3 - import { checkShortcut, runCmd } from './scripts/helpers' 4 - import PageView from './components/Page.svelte' 5 - import type { Page } from './components/Page.svelte' 6 - import { onDestroy } from 'svelte' 7 - import FileDrop from 'svelte-tauri-filedrop' 8 - import { fade } from 'svelte/transition' 2 + import { dialog, event } from '@tauri-apps/api' 3 + import { checkShortcut, runCmd } from './scripts/helpers' 4 + import PageView from './components/Page.svelte' 5 + import type { Page } from './components/Page.svelte' 6 + import { onDestroy } from 'svelte' 7 + import FileDrop from 'svelte-tauri-filedrop' 8 + import { fade } from 'svelte/transition' 9 9 10 - type File = { 11 - path: string 12 - dirty: boolean 13 - } 14 - type App = { 15 - current_index: number 16 - files: File[] 17 - } 18 - let app: App = { 19 - current_index: 0, 20 - files: [], 21 - } 22 - async function getApp() { 23 - app = await runCmd<App>('get_app') 24 - } 25 - getApp() 10 + type File = { 11 + path: string 12 + dirty: boolean 13 + } 14 + type App = { 15 + current_index: number 16 + files: File[] 17 + } 18 + let app: App = { 19 + current_index: 0, 20 + files: [], 21 + } 22 + async function getApp() { 23 + app = await runCmd<App>('get_app') 24 + } 25 + getApp() 26 26 27 - let page: Page | null = null 28 - $: if (app) getPage() 29 - async function getPage() { 30 - let newPage = await runCmd<Page | null>('get_page') 31 - if (!page || !newPage || newPage.path !== page.path) { 32 - page = newPage 33 - } 34 - } 27 + let page: Page | null = null 28 + $: if (app) getPage() 29 + async function getPage() { 30 + let newPage = await runCmd<Page | null>('get_page') 31 + if (!page || !newPage || newPage.path !== page.path) { 32 + page = newPage 33 + } 34 + } 35 35 36 - async function openFiles(paths: string[]) { 37 - await runCmd<App>('open_files', { paths }) 38 - getApp() 39 - } 40 - let extensions = [ 41 - // 42 - 'aiff', 43 - 'mp3', 44 - 'm4a', 45 - 'mp4', 46 - 'm4p', 47 - 'm4b', 48 - 'm4r', 49 - 'm4v', 50 - 'opus', 51 - 'wav', 52 - ] 53 - async function openDialog() { 54 - let paths = await dialog.open({ 55 - filters: [{ name: 'Audio/Video file', extensions }], 56 - multiple: true, 57 - directory: false, 58 - }) 59 - if (typeof paths === 'string') { 60 - paths = [paths] 61 - } 62 - if (paths !== null) { 63 - await openFiles(paths) 64 - } 65 - } 66 - async function show(index: number) { 67 - if (app.current_index !== index) { 68 - await runCmd<App>('show', { index }) 69 - getApp() 70 - } 71 - } 72 - async function close(index: number) { 73 - if (app.files[index].dirty) { 74 - let confirmed = window.confirm('Close without saving?') 75 - if (!confirmed) return 76 - } 77 - await runCmd('close_file', { index }) 78 - getApp() 79 - } 80 - async function saveFile(saveAs: boolean) { 81 - await runCmd('save_file', { index: app.current_index, saveAs }) 82 - getApp() 83 - } 84 - async function filesKeydown(e: KeyboardEvent) { 85 - if (checkShortcut(e, 'ArrowUp')) { 86 - e.preventDefault() 87 - if (app.current_index >= 1) { 88 - show(app.current_index - 1) 89 - } 90 - } else if (checkShortcut(e, 'ArrowDown')) { 91 - e.preventDefault() 92 - if (app.current_index < app.files.length - 1) { 93 - show(app.current_index + 1) 94 - } 95 - } 96 - } 97 - const unlistenFuture = event.listen('menu', async ({ payload }) => { 98 - if (payload === 'Open...') { 99 - openDialog() 100 - } else if (payload === 'Close') { 101 - if (app.files.length === 0) { 102 - await runCmd('close_window') 103 - } else { 104 - close(app.current_index) 105 - } 106 - } else if (payload === 'Save') { 107 - saveFile(false) 108 - } else if (payload === 'Save As...') { 109 - saveFile(true) 110 - } 111 - }) 112 - onDestroy(async () => { 113 - const unlisten = await unlistenFuture 114 - unlisten() 115 - }) 36 + async function openFiles(paths: string[]) { 37 + await runCmd<App>('open_files', { paths }) 38 + getApp() 39 + } 40 + let extensions = [ 41 + // 42 + 'aiff', 43 + 'mp3', 44 + 'm4a', 45 + 'mp4', 46 + 'm4p', 47 + 'm4b', 48 + 'm4r', 49 + 'm4v', 50 + 'opus', 51 + 'wav', 52 + ] 53 + async function openDialog() { 54 + let paths = await dialog.open({ 55 + filters: [{ name: 'Audio/Video file', extensions }], 56 + multiple: true, 57 + directory: false, 58 + }) 59 + if (typeof paths === 'string') { 60 + paths = [paths] 61 + } 62 + if (paths !== null) { 63 + await openFiles(paths) 64 + } 65 + } 66 + async function show(index: number) { 67 + if (app.current_index !== index) { 68 + await runCmd<App>('show', { index }) 69 + getApp() 70 + } 71 + } 72 + async function close(index: number) { 73 + if (app.files[index].dirty) { 74 + let confirmed = window.confirm('Close without saving?') 75 + if (!confirmed) return 76 + } 77 + await runCmd('close_file', { index }) 78 + getApp() 79 + } 80 + async function saveFile(saveAs: boolean) { 81 + await runCmd('save_file', { index: app.current_index, saveAs }) 82 + getApp() 83 + } 84 + async function filesKeydown(e: KeyboardEvent) { 85 + if (checkShortcut(e, 'ArrowUp')) { 86 + e.preventDefault() 87 + if (app.current_index >= 1) { 88 + show(app.current_index - 1) 89 + } 90 + } else if (checkShortcut(e, 'ArrowDown')) { 91 + e.preventDefault() 92 + if (app.current_index < app.files.length - 1) { 93 + show(app.current_index + 1) 94 + } 95 + } 96 + } 97 + const unlistenFuture = event.listen('menu', async ({ payload }) => { 98 + if (payload === 'Open...') { 99 + openDialog() 100 + } else if (payload === 'Close') { 101 + if (app.files.length === 0) { 102 + await runCmd('close_window') 103 + } else { 104 + close(app.current_index) 105 + } 106 + } else if (payload === 'Save') { 107 + saveFile(false) 108 + } else if (payload === 'Save As...') { 109 + saveFile(true) 110 + } 111 + }) 112 + onDestroy(async () => { 113 + const unlisten = await unlistenFuture 114 + unlisten() 115 + }) 116 116 </script> 117 117 118 118 <main> 119 - <div class="sidebar"> 120 - <div class="topbar"> 121 - <button on:click={openDialog}>Open Files</button> 122 - </div> 123 - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 124 - <div class="files" tabindex="0" on:keydown={filesKeydown}> 125 - {#each app.files as file, i} 126 - <!-- svelte-ignore a11y-click-events-have-key-events --> 127 - <div class="file" class:selected={i === app.current_index} on:click={() => show(i)}> 128 - <div class="icon dirty"> 129 - {#if file.dirty} 130 - <svg width="6" height="6" xmlns="http://www.w3.org/2000/svg"> 131 - <circle cx="3" cy="3" r="2.5" /> 132 - </svg> 133 - {/if} 134 - </div> 135 - <!-- svelte-ignore a11y-click-events-have-key-events --> 136 - <div class="icon x" on:click|stopPropagation={() => close(i)}> 137 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 138 - ><path 139 - d="M23.954 21.03l-9.184-9.095 9.092-9.174-2.832-2.807-9.09 9.179-9.176-9.088-2.81 2.81 9.186 9.105-9.095 9.184 2.81 2.81 9.112-9.192 9.18 9.1z" 140 - /></svg 141 - > 142 - </div> 143 - {file.path.replace(/^.*[\\/]/, '')} 144 - </div> 145 - {/each} 146 - </div> 147 - <FileDrop {extensions} handleFiles={openFiles} let:files> 148 - {#if files.length > 0} 149 - <div class="dropzone" transition:fade={{ duration: 100 }} /> 150 - {/if} 151 - </FileDrop> 152 - </div> 153 - <div class="main"> 154 - {#if page} 155 - <button on:click={() => saveFile(false)} tabindex="0">Save</button> 156 - <PageView {page} on:appRefresh={getApp} /> 157 - {/if} 158 - </div> 119 + <div class="sidebar"> 120 + <div class="topbar"> 121 + <button on:click={openDialog}>Open Files</button> 122 + </div> 123 + <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 124 + <div class="files" tabindex="0" on:keydown={filesKeydown}> 125 + {#each app.files as file, i} 126 + <!-- svelte-ignore a11y-click-events-have-key-events --> 127 + <div class="file" class:selected={i === app.current_index} on:click={() => show(i)}> 128 + <div class="icon dirty"> 129 + {#if file.dirty} 130 + <svg width="6" height="6" xmlns="http://www.w3.org/2000/svg"> 131 + <circle cx="3" cy="3" r="2.5" /> 132 + </svg> 133 + {/if} 134 + </div> 135 + <!-- svelte-ignore a11y-click-events-have-key-events --> 136 + <div class="icon x" on:click|stopPropagation={() => close(i)}> 137 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 138 + ><path 139 + d="M23.954 21.03l-9.184-9.095 9.092-9.174-2.832-2.807-9.09 9.179-9.176-9.088-2.81 2.81 9.186 9.105-9.095 9.184 2.81 2.81 9.112-9.192 9.18 9.1z" 140 + /></svg 141 + > 142 + </div> 143 + {file.path.replace(/^.*[\\/]/, '')} 144 + </div> 145 + {/each} 146 + </div> 147 + <FileDrop {extensions} handleFiles={openFiles} let:files> 148 + {#if files.length > 0} 149 + <div class="dropzone" transition:fade={{ duration: 100 }} /> 150 + {/if} 151 + </FileDrop> 152 + </div> 153 + <div class="main"> 154 + {#if page} 155 + <button on:click={() => saveFile(false)} tabindex="0">Save</button> 156 + <PageView {page} on:appRefresh={getApp} /> 157 + {/if} 158 + </div> 159 159 </main> 160 160 161 161 <style lang="sass">
+9 -9
src/components/MultiField.svelte
··· 1 1 <script lang="ts"> 2 - export let value: string[] = [] 2 + export let value: string[] = [] 3 3 </script> 4 4 5 5 <div class="content"> 6 - {#if value.length === 1} 7 - {value[0]} 8 - {:else} 9 - <!-- {#each ['Omri', 'Aero Chord'] as artist} --> 10 - {#each value as item} 11 - <span class="item">{item}</span> 12 - {/each} 13 - {/if} 6 + {#if value.length === 1} 7 + {value[0]} 8 + {:else} 9 + <!-- {#each ['Omri', 'Aero Chord'] as artist} --> 10 + {#each value as item} 11 + <span class="item">{item}</span> 12 + {/each} 13 + {/if} 14 14 </div> 15 15 16 16 <style lang="sass">
+203 -203
src/components/Page.svelte
··· 1 1 <script context="module" lang="ts"> 2 - export type Frame = { 3 - Text?: { id: string; value: string } 4 - } 5 - export type Image = { 6 - index: number 7 - total_images: number 8 - data: Uint8Array 9 - mime_type: string 10 - description: string | null 11 - picture_type: string | null 12 - } 13 - export type Comment = { 14 - lang: string | null 15 - description: string | null 16 - text: string 17 - } 18 - export type Page = { 19 - path: string 20 - title: string 21 - artists: string[] 22 - album: string 23 - album_artists: string[] 24 - composer: string[] 25 - groupings: string[] 26 - genres: string[] 27 - track_num: number 28 - track_total: number 29 - disc_num: number 30 - disc_total: number 31 - compilation: boolean 32 - bpm: string 33 - comments: Comment[] 34 - frames: Frame[] 35 - } 2 + export type Frame = { 3 + Text?: { id: string; value: string } 4 + } 5 + export type Image = { 6 + index: number 7 + total_images: number 8 + data: Uint8Array 9 + mime_type: string 10 + description: string | null 11 + picture_type: string | null 12 + } 13 + export type Comment = { 14 + lang: string | null 15 + description: string | null 16 + text: string 17 + } 18 + export type Page = { 19 + path: string 20 + title: string 21 + artists: string[] 22 + album: string 23 + album_artists: string[] 24 + composer: string[] 25 + groupings: string[] 26 + genres: string[] 27 + track_num: number 28 + track_total: number 29 + disc_num: number 30 + disc_total: number 31 + compilation: boolean 32 + bpm: string 33 + comments: Comment[] 34 + frames: Frame[] 35 + } 36 36 </script> 37 37 38 38 <script lang="ts"> 39 - import { createEventDispatcher } from 'svelte' 40 - import { runCmd } from '../scripts/helpers' 41 - import { dialog } from '@tauri-apps/api' 42 - import FileDrop from 'svelte-tauri-filedrop' 43 - import MultiField from './MultiField.svelte' 44 - import { fade } from 'svelte/transition' 39 + import { createEventDispatcher } from 'svelte' 40 + import { runCmd } from '../scripts/helpers' 41 + import { dialog } from '@tauri-apps/api' 42 + import FileDrop from 'svelte-tauri-filedrop' 43 + import MultiField from './MultiField.svelte' 44 + import { fade } from 'svelte/transition' 45 45 46 - export let page: Page 46 + export let page: Page 47 47 48 - let image: Image | null = null 49 - $: if (page) { 50 - image = null 51 - getImage(null) 52 - } 53 - async function getImage(index: number | null) { 54 - image = (await runCmd('get_image', { index })) as Image | null 55 - } 48 + let image: Image | null = null 49 + $: if (page) { 50 + image = null 51 + getImage(null) 52 + } 53 + async function getImage(index: number | null) { 54 + image = (await runCmd('get_image', { index })) as Image | null 55 + } 56 56 57 - const dispatch = createEventDispatcher() 58 - async function removeImage() { 59 - if (image) { 60 - await runCmd('remove_image', { index: image.index }) 61 - getImage(null) 62 - dispatch('appRefresh') 63 - } 64 - } 65 - async function setImage(path?: string) { 66 - if (!path) { 67 - let pathResult = await dialog.open({ 68 - filters: [{ name: 'Audio file', extensions: ['jpg', 'jpeg', 'png', 'bmp'] }], 69 - multiple: false, 70 - directory: false, 71 - }) 72 - if (typeof pathResult === 'string') path = pathResult 73 - } 74 - if (image) { 75 - await runCmd('set_image', { index: image.index, path }) 76 - getImage(image.index) 77 - } else { 78 - await runCmd('set_image', { index: 0, path }) 79 - getImage(0) 80 - } 81 - dispatch('appRefresh') 82 - } 83 - let showFrames = false 84 - let svgWidth = 0 57 + const dispatch = createEventDispatcher() 58 + async function removeImage() { 59 + if (image) { 60 + await runCmd('remove_image', { index: image.index }) 61 + getImage(null) 62 + dispatch('appRefresh') 63 + } 64 + } 65 + async function setImage(path?: string) { 66 + if (!path) { 67 + let pathResult = await dialog.open({ 68 + filters: [{ name: 'Audio file', extensions: ['jpg', 'jpeg', 'png', 'bmp'] }], 69 + multiple: false, 70 + directory: false, 71 + }) 72 + if (typeof pathResult === 'string') path = pathResult 73 + } 74 + if (image) { 75 + await runCmd('set_image', { index: image.index, path }) 76 + getImage(image.index) 77 + } else { 78 + await runCmd('set_image', { index: 0, path }) 79 + getImage(0) 80 + } 81 + dispatch('appRefresh') 82 + } 83 + let showFrames = false 84 + let svgWidth = 0 85 85 </script> 86 86 87 87 <main> 88 - <div class="left"> 89 - <div class="cover"> 90 - {#if image} 91 - <img src={'data:' + image.mime_type + ';base64,' + image.data} alt="" /> 92 - {:else} 93 - <div class="svg-cover" bind:clientWidth={svgWidth} style={'height:' + svgWidth + 'px'}> 94 - <svg 95 - xmlns="http://www.w3.org/2000/svg" 96 - preserveAspectRatio="xMidYMin meet" 97 - viewBox="0 0 24 24" 98 - > 99 - <path 100 - d="M23 0l-15.996 3.585v13.04c-2.979-.589-6.004 1.671-6.004 4.154 0 2.137 1.671 3.221 3.485 3.221 2.155 0 4.512-1.528 4.515-4.638v-10.9l12-2.459v8.624c-2.975-.587-6 1.664-6 4.141 0 2.143 1.715 3.232 3.521 3.232 2.14 0 4.476-1.526 4.479-4.636v-17.364z" 101 - /> 102 - </svg> 103 - </div> 104 - {/if} 105 - <FileDrop extensions={['jpeg', 'jpg', 'png', 'bmp']} handleOneFile={setImage} let:files> 106 - {#if files.length > 0} 107 - <div class="dropzone" transition:fade={{ duration: 100 }} /> 108 - {/if} 109 - </FileDrop> 110 - </div> 111 - {#if image} 112 - <div> 113 - <button on:click={removeImage}>Remove</button> 114 - <button on:click={() => setImage()}>Replace</button> 115 - </div> 116 - <div class="text">{image.index + 1} of {image.total_images}</div> 117 - <div class="text">{image.mime_type}</div> 118 - {#if image.picture_type} 119 - <div class="text">Type: {image.picture_type}</div> 120 - {/if} 121 - {#if image.description} 122 - <div class="text">Description: {image.description}</div> 123 - {/if} 124 - {:else} 125 - <div> 126 - <button on:click={() => setImage()}>Add</button> 127 - </div> 128 - {/if} 129 - </div> 130 - <div class="right"> 131 - <div class="row"> 132 - <span class="label">Path</span> 133 - <span class="content">{page.path}</span> 134 - </div> 135 - <div class="row"> 136 - <span class="label">Title</span> 137 - <span class="content">{page.title}</span> 138 - </div> 139 - <div class="row"> 140 - <span class="label">Artist</span> 141 - <MultiField value={page.artists} /> 142 - </div> 143 - <div class="row"> 144 - <span class="label">Album</span> 145 - <span class="content">{page.album}</span> 146 - </div> 147 - <div class="row"> 148 - <span class="label">Album artist</span> 149 - <MultiField value={page.album_artists} /> 150 - </div> 151 - <div class="row"> 152 - <span class="label">Composer</span> 153 - <MultiField value={page.composer} /> 154 - </div> 155 - <div class="row"> 156 - <span class="label">Grouping</span> 157 - <MultiField value={page.groupings} /> 158 - </div> 159 - <div class="row"> 160 - <span class="label">Genre</span> 161 - <MultiField value={page.genres} /> 162 - </div> 163 - <div class="row"> 164 - <span class="label">Track</span> 165 - <div class="content">{page.track_num || '_'} of {page.track_total || '_'}</div> 166 - </div> 167 - <div class="row"> 168 - <span class="label">Disc number</span> 169 - <div class="content">{page.disc_num || '_'} of {page.disc_total || '_'}</div> 170 - </div> 171 - <div class="row"> 172 - <span class="label">Compilation</span> 173 - <div class="content">{page.compilation ? 'Yes' : 'No'}</div> 174 - </div> 175 - <div class="row"> 176 - <span class="label">BPM</span> 177 - <div class="content">{page.bpm}</div> 178 - </div> 179 - <div class="row"> 180 - <span class="label">Comments</span> 181 - <div> 182 - {#each page.comments as comment} 183 - <div class="content comment"> 184 - {#if comment.lang !== null} 185 - Lang: {comment.lang} 186 - <br /> 187 - {/if} 188 - {#if comment.description !== null} 189 - Description: {comment.description} 190 - <br /> 191 - <br /> 192 - {/if} 193 - {comment.text} 194 - <br /> 195 - <br /> 196 - </div> 197 - {/each} 198 - </div> 199 - </div> 200 - <button class="toggle" tabindex="0" on:click={() => (showFrames = !showFrames)} 201 - >{showFrames ? 'Hide tags' : 'Show tags'}</button 202 - > 203 - <div class="frames"> 204 - {#if showFrames} 205 - {#each page.frames as frame} 206 - {#if frame.Text} 207 - <div class="frame-label">{frame.Text.id}</div> 208 - <div class="content">{frame.Text.value}</div> 209 - {/if} 210 - {/each} 211 - {/if} 212 - </div> 213 - </div> 88 + <div class="left"> 89 + <div class="cover"> 90 + {#if image} 91 + <img src={'data:' + image.mime_type + ';base64,' + image.data} alt="" /> 92 + {:else} 93 + <div class="svg-cover" bind:clientWidth={svgWidth} style={'height:' + svgWidth + 'px'}> 94 + <svg 95 + xmlns="http://www.w3.org/2000/svg" 96 + preserveAspectRatio="xMidYMin meet" 97 + viewBox="0 0 24 24" 98 + > 99 + <path 100 + d="M23 0l-15.996 3.585v13.04c-2.979-.589-6.004 1.671-6.004 4.154 0 2.137 1.671 3.221 3.485 3.221 2.155 0 4.512-1.528 4.515-4.638v-10.9l12-2.459v8.624c-2.975-.587-6 1.664-6 4.141 0 2.143 1.715 3.232 3.521 3.232 2.14 0 4.476-1.526 4.479-4.636v-17.364z" 101 + /> 102 + </svg> 103 + </div> 104 + {/if} 105 + <FileDrop extensions={['jpeg', 'jpg', 'png', 'bmp']} handleOneFile={setImage} let:files> 106 + {#if files.length > 0} 107 + <div class="dropzone" transition:fade={{ duration: 100 }} /> 108 + {/if} 109 + </FileDrop> 110 + </div> 111 + {#if image} 112 + <div> 113 + <button on:click={removeImage}>Remove</button> 114 + <button on:click={() => setImage()}>Replace</button> 115 + </div> 116 + <div class="text">{image.index + 1} of {image.total_images}</div> 117 + <div class="text">{image.mime_type}</div> 118 + {#if image.picture_type} 119 + <div class="text">Type: {image.picture_type}</div> 120 + {/if} 121 + {#if image.description} 122 + <div class="text">Description: {image.description}</div> 123 + {/if} 124 + {:else} 125 + <div> 126 + <button on:click={() => setImage()}>Add</button> 127 + </div> 128 + {/if} 129 + </div> 130 + <div class="right"> 131 + <div class="row"> 132 + <span class="label">Path</span> 133 + <span class="content">{page.path}</span> 134 + </div> 135 + <div class="row"> 136 + <span class="label">Title</span> 137 + <span class="content">{page.title}</span> 138 + </div> 139 + <div class="row"> 140 + <span class="label">Artist</span> 141 + <MultiField value={page.artists} /> 142 + </div> 143 + <div class="row"> 144 + <span class="label">Album</span> 145 + <span class="content">{page.album}</span> 146 + </div> 147 + <div class="row"> 148 + <span class="label">Album artist</span> 149 + <MultiField value={page.album_artists} /> 150 + </div> 151 + <div class="row"> 152 + <span class="label">Composer</span> 153 + <MultiField value={page.composer} /> 154 + </div> 155 + <div class="row"> 156 + <span class="label">Grouping</span> 157 + <MultiField value={page.groupings} /> 158 + </div> 159 + <div class="row"> 160 + <span class="label">Genre</span> 161 + <MultiField value={page.genres} /> 162 + </div> 163 + <div class="row"> 164 + <span class="label">Track</span> 165 + <div class="content">{page.track_num || '_'} of {page.track_total || '_'}</div> 166 + </div> 167 + <div class="row"> 168 + <span class="label">Disc number</span> 169 + <div class="content">{page.disc_num || '_'} of {page.disc_total || '_'}</div> 170 + </div> 171 + <div class="row"> 172 + <span class="label">Compilation</span> 173 + <div class="content">{page.compilation ? 'Yes' : 'No'}</div> 174 + </div> 175 + <div class="row"> 176 + <span class="label">BPM</span> 177 + <div class="content">{page.bpm}</div> 178 + </div> 179 + <div class="row"> 180 + <span class="label">Comments</span> 181 + <div> 182 + {#each page.comments as comment} 183 + <div class="content comment"> 184 + {#if comment.lang !== null} 185 + Lang: {comment.lang} 186 + <br /> 187 + {/if} 188 + {#if comment.description !== null} 189 + Description: {comment.description} 190 + <br /> 191 + <br /> 192 + {/if} 193 + {comment.text} 194 + <br /> 195 + <br /> 196 + </div> 197 + {/each} 198 + </div> 199 + </div> 200 + <button class="toggle" tabindex="0" on:click={() => (showFrames = !showFrames)} 201 + >{showFrames ? 'Hide tags' : 'Show tags'}</button 202 + > 203 + <div class="frames"> 204 + {#if showFrames} 205 + {#each page.frames as frame} 206 + {#if frame.Text} 207 + <div class="frame-label">{frame.Text.id}</div> 208 + <div class="content">{frame.Text.value}</div> 209 + {/if} 210 + {/each} 211 + {/if} 212 + </div> 213 + </div> 214 214 </main> 215 215 216 216 <style lang="sass">
+7 -7
src/index.html
··· 1 1 <!DOCTYPE html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 - </head> 7 - <body> 8 - <script type="module" src="./main.ts"></script> 9 - </body> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + </head> 7 + <body> 8 + <script type="module" src="./main.ts"></script> 9 + </body> 10 10 </html>
+1 -1
src/main.ts
··· 1 1 import App from './App.svelte' 2 2 3 3 const app = new App({ 4 - target: document.body, 4 + target: document.body, 5 5 }) 6 6 7 7 export default app
+30 -30
src/scripts/helpers.ts
··· 2 2 import type { event } from '@tauri-apps/api' 3 3 4 4 export function popup(msg: string) { 5 - invoke('error_popup', { msg }) 5 + invoke('error_popup', { msg }) 6 6 } 7 7 8 8 export async function runCmd<T = unknown>(cmd: string, options: { [key: string]: unknown } = {}) { 9 - return (await invoke(cmd, options).catch(popup)) as T 9 + return (await invoke(cmd, options).catch(popup)) as T 10 10 } 11 11 12 12 export function extractUnlistener(futureUnlistener: Promise<event.UnlistenFn>) { 13 - return async () => { 14 - const unlisten = await futureUnlistener 15 - unlisten() 16 - } 13 + return async () => { 14 + const unlisten = await futureUnlistener 15 + unlisten() 16 + } 17 17 } 18 18 19 19 type ShortcutOptions = { 20 - shift?: boolean 21 - alt?: boolean 22 - cmdOrCtrl?: boolean 20 + shift?: boolean 21 + alt?: boolean 22 + cmdOrCtrl?: boolean 23 23 } 24 24 const isMac = navigator.userAgent.indexOf('Mac') != -1 25 25 26 26 function checkModifiers(e: KeyboardEvent | MouseEvent, options: ShortcutOptions) { 27 - const target = { 28 - shift: options.shift || false, 29 - alt: options.alt || false, 30 - ctrl: (!isMac && options.cmdOrCtrl) || false, 31 - meta: (isMac && options.cmdOrCtrl) || false, 32 - } 27 + const target = { 28 + shift: options.shift || false, 29 + alt: options.alt || false, 30 + ctrl: (!isMac && options.cmdOrCtrl) || false, 31 + meta: (isMac && options.cmdOrCtrl) || false, 32 + } 33 33 34 - const pressed = { 35 - shift: !!e.shiftKey, 36 - alt: !!e.altKey, 37 - ctrl: !!e.ctrlKey, 38 - meta: !!e.metaKey, 39 - } 34 + const pressed = { 35 + shift: !!e.shiftKey, 36 + alt: !!e.altKey, 37 + ctrl: !!e.ctrlKey, 38 + meta: !!e.metaKey, 39 + } 40 40 41 - return ( 42 - pressed.shift === target.shift && 43 - pressed.alt === target.alt && 44 - pressed.ctrl === target.ctrl && 45 - pressed.meta === target.meta 46 - ) 41 + return ( 42 + pressed.shift === target.shift && 43 + pressed.alt === target.alt && 44 + pressed.ctrl === target.ctrl && 45 + pressed.meta === target.meta 46 + ) 47 47 } 48 48 49 49 export function checkShortcut(e: KeyboardEvent, key: string, options: ShortcutOptions = {}) { 50 - if (e.key.toUpperCase() !== key.toUpperCase()) return false 51 - return checkModifiers(e, options) 50 + if (e.key.toUpperCase() !== key.toUpperCase()) return false 51 + return checkModifiers(e, options) 52 52 } 53 53 export function checkMouseShortcut(e: MouseEvent, options: ShortcutOptions = {}) { 54 - return checkModifiers(e, options) 54 + return checkModifiers(e, options) 55 55 }
+20 -20
vite.config.js
··· 2 2 import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte' 3 3 4 4 export default defineConfig({ 5 - root: './src', 6 - base: './', // use relative paths 7 - publicDir: '../public', 8 - clearScreen: false, 9 - server: { 10 - port: 8000, 11 - strictPort: true, 12 - }, 13 - build: { 14 - outDir: '../build', 15 - emptyOutDir: true, 16 - minify: false, 17 - sourcemap: true, 18 - target: ['chrome64', 'edge79', 'firefox62', 'safari11.1'], 19 - }, 20 - plugins: [ 21 - svelte({ 22 - preprocess: vitePreprocess(), 23 - }), 24 - ], 5 + root: './src', 6 + base: './', // use relative paths 7 + publicDir: '../public', 8 + clearScreen: false, 9 + server: { 10 + port: 8000, 11 + strictPort: true, 12 + }, 13 + build: { 14 + outDir: '../build', 15 + emptyOutDir: true, 16 + minify: false, 17 + sourcemap: true, 18 + target: ['chrome64', 'edge79', 'firefox62', 'safari11.1'], 19 + }, 20 + plugins: [ 21 + svelte({ 22 + preprocess: vitePreprocess(), 23 + }), 24 + ], 25 25 })