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", "warning",
"folder-filled", "folder-filled",
"loupe", "loupe",
"library",
] } ] }
reqwest = { version = "0.11.20", features = ["json"] } reqwest = { version = "0.11.20", features = ["json"] }
serde = { version = "1.0.188", features = ["derive"] } serde = { version = "1.0.188", features = ["derive"] }

View File

@@ -16,7 +16,7 @@ use crate::{
config, config,
globals::CONFIG, globals::CONFIG,
jellyfin_api::api_client::ApiClient, jellyfin_api::api_client::ApiClient,
library::{Library, LibraryOutput, LIBRARY_BROKER}, library::{collection::Collection, Library, LibraryOutput, LIBRARY_BROKER},
locales::tera_tr, locales::tera_tr,
media_details::MediaDetails, media_details::MediaDetails,
meson_config::APP_ID, meson_config::APP_ID,
@@ -60,6 +60,7 @@ pub struct App {
account_list: Controller<AccountList>, account_list: Controller<AccountList>,
library: Option<Controller<Library>>, library: Option<Controller<Library>>,
media_details: Option<Controller<MediaDetails>>, media_details: Option<Controller<MediaDetails>>,
collection: Option<Controller<Collection>>,
video_player: OnceCell<Controller<VideoPlayer>>, video_player: OnceCell<Controller<VideoPlayer>>,
server: Option<config::Server>, server: Option<config::Server>,
account: Option<config::Account>, account: Option<config::Account>,
@@ -73,6 +74,7 @@ pub enum AppInput {
ServerSelected(config::Server), ServerSelected(config::Server),
AccountSelected(config::Server, config::Account), AccountSelected(config::Server, config::Account),
ShowDetails(BaseItemDto), ShowDetails(BaseItemDto),
ShowCollection(BaseItemDto),
PlayVideo(BaseItemDto), PlayVideo(BaseItemDto),
SignOut, SignOut,
SetThemeDark(bool), SetThemeDark(bool),
@@ -162,6 +164,7 @@ impl Component for App {
account_list, account_list,
library: None, library: None,
media_details: None, media_details: None,
collection: None,
video_player: OnceCell::new(), video_player: OnceCell::new(),
server: None, server: None,
account: None, account: None,
@@ -228,6 +231,22 @@ impl Component for App {
self.media_details = Some(media_details); 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) => { AppInput::PlayVideo(item) => {
if self.video_player.get().is_none() { if self.video_player.get().is_none() {
let video_player = VideoPlayer::builder() 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 jellyfin_api::types::{BaseItemDto, BaseItemDtoQueryResult};
use uuid::Uuid;
use crate::jellyfin_api::{api_client::ApiClient, models::collection_type::CollectionType}; use crate::jellyfin_api::{
api_client::ApiClient,
#[derive(Clone, Debug)] models::{collection_type::CollectionType, user_view::UserView},
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:#?}");
}
}
impl ApiClient { impl ApiClient {
pub async fn get_user_views(&self) -> Result<Vec<UserView>> { pub async fn get_user_views(
let url = self &self,
start_index: Option<usize>,
limit: Option<usize>,
) -> Result<(Vec<UserView>, usize)> {
let mut url = self
.root .root
.join(&format!("Users/{}/Views", self.account.id)) .join(&format!("Users/{}/Views", self.account.id))
.unwrap(); .unwrap();
let res: BaseItemDtoQueryResult = self.client.get(url).send().await?.json().await?;
let items = res.items.ok_or(anyhow::anyhow!("No items returned"))?; {
items let mut query_pairs = url.query_pairs_mut();
.iter() if let Some(start_index) = start_index {
.map(|item| UserView::try_from(item.clone())) query_pairs.append_pair("StartIndex", &start_index.to_string());
.collect() }
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, &self,
view: &UserView, collection: &BaseItemDto,
start_index: usize, start_index: usize,
limit: usize, limit: usize,
) -> Result<(Vec<BaseItemDto>, usize)> { ) -> Result<(Vec<BaseItemDto>, usize)> {
let collection_type = CollectionType::from(collection.collection_type.clone());
let mut url = self let mut url = self
.root .root
.join(&format!("Users/{}/Items", self.account.id)) .join(&format!("Users/{}/Items", self.account.id))
.unwrap(); .unwrap();
url.query_pairs_mut() 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("SortBy", "SortName,ProductionYear")
.append_pair("SortOrder", "Ascending") .append_pair("SortOrder", "Ascending")
.append_pair("Recursive", "true") .append_pair("Recursive", "true")
.append_pair("StartIndex", &start_index.to_string()) .append_pair("StartIndex", &start_index.to_string())
.append_pair("Limit", &limit.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() url.query_pairs_mut()
.append_pair("IncludeItemTypes", &item_type.to_string()); .append_pair("IncludeItemTypes", &item_type.to_string());
} }

View File

@@ -1,2 +1,3 @@
pub mod collection_type; pub mod collection_type;
pub mod display_preferences; 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()) 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> { 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) { let item_id = match item.parent_backdrop_item_id.or(item.id) {
Some(item_id) => item_id, Some(item_id) => item_id,

View File

@@ -1,91 +1,151 @@
use std::sync::Arc; use std::sync::Arc;
use adw::prelude::*;
use anyhow::Result; use anyhow::Result;
use gtk::prelude::*;
use jellyfin_api::types::BaseItemDto; use jellyfin_api::types::BaseItemDto;
use relm4::prelude::*; use relm4::prelude::*;
use crate::{ use crate::{
jellyfin_api::{api::views::UserView, api_client::ApiClient}, borgar::borgar_menu::{BorgarMenu, BorgarMenuAuth},
library::media_page::{MediaPage, MediaPageInput}, config::{Account, Server},
jellyfin_api::api_client::ApiClient,
library::{
media_page::{MediaPage, MediaPageInput},
media_tile::MediaTileDisplay,
},
tr,
utils::empty_component::EmptyComponent, utils::empty_component::EmptyComponent,
}; };
use super::{media_fetcher::Fetcher, media_page::MediaPageInit}; use super::{media_fetcher::Fetcher, media_page::MediaPageInit};
pub struct Collection { pub struct Collection {
media_page: Controller<MediaPage<ViewItemsFetcher, EmptyComponent>>, api_client: Arc<ApiClient>,
initialized: bool, collection: BaseItemDto,
borgar_menu: Controller<BorgarMenu>,
media_page: Controller<MediaPage<CollectionItemsFetcher, EmptyComponent>>,
} }
#[derive(Debug)] #[derive(Debug)]
pub enum CollectionInput { pub enum CollectionInput {
Visible, Refresh,
} }
#[relm4::component(pub)] #[relm4::component(pub)]
impl SimpleComponent for Collection { impl Component for Collection {
type Init = (Arc<ApiClient>, UserView); type Init = (Arc<ApiClient>, BaseItemDto, Server, Account);
type Input = CollectionInput; type Input = CollectionInput;
type Output = (); type Output = ();
type CommandOutput = ();
view! { 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( fn init(
init: Self::Init, init: Self::Init,
root: &Self::Root, root: &Self::Root,
_sender: ComponentSender<Self>, sender: ComponentSender<Self>,
) -> ComponentParts<Self> { ) -> ComponentParts<Self> {
let (api_client, view) = init; let (api_client, collection, server, account) = init;
let fetcher = ViewItemsFetcher {
api_client: api_client.clone(),
view,
};
let model = Collection { let model = Collection {
media_page: MediaPage::builder() api_client: api_client.clone(),
.launch(MediaPageInit { collection: collection.clone(),
api_client, borgar_menu: BorgarMenu::builder()
fetcher, .launch(Some(BorgarMenuAuth {
empty_component: None, api_client: api_client.clone(),
}) server,
account,
}))
.detach(), .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!(); let widgets = view_output!();
ComponentParts { model, widgets } ComponentParts { model, widgets }
} }
fn update_with_view(
fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>) { &mut self,
widgets: &mut Self::Widgets,
message: Self::Input,
sender: ComponentSender<Self>,
_root: &Self::Root,
) {
match message { match message {
CollectionInput::Visible if !self.initialized => { CollectionInput::Refresh => {
self.initialized = true; let toolbar_view = &widgets.toolbar_view;
self.media_page.emit(MediaPageInput::NextPage); 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>, 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)> { async fn fetch(&self, start_index: usize, limit: usize) -> Result<(Vec<BaseItemDto>, usize)> {
self.api_client self.api_client
.get_view_items(&self.view, start_index, limit) .get_collection_items(&self.collection, start_index, limit)
.await .await
} }
fn title(&self) -> String { 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, 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::api_client::ApiClient;
use crate::jellyfin_api::models::display_preferences::{DisplayPreferences, HomeSection}; 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::continue_watching::HomeSectionContinueWatching;
use super::home_sections::latest::HomeSectionLatest; use super::home_sections::latest::HomeSectionLatest;
use super::home_sections::my_media::{HomeSectionMyMedia, HomeSectionMyMediaInit};
use super::home_sections::next_up::HomeSectionNextUp; use super::home_sections::next_up::HomeSectionNextUp;
use super::library_container::LibraryContainer; use super::library_container::LibraryContainer;
@@ -18,18 +19,13 @@ enum HomeSectionController {
ContinueWatching(Controller<HomeSectionContinueWatching>), ContinueWatching(Controller<HomeSectionContinueWatching>),
Latest(Controller<HomeSectionLatest>), Latest(Controller<HomeSectionLatest>),
NextUp(Controller<HomeSectionNextUp>), NextUp(Controller<HomeSectionNextUp>),
MyMedia(Controller<HomeSectionMyMedia>),
} }
pub struct Home { pub struct Home {
_sections: Vec<HomeSectionController>, sections: Vec<HomeSectionController>,
} }
#[derive(Debug)]
pub enum HomeInput {}
#[derive(Debug)]
pub enum HomeOutput {}
pub struct HomeInit { pub struct HomeInit {
pub api_client: Arc<ApiClient>, pub api_client: Arc<ApiClient>,
pub display_preferences: DisplayPreferences, pub display_preferences: DisplayPreferences,
@@ -38,9 +34,9 @@ pub struct HomeInit {
#[relm4::component(pub)] #[relm4::component(pub)]
impl SimpleComponent for Home { impl SimpleComponent for Home {
type Input = HomeInput;
type Output = HomeOutput;
type Init = HomeInit; type Init = HomeInit;
type Input = ();
type Output = ();
view! { view! {
gtk::ScrolledWindow { gtk::ScrolledWindow {
@@ -63,7 +59,7 @@ impl SimpleComponent for Home {
root: &Self::Root, root: &Self::Root,
_sender: ComponentSender<Self>, _sender: ComponentSender<Self>,
) -> ComponentParts<Self> { ) -> ComponentParts<Self> {
let mut model = Home { _sections: vec![] }; let mut model = Home { sections: vec![] };
let widgets = view_output!(); let widgets = view_output!();
let sections_container = &widgets.sections_container; let sections_container = &widgets.sections_container;
@@ -94,7 +90,7 @@ impl Home {
.launch(api_client.clone()) .launch(api_client.clone())
.detach(); .detach();
sections_container.append(section.widget()); sections_container.append(section.widget());
self._sections self.sections
.push(HomeSectionController::ContinueWatching(section)); .push(HomeSectionController::ContinueWatching(section));
} }
HomeSection::LatestMedia => { HomeSection::LatestMedia => {
@@ -102,14 +98,25 @@ impl Home {
.launch((api_client.clone(), user_views.clone())) .launch((api_client.clone(), user_views.clone()))
.detach(); .detach();
sections_container.append(section.widget()); sections_container.append(section.widget());
self._sections.push(HomeSectionController::Latest(section)); self.sections.push(HomeSectionController::Latest(section));
} }
HomeSection::NextUp => { HomeSection::NextUp => {
let section = HomeSectionNextUp::builder() let section = HomeSectionNextUp::builder()
.launch(api_client.clone()) .launch(api_client.clone())
.detach(); .detach();
sections_container.append(section.widget()); 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::{ use crate::{
jellyfin_api::{ 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::{ library::media_list::{
MediaList, MediaListInit, MediaListOutput, MediaListType, MediaListTypeLatestParams, MediaList, MediaListInit, MediaListOutput, MediaListType, MediaListTypeLatestParams,
@@ -56,22 +60,14 @@ impl Component for HomeSectionLatest {
let widgets = view_output!(); let widgets = view_output!();
let user_views: Vec<&UserView> = user_views let user_views = user_views.filter_supported();
.iter()
.filter(|view| {
matches!(
view.collection_type,
CollectionType::Movies | CollectionType::TvShows
)
})
.collect();
for view in user_views { for view in user_views {
let row = LatestRow::builder() let row = LatestRow::builder()
.launch((api_client.clone(), view.clone())) .launch((api_client.clone(), view.clone()))
.forward(sender.input_sender(), identity); .forward(sender.input_sender(), identity);
root.append(row.widget()); root.append(row.widget());
model.rows.insert(view.id.to_string(), row); model.rows.insert(view.id().to_string(), row);
} }
ComponentParts { model, widgets } ComponentParts { model, widgets }
@@ -124,7 +120,7 @@ impl SimpleComponent for LatestRow {
let widgets = view_output!(); 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::Movies => tr!("library-section-title.latest-movies").to_string(),
CollectionType::TvShows => tr!("library-section-title.latest-shows").to_string(), CollectionType::TvShows => tr!("library-section-title.latest-shows").to_string(),
CollectionType::Music => tr!("library-section-title.latest-music").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() let media_list = MediaList::builder()
.launch(MediaListInit { .launch(MediaListInit {
api_client, 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(), label: title_text.to_string(),
}) })
.forward(sender.input_sender(), |o| o.into()); .forward(sender.input_sender(), |o| o.into());

View File

@@ -1,3 +1,4 @@
pub mod continue_watching; pub mod continue_watching;
pub mod latest; pub mod latest;
pub mod my_media;
pub mod next_up; 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 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; 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 { pub(crate) struct MediaCarousel {
media_tile_display: MediaTileDisplay, media_tile_display: MediaTileDisplay,
media_tiles: Vec<AsyncController<MediaTile>>, media_tiles: Vec<MediaCarouselItem>,
pages: Vec<gtk::Box>, pages: Vec<gtk::Box>,
} }
pub(crate) struct MediaCarouselInit { pub(crate) struct MediaCarouselInit {
pub(crate) media: Vec<BaseItemDto>, pub(crate) media: Vec<BaseItemDto>,
pub(crate) media_tile_display: MediaTileDisplay, pub(crate) media_tile_display: MediaTileDisplay,
pub(crate) carousel_type: MediaCarouselType,
pub(crate) api_client: Arc<ApiClient>, pub(crate) api_client: Arc<ApiClient>,
pub(crate) label: String, pub(crate) label: String,
} }
@@ -142,15 +165,23 @@ impl Component for MediaCarousel {
api_client, api_client,
media, media,
media_tile_display, media_tile_display,
carousel_type,
label, label,
} = init; } = init;
let media_tiles: Vec<AsyncController<MediaTile>> = media let media_tiles = media
.iter() .iter()
.map(|media| { .map(|media| match carousel_type {
MediaCarouselType::Tiles => MediaCarouselItem::Tile(
MediaTile::builder() MediaTile::builder()
.launch((media.clone(), media_tile_display, api_client.clone())) .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(); .collect();
@@ -192,7 +223,7 @@ impl Component for MediaCarousel {
} }
self.pages.clear(); 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(); self.media_tiles.chunks(tiles_per_page as usize).collect();
for chunk in media_tile_chunks { for chunk in media_tile_chunks {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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