Collections (#58)

Reviewed-on: https://codeberg.org/avery42/delfin/pulls/58
Co-authored-by: Avery <git@avery.cafe>
Co-committed-by: Avery <git@avery.cafe>
This commit is contained in:
Avery
2024-01-09 01:34:16 +00:00
committed by Avery
parent abfabafe39
commit dfcfee42e3
20 changed files with 632 additions and 190 deletions

View File

@@ -42,6 +42,7 @@ relm4-icons = { version = "0.7.0-alpha.2", features = [
"warning",
"folder-filled",
"loupe",
"library",
] }
reqwest = { version = "0.11.20", features = ["json"] }
serde = { version = "1.0.188", features = ["derive"] }

View File

@@ -16,7 +16,7 @@ use crate::{
config,
globals::CONFIG,
jellyfin_api::api_client::ApiClient,
library::{Library, LibraryOutput, LIBRARY_BROKER},
library::{collection::Collection, Library, LibraryOutput, LIBRARY_BROKER},
locales::tera_tr,
media_details::MediaDetails,
meson_config::APP_ID,
@@ -60,6 +60,7 @@ pub struct App {
account_list: Controller<AccountList>,
library: Option<Controller<Library>>,
media_details: Option<Controller<MediaDetails>>,
collection: Option<Controller<Collection>>,
video_player: OnceCell<Controller<VideoPlayer>>,
server: Option<config::Server>,
account: Option<config::Account>,
@@ -73,6 +74,7 @@ pub enum AppInput {
ServerSelected(config::Server),
AccountSelected(config::Server, config::Account),
ShowDetails(BaseItemDto),
ShowCollection(BaseItemDto),
PlayVideo(BaseItemDto),
SignOut,
SetThemeDark(bool),
@@ -162,6 +164,7 @@ impl Component for App {
account_list,
library: None,
media_details: None,
collection: None,
video_player: OnceCell::new(),
server: None,
account: None,
@@ -228,6 +231,22 @@ impl Component for App {
self.media_details = Some(media_details);
}
}
AppInput::ShowCollection(collection) => {
if let (Some(api_client), Some(server), Some(account)) =
(&self.api_client, &self.server, &self.account)
{
let collection = Collection::builder()
.launch((
api_client.clone(),
collection,
server.clone(),
account.clone(),
))
.detach();
navigation.push(collection.widget());
self.collection = Some(collection);
}
}
AppInput::PlayVideo(item) => {
if self.video_player.get().is_none() {
let video_player = VideoPlayer::builder()

View File

@@ -1,67 +1,67 @@
use anyhow::{bail, Context, Ok, Result};
use anyhow::{Context, Ok, Result};
use jellyfin_api::types::{BaseItemDto, BaseItemDtoQueryResult};
use uuid::Uuid;
use crate::jellyfin_api::{api_client::ApiClient, models::collection_type::CollectionType};
#[derive(Clone, Debug)]
pub struct UserView {
pub id: Uuid,
pub name: String,
pub collection_type: CollectionType,
}
impl TryFrom<BaseItemDto> for UserView {
type Error = anyhow::Error;
fn try_from(value: BaseItemDto) -> std::result::Result<Self, Self::Error> {
if let (Some(id), Some(name)) = (value.id, value.name.clone()) {
return Ok(Self {
id,
name,
collection_type: value.collection_type.into(),
});
}
bail!("UserView was missing required properties: {value:#?}");
}
}
use crate::jellyfin_api::{
api_client::ApiClient,
models::{collection_type::CollectionType, user_view::UserView},
};
impl ApiClient {
pub async fn get_user_views(&self) -> Result<Vec<UserView>> {
let url = self
pub async fn get_user_views(
&self,
start_index: Option<usize>,
limit: Option<usize>,
) -> Result<(Vec<UserView>, usize)> {
let mut url = self
.root
.join(&format!("Users/{}/Views", self.account.id))
.unwrap();
let res: BaseItemDtoQueryResult = self.client.get(url).send().await?.json().await?;
let items = res.items.ok_or(anyhow::anyhow!("No items returned"))?;
items
.iter()
.map(|item| UserView::try_from(item.clone()))
.collect()
{
let mut query_pairs = url.query_pairs_mut();
if let Some(start_index) = start_index {
query_pairs.append_pair("StartIndex", &start_index.to_string());
}
if let Some(limit) = limit {
query_pairs.append_pair("Limit", &limit.to_string());
}
}
pub async fn get_view_items(
let res: BaseItemDtoQueryResult = self.client.get(url).send().await?.json().await?;
let items = res.items.context("No items returned")?;
let total_record_count = res
.total_record_count
.context("Total record count not returned")?;
let items: Result<Vec<UserView>, _> =
items.into_iter().map(|item| item.try_into()).collect();
Ok((items?, total_record_count as usize))
}
pub async fn get_collection_items(
&self,
view: &UserView,
collection: &BaseItemDto,
start_index: usize,
limit: usize,
) -> Result<(Vec<BaseItemDto>, usize)> {
let collection_type = CollectionType::from(collection.collection_type.clone());
let mut url = self
.root
.join(&format!("Users/{}/Items", self.account.id))
.unwrap();
url.query_pairs_mut()
.append_pair("ParentId", &view.id.to_string())
.append_pair("ParentId", &collection.id.unwrap().to_string())
.append_pair("SortBy", "SortName,ProductionYear")
.append_pair("SortOrder", "Ascending")
.append_pair("Recursive", "true")
.append_pair("StartIndex", &start_index.to_string())
.append_pair("Limit", &limit.to_string());
if let Some(item_type) = view.collection_type.item_type() {
if let Some(item_type) = collection_type.item_type() {
url.query_pairs_mut()
.append_pair("IncludeItemTypes", &item_type.to_string());
}

View File

@@ -1,2 +1,3 @@
pub mod collection_type;
pub mod display_preferences;
pub mod user_view;

View File

@@ -0,0 +1,61 @@
use anyhow::bail;
use jellyfin_api::types::BaseItemDto;
use uuid::Uuid;
use crate::tr;
use super::collection_type::CollectionType;
#[derive(Clone, Debug)]
pub struct UserView(BaseItemDto);
impl UserView {
pub fn id(&self) -> Uuid {
self.0.id.unwrap()
}
pub fn name(&self) -> String {
self.0
.name
.clone()
.unwrap_or(tr!("library-unnamed-collection").to_string())
}
pub fn collection_type(&self) -> CollectionType {
self.0.collection_type.clone().into()
}
}
impl TryFrom<BaseItemDto> for UserView {
type Error = anyhow::Error;
fn try_from(item: BaseItemDto) -> std::result::Result<Self, Self::Error> {
if item.id.is_none() {
bail!("Item was missing ID: {item:#?}");
}
Ok(Self(item))
}
}
impl From<UserView> for BaseItemDto {
fn from(val: UserView) -> Self {
val.0
}
}
pub trait FilterSupported {
fn filter_supported(self) -> Self;
}
impl FilterSupported for Vec<UserView> {
fn filter_supported(self) -> Self {
self.into_iter()
.filter(|view| {
matches!(
view.collection_type(),
CollectionType::Movies | CollectionType::TvShows
)
})
.collect()
}
}

View File

@@ -34,6 +34,20 @@ impl ApiClient {
Ok(url.to_string())
}
pub fn get_collection_thumbnail_url(&self, item: &BaseItemDto) -> Result<String> {
let item_id = match item.parent_backdrop_item_id.or(item.id) {
Some(item_id) => item_id,
None => bail!("Missing parent backdrop item ID"),
};
let mut url = self.root.join(&format!("Items/{item_id}/Images/Primary"))?;
url.query_pairs_mut()
.append_pair("fillWidth", "700")
.append_pair("quality", "100");
Ok(url.to_string())
}
pub fn get_parent_or_item_backdrop_url(&self, item: &BaseItemDto) -> Result<String> {
let item_id = match item.parent_backdrop_item_id.or(item.id) {
Some(item_id) => item_id,

View File

@@ -1,91 +1,151 @@
use std::sync::Arc;
use adw::prelude::*;
use anyhow::Result;
use gtk::prelude::*;
use jellyfin_api::types::BaseItemDto;
use relm4::prelude::*;
use crate::{
jellyfin_api::{api::views::UserView, api_client::ApiClient},
library::media_page::{MediaPage, MediaPageInput},
borgar::borgar_menu::{BorgarMenu, BorgarMenuAuth},
config::{Account, Server},
jellyfin_api::api_client::ApiClient,
library::{
media_page::{MediaPage, MediaPageInput},
media_tile::MediaTileDisplay,
},
tr,
utils::empty_component::EmptyComponent,
};
use super::{media_fetcher::Fetcher, media_page::MediaPageInit};
pub struct Collection {
media_page: Controller<MediaPage<ViewItemsFetcher, EmptyComponent>>,
initialized: bool,
api_client: Arc<ApiClient>,
collection: BaseItemDto,
borgar_menu: Controller<BorgarMenu>,
media_page: Controller<MediaPage<CollectionItemsFetcher, EmptyComponent>>,
}
#[derive(Debug)]
pub enum CollectionInput {
Visible,
Refresh,
}
#[relm4::component(pub)]
impl SimpleComponent for Collection {
type Init = (Arc<ApiClient>, UserView);
impl Component for Collection {
type Init = (Arc<ApiClient>, BaseItemDto, Server, Account);
type Input = CollectionInput;
type Output = ();
type CommandOutput = ();
view! {
gtk::Box {}
adw::NavigationPage {
#[name = "toolbar_view"]
#[wrap(Some)]
set_child = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {
pack_end = model.borgar_menu.widget(),
pack_end = &gtk::Button::from_icon_name("refresh") {
set_tooltip: tr!("library-collection-refresh-button"),
connect_clicked[sender] => move |_| {
sender.input(CollectionInput::Refresh);
},
},
},
#[wrap(Some)]
set_content = model.media_page.widget(),
},
set_title: &model.collection
.name
.as_ref()
.unwrap_or(tr!("library-unnamed-collection"))
.clone(),
}
}
fn init(
init: Self::Init,
root: &Self::Root,
_sender: ComponentSender<Self>,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let (api_client, view) = init;
let fetcher = ViewItemsFetcher {
api_client: api_client.clone(),
view,
};
let (api_client, collection, server, account) = init;
let model = Collection {
media_page: MediaPage::builder()
.launch(MediaPageInit {
api_client,
fetcher,
empty_component: None,
})
api_client: api_client.clone(),
collection: collection.clone(),
borgar_menu: BorgarMenu::builder()
.launch(Some(BorgarMenuAuth {
api_client: api_client.clone(),
server,
account,
}))
.detach(),
initialized: false,
media_page: new_media_page(&api_client, collection),
};
root.append(model.media_page.widget());
model.media_page.emit(MediaPageInput::NextPage);
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>) {
fn update_with_view(
&mut self,
widgets: &mut Self::Widgets,
message: Self::Input,
sender: ComponentSender<Self>,
_root: &Self::Root,
) {
match message {
CollectionInput::Visible if !self.initialized => {
self.initialized = true;
self.media_page.emit(MediaPageInput::NextPage);
CollectionInput::Refresh => {
let toolbar_view = &widgets.toolbar_view;
let media_page = new_media_page(&self.api_client, self.collection.clone());
toolbar_view.set_content(Some(media_page.widget()));
media_page.emit(MediaPageInput::NextPage);
self.media_page = media_page;
}
_ => {}
}
self.update_view(widgets, sender);
}
}
struct ViewItemsFetcher {
fn new_media_page(
api_client: &Arc<ApiClient>,
collection: BaseItemDto,
) -> Controller<MediaPage<CollectionItemsFetcher, EmptyComponent>> {
let fetcher = CollectionItemsFetcher {
api_client: api_client.clone(),
collection,
};
MediaPage::builder()
.launch(MediaPageInit {
api_client: api_client.clone(),
fetcher,
empty_component: None,
media_tile_display: Some(MediaTileDisplay::CoverLarge),
})
.detach()
}
struct CollectionItemsFetcher {
api_client: Arc<ApiClient>,
view: UserView,
collection: BaseItemDto,
}
impl Fetcher for ViewItemsFetcher {
impl Fetcher for CollectionItemsFetcher {
async fn fetch(&self, start_index: usize, limit: usize) -> Result<(Vec<BaseItemDto>, usize)> {
self.api_client
.get_view_items(&self.view, start_index, limit)
.get_collection_items(&self.collection, start_index, limit)
.await
}
fn title(&self) -> String {
self.view.name.clone()
self.collection
.name
.as_ref()
.unwrap_or(tr!("library-unnamed-collection"))
.clone()
}
}

View File

@@ -0,0 +1,85 @@
use std::sync::Arc;
use anyhow::Result;
use gtk::prelude::*;
use jellyfin_api::types::BaseItemDto;
use relm4::prelude::*;
use crate::{
jellyfin_api::{api_client::ApiClient, models::user_view::FilterSupported},
library::{
media_page::{MediaPageInit, MediaPageInput},
media_tile::MediaTileDisplay,
},
utils::empty_component::EmptyComponent,
};
use super::{media_fetcher::Fetcher, media_page::MediaPage};
pub struct Collections {
media_page: Controller<MediaPage<CollectionsFetcher, EmptyComponent>>,
}
#[relm4::component(pub)]
impl SimpleComponent for Collections {
type Init = Arc<ApiClient>;
type Input = ();
type Output = ();
view! {
gtk::Box {
append = model.media_page.widget(),
}
}
fn init(
api_client: Self::Init,
root: &Self::Root,
_sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let fetcher = CollectionsFetcher {
api_client: api_client.clone(),
};
let model = Collections {
media_page: MediaPage::builder()
.launch(MediaPageInit {
api_client,
fetcher,
empty_component: None,
media_tile_display: Some(MediaTileDisplay::CollectionWide),
})
.detach(),
};
model.media_page.emit(MediaPageInput::NextPage);
let widgets = view_output!();
ComponentParts { model, widgets }
}
}
struct CollectionsFetcher {
api_client: Arc<ApiClient>,
}
impl Fetcher for CollectionsFetcher {
async fn fetch(&self, start_index: usize, limit: usize) -> Result<(Vec<BaseItemDto>, usize)> {
let (collections, total) = self
.api_client
.get_user_views(Some(start_index), Some(limit))
.await?;
let collections = collections
.filter_supported()
.into_iter()
.map(|view| view.into())
.collect();
// TODO: numbering will be off
Ok((collections, total))
}
fn title(&self) -> String {
"Collections".into()
}
}

View File

@@ -5,12 +5,13 @@ use relm4::{
gtk, prelude::*, Component, ComponentParts, ComponentSender, Controller, SimpleComponent,
};
use crate::jellyfin_api::api::views::UserView;
use crate::jellyfin_api::api_client::ApiClient;
use crate::jellyfin_api::models::display_preferences::{DisplayPreferences, HomeSection};
use crate::jellyfin_api::models::user_view::UserView;
use super::home_sections::continue_watching::HomeSectionContinueWatching;
use super::home_sections::latest::HomeSectionLatest;
use super::home_sections::my_media::{HomeSectionMyMedia, HomeSectionMyMediaInit};
use super::home_sections::next_up::HomeSectionNextUp;
use super::library_container::LibraryContainer;
@@ -18,18 +19,13 @@ enum HomeSectionController {
ContinueWatching(Controller<HomeSectionContinueWatching>),
Latest(Controller<HomeSectionLatest>),
NextUp(Controller<HomeSectionNextUp>),
MyMedia(Controller<HomeSectionMyMedia>),
}
pub struct Home {
_sections: Vec<HomeSectionController>,
sections: Vec<HomeSectionController>,
}
#[derive(Debug)]
pub enum HomeInput {}
#[derive(Debug)]
pub enum HomeOutput {}
pub struct HomeInit {
pub api_client: Arc<ApiClient>,
pub display_preferences: DisplayPreferences,
@@ -38,9 +34,9 @@ pub struct HomeInit {
#[relm4::component(pub)]
impl SimpleComponent for Home {
type Input = HomeInput;
type Output = HomeOutput;
type Init = HomeInit;
type Input = ();
type Output = ();
view! {
gtk::ScrolledWindow {
@@ -63,7 +59,7 @@ impl SimpleComponent for Home {
root: &Self::Root,
_sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let mut model = Home { _sections: vec![] };
let mut model = Home { sections: vec![] };
let widgets = view_output!();
let sections_container = &widgets.sections_container;
@@ -94,7 +90,7 @@ impl Home {
.launch(api_client.clone())
.detach();
sections_container.append(section.widget());
self._sections
self.sections
.push(HomeSectionController::ContinueWatching(section));
}
HomeSection::LatestMedia => {
@@ -102,14 +98,25 @@ impl Home {
.launch((api_client.clone(), user_views.clone()))
.detach();
sections_container.append(section.widget());
self._sections.push(HomeSectionController::Latest(section));
self.sections.push(HomeSectionController::Latest(section));
}
HomeSection::NextUp => {
let section = HomeSectionNextUp::builder()
.launch(api_client.clone())
.detach();
sections_container.append(section.widget());
self._sections.push(HomeSectionController::NextUp(section));
self.sections.push(HomeSectionController::NextUp(section));
}
HomeSection::MyMedia | HomeSection::MyMediaSmall => {
let section = HomeSectionMyMedia::builder()
.launch(HomeSectionMyMediaInit {
api_client: api_client.clone(),
user_views: user_views.clone(),
small: matches!(section, HomeSection::MyMediaSmall),
})
.detach();
sections_container.append(section.widget());
self.sections.push(HomeSectionController::MyMedia(section));
}
_ => {}
}

View File

@@ -10,7 +10,11 @@ use uuid::Uuid;
use crate::{
jellyfin_api::{
api::views::UserView, api_client::ApiClient, models::collection_type::CollectionType,
api_client::ApiClient,
models::{
collection_type::CollectionType,
user_view::{FilterSupported, UserView},
},
},
library::media_list::{
MediaList, MediaListInit, MediaListOutput, MediaListType, MediaListTypeLatestParams,
@@ -56,22 +60,14 @@ impl Component for HomeSectionLatest {
let widgets = view_output!();
let user_views: Vec<&UserView> = user_views
.iter()
.filter(|view| {
matches!(
view.collection_type,
CollectionType::Movies | CollectionType::TvShows
)
})
.collect();
let user_views = user_views.filter_supported();
for view in user_views {
let row = LatestRow::builder()
.launch((api_client.clone(), view.clone()))
.forward(sender.input_sender(), identity);
root.append(row.widget());
model.rows.insert(view.id.to_string(), row);
model.rows.insert(view.id().to_string(), row);
}
ComponentParts { model, widgets }
@@ -124,7 +120,7 @@ impl SimpleComponent for LatestRow {
let widgets = view_output!();
let title_text = match view.collection_type {
let title_text = match view.collection_type() {
CollectionType::Movies => tr!("library-section-title.latest-movies").to_string(),
CollectionType::TvShows => tr!("library-section-title.latest-shows").to_string(),
CollectionType::Music => tr!("library-section-title.latest-music").to_string(),
@@ -137,7 +133,7 @@ impl SimpleComponent for LatestRow {
let media_list = MediaList::builder()
.launch(MediaListInit {
api_client,
list_type: MediaListType::Latest(MediaListTypeLatestParams { view_id: view.id }),
list_type: MediaListType::Latest(MediaListTypeLatestParams { view_id: view.id() }),
label: title_text.to_string(),
})
.forward(sender.input_sender(), |o| o.into());

View File

@@ -1,3 +1,4 @@
pub mod continue_watching;
pub mod latest;
pub mod my_media;
pub mod next_up;

View File

@@ -0,0 +1,74 @@
use std::sync::Arc;
use gtk::prelude::*;
use relm4::prelude::*;
use crate::{
jellyfin_api::{
api_client::ApiClient,
models::user_view::{FilterSupported, UserView},
},
library::media_list::{MediaList, MediaListInit, MediaListOutput, MediaListType},
tr,
};
pub struct HomeSectionMyMedia {
media_list: AsyncController<MediaList>,
}
pub struct HomeSectionMyMediaInit {
pub api_client: Arc<ApiClient>,
pub user_views: Vec<UserView>,
pub small: bool,
}
#[relm4::component(pub)]
impl Component for HomeSectionMyMedia {
type Init = HomeSectionMyMediaInit;
type Input = MediaListOutput;
type Output = ();
type CommandOutput = ();
view! {
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 16,
model.media_list.widget(),
}
}
fn init(
init: Self::Init,
root: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let HomeSectionMyMediaInit {
api_client,
user_views,
small,
} = init;
let user_views = user_views.filter_supported();
let media_list = MediaList::builder()
.launch(MediaListInit {
api_client,
list_type: MediaListType::MyMedia { user_views, small },
label: tr!("library-section-title.my-media").to_string(),
})
.forward(sender.input_sender(), |m| m);
let model = HomeSectionMyMedia { media_list };
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>, root: &Self::Root) {
match message {
MediaListOutput::Empty(_) => root.set_visible(false),
}
}
}

View File

@@ -0,0 +1,53 @@
use gtk::prelude::*;
use jellyfin_api::types::BaseItemDto;
use relm4::prelude::*;
use crate::{
app::{AppInput, APP_BROKER},
jellyfin_api::models::collection_type::CollectionType,
tr,
};
use super::media_tile::MediaTileDisplay;
pub struct MediaButton;
#[relm4::component(pub)]
impl SimpleComponent for MediaButton {
type Init = (BaseItemDto, MediaTileDisplay);
type Input = ();
type Output = ();
view! {
gtk::Button {
add_css_class: "pill",
set_width_request: display.width(),
set_margin_bottom: 10,
#[wrap(Some)]
set_child = &adw::ButtonContent {
set_label: media.name.as_ref().unwrap_or(tr!("library-unnamed-collection")),
set_icon_name: &collection_type.icon(),
set_halign: gtk::Align::Center,
},
connect_clicked[media] => move |_| {
APP_BROKER.send(AppInput::ShowCollection(media.clone()));
},
}
}
fn init(
init: Self::Init,
root: &Self::Root,
_sender: relm4::ComponentSender<Self>,
) -> relm4::ComponentParts<Self> {
let (media, display) = init;
let collection_type: CollectionType = media.clone().collection_type.into();
let model = MediaButton;
let widgets = view_output!();
ComponentParts { model, widgets }
}
}

View File

@@ -7,19 +7,42 @@ use relm4::{prelude::*, ComponentParts};
use crate::{globals::SHIFT_STATE, jellyfin_api::api_client::ApiClient};
use super::media_tile::{MediaTile, MediaTileDisplay};
use super::{
media_button::MediaButton,
media_tile::{MediaTile, MediaTileDisplay},
};
const MIN_PADDING: i32 = 24;
pub(crate) enum MediaCarouselItem {
Tile(AsyncController<MediaTile>),
Button(Controller<MediaButton>),
}
impl MediaCarouselItem {
fn widget(&self) -> &gtk::Widget {
match self {
MediaCarouselItem::Tile(media_tile) => media_tile.widget().upcast_ref(),
MediaCarouselItem::Button(media_button) => media_button.widget().upcast_ref(),
}
}
}
pub(crate) enum MediaCarouselType {
Tiles,
Buttons,
}
pub(crate) struct MediaCarousel {
media_tile_display: MediaTileDisplay,
media_tiles: Vec<AsyncController<MediaTile>>,
media_tiles: Vec<MediaCarouselItem>,
pages: Vec<gtk::Box>,
}
pub(crate) struct MediaCarouselInit {
pub(crate) media: Vec<BaseItemDto>,
pub(crate) media_tile_display: MediaTileDisplay,
pub(crate) carousel_type: MediaCarouselType,
pub(crate) api_client: Arc<ApiClient>,
pub(crate) label: String,
}
@@ -142,15 +165,23 @@ impl Component for MediaCarousel {
api_client,
media,
media_tile_display,
carousel_type,
label,
} = init;
let media_tiles: Vec<AsyncController<MediaTile>> = media
let media_tiles = media
.iter()
.map(|media| {
.map(|media| match carousel_type {
MediaCarouselType::Tiles => MediaCarouselItem::Tile(
MediaTile::builder()
.launch((media.clone(), media_tile_display, api_client.clone()))
.detach()
.detach(),
),
MediaCarouselType::Buttons => MediaCarouselItem::Button(
MediaButton::builder()
.launch((media.clone(), media_tile_display))
.detach(),
),
})
.collect();
@@ -192,7 +223,7 @@ impl Component for MediaCarousel {
}
self.pages.clear();
let media_tile_chunks: Vec<&[AsyncController<MediaTile>]> =
let media_tile_chunks: Vec<&[MediaCarouselItem]> =
self.media_tiles.chunks(tiles_per_page as usize).collect();
for chunk in media_tile_chunks {

View File

@@ -8,10 +8,12 @@ use relm4::{
};
use uuid::Uuid;
use crate::jellyfin_api::{api::latest::GetNextUpOptions, api_client::ApiClient};
use crate::jellyfin_api::{
api::latest::GetNextUpOptions, api_client::ApiClient, models::user_view::UserView,
};
use super::{
media_carousel::{MediaCarousel, MediaCarouselInit},
media_carousel::{MediaCarousel, MediaCarouselInit, MediaCarouselType},
media_tile::MediaTileDisplay,
};
@@ -20,11 +22,15 @@ enum MediaListContents {
Carousel(Controller<MediaCarousel>),
}
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub enum MediaListType {
ContinueWatching,
Latest(MediaListTypeLatestParams),
NextUp,
MyMedia {
user_views: Vec<UserView>,
small: bool,
},
}
#[derive(Clone, Copy, Debug)]
@@ -78,7 +84,7 @@ impl MediaList {
sender: &AsyncComponentSender<Self>,
) -> Self {
let api_client = Arc::clone(&init.api_client);
let list_type = init.list_type;
let list_type = &init.list_type;
let label = init.label.clone();
let media = match list_type {
@@ -94,16 +100,32 @@ impl MediaList {
.get_next_up(GetNextUpOptions::default())
.await
.expect("Error getting continue watching."),
MediaListType::MyMedia {
user_views,
small: _,
} => user_views
.clone()
.into_iter()
.map(|view| view.into())
.collect(),
};
if media.is_empty() {
sender
.output(MediaListOutput::Empty(get_view_id(&list_type)))
.output(MediaListOutput::Empty(get_view_id(list_type)))
.unwrap();
}
let media_tile_display = match list_type {
MediaListType::ContinueWatching | MediaListType::NextUp => MediaTileDisplay::Wide,
MediaListType::Latest(_) => MediaTileDisplay::Cover,
MediaListType::MyMedia { small, .. } if *small => MediaTileDisplay::Buttons,
MediaListType::MyMedia { .. } => MediaTileDisplay::CollectionWide,
};
let carousel_type = match list_type {
MediaListType::MyMedia { small, .. } if *small => MediaCarouselType::Buttons,
_ => MediaCarouselType::Tiles,
};
let contents = {
@@ -111,6 +133,7 @@ impl MediaList {
.launch(MediaCarouselInit {
media,
media_tile_display,
carousel_type,
api_client,
label,
})
@@ -127,7 +150,9 @@ impl MediaList {
fn get_view_id(list_type: &MediaListType) -> Option<Uuid> {
match list_type {
MediaListType::ContinueWatching | MediaListType::NextUp => None,
MediaListType::ContinueWatching | MediaListType::NextUp | MediaListType::MyMedia { .. } => {
None
}
MediaListType::Latest(params) => Some(params.view_id),
}
}

View File

@@ -25,6 +25,7 @@ where
fetcher: MediaFetcher<F>,
empty_component: Option<Controller<EmptyComponent>>,
media_grid: Option<Controller<MediaGrid>>,
media_tile_display: MediaTileDisplay,
state: FetcherState,
count: Option<FetcherCount>,
}
@@ -33,6 +34,7 @@ pub struct MediaPageInit<F, C: Component> {
pub api_client: Arc<ApiClient>,
pub fetcher: F,
pub empty_component: Option<Controller<C>>,
pub media_tile_display: Option<MediaTileDisplay>,
}
#[derive(Debug)]
@@ -167,6 +169,7 @@ where
api_client,
fetcher,
empty_component,
media_tile_display,
} = init;
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -187,6 +190,7 @@ where
fetcher,
empty_component,
media_grid: None,
media_tile_display: media_tile_display.unwrap_or(MediaTileDisplay::Wide),
state: FetcherState::Empty,
count: None,
};
@@ -221,7 +225,7 @@ where
let media_grid = MediaGrid::builder()
.launch(MediaGridInit {
media: display.items.clone(),
media_tile_display: MediaTileDisplay::CoverLarge,
media_tile_display: self.media_tile_display,
api_client: self.api_client.clone(),
})
.detach();

View File

@@ -1,7 +1,7 @@
use std::{collections::VecDeque, sync::Arc};
use gdk::Texture;
use jellyfin_api::types::BaseItemDto;
use jellyfin_api::types::{BaseItemDto, BaseItemKind};
use relm4::{
gtk::{self, gdk, gdk_pixbuf, glib::markup_escape_text, prelude::*},
prelude::{AsyncComponent, AsyncComponentParts},
@@ -15,13 +15,15 @@ use crate::{
utils::{item_name::ItemName, playable::get_next_playable_media},
};
use super::LIBRARY_BROKER;
use super::{LibraryInput, LIBRARY_BROKER};
#[derive(Clone, Copy)]
pub enum MediaTileDisplay {
Cover,
CoverLarge,
Wide,
CollectionWide,
Buttons,
}
impl MediaTileDisplay {
@@ -30,6 +32,8 @@ impl MediaTileDisplay {
Self::Cover => 133,
Self::CoverLarge => 175,
Self::Wide => 300,
Self::CollectionWide => 300,
Self::Buttons => 300,
}
}
@@ -38,6 +42,8 @@ impl MediaTileDisplay {
Self::Cover => 200,
Self::CoverLarge => 262,
Self::Wide => 175,
Self::CollectionWide => 175,
Self::Buttons => 0,
}
}
}
@@ -67,6 +73,7 @@ pub struct MediaTile {
#[derive(Debug)]
pub enum MediaTileInput {
Play,
ShowDetails,
}
#[derive(Debug)]
@@ -107,8 +114,10 @@ impl AsyncComponent for MediaTile {
},
add_controller = gtk::EventControllerMotion {
connect_enter[root] => move |_, _, _| {
connect_enter[root, media] => move |_, _, _| {
if !matches!(media.type_, Some(BaseItemKind::CollectionFolder)) {
root.add_css_class("hover");
}
},
connect_leave[root] => move |_| {
root.remove_css_class("hover");
@@ -172,8 +181,8 @@ impl AsyncComponent for MediaTile {
set_markup: &get_item_label(&model.media),
add_controller = gtk::GestureClick {
connect_pressed => move |_, _, _, _| {
APP_BROKER.send(AppInput::ShowDetails(media.clone()));
connect_pressed[sender] => move |_, _, _, _| {
sender.input(MediaTileInput::ShowDetails);
},
},
},
@@ -214,14 +223,33 @@ impl AsyncComponent for MediaTile {
) {
match message {
MediaTileInput::Play => {
match get_next_playable_media(self.api_client.clone(), self.media.clone()).await {
match self.media.type_ {
Some(BaseItemKind::CollectionFolder) => {
APP_BROKER.send(AppInput::ShowCollection(self.media.clone()));
}
_ => {
match get_next_playable_media(self.api_client.clone(), self.media.clone())
.await
{
Some(media) => APP_BROKER.send(AppInput::PlayVideo(media)),
_ => {
let mut message = "No playable media found".to_string();
if let Some(name) = self.media.name.as_ref() {
message += &format!(" for {name}");
}
LIBRARY_BROKER.send(super::LibraryInput::Toast(message))
LIBRARY_BROKER.send(LibraryInput::Toast(message))
}
};
}
};
}
MediaTileInput::ShowDetails => {
match self.media.type_ {
Some(BaseItemKind::CollectionFolder) => {
APP_BROKER.send(AppInput::ShowCollection(self.media.clone()));
}
_ => {
APP_BROKER.send(AppInput::ShowDetails(self.media.clone()));
}
};
}
@@ -252,6 +280,9 @@ async fn get_thumbnail(
MediaTileDisplay::Cover | MediaTileDisplay::CoverLarge => {
api_client.get_parent_or_item_thumbnail_url(media)
}
MediaTileDisplay::CollectionWide | MediaTileDisplay::Buttons => {
api_client.get_collection_thumbnail_url(media)
}
};
let img_url = match img_url {
Ok(img_url) => img_url,
@@ -274,7 +305,7 @@ async fn get_thumbnail(
let resized = gdk_pixbuf::Pixbuf::new(
gdk_pixbuf::Colorspace::Rgb,
false,
true,
8,
tile_display.width(),
tile_display.height(),

View File

@@ -1,7 +1,9 @@
mod collection;
pub mod collection;
pub mod collections;
mod home;
mod home_sections;
mod library_container;
mod media_button;
mod media_carousel;
mod media_fetcher;
mod media_grid;
@@ -16,11 +18,7 @@ use relm4::{
binding::BoolBinding,
ComponentController, RelmObjectExt, SharedState,
};
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
use uuid::Uuid;
use std::sync::{Arc, RwLock};
use adw::prelude::*;
use gtk::glib;
@@ -31,9 +29,8 @@ use crate::{
borgar::borgar_menu::{BorgarMenu, BorgarMenuAuth},
config::{Account, Server},
jellyfin_api::{
api::views::UserView,
api_client::ApiClient,
models::{collection_type::CollectionType, display_preferences::DisplayPreferences},
models::{display_preferences::DisplayPreferences, user_view::UserView},
},
media_details::MEDIA_DETAILS_REFRESH_QUEUED,
tr,
@@ -44,7 +41,7 @@ use crate::{
};
use self::{
collection::{Collection, CollectionInput},
collections::Collections,
home::{Home, HomeInit},
search::{
search_bar::SearchBar,
@@ -69,7 +66,7 @@ pub struct Library {
state: LibraryState,
search_results: Controller<SearchResults>,
home: Option<Controller<Home>>,
collections: HashMap<Uuid, Controller<Collection>>,
collections: Option<Controller<Collections>>,
searching: BoolBinding,
// Store previous view stack child so we can go back from search
previous_stack_child: Arc<RwLock<String>>,
@@ -78,7 +75,6 @@ pub struct Library {
#[derive(Debug)]
pub enum LibraryInput {
SetLibraryState(LibraryState),
MediaSelected(BaseItemDto),
Refresh,
Shown,
ViewStackChildVisible(String),
@@ -291,7 +287,7 @@ impl Component for Library {
state: LibraryState::Loading,
search_results: SearchResults::builder().launch(api_client).detach(),
home: None,
collections: HashMap::default(),
collections: None,
searching: BoolBinding::default(),
previous_stack_child: Arc::new(RwLock::new("home".into())),
};
@@ -363,11 +359,6 @@ impl Component for Library {
}
};
}
LibraryInput::MediaSelected(media) => {
sender
.output(LibraryOutput::PlayVideo(Box::new(media)))
.unwrap();
}
LibraryInput::Refresh => {
let view_stack = &widgets.view_stack;
@@ -378,8 +369,8 @@ impl Component for Library {
if let Some(home) = self.home.take() {
view_stack.remove(home.widget());
}
for (_id, collection) in self.collections.drain() {
view_stack.remove(collection.widget());
if let Some(collections) = self.collections.take() {
view_stack.remove(collections.widget());
}
self.initial_fetch(&sender);
@@ -392,12 +383,6 @@ impl Component for Library {
*LIBRARY_REFRESH_QUEUED.write() = false;
}
LibraryInput::ViewStackChildVisible(name) => {
if let Ok(id) = Uuid::parse_str(&name) {
if let Some(collection) = self.collections.get(&id) {
collection.emit(CollectionInput::Visible);
}
}
if name != "search" {
self.searching.set_value(false);
}
@@ -462,15 +447,18 @@ impl Library {
}
}
match tokio::try_join!(async { api_client.get_user_views().await }, async {
match tokio::try_join!(
async { api_client.get_user_views(None, None).await },
async {
api_client
// We might eventually want client-specific settings, but for
// now use the Jellyfin ("emby") client settings
.get_user_display_preferences("emby")
.await
}) {
}
) {
Ok((user_views, display_preferences)) => {
LibraryCommandOutput::LibraryLoaded(user_views, display_preferences)
LibraryCommandOutput::LibraryLoaded(user_views.0, display_preferences)
}
Err(err) => {
println!("Error loading library: {err}");
@@ -506,34 +494,20 @@ impl Library {
);
self.home = Some(home);
let user_views: Vec<&UserView> = user_views
.iter()
.filter(|view| {
matches!(
view.collection_type,
CollectionType::Movies | CollectionType::TvShows
)
})
.collect();
view_stack.set_visible_child_name("home");
// TODO: handle overflow when user has too many collections
// For now we limit them to 5, user can change order in Jellyfin settings
for &view in user_views.iter().take(5) {
let collection = Collection::builder()
.launch((self.api_client.clone(), view.clone()))
let collections = Collections::builder()
.launch(self.api_client.clone())
.detach();
view_stack.add_titled_with_icon(
collection.widget(),
Some(&view.id.to_string()),
&view.name.clone(),
&view.collection_type.icon(),
collections.widget(),
Some("collections"),
"Collections",
"library",
);
self.collections.insert(view.id, collection);
}
view_stack.set_visible_child_name("home");
self.collections = Some(collections);
}
}

View File

@@ -11,6 +11,7 @@ use crate::{
library::{
media_fetcher::Fetcher,
media_page::{MediaPage, MediaPageInit, MediaPageInput},
media_tile::MediaTileDisplay,
},
tr,
};
@@ -78,6 +79,7 @@ impl Component for SearchResults {
.launch(self.api_client.clone())
.detach(),
),
media_tile_display: Some(MediaTileDisplay::CoverLarge),
})
.detach();

View File

@@ -15,6 +15,7 @@ library-section-title =
.latest-movies = Latest Movies
.latest-shows = Latest Shows
.latest-music = Latest Music
.my-media = My Media
library-episode-name-with-season-and-episode = S{ $seasonNumber }:E{ $episodeNumber } - { $episodeName }
library-series-and-episode-name = { $seriesName } - { $episodeName }
library-media-tile-unnamed-item = Unnamed Item
@@ -23,3 +24,5 @@ library-search-empty =
.title = Start typing to search
.description = Or, try one of these suggestions:
library-search-title = Results for “{ $searchText }”
library-unnamed-collection = Unnamed Collection
library-collection-refresh-button = Refresh collection