diff --git a/README.md b/README.md index 02f0544..c170c6c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Get the AppImage [here](https://github.com/ronanru/ronix/releases/tag/v0.2.0) - [x] Plays music (All traditional music player features) - [x] Fuzzy search - [x] Multiple themes -- [ ] Song manager (Ability to edit song tags, delete songs) +- [x] Song manager (Ability to edit song tags, delete songs) - [x] Song downloader with yt-dlp - [x] Publish on the AUR - [ ] Publish on flathub diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 61990dd..00d14ed 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "ronix" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-stream", "directories", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f9b9a72..d66e099 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ronix" -version = "0.3.0" +version = "0.4.0" description = "Music Player and Library Manager" authors = ["Matvey Ryabchikov"] edition = "2021" diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index ef48d97..f6c8be1 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -33,7 +33,7 @@ pub fn get_router() -> RouterBuilder { .current_dir(&folders[0]) .status(); if !sacad.map(|s| s.success()).unwrap_or(false) { - return "Failed to convert video with sacad_r"; + return "Failed to download cover art with sacad_r"; } *ctx.library.lock().unwrap() = library::read_from_dirs(&folders); "Download successful" diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 369b152..8443719 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -1,16 +1,17 @@ use crate::{Context, PlayerScope}; use fuse_rust::Fuse; -use lofty::{Accessor, AudioFile, MimeType, Probe, TaggedFileExt}; +use lofty::{Accessor, AudioFile, MimeType, Probe, TagExt, TaggedFileExt}; use nanoid::nanoid; use rand::prelude::*; use rspc::{Router, RouterBuilder, Type}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fs::{self, create_dir_all, File}, hash::Hash, io::Write, path::PathBuf, + process::Command, }; use walkdir::WalkDir; @@ -187,6 +188,14 @@ struct SearchResults { songs: Vec, } +#[derive(Deserialize, Type)] +struct EditSongInput { + id: String, + title: String, + album: String, + artist: String, +} + pub fn get_router() -> RouterBuilder { Router::::new() .query("get", |t| { @@ -243,4 +252,55 @@ pub fn get_router() -> RouterBuilder { } }) }) + .mutation("editSong", |t| { + t(|ctx, input: EditSongInput| { + let mut library = ctx.library.lock().unwrap(); + match library.songs.get(&input.id) { + Some(song) => { + match Probe::open(&song.path) + .ok() + .map(|f| f.read().ok()) + .flatten() + { + Some(mut tagged_file) => { + let tags_option = match tagged_file.primary_tag_mut() { + Some(primary_tag) => Some(primary_tag), + None => tagged_file.first_tag_mut(), + }; + match tags_option { + Some(tags) => { + tags.set_title(input.title); + tags.set_album(input.album); + tags.set_artist(input.artist); + for i in 0..tags.picture_count() { + tags.remove_picture(i as usize); + } + if tags.save_to_path(&song.path).is_err() { + return "Failed to edit song"; + } + let sacad = Command::new("sacad_r") + .args(["-f", ".", "600", "+"]) + .current_dir(&song.path.parent().unwrap()) + .status(); + if !sacad.map(|s| s.success()).unwrap_or(false) { + return "Failed to download cover art with sacad_r"; + } + *library = read_from_dirs(&ctx.config.lock().unwrap().music_folders); + "Successfully edited" + } + None => "Could not edit song", + } + } + None => "Could not find song to edit", + } + } + None => "Could not find song to edit", + } + }) + }) + .mutation("refresh", |t| { + t(|ctx, _: ()| { + *ctx.library.lock().unwrap() = read_from_dirs(&ctx.config.lock().unwrap().music_folders); + }) + }) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9d86610..18a1c4b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "ronix", - "version": "0.3.0" + "version": "0.4.0" }, "tauri": { "allowlist": { diff --git a/src/components/menu.tsx b/src/components/menu.tsx index 2bf5144..3268d47 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -1,5 +1,13 @@ +import { api } from '@/api'; +import { refetchLibrary } from '@/library'; import { navigate } from '@/router'; -import { FolderIcon, MenuIcon, PlusIcon, SettingsIcon } from 'lucide-solid'; +import { + FolderIcon, + MenuIcon, + PlusIcon, + RefreshCcwIcon, + SettingsIcon, +} from 'lucide-solid'; import { For, Show, @@ -36,6 +44,12 @@ const Menu: Component<{ }, id: 'downloadSong', }, + { + icon: RefreshCcwIcon, + name: 'Refresh library', + onClick: () => api.mutation(['library.refresh']).then(refetchLibrary), + id: 'refreshLibrary', + }, { icon: FolderIcon, name: 'Library Manager', diff --git a/src/components/ui/textInput.tsx b/src/components/ui/textInput.tsx new file mode 100644 index 0000000..5437d19 --- /dev/null +++ b/src/components/ui/textInput.tsx @@ -0,0 +1,30 @@ +import { + ComponentProps, + createUniqueId, + splitProps, + type Component, +} from 'solid-js'; + +const TextInput: Component< + { + label: string; + } & ComponentProps<'input'> +> = (props) => { + const id = createUniqueId(); + + const [local, otherProps] = splitProps(props, ['label']); + + return ( +
+ + +
+ ); +}; + +export default TextInput; diff --git a/src/gen/tauri-types.ts b/src/gen/tauri-types.ts index 7884db7..2895418 100644 --- a/src/gen/tauri-types.ts +++ b/src/gen/tauri-types.ts @@ -11,6 +11,8 @@ export type Procedures = { mutations: { key: "config.set", input: Config, result: null } | { key: "library.deleteSong", input: string, result: string } | + { key: "library.editSong", input: EditSongInput, result: string } | + { key: "library.refresh", input: never, result: null } | { key: "player.nextSong", input: never, result: null } | { key: "player.playSong", input: PlaySongInput, result: null } | { key: "player.previousSong", input: never, result: null } | @@ -25,15 +27,17 @@ export type Procedures = { export type RepeatMode = "None" | "One" | "All" -export type PlaySongInput = { song_id: string; scope: PlayerScope } +export type Song = { title: string; path: string; duration: number; album: string } -export type Artist = { name: string } +export type PlaySongInput = { song_id: string; scope: PlayerScope } export type MainColor = "Slate" | "Gray" | "Zinc" | "Neutral" | "Stone" +export type SearchResults = { artists: string[]; albums: string[]; songs: string[] } + export type Library = { artists: { [key: string]: Artist }; albums: { [key: string]: Album }; songs: { [key: string]: Song } } -export type Song = { title: string; path: string; duration: number; album: string } +export type EditSongInput = { id: string; title: string; album: string; artist: string } export type CurrentSongData = { current_song: string | null; song_started_at: number; paused_at: number | null; volume: number } @@ -43,6 +47,6 @@ export type Config = { music_folders: string[]; dark_mode: boolean; main_color: export type PlayerScope = "Library" | { Album: string } | { Artist: string } -export type SearchResults = { artists: string[]; albums: string[]; songs: string[] } +export type Artist = { name: string } export type AccentColor = "Red" | "Orange" | "Amber" | "Yellow" | "Lime" | "Green" | "Emerald" | "Teal" | "Cyan" | "Blue" | "Indigo" | "Violet" | "Purple" | "Fuchsia" | "Pink" | "Rose" diff --git a/src/songButton.tsx b/src/songButton.tsx index 8cd8dfa..ae233cb 100644 --- a/src/songButton.tsx +++ b/src/songButton.tsx @@ -1,7 +1,7 @@ +import { PencilIcon, Trash2Icon } from 'lucide-solid'; import { Show, type Component } from 'solid-js'; import CoverArt from './components/coverArt'; import Button from './components/ui/button'; -import { Trash2Icon } from 'lucide-solid'; const SongButton: Component<{ title: string; @@ -13,6 +13,7 @@ const SongButton: Component<{ onClick?: (e: MouseEvent) => void; isManager?: boolean; onDelete?: () => void; + onEdit?: () => void; }> = (props) => { return ( - {Math.floor(props.duration / 60)}: {(props.duration % 60).toString().padStart(2, '0')} -

} +

+ } > + diff --git a/src/views/songList.tsx b/src/views/songList.tsx index 6caa757..d4a548d 100644 --- a/src/views/songList.tsx +++ b/src/views/songList.tsx @@ -1,9 +1,17 @@ import { api } from '@/api'; import Button from '@/components/ui/button'; import Modal from '@/components/ui/modal'; +import TextInput from '@/components/ui/textInput'; import { library, refetchLibrary } from '@/library'; import SongButton from '@/songButton'; -import { For, Show, createSignal, type Component } from 'solid-js'; +import { + For, + Match, + Show, + Switch, + createSignal, + type Component, +} from 'solid-js'; const SongList: Component<{ albums?: string[]; @@ -12,7 +20,12 @@ const SongList: Component<{ noSort?: boolean; isManager?: boolean; }> = (props) => { - const [songToDelete, setSongToDelete] = createSignal(null); + const [songToEdit, setSongToEdit] = createSignal(null); + const [operation, setOperation] = createSignal<'EDIT' | 'DELETE'>('EDIT'); + + const songEditData = () => library()?.songs[songToEdit()!]; + const albumEditData = () => library()?.albums[songEditData()!.album!]; + const artistEditData = () => library()?.artists[albumEditData()!.artist!]; const [isLoading, setIsLoading] = createSignal(false); const [returnText, setReturnText] = createSignal(null); @@ -47,37 +60,95 @@ const SongList: Component<{ return ( <> setSongToDelete(null) !== null} + isOpen={!!props.isManager && !!songToEdit()} + onClose={() => setSongToEdit(null) !== null} title={ returnText() ?? (isLoading() - ? 'Deleting...' - : `Do you really want to delete "${library()?.songs[songToDelete()!] - .title}"?`) + ? { + DELETE: 'Deleting...', + EDIT: 'Editing...', + }[operation()] + : `${ + { + DELETE: 'Do you really want to delete', + EDIT: 'Edit', + }[operation()] + } "${library()?.songs[songToEdit()!].title}"?`) } disableClosing={!returnText()} > -
- - -
+ + +
+ + +
+
+ +
{ + e.preventDefault(); + setIsLoading(true); + const formData = new FormData(e.currentTarget); + api + .mutation([ + 'library.editSong', + { + album: formData.get('album') as string, + title: formData.get('title') as string, + artist: formData.get('artist') as string, + id: songToEdit()!, + }, + ]) + .then((text) => { + setReturnText(text); + refetchLibrary(); + }); + }} + > + + + +
+ + +
+ +
+
+
{ - setSongToDelete(song.id); + setSongToEdit(song.id); + setOperation('DELETE'); + setIsLoading(false); + }} + onEdit={() => { + setSongToEdit(song.id); + setOperation('EDIT'); setIsLoading(false); }} onClick={() =>