Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::app::runtime;
use crate::app::viewport;
use crate::app::App;
use crate::app::LibraryCommand;
use bird_player::services::YoutubeDownloadEvent;
use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::mpsc::channel;
use std::sync::Arc;
Expand All @@ -16,6 +17,8 @@ pub struct BirdBootCfg {
pub db: Arc<db::Database>,
pub lib_cmd_tx: std::sync::mpsc::Sender<LibraryCommand>,
pub lib_cmd_rx: std::sync::mpsc::Receiver<LibraryCommand>,
pub youtube_download_tx: std::sync::mpsc::Sender<YoutubeDownloadEvent>,
pub youtube_download_rx: std::sync::mpsc::Receiver<YoutubeDownloadEvent>,
pub is_processing_ui_change: Arc<AtomicBool>,
}

Expand All @@ -30,13 +33,16 @@ pub fn start_app() -> Result<()> {
tracing::info!("Database initialized successfully");

let (lib_cmd_tx, lib_cmd_rx) = channel();
let (youtube_download_tx, youtube_download_rx) = channel();
let is_processing_ui_change = Arc::new(AtomicBool::new(false));
let is_processing_ui_change_thread = is_processing_ui_change.clone();

let boot_cfg = BirdBootCfg {
db: database,
lib_cmd_tx,
lib_cmd_rx,
youtube_download_tx,
youtube_download_rx,
is_processing_ui_change,
};

Expand Down
101 changes: 99 additions & 2 deletions src/app/components/playback_info_panel.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use super::AppComponent;
use crate::app::style::tokens;
use crate::app::style::{icons, player_button, tokens, ButtonExt};
use crate::app::t;
use crate::app::App;
use eframe::egui::{self, Align, Layout, RichText};
use eframe::egui::{self, Align, Layout, RichText, TextEdit};

const DESCRIPTION_PREVIEW_LENGTH: usize = 30;

Expand All @@ -11,6 +12,8 @@ impl AppComponent for PlaybackInfoPanel {
type Context = App;

fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) {
Self::render_download_entry(ctx, ui);

// Check if we have a playing playlist
let playing_playlist_idx = ctx.app_settings.playing_playlist_idx;

Expand All @@ -25,6 +28,100 @@ impl AppComponent for PlaybackInfoPanel {
}

impl PlaybackInfoPanel {
fn render_download_entry(ctx: &mut App, ui: &mut egui::Ui) {
let active =
ctx.ui_state.show_youtube_download_dialog || ctx.ui_state.youtube_download_in_progress;
let button = ui
.add(player_button(icons::DOWNLOAD, active))
.on_hover_text(t("download_authorized_audio"));

if button.clicked() {
ctx.ui_state.show_youtube_download_dialog = true;
}

Self::render_download_dialog(ctx, ui.ctx().clone());
}

fn render_download_dialog(ctx: &mut App, egui_ctx: egui::Context) {
if !ctx.ui_state.show_youtube_download_dialog {
return;
}

let mut open = ctx.ui_state.show_youtube_download_dialog;
egui::Window::new(t("download_authorized_audio"))
.id(egui::Id::new("youtube_download_dialog"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.show(&egui_ctx, |ui| {
ui.set_min_width(420.0);
ui.label(RichText::new(t("authorized_audio_notice")).weak());
ui.add_space(tokens::spacing::XS);

ui.label(t("youtube_url"));
ui.add_enabled(
!ctx.ui_state.youtube_download_in_progress,
TextEdit::singleline(&mut ctx.ui_state.youtube_download_url)
.hint_text("https://www.youtube.com/watch?v=..."),
);

ui.add_space(tokens::spacing::XS);
ui.label(t("save_to"));
ui.horizontal(|ui| {
let mut path_text = ctx.ui_state.youtube_download_dir.display().to_string();
ui.add_enabled(
false,
TextEdit::singleline(&mut path_text).desired_width(340.0),
);

let choose = ui
.add_enabled(
!ctx.ui_state.youtube_download_in_progress,
egui::Button::new(icons::FOLDER).player_style(),
)
.on_hover_text(t("choose_download_folder"));
if choose.clicked() {
if let Some(folder) = rfd::FileDialog::new()
.set_directory(&ctx.ui_state.youtube_download_dir)
.pick_folder()
{
ctx.ui_state.youtube_download_dir = folder;
}
}
});

if let Some(status) = &ctx.ui_state.youtube_download_status {
ui.add_space(tokens::spacing::XS);
ui.label(RichText::new(status).weak());
}

ui.add_space(tokens::spacing::SM);
ui.horizontal(|ui| {
let can_download = !ctx.ui_state.youtube_download_in_progress
&& !ctx.ui_state.youtube_download_url.trim().is_empty();
if ui
.add_enabled(can_download, egui::Button::new(t("download")))
.clicked()
{
ctx.start_youtube_download();
}

if ui
.add_enabled(
!ctx.ui_state.youtube_download_in_progress,
egui::Button::new(t("clear")),
)
.clicked()
{
ctx.ui_state.youtube_download_url.clear();
ctx.ui_state.youtube_download_status = None;
}
});
});

ctx.ui_state.show_youtube_download_dialog = open;
}

fn render_playlist_info(ctx: &App, ui: &mut egui::Ui, playlist_idx: usize) {
if let Some(playlist) = ctx.playlists.get(playlist_idx) {
ui.with_layout(Layout::top_down(Align::RIGHT), |ui| {
Expand Down
73 changes: 72 additions & 1 deletion src/app/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize};

use super::error::AppLoadError;
use super::i18n;
use super::lib_services::{LibraryImportService, LyricsManager, PlayerRestoreService};
use super::lib_services::{
LibraryImportService, LyricsManager, PlayerRestoreService, YoutubeDownloadEvent,
YoutubeDownloadService,
};
use super::library::{Library, LibraryCommand, LibraryPath};
pub use super::library::{LibraryItem, LibraryPathId};
use super::libstate::lyrics_state::LyricsFetchState;
Expand Down Expand Up @@ -88,6 +91,22 @@ impl App {
.lib_cmd_rx
}

pub fn youtube_download_tx(&self) -> &Sender<YoutubeDownloadEvent> {
&self
.boot_cfg
.as_ref()
.expect("boot_cfg not initialized")
.youtube_download_tx
}

pub fn youtube_download_rx(&self) -> &Receiver<YoutubeDownloadEvent> {
&self
.boot_cfg
.as_ref()
.expect("boot_cfg not initialized")
.youtube_download_rx
}

pub fn is_processing_ui_change(&self) -> Arc<AtomicBool> {
Arc::clone(
&self
Expand Down Expand Up @@ -271,6 +290,58 @@ impl App {
LibraryImportService::import_library_path(lib_path, lib_cmd_tx, album_art_dir);
}

pub fn start_youtube_download(&mut self) {
if self.ui_state.youtube_download_in_progress {
return;
}

let url = self.ui_state.youtube_download_url.trim().to_string();
if url.is_empty() {
self.ui_state.youtube_download_status = Some(i18n::t("url_required"));
return;
}

self.ui_state.youtube_download_in_progress = true;
self.ui_state.youtube_download_status = Some(i18n::t("downloading"));

YoutubeDownloadService::download_authorized_audio(
url,
self.ui_state.youtube_download_dir.clone(),
self.youtube_download_tx().clone(),
);
}

pub fn handle_youtube_download_event(&mut self, event: YoutubeDownloadEvent) {
self.ui_state.youtube_download_in_progress = false;

match event {
YoutubeDownloadEvent::Finished(Ok(result)) => {
let file_count = result.downloaded_files.len();
self.ui_state.youtube_download_status = Some(if file_count == 0 {
i18n::t("download_finished_resync")
} else {
i18n::tf("downloaded_files_resync", &[&file_count.to_string()])
});
Comment thread
RetricSu marked this conversation as resolved.

self.library.add_path(result.output_dir.clone());
let path_to_import = self
.library
.paths()
.iter()
.find(|p| *p.path() == result.output_dir)
.cloned();

if let Some(path) = path_to_import {
self.library.set_path_to_not_imported(path.id());
self.import_library_paths(&path);
}
}
YoutubeDownloadEvent::Finished(Err(err)) => {
self.ui_state.youtube_download_status = Some(err);
}
}
}

pub fn update_track_lyrics(&mut self, track_key: String, lyrics: Option<&str>) {
let db_conn = self.db().connection();
let player = self.runtime.as_mut().map(|rt| &mut rt.player);
Expand Down
52 changes: 52 additions & 0 deletions src/app/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ pub fn init() {
"remove_from_library".to_string(),
"Remove from library".to_string(),
);
en.insert(
"download_authorized_audio".to_string(),
"Download authorized audio".to_string(),
);
en.insert(
"authorized_audio_notice".to_string(),
"Only download audio you are authorized to save locally.".to_string(),
);
en.insert("youtube_url".to_string(), "YouTube URL".to_string());
en.insert("save_to".to_string(), "Save to".to_string());
en.insert(
"choose_download_folder".to_string(),
"Choose download folder".to_string(),
);
en.insert("download".to_string(), "Download".to_string());
en.insert("clear".to_string(), "Clear".to_string());
en.insert("url_required".to_string(), "URL is required".to_string());
en.insert("downloading".to_string(), "Downloading...".to_string());
en.insert(
"download_finished_resync".to_string(),
"Download finished. Re-syncing folder...".to_string(),
);
en.insert(
"downloaded_files_resync".to_string(),
"Downloaded {} file(s). Re-syncing folder...".to_string(),
);

// Playlist tabs component
en.insert("rename".to_string(), "Rename".to_string());
Expand Down Expand Up @@ -203,6 +229,32 @@ pub fn init() {
"全部添加到播放列表".to_string(),
);
zh.insert("remove_from_library".to_string(), "从库中移除".to_string());
zh.insert(
"download_authorized_audio".to_string(),
"下载已授权音频".to_string(),
);
zh.insert(
"authorized_audio_notice".to_string(),
"请只下载你有权保存到本地的音频。".to_string(),
);
zh.insert("youtube_url".to_string(), "YouTube URL".to_string());
zh.insert("save_to".to_string(), "保存到".to_string());
zh.insert(
"choose_download_folder".to_string(),
"选择下载文件夹".to_string(),
);
zh.insert("download".to_string(), "下载".to_string());
zh.insert("clear".to_string(), "清空".to_string());
zh.insert("url_required".to_string(), "请输入 URL".to_string());
zh.insert("downloading".to_string(), "正在下载...".to_string());
zh.insert(
"download_finished_resync".to_string(),
"下载完成,正在重新同步文件夹...".to_string(),
);
zh.insert(
"downloaded_files_resync".to_string(),
"已下载 {} 个文件,正在重新同步文件夹...".to_string(),
);

// Playlist tabs component
zh.insert("rename".to_string(), "重命名".to_string());
Expand Down
22 changes: 22 additions & 0 deletions src/app/state/ui_state.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::app::lib_services::YoutubeDownloadService;
use crate::app::libstate::lyrics_state::LyricsFetchState;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Instant;

/// UI-specific state that doesn't need to be persisted
Expand Down Expand Up @@ -41,6 +43,21 @@ pub struct UiState {
/// Whether library import is currently running
pub is_importing: bool,

/// Whether the authorized audio download window is open.
pub show_youtube_download_dialog: bool,

/// URL entered in the authorized audio download form.
pub youtube_download_url: String,

/// Destination folder for authorized audio downloads.
pub youtube_download_dir: PathBuf,

/// Whether a yt-dlp download task is currently running.
pub youtube_download_in_progress: bool,

/// Last user-facing download status or error message.
pub youtube_download_status: Option<String>,

/// Volume value to restore when the user un-mutes via the speaker icon.
/// `None` while not muted. Not serialized: a fresh launch always starts
/// with whatever volume the player itself remembers.
Expand Down Expand Up @@ -75,6 +92,11 @@ impl Default for UiState {
lyrics_fetch_state: LyricsFetchState::Idle,
last_window_title: None,
is_importing: false,
show_youtube_download_dialog: false,
youtube_download_url: String::new(),
youtube_download_dir: YoutubeDownloadService::default_download_dir(),
youtube_download_in_progress: false,
youtube_download_status: None,
volume_before_mute: None,
last_persistence_save: Instant::now(),
desktop_lyrics_font_size: 48.0,
Expand Down
2 changes: 2 additions & 0 deletions src/app/style/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub const VOLUME_MUTE: &str = p::SPEAKER_X;
pub const LYRICS_TOGGLE: &str = p::MICROPHONE_STAGE;
pub const SEARCH: &str = p::MAGNIFYING_GLASS;
pub const CLOSE: &str = p::X;
pub const DOWNLOAD: &str = p::DOWNLOAD_SIMPLE;
pub const FOLDER: &str = p::FOLDER_OPEN;
/// Generic "add / new" affordance — used by library/playlist "+" buttons.
pub const PLUS: &str = p::PLUS;
/// Indicator shown in the playlist's lyrics column when a track has lyrics.
Expand Down
14 changes: 13 additions & 1 deletion src/app/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ impl App {
self.process_library_command(lib_cmd);
}
}

fn refresh_youtube_download_processor(&mut self) {
let mut events = Vec::new();
while let Ok(event) = self.youtube_download_rx().try_recv() {
events.push(event);
}

for event in events {
self.handle_youtube_download_event(event);
}
}
}

impl eframe::App for App {
Expand All @@ -176,9 +187,10 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
self.refresh_library_command_processor();
self.refresh_youtube_download_processor();
self.pump_audio_events();

if self.ui_state.is_importing {
if self.ui_state.is_importing || self.ui_state.youtube_download_in_progress {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.refresh_lyrics_display();
Expand Down
Loading
Loading