diff --git a/maxima-lib/src/core/service_layer.rs b/maxima-lib/src/core/service_layer.rs index 08f6287..c7cadd6 100644 --- a/maxima-lib/src/core/service_layer.rs +++ b/maxima-lib/src/core/service_layer.rs @@ -640,7 +640,7 @@ service_layer_type!(Asset, { }); service_layer_type!(GameHub, { - background_video: ServiceAsset, + background_video: Option, hero_background: ServiceImageRendition, }); diff --git a/maxima-ui/Cargo.toml b/maxima-ui/Cargo.toml index 6140141..dce8cab 100644 --- a/maxima-ui/Cargo.toml +++ b/maxima-ui/Cargo.toml @@ -26,6 +26,7 @@ strum_macros = "0.25.3" sys-locale = "0.3.1" humansize = { version = "2.0.0", features = ["no_alloc"] } fuzzy-matcher = "*" +ffmpeg-next = "7.0.2" [target.'cfg(windows)'.dependencies] is_elevated = "0.1.2" diff --git a/maxima-ui/res/locale/en_us.json b/maxima-ui/res/locale/en_us.json index 213be51..a028dc6 100644 --- a/maxima-ui/res/locale/en_us.json +++ b/maxima-ui/res/locale/en_us.json @@ -89,7 +89,8 @@ "settings_view": { "interface" : { "header": "Interface", - "language": "Language" + "language": "Language", + "videos": "Background Videos" }, "game_installation" : { "header": "Game Installation", diff --git a/maxima-ui/src/bridge/get_games.rs b/maxima-ui/src/bridge/get_games.rs index fa73d93..4377dfd 100644 --- a/maxima-ui/src/bridge/get_games.rs +++ b/maxima-ui/src/bridge/get_games.rs @@ -1,13 +1,39 @@ use anyhow::{Ok, Result, bail}; use egui::Context; use log::{debug, info}; -use maxima::{core::{service_layer::{ServiceGame, ServiceGameImagesRequestBuilder, ServiceLayerClient, SERVICE_REQUEST_GAMEIMAGES}, LockedMaxima}, util::native::maxima_dir}; +use maxima::{core::{service_layer::{ServiceGame, ServiceGameHubCollection, ServiceGameImagesRequestBuilder, ServiceHeroBackgroundImageRequestBuilder, ServiceLayerClient, SERVICE_REQUEST_GAMEIMAGES, SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE}, LockedMaxima}, util::native::maxima_dir}; use std::{fs, sync::mpsc::Sender}; use crate::{ bridge_thread::{InteractThreadGameListResponse, MaximaLibResponse}, ui_image::UIImageCacheLoaderCommand, GameDetailsWrapper, GameInfo }; +fn get_preferred_bg_hero(heroes: &Option) -> Option { + if heroes.is_none() { + return None + } + let bg = heroes.as_ref().unwrap().items().get(0); + + if bg.is_none() { + return None; + } + let bg = bg.as_ref().unwrap().hero_background(); + + if let Some(img) = bg.aspect_10x3_image() { + return Some(img.path().clone()); + } + + if let Some(img) = bg.aspect_2x1_image() { + return Some(img.path().clone()); + } + + if let Some(img) = bg.aspect_16x9_image() { + return Some(img.path().clone()); + } + + None +} + async fn get_preferred_hero_image(images: &Option) -> Option { if images.is_none() { return None; @@ -56,22 +82,24 @@ fn get_logo_image(images: &Option) -> Option { async fn handle_images(slug: String, locale: String, has_hero: bool, has_logo: bool, channel: Sender, service_layer: ServiceLayerClient) -> Result<()> { debug!("handling image downloads for {}", &slug); - let images = service_layer - .request(SERVICE_REQUEST_GAMEIMAGES, ServiceGameImagesRequestBuilder::default() - .should_fetch_context_image(!has_logo) - .should_fetch_backdrop_images(!has_hero) - .game_slug(slug.clone()) - .locale(locale.clone()) - .build()?).await?; + let g_images = if has_hero && has_logo { None } else { + service_layer + .request(SERVICE_REQUEST_GAMEIMAGES, ServiceGameImagesRequestBuilder::default() + .should_fetch_context_image(!has_logo) + .should_fetch_backdrop_images(!has_hero) + .game_slug(slug.clone()) + .locale(locale.clone()) + .build()?).await? + }; if !has_hero { - if let Some(hero) = get_preferred_hero_image(&images).await { + if let Some(hero) = get_preferred_hero_image(&g_images).await { channel.send(UIImageCacheLoaderCommand::ProvideRemote(crate::ui_image::UIImageType::Hero(slug.clone()), hero)).unwrap() } } if !has_logo { - if let Some(logo) = get_logo_image(&images) { + if let Some(logo) = get_logo_image(&g_images) { channel.send(UIImageCacheLoaderCommand::ProvideRemote(crate::ui_image::UIImageType::Logo(slug), logo))? } else { channel.send(UIImageCacheLoaderCommand::Stub(crate::ui_image::UIImageType::Logo(slug)))? @@ -106,17 +134,33 @@ pub async fn get_games_request( } for game in owned_games { - info!("processing {}", &game.base_offer().slug()); + let slug = game.base_offer().slug().clone(); + info!("processing {}", &slug); + + // it'd be better if we could do this in the task but by then the info is long gone + let h_images: Option = { + service_layer.request(SERVICE_REQUEST_GETHEROBACKGROUNDIMAGE, + ServiceHeroBackgroundImageRequestBuilder::default() + .game_slug(slug.clone()) + .locale(locale.clone()) + .build()?).await? + }; + let game_info = GameInfo { - slug: game.base_offer().slug().to_string(), + slug: slug.clone(), offer: game.base_offer().offer().offer_id().to_string(), name: game.name(), details: GameDetailsWrapper::Unloaded, + bg_video: if let Some(imgs) = &h_images { + if let Some(vid) = imgs.items().get(0).unwrap().background_video() { + vid.url().clone() + } else { None } + } else { None }, dlc: game.extra_offers().clone(), installed: game.base_offer().installed().await, has_cloud_saves: game.base_offer().offer().has_cloud_save(), }; - let slug = game_info.slug.clone(); + let settings = crate::GameSettings { //TODO: eventually support cloud saves, the option is here for that but for now, keep it disabled in ui! cloud_saves: true, @@ -129,6 +173,11 @@ pub async fn get_games_request( }); channel.send(res)?; + let background_hero = maxima_dir() + .unwrap() + .join("cache/ui/images/") + .join(&slug) + .join("background.jpg"); let game_hero = maxima_dir() .unwrap() .join("cache/ui/images/") @@ -141,8 +190,9 @@ pub async fn get_games_request( .join("logo.png"); let has_hero = fs::metadata(&game_hero).is_ok(); let has_logo = fs::metadata(&game_logo).is_ok(); + let has_background = fs::metadata(&background_hero).is_ok(); - if !has_hero || !has_logo { + if !has_hero || !has_logo{ //we're like 20 tasks deep i swear but this shit's gonna be real fast, trust let slug_send = slug.clone(); let locale_send = locale.clone(); @@ -152,6 +202,12 @@ pub async fn get_games_request( tokio::task::yield_now().await; } + + if !has_background { + if let Some(background_image) = get_preferred_bg_hero(&h_images) { + channel1.send(UIImageCacheLoaderCommand::ProvideRemote(crate::ui_image::UIImageType::Background(slug.clone()), background_image)).unwrap() + } + } egui::Context::request_repaint(&ctx); } diff --git a/maxima-ui/src/bridge_thread.rs b/maxima-ui/src/bridge_thread.rs index 95108dc..858b527 100644 --- a/maxima-ui/src/bridge_thread.rs +++ b/maxima-ui/src/bridge_thread.rs @@ -6,7 +6,7 @@ use std::{ panic, path::PathBuf, sync::mpsc::{Receiver, Sender}, time::{Duration, SystemTime} }; -use maxima::{content::manager::{ContentManager, QueuedGameBuilder}, core::{dip::{DiPManifest, DIP_RELATIVE_PATH}, service_layer::ServicePlayer, LockedMaxima, Maxima, MaximaOptionsBuilder}, util::registry::{check_registry_validity, set_up_registry}}; +use maxima::{content::manager::{ContentManager, QueuedGameBuilder}, core::{dip::{DiPManifest, DIP_RELATIVE_PATH}, service_layer::{ServicePlayer, ServiceGameHubCollection}, LockedMaxima, Maxima, MaximaOptionsBuilder}, util::registry::{check_registry_validity, set_up_registry}}; use crate::{ bridge::{ game_details::game_details_request, diff --git a/maxima-ui/src/main.rs b/maxima-ui/src/main.rs index 6e4e8da..a3d2ff8 100644 --- a/maxima-ui/src/main.rs +++ b/maxima-ui/src/main.rs @@ -1,4 +1,5 @@ #![feature(slice_pattern)] +extern crate ffmpeg_next as ffmpeg; use anyhow::bail; use clap::{arg, command, Parser}; @@ -7,6 +8,7 @@ use egui::style::{ScrollStyle, Spacing}; use egui::Style; use log::{error, warn}; use maxima::core::library::OwnedOffer; +use renderers::media_player::{Player, State}; use strum_macros::EnumIter; use views::downloads_view::{downloads_view, QueuedDownload}; use views::undefinied_view::coming_soon_view; @@ -82,6 +84,12 @@ struct Args { #[tokio::main] async fn main() { init_logger(); + let ffmpeg_success = if let Err(err) = ffmpeg::init() { + log::error!("Unable to initialize ffmpeg {err:?}"); + false + } else { + true + }; let mut args = Args::parse(); if !cfg!(debug_assertions) { @@ -121,7 +129,7 @@ async fn main() { "Maxima", native_options, Box::new(move |cc| { - let app = MaximaEguiApp::new(cc, args); + let app = MaximaEguiApp::new(cc, args, ffmpeg_success); // Run initialization code that needs access to the UI here, but DO NOT run any long-runtime functions here, // as it's before the UI is shown if args.no_login { @@ -219,6 +227,7 @@ pub struct GameInfo { name: String, /// Game info details: GameDetailsWrapper, + bg_video: Option, // not the best place to put it dlc: Vec, installed: bool, has_cloud_saves: bool, @@ -286,6 +295,8 @@ pub struct MaximaEguiApp { app_bg_renderer: Option, /// Image cache img_cache: UIImageCache, + /// Renderer for the app's background videos + app_bg_media_player: Option, /// Translations locale: TranslationManager, /// If a core thread has crashed and made the UI unstable @@ -317,7 +328,8 @@ pub enum FrontendLanguage { pub struct FrontendSettings { default_install_folder: String, language: FrontendLanguage, - game_settings: HashMap + game_settings: HashMap, + videos: bool, } impl FrontendSettings { @@ -326,6 +338,7 @@ impl FrontendSettings { default_install_folder: String::new(), language: FrontendLanguage::SystemDefault, game_settings: HashMap::new(), + videos: true, } } } @@ -335,7 +348,7 @@ const F9B233: Color32 = Color32::from_rgb(249, 178, 51); const WIDGET_HOVER: Color32 = Color32::from_rgb(255, 188, 61); impl MaximaEguiApp { - fn new(cc: &eframe::CreationContext<'_>, args: Args) -> Self { + fn new(cc: &eframe::CreationContext<'_>, args: Args, ffmpeg_success: bool) -> Self { let style: Style = Style { spacing: Spacing { scroll: ScrollStyle { @@ -456,6 +469,9 @@ impl MaximaEguiApp { game_view_bg_renderer: GameViewBgRenderer::new(cc), app_bg_renderer: AppBgRenderer::new(cc), img_cache, + app_bg_media_player: if settings.videos && ffmpeg_success { + Some(Player::new(&cc.egui_ctx)) + } else { None }, locale: TranslationManager::new(&settings.language), critical_bg_thread_crashed: false, backend: BridgeThread::new(&cc.egui_ctx, remote_provider_channel), //please don't fucking break @@ -631,6 +647,7 @@ impl eframe::App for MaximaEguiApp { let has_game_img = self.backend_state == BackendStallState::BingChilling && self.games.len() > 0; let gaming = self.page_view == PageType::Games && has_game_img; let how_game: f32 = ctx.animate_bool(egui::Id::new("MainAppBackgroundGamePageFadeBool"), gaming); + if has_game_img { if self.game_sel.is_empty() && self.games.len() > 0 { @@ -638,14 +655,56 @@ impl eframe::App for MaximaEguiApp { self.game_sel = key.clone() } } - //TODO: background - match &self.img_cache.get(ui_image::UIImageType::Hero(self.games[&self.game_sel].slug.clone())) { - Some(tex) => render.draw(ui, fullrect, tex.size_vec2(), tex.id(), how_game), - None => { render.draw(ui, fullrect, fullrect.size(), TextureId::Managed(1), 0.0); }, + let game = &self.games[&self.game_sel]; + + let player_state: Option<(TextureId, Vec2)> = { + match &mut self.app_bg_media_player { + Some(player) => { + if let Some(video) = &game.bg_video { + player.start(&video); + match player.state() { + State::Paused => { + if gaming { + player.unpause(); + } + Some((player.texture(), player.size())) + } + State::Playing => { + if !gaming { + player.pause(); + } + if player.ready_to_show() { + Some((player.texture(), player.size())) + } else { + None + } + }, + State::EndOfFile | State::Stopped => { + None + } + } + } + else { + player.stop(); + None + } + }, + None => None + + } + }; + + if let Some((tex, size)) = player_state { + render.draw(ui, fullrect, size, tex, how_game); + } else if let Some(img) = &self.img_cache.get(ui_image::UIImageType::Background(game.slug.clone())) { + render.draw(ui, fullrect, img.size_vec2(), img.id(), how_game); + } else { + render.draw(ui, fullrect, fullrect.size(), TextureId::Managed(1), 0.0); } } else { render.draw(ui, fullrect, fullrect.size(), TextureId::Managed(1), 0.0); } + } let app_rect = ui.available_rect_before_wrap().clone(); match self.backend_state { diff --git a/maxima-ui/src/renderers/media_player.rs b/maxima-ui/src/renderers/media_player.rs new file mode 100644 index 0000000..bed9a45 --- /dev/null +++ b/maxima-ui/src/renderers/media_player.rs @@ -0,0 +1,184 @@ +use eframe::glow; +use anyhow::Result; +use egui::{Color32, ColorImage, TextureHandle, TextureId, Vec2}; +use ffmpeg::media::Type; +use ffmpeg::util::frame::Video; +use ffmpeg::software::scaling::{Context, Flags}; +use ffmpeg::format::{input, context::Input}; +use log::error; +use std::thread; +use std::sync::Arc; +use egui::mutex::Mutex; + + +#[derive(Debug, Clone, Copy)] +pub enum State { + Stopped, + Paused, + Playing, + EndOfFile +} + +pub struct Player { + current_location: String, + texture: TextureHandle, + ctx: egui::Context, + play_thread: Option>>, + state: Arc>, + thread_stop: Arc>, + ready_to_show: Arc>, + video_res: Arc>, +} + +impl Player { + pub fn new(ctx: &egui::Context) -> Self { + let texture_handle = ctx.load_texture("video", ColorImage::example(), Default::default()); + + Self { + current_location: String::new(), + ctx: ctx.clone(), + texture: texture_handle, + play_thread: None, + state: Arc::new(Mutex::new(State::Stopped)), + thread_stop: Arc::new(Mutex::new(false)), + ready_to_show: Arc::new(Mutex::new(false)), + video_res: Arc::new(Mutex::new(Vec2::new(0.0, 0.0))) + } + } + + pub fn texture(&self) -> TextureId { + self.texture.id() + } + + pub fn size(&self) -> Vec2 { + (*self.video_res.lock()).clone() + } + + pub fn ready_to_show(&self) -> bool { + *self.ready_to_show.lock() + } + + pub fn state(&self) -> State { + *self.state.lock() + } + + pub fn start(&mut self, location: &str) { + if location != self.current_location { + self.stop(); + } + if self.play_thread.is_some() { + return + } + + let location = location.to_owned(); + self.current_location = location.clone(); + + *self.thread_stop.lock() = false; + *self.state.lock() = State::Playing; + let ctx = self.ctx.clone(); + let thread_stop = self.thread_stop.clone(); + let state = self.state.clone(); + let mut texture = self.texture.clone(); + let ready_to_show = self.ready_to_show.clone(); + let video_res = self.video_res.clone(); + self.play_thread = Some(thread::spawn(move || { + let mut input = input(&location)?; + let stream = input.streams().best(Type::Video).ok_or(ffmpeg::Error::StreamNotFound)?; + let video_index = stream.index(); + let ctx_decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())?; + let mut decoder = ctx_decoder.decoder().video()?; + let frame_rate = stream.avg_frame_rate().numerator() as f64 / stream.avg_frame_rate().denominator() as f64; + let wait_duration = std::time::Duration::from_millis((1000.0 / frame_rate) as u64); + *video_res.lock() = Vec2::new(decoder.width() as f32, decoder.height() as f32); + let mut scaler = Context::get(decoder.format(), decoder.width(), decoder.height(), ffmpeg::format::Pixel::RGB24, decoder.width(), decoder.height(), Flags::BILINEAR).unwrap(); + loop { + if matches!(*state.lock(), State::Paused) { + std::thread::sleep(std::time::Duration::from_millis(200)); + continue; + } + let mut frame = Video::empty(); + match decoder.receive_frame(&mut frame) { + Ok(_) => { + let mut rgb_frame = Video::empty(); + match scaler.run(&frame, &mut rgb_frame) { + Ok(_) => { + let image = video_frame_to_image(rgb_frame); + texture.set(image, Default::default()); + *ready_to_show.lock() = true; + ctx.request_repaint() + }, + Err(err) => error!("scaler error {:?}", err) + } + } + Err(err) => { + if matches!(err, ffmpeg::Error::Eof) { + *state.lock() = State::EndOfFile; + *ready_to_show.lock() = false; + ctx.request_repaint(); + break + } + if let ffmpeg::Error::Other { errno } = err { + if errno == ffmpeg::error::EAGAIN { + if let Some((stream, packet)) = input.packets().next() { + if stream.index() == video_index { + decoder.send_packet(&packet); + } + } + else { + decoder.send_eof(); + } + continue + } + } + error!("player error {:?}", err) + }, + } + thread::sleep(wait_duration); + if *thread_stop.lock() { + break + } + } + Ok(()) + })); + } + + pub fn stop(&mut self) { + *self.state.lock() = State::Stopped; + self.ctx.request_repaint(); + if let Some(th) = self.play_thread.take() { + *self.thread_stop.lock() = true; + let _ = th.join(); + } + self.current_location = String::new(); + } + pub fn pause(&mut self) { + *self.state.lock() = State::Paused; + } + pub fn unpause(&mut self) { + if self.play_thread.is_some() { + *self.state.lock() = State::Playing; + } + } +} + + +fn video_frame_to_image(frame: Video) -> ColorImage { + let size = [frame.width() as usize, frame.height() as usize]; + let data = frame.data(0); + let stride = frame.stride(0); + let pixel_size_bytes = 3; + let byte_width: usize = pixel_size_bytes * frame.width() as usize; + let height: usize = frame.height() as usize; + let mut pixels = vec![]; + for line in 0..height { + let begin = line * stride; + let end = begin + byte_width; + let data_line = &data[begin..end]; + pixels.extend( + data_line + .chunks_exact(pixel_size_bytes) + .map(|p| Color32::from_rgb(p[0], p[1], p[2])), + ) + } + ColorImage { size, pixels } +} diff --git a/maxima-ui/src/renderers/mod.rs b/maxima-ui/src/renderers/mod.rs index 35696f6..8dc2ae1 100644 --- a/maxima-ui/src/renderers/mod.rs +++ b/maxima-ui/src/renderers/mod.rs @@ -1,2 +1,3 @@ pub mod app_bg_renderer; pub mod game_view_bg_renderer; +pub mod media_player; diff --git a/maxima-ui/src/translation_manager.rs b/maxima-ui/src/translation_manager.rs index cd14900..6dfd7d1 100644 --- a/maxima-ui/src/translation_manager.rs +++ b/maxima-ui/src/translation_manager.rs @@ -147,6 +147,8 @@ pub struct LocalizedInterfaceSettings { pub header: String, /// Label for a combo box to select the frontend's language pub language: String, + /// Label for a checkbox to enable background videos + pub videos: String, } #[derive(Deserialize)] diff --git a/maxima-ui/src/views/downloads_view.rs b/maxima-ui/src/views/downloads_view.rs index 27bef47..64b5294 100644 --- a/maxima-ui/src/views/downloads_view.rs +++ b/maxima-ui/src/views/downloads_view.rs @@ -129,4 +129,4 @@ pub fn downloads_view(app : &mut MaximaEguiApp, ui: &mut Ui) { render_queued(app, ui, &game, false); } } -} \ No newline at end of file +} diff --git a/maxima-ui/src/views/game_view.rs b/maxima-ui/src/views/game_view.rs index e5f5e74..2292a11 100644 --- a/maxima-ui/src/views/game_view.rs +++ b/maxima-ui/src/views/game_view.rs @@ -566,4 +566,4 @@ pub fn games_view(app : &mut MaximaEguiApp, ui: &mut Ui) { fn bezier_ease(t: f32) -> f32 { t * t * (3.0 - 2.0 * t) -} \ No newline at end of file +} diff --git a/maxima-ui/src/views/settings_view.rs b/maxima-ui/src/views/settings_view.rs index 8d53eff..8c2cff0 100644 --- a/maxima-ui/src/views/settings_view.rs +++ b/maxima-ui/src/views/settings_view.rs @@ -1,4 +1,7 @@ -use egui::{vec2, Ui}; +use std::sync::Arc; + +use egui::{vec2, Color32, Ui}; +use log::info; use crate::{widgets::enum_dropdown::enum_dropdown, FrontendLanguage, MaximaEguiApp}; @@ -10,12 +13,25 @@ enum SettingsViewDemoTheme { } pub fn settings_view(app: &mut MaximaEguiApp, ui: &mut Ui) { - ui.style_mut().spacing.interact_size.y = 30.0; + ui.style_mut().spacing.interact_size = vec2(100.0, 30.0); + ui.style_mut().spacing.icon_width = ui.style().spacing.interact_size.y; + ui.style_mut().visuals.widgets.hovered.fg_stroke.color = Color32::WHITE; ui.heading(&app.locale.localization.settings_view.interface.header); ui.separator(); ui.horizontal(|ui| { enum_dropdown(ui, "Settings_LanguageComboBox".to_owned(), &mut app.settings.language, 150.0, &app.locale.localization.settings_view.interface.language, &app.locale); }); + if ui.checkbox(&mut app.settings.videos, &app.locale.localization.settings_view.interface.videos).clicked() { + if app.settings.videos { + if app.app_bg_media_player.is_none() { + app.app_bg_media_player = Some(crate::renderers::media_player::Player::new(ui.ctx())); + } + } else { + if app.app_bg_media_player.is_some() { + app.app_bg_media_player = None; + } + } + } ui.heading(""); ui.heading(&app.locale.localization.settings_view.game_installation.header); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly"