diff --git a/backend/src/api/id.rs b/backend/src/api/id.rs
index fd69112b2..e886f06ad 100644
--- a/backend/src/api/id.rs
+++ b/backend/src/api/id.rs
@@ -83,6 +83,7 @@ define_kinds![
block = b"bl",
series = b"sr",
event = b"ev",
+ playlist = b"pl",
search_realm = b"rs",
search_event = b"es",
search_series = b"ss",
diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs
index 3ccb8950b..cff31a219 100644
--- a/backend/src/api/model/event.rs
+++ b/backend/src/api/model/event.rs
@@ -1,5 +1,3 @@
-use std::fmt;
-
use chrono::{DateTime, Utc};
use postgres_types::ToSql;
use serde::{Serialize, Deserialize};
@@ -18,27 +16,26 @@ use crate::{
util::{impl_from_db, select},
},
prelude::*,
- util::lazy_format,
};
#[derive(Debug)]
pub(crate) struct AuthorizedEvent {
- key: Key,
- series: Option,
- opencast_id: String,
- is_live: bool,
+ pub(crate) key: Key,
+ pub(crate) series: Option,
+ pub(crate) opencast_id: String,
+ pub(crate) is_live: bool,
- title: String,
- description: Option,
- created: DateTime,
- creators: Vec,
+ pub(crate) title: String,
+ pub(crate) description: Option,
+ pub(crate) created: DateTime,
+ pub(crate) creators: Vec,
- metadata: ExtraMetadata,
- read_roles: Vec,
- write_roles: Vec,
+ pub(crate) metadata: ExtraMetadata,
+ pub(crate) read_roles: Vec,
+ pub(crate) write_roles: Vec,
- synced_data: Option,
+ pub(crate) synced_data: Option,
}
#[derive(Debug)]
@@ -300,14 +297,12 @@ impl AuthorizedEvent {
pub(crate) async fn load_for_series(
series_key: Key,
- order: EventSortOrder,
context: &Context,
) -> ApiResult> {
let selection = Self::select();
let query = format!(
"select {selection} from events \
- where series = $2 and (read_roles || 'ROLE_ADMIN'::text) && $1 {}",
- order.to_sql(),
+ where series = $2 and (read_roles || 'ROLE_ADMIN'::text) && $1",
);
context.db
.query_mapped(
@@ -557,14 +552,6 @@ impl Default for EventSortOrder {
}
}
-impl EventSortOrder {
- /// Returns an SQL query fragment like `order by foo asc`.
- fn to_sql(&self) -> impl fmt::Display {
- let Self { column, direction } = *self;
- lazy_format!("order by {} {}", column.to_sql(), direction.to_sql())
- }
-}
-
impl EventSortColumn {
fn to_sql(self) -> &'static str {
match self {
diff --git a/backend/src/api/model/mod.rs b/backend/src/api/model/mod.rs
index 589068aef..38941aefb 100644
--- a/backend/src/api/model/mod.rs
+++ b/backend/src/api/model/mod.rs
@@ -5,6 +5,7 @@ pub(crate) mod acl;
pub(crate) mod block;
pub(crate) mod event;
pub(crate) mod known_roles;
+pub(crate) mod playlist;
pub(crate) mod realm;
pub(crate) mod search;
pub(crate) mod series;
diff --git a/backend/src/api/model/playlist/mod.rs b/backend/src/api/model/playlist/mod.rs
new file mode 100644
index 000000000..6a36691b6
--- /dev/null
+++ b/backend/src/api/model/playlist/mod.rs
@@ -0,0 +1,163 @@
+use juniper::graphql_object;
+use postgres_types::ToSql;
+
+use crate::{
+ api::{
+ common::NotAllowed, err::ApiResult, Context, Id, Node
+ },
+ db::{types::Key, util::{impl_from_db, select}},
+ prelude::*,
+};
+
+use super::event::AuthorizedEvent;
+
+
+#[derive(juniper::GraphQLUnion)]
+#[graphql(Context = Context)]
+pub(crate) enum Playlist {
+ Playlist(AuthorizedPlaylist),
+ NotAllowed(NotAllowed),
+}
+
+pub(crate) struct AuthorizedPlaylist {
+ pub(crate) key: Key,
+ opencast_id: String,
+ title: String,
+ description: Option,
+
+ read_roles: Vec,
+ #[allow(dead_code)] // TODO
+ write_roles: Vec,
+}
+
+
+#[derive(juniper::GraphQLUnion)]
+#[graphql(Context = Context)]
+pub(crate) enum PlaylistEntry {
+ Event(AuthorizedEvent),
+ NotAllowed(NotAllowed),
+ Missing(Missing),
+}
+
+/// The data referred to by a playlist entry was not found.
+pub(crate) struct Missing;
+crate::api::util::impl_object_with_dummy_field!(Missing);
+
+
+impl_from_db!(
+ AuthorizedPlaylist,
+ select: {
+ playlists.{ id, opencast_id, title, description, read_roles, write_roles },
+ },
+ |row| {
+ Self {
+ key: row.id(),
+ opencast_id: row.opencast_id(),
+ title: row.title(),
+ description: row.description(),
+ read_roles: row.read_roles(),
+ write_roles: row.write_roles(),
+ }
+ },
+);
+
+impl Playlist {
+ pub(crate) async fn load_by_id(id: Id, context: &Context) -> ApiResult> {
+ if let Some(key) = id.key_for(Id::SERIES_KIND) {
+ Self::load_by_key(key, context).await
+ } else {
+ Ok(None)
+ }
+ }
+
+ pub(crate) async fn load_by_key(key: Key, context: &Context) -> ApiResult > {
+ Self::load_by_any_id("id", &key, context).await
+ }
+
+ pub(crate) async fn load_by_opencast_id(id: String, context: &Context) -> ApiResult > {
+ Self::load_by_any_id("opencast_id", &id, context).await
+ }
+
+ async fn load_by_any_id(
+ col: &str,
+ id: &(dyn ToSql + Sync),
+ context: &Context,
+ ) -> ApiResult > {
+ let selection = AuthorizedPlaylist::select();
+ let query = format!("select {selection} from playlists where {col} = $1");
+ context.db
+ .query_opt(&query, &[id])
+ .await?
+ .map(|row| {
+ let playlist = AuthorizedPlaylist::from_row_start(&row);
+ if context.auth.overlaps_roles(&playlist.read_roles) {
+ Playlist::Playlist(playlist)
+ } else {
+ Playlist::NotAllowed(NotAllowed)
+ }
+ })
+ .pipe(Ok)
+ }
+}
+
+/// Represents an Opencast series.
+#[graphql_object(Context = Context)]
+impl AuthorizedPlaylist {
+ fn id(&self) -> Id {
+ Node::id(self)
+ }
+
+ fn opencast_id(&self) -> &str {
+ &self.opencast_id
+ }
+
+ fn title(&self) -> &str {
+ &self.title
+ }
+
+ fn description(&self) -> Option<&str> {
+ self.description.as_deref()
+ }
+
+ async fn entries(&self, context: &Context) -> ApiResult> {
+ let (selection, mapping) = select!(
+ found: "events.id is not null",
+ event: AuthorizedEvent,
+ );
+ let query = format!("\
+ with entries as (\
+ select unnest(entries) as entry \
+ from playlists \
+ where id = $1\
+ ),
+ event_ids as (\
+ select (entry).content_id as id \
+ from entries \
+ where (entry).type = 'event'\
+ )
+ select {selection} from event_ids \
+ left join events on events.opencast_id = event_ids.id\
+ ");
+ context.db
+ .query_mapped(&query, dbargs![&self.key], |row| {
+ if !mapping.found.of::(&row) {
+ return PlaylistEntry::Missing(Missing);
+ }
+
+ let event = AuthorizedEvent::from_row(&row, mapping.event);
+ if !context.auth.overlaps_roles(&event.read_roles) {
+ return PlaylistEntry::NotAllowed(NotAllowed);
+ }
+
+ PlaylistEntry::Event(event)
+ })
+ .await?
+ .pipe(Ok)
+ }
+}
+
+impl Node for AuthorizedPlaylist {
+ fn id(&self) -> Id {
+ Id::playlist(self.key)
+ }
+}
diff --git a/backend/src/api/model/series.rs b/backend/src/api/model/series.rs
index e1dc01281..4a358158e 100644
--- a/backend/src/api/model/series.rs
+++ b/backend/src/api/model/series.rs
@@ -9,7 +9,7 @@ use crate::{
Id,
model::{
realm::Realm,
- event::{AuthorizedEvent, EventSortOrder}
+ event::AuthorizedEvent,
},
Node,
},
@@ -145,9 +145,8 @@ impl Series {
.pipe(Ok)
}
- #[graphql(arguments(order(default = Default::default())))]
- async fn events(&self, order: EventSortOrder, context: &Context) -> ApiResult> {
- AuthorizedEvent::load_for_series(self.key, order, context).await
+ async fn events(&self, context: &Context) -> ApiResult> {
+ AuthorizedEvent::load_for_series(self.key, context).await
}
}
diff --git a/backend/src/api/query.rs b/backend/src/api/query.rs
index c5324c423..eb1c5787e 100644
--- a/backend/src/api/query.rs
+++ b/backend/src/api/query.rs
@@ -9,6 +9,7 @@ use super::{
model::{
event::{AuthorizedEvent, Event},
known_roles::{self, KnownGroup, KnownUsersSearchOutcome},
+ playlist::Playlist,
realm::Realm,
search::{self, EventSearchOutcome, Filters, SearchOutcome, SeriesSearchOutcome},
series::Series,
@@ -66,6 +67,16 @@ impl Query {
Series::load_by_id(id, context).await
}
+ /// Returns a playlist by its Opencast ID.
+ async fn playlist_by_opencast_id(id: String, context: &Context) -> ApiResult> {
+ Playlist::load_by_opencast_id(id, context).await
+ }
+
+ /// Returns a playlist by its ID.
+ async fn playlist_by_id(id: Id, context: &Context) -> ApiResult > {
+ Playlist::load_by_id(id, context).await
+ }
+
/// Returns the current user.
fn current_user(context: &Context) -> Option<&User> {
match &context.auth {
diff --git a/backend/src/db/cmd.rs b/backend/src/db/cmd.rs
index 2fdf9f535..c5ac7831d 100644
--- a/backend/src/db/cmd.rs
+++ b/backend/src/db/cmd.rs
@@ -245,7 +245,7 @@ async fn clear(db: &mut Db, config: &Config, yes: bool) -> Result<()> {
// Next we drop all types.
for ty in types {
- tx.execute(&format!("drop type if exists {ty}"), &[]).await?;
+ tx.execute(&format!("drop type if exists {ty} cascade"), &[]).await?;
trace!("Dropped type {ty}");
}
diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs
index dd08e7df2..369efcf6d 100644
--- a/backend/src/db/migrations.rs
+++ b/backend/src/db/migrations.rs
@@ -366,4 +366,5 @@ static MIGRATIONS: Lazy> = include_migrations![
31: "series-metadata",
32: "custom-actions",
33: "event-slide-text-and-segments",
+ 34: "playlists",
];
diff --git a/backend/src/db/migrations/34-playlists.sql b/backend/src/db/migrations/34-playlists.sql
new file mode 100644
index 000000000..ed7384377
--- /dev/null
+++ b/backend/src/db/migrations/34-playlists.sql
@@ -0,0 +1,45 @@
+select prepare_randomized_ids('playlist');
+
+
+create type playlist_entry_type as enum ('event');
+
+-- All fields should never be null.
+create type playlist_entry as (
+ -- The Opencast ID of this entry. Not a UUID.
+ opencast_id bigint,
+
+ type playlist_entry_type,
+
+ -- The Opencast ID of the referenced content.
+ content_id text
+);
+
+create table playlists (
+ id bigint primary key default randomized_id('playlist'),
+ opencast_id text not null unique,
+
+ title text not null,
+ description text,
+ creator text,
+
+ entries playlist_entry[] not null,
+
+ read_roles text[] not null,
+ write_roles text[] not null,
+
+ updated timestamp with time zone not null,
+
+ constraint read_roles_no_null_value check (array_position(read_roles, null) is null),
+ constraint write_roles_no_null_value check (array_position(write_roles, null) is null),
+ constraint entries_no_null_value check (array_position(entries, null) is null)
+);
+
+
+-- To perform queries like `write_roles && $1` on the whole table. Probably just
+-- to list all playlists that a user has write access to.
+create index idx_playlists_write_roles on events using gin (write_roles);
+
+
+-- Search index ---------------------------------------------------------------
+
+-- TODO
diff --git a/backend/src/db/types.rs b/backend/src/db/types.rs
index 6312fdf80..a5586ded8 100644
--- a/backend/src/db/types.rs
+++ b/backend/src/db/types.rs
@@ -97,6 +97,23 @@ pub enum SeriesState {
Waiting,
}
+/// Represents the `playlist_entry_type` type defined in `31-playlists.sql`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSql, ToSql)]
+#[postgres(name = "playlist_entry_type")]
+pub enum PlaylistEntryType {
+ #[postgres(name = "event")]
+ Event,
+}
+
+/// Represents the `playlist_entry` type defined in `31-playlists.sql`.
+#[derive(Debug, FromSql, ToSql, Clone)]
+#[postgres(name = "playlist_entry")]
+pub struct PlaylistEntry {
+ pub opencast_id: i64,
+ #[postgres(name = "type")]
+ pub ty: PlaylistEntryType,
+ pub content_id: String,
+}
/// Represents extra metadata in the DB. Is a map from "namespace" to a
/// `string -> string array` map.
diff --git a/backend/src/sync/harvest/mod.rs b/backend/src/sync/harvest/mod.rs
index 2389ccdfb..56ebcff10 100644
--- a/backend/src/sync/harvest/mod.rs
+++ b/backend/src/sync/harvest/mod.rs
@@ -8,7 +8,11 @@ use tokio_postgres::types::ToSql;
use crate::{
auth::ROLE_ADMIN,
config::Config,
- db::{types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState}, DbConnection},
+ db::{
+ self,
+ DbConnection,
+ types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState},
+ },
prelude::*,
};
use super::{status::SyncStatus, OcClient};
@@ -134,6 +138,8 @@ async fn store_in_db(
let mut removed_events = 0;
let mut upserted_series = 0;
let mut removed_series = 0;
+ let mut upserted_playlists = 0;
+ let mut removed_playlists = 0;
for item in items {
// Make sure we haven't received this update yet. The code below can
@@ -277,23 +283,85 @@ async fn store_in_db(
removed_series += 1;
}
- HarvestItem::Unknown { kind, .. } => {
- warn!("Unknown item of kind '{kind}' in harvest response. \
- You might need to update Tobira.");
+ HarvestItem::Playlist {
+ id: opencast_id,
+ title,
+ description,
+ creator,
+ acl,
+ entries,
+ updated,
+ } => {
+ let entries = entries.into_iter().filter_map(|e| {
+ // We do not store entries that we don't know, meaning that
+ // a resync is required as soon as Tobira learns about
+ // these new entries. But that's fine as that's likely
+ // required anyway, given that more changes have to be done.
+ let ty = match e.ty.as_str() {
+ "E" => db::types::PlaylistEntryType::Event,
+ _ => return None,
+ };
+
+ Some(db::types::PlaylistEntry {
+ opencast_id: e.id,
+ ty,
+ content_id: e.content_id,
+ })
+ }).collect::>();
+
+ upsert(db, "playlists", "opencast_id", &[
+ ("opencast_id", &opencast_id),
+ ("title", &title),
+ ("description", &description),
+ ("creator", &creator),
+ ("read_roles", &acl.read),
+ ("write_roles", &acl.write),
+ ("entries", &entries),
+ ("updated", &updated),
+ ]).await?;
+
+ trace!(opencast_id, title, "Inserted or updated playlist");
+ upserted_playlists += 1;
+ }
+
+ HarvestItem::PlaylistDeleted { id: opencast_id, .. } => {
+ let rows_affected = db
+ .execute("delete from playlists where opencast_id = $1", &[&opencast_id])
+ .await?;
+ check_affected_rows_removed(rows_affected, "playlist", &opencast_id);
+ removed_playlists += 1;
+ }
+
+ HarvestItem::Unknown { kind, updated } => {
+ let known = [
+ "event",
+ "event-deleted",
+ "series",
+ "series-deleted",
+ "playlist",
+ "playlist-deleted",
+ ];
+
+ if known.contains(&&*kind) {
+ warn!("Could not deserialize item in harvest response for \
+ kind '{kind}' (updated {updated})");
+ } else {
+ warn!("Unknown item of kind '{kind}' in harvest response. \
+ You might need to update Tobira.");
+ }
}
}
}
- if upserted_events == 0 && upserted_series == 0 && removed_events == 0 && removed_series == 0 {
+ if upserted_events == 0 && upserted_series == 0 && upserted_playlists == 0
+ && removed_events == 0 && removed_series == 0 && removed_playlists == 0
+ {
trace!("Harvest outcome: nothing changed!");
} else {
info!(
- "Harvest outcome: upserted {} events, upserted {} series, \
- removed {} events, removed {} series (in {:.2?})",
- upserted_events,
- upserted_series,
- removed_events,
- removed_series,
+ upserted_events, upserted_series, upserted_playlists,
+ removed_events, removed_series, removed_playlists,
+ "Harvest done in {:.2?}",
before.elapsed(),
);
}
diff --git a/backend/src/sync/harvest/response.rs b/backend/src/sync/harvest/response.rs
index 1ddfd6aff..788350edc 100644
--- a/backend/src/sync/harvest/response.rs
+++ b/backend/src/sync/harvest/response.rs
@@ -72,6 +72,25 @@ pub(crate) enum HarvestItem {
updated: DateTime,
},
+ #[serde(rename_all = "camelCase")]
+ Playlist {
+ id: String,
+ title: String,
+ description: Option,
+ creator: Option,
+ acl: Acl,
+ entries: Vec,
+ #[serde(with = "chrono::serde::ts_milliseconds")]
+ updated: DateTime,
+ },
+
+ #[serde(rename_all = "camelCase")]
+ PlaylistDeleted {
+ id: String,
+ #[serde(with = "chrono::serde::ts_milliseconds")]
+ updated: DateTime,
+ },
+
#[serde(untagged)]
Unknown {
kind: String,
@@ -87,6 +106,8 @@ impl HarvestItem {
Self::EventDeleted { updated, .. } => updated,
Self::Series { updated, .. } => updated,
Self::SeriesDeleted { updated, .. } => updated,
+ Self::Playlist { updated, .. } => updated,
+ Self::PlaylistDeleted { updated, .. } => updated,
Self::Unknown { updated, .. } => updated,
}
}
@@ -155,3 +176,12 @@ pub(crate) struct Acl {
#[serde(flatten)]
pub(crate) custom_actions: CustomActions,
}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct PlaylistEntry {
+ pub id: i64,
+ #[serde(rename = "type")]
+ pub ty: String,
+ pub content_id: String,
+}
diff --git a/backend/src/util.rs b/backend/src/util.rs
index 49e754685..093046aca 100644
--- a/backend/src/util.rs
+++ b/backend/src/util.rs
@@ -1,4 +1,3 @@
-use std::fmt;
use bytes::Bytes;
use http_body_util::BodyExt;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
@@ -10,35 +9,6 @@ use secrecy::Secret;
use crate::{http::Response, prelude::*};
-/// A lazy `fmt` formatter, specified by a callable. Usually created via
-/// `lazy_format!`.
-///
-/// This is particularly useful in situations where you want a method to return
-/// a formatted value, but don't want to return an allocated `String`. For
-/// example, if the returned value is formatted into yet another value anyway,
-/// allocating a string is useless. Instead of returning `String`, you then
-/// return `impl fmt::Display + '_`.
-pub(crate) struct LazyFormat fmt::Result>(pub F);
-
-impl fmt::Display for LazyFormat
-where
- F: Fn(&mut fmt::Formatter) -> fmt::Result,
-{
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- (self.0)(f)
- }
-}
-
-macro_rules! lazy_format {
- ($fmt:literal $($t:tt)*) => {
- crate::util::LazyFormat(move |f| write!(f, $fmt $($t)*))
- };
-}
-
-pub(crate) use lazy_format;
-
-
-
/// An empty `enum` for signaling the fact that a function (potentially) never returns.
/// Note that you can't construct a value of this type, so a function returning it
/// can never return. A function returning `Result` never returns
diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml
index f38ec9b0b..6891c5167 100644
--- a/frontend/src/i18n/locales/de.yaml
+++ b/frontend/src/i18n/locales/de.yaml
@@ -56,6 +56,7 @@ not-found:
page-not-found: Seite nicht gefunden
video-not-found: Video nicht gefunden
series-not-found: Serie nicht gefunden
+ playlist-not-found: Playlist nicht gefunden
page-explanation: >
Die von Ihnen gewünschte Seite existiert nicht. Sie wurde möglicherweise
gelöscht oder umbenannt.
@@ -65,6 +66,9 @@ not-found:
series-explanation: >
Die von Ihnen gewünschte Serie existiert nicht. Sie wurde möglicherweise
gelöscht oder verschoben.
+ playlist-explanation: >
+ Die von Ihnen gewünschte Playlist existiert nicht. Sie wurde möglicherweise
+ gelöscht oder verschoben.
url-typo: >
Falls Sie die Adresse/URL manuell eingegeben haben, prüfen Sie diese auf
Fehler.
@@ -176,8 +180,6 @@ series:
series: Serie
deleted: Gelöschte Serie
deleted-series-block: Die hier referenzierte Serie wurde gelöscht.
- no-events: Diese Serie enthält keine Videos oder Sie sind nicht berechtigt, diese zu sehen.
- upcoming-live-streams: "Anstehende Livestreams ({{count}})"
entry-of-series-thumbnail: "Vorschlaubild für Teil von „{{series}}“"
videos:
heading: Videos
@@ -186,9 +188,17 @@ series:
text: >
Die Daten der gewünschten Serie wurden leider noch nicht vollständig übertragen. Dies sollte
in Kürze automatisch passieren. Versuchen Sie es in wenigen Minuten noch einmal!
+
+videolist-block:
+ upcoming-live-streams: "Anstehende Livestreams ({{count}})"
+ missing-video: Video nicht gefunden
+ unauthorized: Fehlende Berechtigung
+ hidden-items_one: 'Ein Video wurde nicht gefunden oder Sie haben keinen Zugriff darauf.'
+ hidden-items_other: '{{count}} Videos wurden nicht gefunden oder Sie haben keinen Zugriff darauf.'
settings:
order: Reihenfolge
order-label: Video Reihenfolge auswählen
+ original: Wie Playlist
new-to-old: Neueste zuerst
old-to-new: Älteste zuerst
a-z: A bis Z
@@ -580,6 +590,7 @@ manage:
api-remote-errors:
view:
event: Sie sind nicht autorisiert, dieses Video zu sehen.
+ playlist: Sie sind nicht autorisiert, diese Playlist zu sehen.
upload:
not-logged-in: Sie müssen angemeldet sein, um Videos hochzuladen.
not-authorized: $t(upload.not-authorized)
diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml
index 1385cf14b..497c55849 100644
--- a/frontend/src/i18n/locales/en.yaml
+++ b/frontend/src/i18n/locales/en.yaml
@@ -55,6 +55,7 @@ not-found:
page-not-found: Page not found
video-not-found: Video not found
series-not-found: Series not found
+ playlist-not-found: Playlist not found
page-explanation: >
The page you want to visit does not exist. It might have been removed or
renamed.
@@ -64,6 +65,9 @@ not-found:
series-explanation: >
The series you want to view does not exist. It might have been removed or
moved.
+ playlist-explanation: >
+ The playlist you want to view does not exist. It might have been removed or
+ moved.
url-typo: >
If you entered the address/URL manually, please double check it for
spelling mistakes.
@@ -173,8 +177,6 @@ series:
series: Series
deleted: Deleted series
deleted-series-block: The series referenced here was deleted.
- no-events: This series does not contain any events, or you might not be authorized to see them.
- upcoming-live-streams: "Upcoming live streams ({{count}})"
entry-of-series-thumbnail: "Thumbnail for entry of “{{series}}”"
videos:
heading: Videos
@@ -183,9 +185,18 @@ series:
text: >
The data of the requested series has not been fully transferred yet. This should
happen automatically soon. Try again in a few minutes.
+
+videolist-block:
+ upcoming-live-streams: "Upcoming live streams ({{count}})"
+ missing-video: Video not found
+ unauthorized: Missing permissions
+ hidden-items_one: 'One video is missing or requires additional permissions to view.'
+ hidden-items_other: '{{count}} videos are missing or require additional permissions to view.'
+ no-videos: No videos
settings:
order: Order
order-label: Choose video order
+ original: Playlist order
new-to-old: Newest first
old-to-new: Oldest first
a-z: A to Z
@@ -554,6 +565,7 @@ manage:
api-remote-errors:
view:
event: You are not authorized to view this video.
+ playlist: You are not authorized to view this playlist.
upload:
not-logged-in: You have to be logged in to upload videos.
not-authorized: $t(upload.not-authorized)
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 28dd2e609..bb469d993 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -18,6 +18,7 @@ import { ManageVideoDetailsRoute } from "./routes/manage/Video/Details";
import { ManageVideoTechnicalDetailsRoute } from "./routes/manage/Video/TechnicalDetails";
import React from "react";
import { ManageVideoAccessRoute } from "./routes/manage/Video/Access";
+import { DirectPlaylistOCRoute, DirectPlaylistRoute } from "./routes/Playlist";
@@ -42,6 +43,8 @@ const {
DirectOpencastVideoRoute,
DirectSeriesRoute,
DirectSeriesOCRoute,
+ DirectPlaylistRoute,
+ DirectPlaylistOCRoute,
ManageRoute,
ManageVideosRoute,
ManageVideoAccessRoute,
diff --git a/frontend/src/routes/NotFound.tsx b/frontend/src/routes/NotFound.tsx
index 0711554f9..b1d199523 100644
--- a/frontend/src/routes/NotFound.tsx
+++ b/frontend/src/routes/NotFound.tsx
@@ -32,7 +32,7 @@ const query = graphql`
`;
type Props = {
- kind: "page" | "video" | "series";
+ kind: "page" | "video" | "series" | "playlist";
};
export const NotFound: React.FC = ({ kind }) => {
@@ -41,6 +41,7 @@ export const NotFound: React.FC = ({ kind }) => {
"page": () => t("not-found.page-not-found"),
"video": () => t("not-found.video-not-found"),
"series": () => t("not-found.series-not-found"),
+ "playlist": () => t("not-found.playlist-not-found"),
});
// Ideally our backend would respond with 404 here, but that's not
@@ -64,6 +65,7 @@ export const NotFound: React.FC = ({ kind }) => {
"page": () => t("not-found.page-explanation"),
"video": () => t("not-found.video-explanation"),
"series": () => t("not-found.series-explanation"),
+ "playlist": () => t("not-found.playlist-explanation"),
})}
{t("not-found.url-typo")}
diff --git a/frontend/src/routes/Playlist.tsx b/frontend/src/routes/Playlist.tsx
new file mode 100644
index 000000000..0b4cba496
--- /dev/null
+++ b/frontend/src/routes/Playlist.tsx
@@ -0,0 +1,153 @@
+import { graphql, readInlineData, useFragment } from "react-relay";
+import { useTranslation } from "react-i18next";
+import { unreachable } from "@opencast/appkit";
+
+import { loadQuery } from "../relay";
+import { makeRoute } from "../rauta";
+import { RootLoader } from "../layout/Root";
+import { Nav } from "../layout/Navigation";
+import { PageTitle } from "../layout/header/ui";
+import { keyOfId, playlistId } from "../util";
+import { NotFound } from "./NotFound";
+import { b64regex } from "./util";
+import { Breadcrumbs } from "../ui/Breadcrumbs";
+import { PlaylistByOpencastIdQuery } from "./__generated__/PlaylistByOpencastIdQuery.graphql";
+import { PlaylistRouteData$key } from "./__generated__/PlaylistRouteData.graphql";
+import { PlaylistByIdQuery } from "./__generated__/PlaylistByIdQuery.graphql";
+import { ErrorPage } from "../ui/error";
+import { VideoListBlock, videoListEventFragment } from "../ui/Blocks/VideoList";
+import { VideoListEventData$key } from "../ui/Blocks/__generated__/VideoListEventData.graphql";
+
+
+export const DirectPlaylistOCRoute = makeRoute({
+ url: ({ ocID }: { ocID: string }) => `/!p/:${ocID}`,
+ match: url => {
+ const regex = new RegExp("^/!p/:([^/]+)$", "u");
+ const matches = regex.exec(url.pathname);
+
+ if (!matches) {
+ return null;
+ }
+
+
+ const opencastId = decodeURIComponent(matches[1]);
+ const query = graphql`
+ query PlaylistByOpencastIdQuery($id: String!) {
+ ... UserData
+ playlist: playlistByOpencastId(id: $id) { ...PlaylistRouteData }
+ rootRealm { ... NavigationData }
+ }
+ `;
+ const queryRef = loadQuery(query, { id: opencastId });
+
+
+ return {
+ render: () => }
+ render={result => }
+ />,
+ dispose: () => queryRef.dispose(),
+ };
+ },
+});
+
+export const DirectPlaylistRoute = makeRoute({
+ url: ({ playlistId }: { playlistId: string }) => `/!p/${keyOfId(playlistId)}`,
+ match: url => {
+ const regex = new RegExp(`^/!p/(${b64regex}+)$`, "u");
+ const matches = regex.exec(url.pathname);
+
+ if (!matches) {
+ return null;
+ }
+
+
+ const id = decodeURIComponent(matches[1]);
+ const query = graphql`
+ query PlaylistByIdQuery($id: ID!) {
+ ... UserData
+ playlist: playlistById(id: $id) { ...PlaylistRouteData }
+ rootRealm { ... NavigationData }
+ }
+ `;
+ const queryRef = loadQuery(query, { id: playlistId(id) });
+
+
+ return {
+ render: () => }
+ render={result => }
+ />,
+ dispose: () => queryRef.dispose(),
+ };
+ },
+});
+
+const fragment = graphql`
+ fragment PlaylistRouteData on Playlist {
+ __typename
+ ... on NotAllowed { dummy } # workaround
+ ... on AuthorizedPlaylist {
+ title
+ description
+ entries {
+ __typename
+ ...on AuthorizedEvent { id, ...VideoListEventData }
+ ...on Missing { dummy }
+ ...on NotAllowed { dummy }
+ }
+ }
+ }
+`;
+
+type PlaylistPageProps = {
+ playlistFrag?: PlaylistRouteData$key | null;
+};
+
+const PlaylistPage: React.FC = ({ playlistFrag }) => {
+ const { t } = useTranslation();
+ const playlist = useFragment(fragment, playlistFrag ?? null);
+
+ if (!playlist) {
+ return ;
+ }
+
+ if (playlist.__typename === "NotAllowed") {
+ return ;
+ }
+ if (playlist.__typename !== "AuthorizedPlaylist") {
+ return unreachable();
+ }
+
+ const items = playlist.entries.map(entry => {
+ if (entry.__typename === "AuthorizedEvent") {
+ const out = readInlineData(videoListEventFragment, entry);
+ return out;
+ } else if (entry.__typename === "Missing") {
+ return "missing";
+ } else if (entry.__typename === "NotAllowed") {
+ return "unauthorized";
+ } else {
+ return unreachable();
+ }
+ });
+
+ return
+
+
+
{playlist.description}
+
+
+
+
;
+};
diff --git a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Series.tsx b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Series.tsx
index cb9eff2f5..da68d6e12 100644
--- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Series.tsx
+++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Series.tsx
@@ -128,29 +128,29 @@ export const EditSeriesBlock: React.FC = ({ block: blockRe
role="group"
aria-labelledby={headingId + "-order"}
>
- {t("series.settings.order")}
+ {t("videolist-block.settings.order")}
= ({ block: blockRe
role="group"
aria-labelledby={headingId + "-view"}
>
- {t("series.settings.layout")}
+ {t("videolist-block.settings.layout")}
= ({ series, ...props }) => {
const { t } = useTranslation();
+ const events = series.events.map(event => (
+ readInlineData(videoListEventFragment, event)
+ ));
if (!isSynced(series)) {
const { title, layout } = props;
- return
+ return
{t("series.not-ready.text")}
- ;
+ ;
}
- return ;
-};
-
-type ReadyProps = SharedFromSeriesProps & {
- series: SyncedOpencastEntity;
-};
-
-type OrderContext = {
- eventOrder: VideoListOrder;
- setEventOrder: (newOrder: VideoListOrder) => void;
-};
-
-const OrderContext = createContext({
- eventOrder: "NEW_TO_OLD",
- setEventOrder: () => {},
-});
-
-
-// ==============================================================================================
-// ===== Main components defining UI
-// ==============================================================================================
-
-const VIDEO_GRID_BREAKPOINT = 600;
-
-const ReadySeriesBlock: React.FC = ({
- basePath,
- title,
- series,
- activeEventId,
- order = "NEW_TO_OLD",
- layout = "GALLERY",
- showTitle = true,
- showMetadata,
-}) => {
- const { t, i18n } = useTranslation();
- const collator = new Intl.Collator(i18n.language);
- const [eventOrder, setEventOrder] = useState(order);
-
- const events = series.events.filter(event =>
- !isPastLiveEvent(event.syncedData?.endTime ?? null, event.isLive)
- && !isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive));
-
- const upcomingLiveEvents = series.events.filter(event =>
- isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive));
-
- const timeMs = (event: Event) =>
- new Date(event.syncedData?.startTime ?? event.created).getTime();
-
- const compareEvents = (a: Event, b: Event, reverseTime = false) =>
- match(eventOrder, {
- "NEW_TO_OLD": () => reverseTime ? timeMs(a) - timeMs(b) : timeMs(b) - timeMs(a),
- "OLD_TO_NEW": () => reverseTime ? timeMs(b) - timeMs(a) : timeMs(a) - timeMs(b),
- "AZ": () => collator.compare(a.title, b.title),
- "ZA": () => collator.compare(b.title, a.title),
- }, unreachable);
-
- const sortedEvents = [...events];
- sortedEvents.sort((a, b) => {
- // Sort all live events before non-live events.
- if (a.isLive !== b.isLive) {
- return +b.isLive - +a.isLive;
+ return compareEvents(a, b, true));
- }
-
- const renderEvents = (events: Event[]) => (
- ({ event, active: event.id === activeEventId }))}
- />
- );
-
-
- const finalTitle = title ?? (showTitle ? series.title : undefined);
- const eventsNotEmpty = series.events.length > 0;
-
- return
-
- {!eventsNotEmpty
- ? {t("series.no-events")}
- : <>
- {upcomingLiveEvents.length > 1 && (
-
- {renderEvents(upcomingLiveEvents)}
-
- )}
- {renderEvents(sortedEvents)}
- >
- }
-
- ;
-};
-
-type Event = SeriesBlockSeriesData$data["events"][0];
-
-type SeriesBlockContainerProps = {
- title?: string;
- description?: string | null;
- children: ReactNode;
- showViewOptions: boolean;
- layout?: VideoListLayout;
-};
-
-type LayoutContext = {
- layoutState: VideoListLayout;
- setLayoutState: (layout: VideoListLayout) => void;
-};
-
-const LayoutContext = createContext({
- layoutState: "GALLERY",
- setLayoutState: () => {},
-});
-
-const SeriesBlockContainer: React.FC = (
- { title, description, children, showViewOptions, layout = "GALLERY" },
-) => {
- const [layoutState, setLayoutState] = useState(layout);
- const isDark = useColorScheme().scheme === "dark";
-
- return
-
- <>
-
- {title &&
{title} }
-
- {description &&
}
- {showViewOptions &&
-
-
-
}
-
-
- {description &&
}
- >
- {children}
-
- ;
-};
-
-
-// ==============================================================================================
-// ===== The menus for choosing order and layout mode
-// ==============================================================================================
-
-const OrderMenu: React.FC = () => {
- const { t } = useTranslation();
- const ref = useRef(null);
- const order = useContext(OrderContext);
-
- const triggerContent = match(order.eventOrder, {
- "NEW_TO_OLD": () => t("series.settings.new-to-old"),
- "OLD_TO_NEW": () => t("series.settings.old-to-new"),
- "AZ": () => t("series.settings.a-z"),
- "ZA": () => t("series.settings.z-a"),
- "%future added value": () => unreachable(),
- });
-
- return {triggerContent}>}
- list={ ref.current?.close()} />}
+ allowOriginalOrder={false}
+ title={props.title ?? (props.showTitle ? series.title : undefined)}
+ description={(props.showMetadata && series.syncedData.description) || undefined}
+ activeEventId={props.activeEventId}
+ basePath={props.basePath}
+ items={events}
/>;
};
-
-const LayoutMenu: React.FC = () => {
- const { t } = useTranslation();
- const state = useContext(LayoutContext);
- const ref = useRef(null);
-
- const icon = match(state.layoutState, {
- SLIDER: () => ,
- GALLERY: () => ,
- LIST: () => ,
- "%future added value": () => unreachable(),
- });
-
- const triggerContent = (
- {icon}
- );
-
- return ref.current?.close()} />}
- />;
-};
-
-type ListProps = {
- type: "layout" | "order";
- close: () => void;
-};
-
-const List: React.FC = ({ type, close }) => {
- const { t } = useTranslation();
- const isDark = useColorScheme().scheme === "dark";
- const { layoutState, setLayoutState } = useContext(LayoutContext);
- const { eventOrder, setEventOrder } = useContext(OrderContext);
- const itemProps = useFloatingItemProps();
- const itemId = useId();
-
- const listStyle = {
- minWidth: 125,
- div: {
- cursor: "default",
- fontSize: 12,
- padding: "8px 14px 4px 14px",
- color: COLORS.neutral60,
- },
- ul: {
- listStyle: "none",
- margin: 0,
- padding: 0,
- },
- };
-
- const handleBlur = (event: React.FocusEvent) => {
- if (!event.currentTarget.contains(event.relatedTarget as HTMLUListElement)) {
- close();
- }
- };
-
- type LayoutTranslationKey = "slider" | "gallery" | "list";
- const layoutItems: [
- VideoListLayout,
- LayoutTranslationKey,
- IconType
- ][] = [
- ["SLIDER", "slider", LuColumns],
- ["GALLERY", "gallery", LuLayoutGrid],
- ["LIST", "list", LuList],
- ];
-
- type OrderTranslationKey = "new-to-old" | "old-to-new" | "a-z" | "z-a";
- const orderItems: [VideoListOrder, OrderTranslationKey][] = [
- ["NEW_TO_OLD", "new-to-old"],
- ["OLD_TO_NEW", "old-to-new"],
- ["AZ", "a-z"],
- ["ZA", "z-a"],
- ];
-
- const sharedProps = (key: LayoutTranslationKey | OrderTranslationKey) => ({
- close: close,
- label: t(`series.settings.${key}`),
- });
-
- const list = match(type, {
- layout: () => <>
- {t("series.settings.layout")}
-
- {layoutItems.map(([layout, translationKey, icon], index) => setLayoutState(layout)}
- />)}
-
- >,
- order: () => <>
- {t("series.settings.order")}
-
- {orderItems.map(([order, orderKey], index) => setEventOrder(order)}
- />)}
-
- >,
- });
-
- return
- {list}
- ;
-};
-
-type MenuItemProps = {
- Icon?: IconType;
- label: string;
- onClick?: () => void;
- close: () => void;
- disabled: boolean;
-};
-
-const MenuItem = React.forwardRef(({
- Icon, label, onClick, close, disabled,
-}, ref) => {
- const isDark = useColorScheme().scheme === "dark";
-
- return (
-
- {
- if (onClick) {
- onClick();
- }
- close();
- }}
- css={{
- display: "flex",
- alignItems: "center",
- gap: 8,
- padding: "8px 14px",
- width: "100%",
- svg: { fontSize: 16 },
- ":hover, :focus": {
- backgroundColor: isDark ? COLORS.neutral10 : COLORS.neutral15,
- },
- ...focusStyle({ inset: true }),
- "&[disabled]": {
- fontWeight: "bold",
- color: COLORS.neutral80,
- pointerEvents: "none",
- ...isDark && { backgroundColor: COLORS.neutral10 },
- },
- }}
- >
- {Icon && }
- {label}
-
-
- );
-});
-
-
-// ==============================================================================================
-// ===== Components for displaying the main part: the video items
-// ==============================================================================================
-
-type ViewProps = {
- basePath: string;
- items: {
- event: Event;
- active: boolean;
- }[];
-};
-
-const Videos: React.FC = ({ basePath, items }) => {
- const { layoutState } = useContext(LayoutContext);
- return match(layoutState, {
- SLIDER: () => ,
- GALLERY: () => ,
- LIST: () => ,
- "%future added value": () => unreachable(),
- });
-};
-
-const ITEM_MIN_SIZE = 250;
-const ITEM_MIN_SIZE_SMALL_SCREENS = 240;
-const ITEM_MAX_SIZE = 330;
-const ITEM_MAX_SIZE_SMALL_SCREENS = 360;
-
-const GalleryView: React.FC = ({ basePath, items }) => (
- // The following is not exactly what we want, but CSS does not allow us to
- // do what we want. Let me elaborate. For the sake of this explanation,
- // let's assume we want the items to be at least 240px and at most 300px
- // wide.
- //
- // What we want is the `repeat(auto-fill)` behavior of the grid. It's nice,
- // but has one crucial limitation: it is not possible to properly specify a
- // max-width for items. That's what we want: Fit as many items as possible
- // given a min item width. With the remaining space, try to grow each item
- // by the same amount. If you already grew each item as much as possible
- // (according to a max item width), then align all items in the center of
- // the container (as if the remaining space was padding-left/right of the
- // container).
- //
- // As you can see below we use `minmax(240px, 1fr)`: a fixed minimum and 1fr
- // as maximum. The minimum works well, but `1fr` as maximum means that the
- // track always takes 1fr of the container width, even if thats more than
- // the `max-width` of the items below. In that case, the `justifySelf`
- // below gets active and aligns the `Item` inside the track.
- //
- // Using `justifySelf: center` means that the remaining space is added
- // around each item, effectively growing the gap between the items. That
- // doesn't look that great.
- //
- // The obvious idea is to use `minmax(240px, 300px)` right? Except that
- // doesn't work. I'm still not sure if its intended by the spec or if
- // browser just implement it incorrectly. But with that, browsers always
- // make the tracks max (300px) wide.
- //
- // One promising solution is to use `minmax(240px, max-content)`. We do need
- // to add a `
` as child of the grid item to
- // explicitly state that the max-content is 300px (otherwise videos without
- // thumbnails break). But the larger problem is that the virtual items that
- // are imagined by `auto-fill` take the width 240px as they don't have a
- // defined max-content. At least that's the case if there are not enough
- // items to completely fill one line. So then the real and virtual items
- // have different widths, leading to weird alignment problems. These are
- // particularly apparent if two series blocks are right next to each other
- // and one of those has few enough videos to not fill a line. So I have not
- // been able to make this approach work.
- //
- // A few other ideas I tried and failed to make work:
- // - Add left and right padding to the container which we manually
- // calculate. Can't get it to work because CSS does not yet offer modulo
- // operations. I haven't found a way to polyfill `mod()` as there isn't
- // even a way to floor/round a number.
- // - Add `margin: 0 auto` to the container and/or put it into a flexbox,
- // both with `inline-grid`. It seems like `auto-fill` just doesn't work
- // with `inline-grid`. And without `inline-grid`, the container always
- // fill the whole container.
- //
- // What I ended up doing now is just putting a band-aid over the biggest
- // ugliness, which is the large gap in the worst screen width when not
- // quite fitting 3 items in a row. That happens inside the screen width
- // range 650px to 1150px. In that range, we `justifySelf: right` every odd
- // item(i.e. the left one in a 2 item line). With this alternating
- // alignment (the default is `left`), it looks as if both items in a line
- // are centered. Crucially, inside this range, there is never a
- // non-2-item-line where the alignment matters (i.e. the space is always
- // filled completely by the items). So this doesn't break anything. There
- // is still a slightly enlarged gap for a small range of screens sizes with
- // 3 items per line. But that's not too bad.
-
- {items.map(({ event, active }) => (
-
- ))}
-
-);
-
-const ListView: React.FC = ({ basePath, items }) => (
-
- {items.map(({ event, active }) => (
- - :first-child": { flex: "0 0 240px" },
- },
- }}
- />
- ))}
-
-);
-
-const SliderView: React.FC = ({ basePath, items }) => {
- const { t } = useTranslation();
- const ref = useRef(null);
- const scrollDistance = 240;
-
- const [rightVisible, setRightVisible] = useState(false);
- const [leftVisible, setLeftVisible] = useState(false);
-
- /**
- * This hides the left and/or right scroll buttons if the slider is scrolled almost all
- * the way to the left or right respectively, or when there is nothing to scroll to.
- */
- const setVisibilities = () => {
- if (ref.current) {
- const totalSliderWidth = ref.current.scrollWidth;
- const scrollPositionLeft = ref.current.scrollLeft;
- const scrollPositionRight = ref.current.scrollLeft + ref.current.offsetWidth;
- setRightVisible(scrollPositionRight < (totalSliderWidth - 16));
- setLeftVisible(scrollPositionLeft > 16);
- }
- };
-
- const scroll = (distance: number) => {
- if (ref.current) {
- ref.current.scrollLeft += distance;
- setVisibilities();
- }
- };
-
- useEffect(setVisibilities, []);
-
- const buttonCss = {
- position: "absolute",
- alignSelf: "center",
- backgroundColor: COLORS.neutral40,
- borderRadius: 24,
- padding: 11,
- transition: "background-color .05s",
- svg: {
- color: "white",
- display: "block",
- fontSize: 26,
- },
- ":hover, :focus": {
- backgroundColor: COLORS.neutral60,
- },
- ...focusStyle({}),
- } as const;
-
- return
-
setVisibilities()} ref={ref} css={{
- display: "flex",
- marginRight: 5,
- overflow: "auto",
- scrollBehavior: "smooth",
- scrollSnapType: "inline mandatory",
- ":first-child > :first-child": {
- scrollMargin: 6,
- },
- }}>
- {items.map(({ event, active }) => (
-
- ))}
- {leftVisible &&
scroll(-scrollDistance)}
- css={{ left: 8, ...buttonCss }}
- > }
- {rightVisible &&
scroll(scrollDistance)}
- css={{ right: 8, ...buttonCss }}
- > }
-
-
;
-};
-
-
-type UpcomingEventsGridProps = React.PropsWithChildren<{
- count: number;
-}>;
-
-const UpcomingEventsGrid: React.FC = ({ count, children }) => {
- const { t } = useTranslation();
-
- return (
-
-
-
- {t("series.upcoming-live-streams", { count })}
-
-
- {children}
-
- );
-};
-
-
-type ItemProps = {
- basePath: string;
- event: Event;
- active: boolean;
- showDescription?: boolean;
- className?: string;
-};
-
-const Item: React.FC = ({
- event,
- basePath,
- active,
- showDescription = false,
- className,
-}) => {
- const TRANSITION_IN_DURATION = "0.15s";
- const TRANSITION_OUT_DURATION = "0.3s";
- const date = event.syncedData?.startTime ?? event.created;
-
- const inner = <>
-
-
-
- {active && }
- {event.title}
-
-
span": {
- display: "inline-block",
- whiteSpace: "nowrap",
- },
- }}>
- {event.creators.length > 0 && {event.creators.join(", ")} }
- {/* `new Date` is well defined for our ISO Date strings */}
-
-
- {showDescription &&
}
-
- >;
-
- const containerStyle = {
- position: "relative",
- display: "block",
- padding: 6,
- borderRadius: 12,
- textDecoration: "none",
- "& a": { color: COLORS.neutral90, textDecoration: "none" },
- ...active && { backgroundColor: COLORS.neutral20 },
- ...!active && {
- "& > div:first-child": {
- transition: `transform ${TRANSITION_OUT_DURATION}, `
- + `box-shadow ${TRANSITION_OUT_DURATION},`
- + `filter ${TRANSITION_OUT_DURATION}`,
- },
- "&:hover > div:first-child, &:focus-visible > div:first-child": {
- boxShadow: "0 6px 10px rgb(0 0 0 / 40%)",
- transform: "perspective(500px) rotateX(7deg) scale(1.05)",
- transitionDuration: TRANSITION_IN_DURATION,
- "& > div:nth-child(2) > div": {
- opacity: 0.2,
- transform: "rotate(30deg)",
- transitionDuration: TRANSITION_IN_DURATION,
- },
- },
- "&:hover img, &:focus-visible img": {
- filter: "brightness(100%)",
- },
- ...focusStyle({}),
- },
- } as const;
-
- return active
- ? {inner}
- : {inner};
-};
diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx
new file mode 100644
index 000000000..e27c1f674
--- /dev/null
+++ b/frontend/src/ui/Blocks/VideoList.tsx
@@ -0,0 +1,974 @@
+import React, {
+ ReactNode,
+ createContext,
+ useContext,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import type { i18n } from "i18next";
+import {
+ match, unreachable, ProtoButton, screenWidthAtMost, screenWidthAbove,
+ useColorScheme, Floating, FloatingHandle, useFloatingItemProps, bug,
+} from "@opencast/appkit";
+import { keyframes } from "@emotion/react";
+import { IconType } from "react-icons";
+import {
+ LuColumns, LuList, LuChevronLeft, LuChevronRight, LuPlay, LuLayoutGrid, LuAlertCircle, LuInfo,
+} from "react-icons/lu";
+import { graphql } from "react-relay";
+
+import { keyOfId } from "../../util";
+import { Link } from "../../router";
+import { VideoListLayout } from "./__generated__/SeriesBlockData.graphql";
+import {
+ BaseThumbnailReplacement, isPastLiveEvent, isUpcomingLiveEvent, Thumbnail,
+ ThumbnailOverlayContainer,
+} from "../Video";
+import { RelativeDate } from "../time";
+import { CollapsibleDescription, SmallDescription } from "../metadata";
+import { darkModeBoxShadow, ellipsisOverflowCss, focusStyle } from "..";
+import { COLORS } from "../../color";
+import { FloatingBaseMenu } from "../FloatingBaseMenu";
+import { VideoListEventData$data } from "./__generated__/VideoListEventData.graphql";
+
+
+
+
+// This uses `@inline` because the fragment is used in different situations,
+// where using `useFragment` is very tricky (or maybe even impossible).
+export const videoListEventFragment = graphql`
+ fragment VideoListEventData on AuthorizedEvent @inline {
+ id
+ title
+ created
+ creators
+ isLive
+ description
+ syncedData {
+ duration
+ thumbnail
+ startTime
+ endTime
+ tracks { resolution }
+ }
+ }
+`;
+type Event = VideoListEventData$data;
+
+
+// ==============================================================================================
+// ===== Main components defining UI
+// ==============================================================================================
+
+type OrderContext = {
+ eventOrder: Order;
+ setEventOrder: (newOrder: Order) => void;
+ allowOriginalOrder: boolean;
+};
+
+const OrderContext = createContext(null);
+
+const VIDEO_GRID_BREAKPOINT = 600;
+
+type VideoListItem = Event | "missing" | "unauthorized";
+
+type Order = "ORIGINAL" | "AZ" | "ZA" | "NEW_TO_OLD" | "OLD_TO_NEW";
+
+export type VideoListBlockProps = {
+ basePath: string;
+ activeEventId?: string;
+ allowOriginalOrder: boolean;
+ initialOrder: Order;
+ initialLayout?: VideoListLayout;
+ title?: string;
+ description?: string;
+ items: ReadonlyArray;
+}
+
+export const VideoListBlock: React.FC = ({
+ basePath,
+ activeEventId,
+ allowOriginalOrder,
+ initialOrder,
+ initialLayout = "GALLERY",
+ title,
+ description,
+ items,
+}) => {
+ const { t, i18n } = useTranslation();
+ const [eventOrder, setEventOrder] = useState(initialOrder);
+ const { mainItems, upcomingLiveEvents, hiddenItems } = orderItems(items, eventOrder, i18n);
+
+ const renderEvents = (events: readonly VideoListItem[]) => (
+ ({
+ item,
+ active: item !== "missing"
+ && item !== "unauthorized"
+ && item.id === activeEventId,
+ }))}
+ />
+ );
+
+ const eventsNotEmpty = items.length > 0;
+
+ return
+
+ {(mainItems.length === 0 && upcomingLiveEvents.length === 0)
+ ? {t("videolist-block.no-videos")}
+ : <>
+ {upcomingLiveEvents.length > 1 && (
+
+ {renderEvents(upcomingLiveEvents)}
+
+ )}
+ {renderEvents(mainItems)}
+ >
+ }
+ {hiddenItems > 0 && (
+
+
+ {t("videolist-block.hidden-items", { count: hiddenItems })}
+
+ )}
+
+ ;
+};
+
+type OrderedItems = {
+ mainItems: readonly VideoListItem[];
+ upcomingLiveEvents: Event[];
+ hiddenItems: number;
+};
+
+const orderItems = (
+ items: readonly VideoListItem[],
+ eventOrder: Order,
+ i18n: i18n,
+): OrderedItems => {
+ if (eventOrder === "ORIGINAL") {
+ return {
+ mainItems: items,
+ upcomingLiveEvents: [],
+ hiddenItems: 0,
+ };
+ }
+
+ const upcomingLiveEvents: Event[] = [];
+ const mainItems: VideoListItem[] = [];
+ let hiddenItems = 0;
+ for (const event of items) {
+ // When the order isn't "original", then we don't show special items
+ // inline, but as a separate note at the bottom.
+ if (event === "missing" || event === "unauthorized") {
+ hiddenItems += 1;
+ continue;
+ }
+
+ if (isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive)) {
+ upcomingLiveEvents.push(event);
+ } else if (!isPastLiveEvent(event.syncedData?.endTime ?? null, event.isLive)) {
+ mainItems.push(event);
+ }
+ }
+
+ const timeMs = (event: Event) =>
+ new Date(event.syncedData?.startTime ?? event.created).getTime();
+
+ const collator = new Intl.Collator(i18n.language);
+ const compareEvents = (a: Event, b: Event, reverseTime = false) =>
+ match(eventOrder, {
+ "NEW_TO_OLD": () => reverseTime ? timeMs(a) - timeMs(b) : timeMs(b) - timeMs(a),
+ "OLD_TO_NEW": () => reverseTime ? timeMs(b) - timeMs(a) : timeMs(a) - timeMs(b),
+ "AZ": () => collator.compare(a.title, b.title),
+ "ZA": () => collator.compare(b.title, a.title),
+ }, unreachable);
+
+
+ mainItems.sort((a, b) => {
+ // Sort all missing and unauthorized items last. The `return` is
+ // basically unreachable code, so it could also be replaced by
+ // `unreachable`.
+ const aSpecial = a === "missing" || a === "unauthorized";
+ const bSpecial = b === "missing" || b === "unauthorized";
+ if (aSpecial || bSpecial) {
+ return +aSpecial - +bSpecial;
+ }
+
+ // Sort all live events before non-live events.
+ if (a.isLive !== b.isLive) {
+ return +b.isLive - +a.isLive;
+ }
+
+ return compareEvents(a, b);
+ });
+
+ // If there is only one upcoming event, it doesn't need an extra box or ordering.
+ if (upcomingLiveEvents.length === 1) {
+ mainItems.unshift(upcomingLiveEvents[0]);
+ } else {
+ upcomingLiveEvents.sort((a, b) => compareEvents(a, b, true));
+ }
+
+ return { mainItems, upcomingLiveEvents, hiddenItems };
+};
+
+
+type VideoListBlockContainerProps = {
+ title?: string;
+ description?: string | null;
+ children: ReactNode;
+ showViewOptions: boolean;
+ initialLayout?: VideoListLayout;
+};
+
+type LayoutContext = {
+ layoutState: VideoListLayout;
+ setLayoutState: (layout: VideoListLayout) => void;
+};
+
+const LayoutContext = createContext({
+ layoutState: "GALLERY",
+ setLayoutState: () => {},
+});
+
+export const VideoListBlockContainer: React.FC = (
+ { title, description, children, showViewOptions, initialLayout = "GALLERY" },
+) => {
+ const [layoutState, setLayoutState] = useState(initialLayout);
+ const isDark = useColorScheme().scheme === "dark";
+
+ return
+
+ <>
+
+ {title &&
{title} }
+
+ {description &&
}
+ {showViewOptions &&
+
+
+
}
+
+
+ {description &&
}
+ >
+ {children}
+
+ ;
+};
+
+
+
+// ==============================================================================================
+// ===== The menus for choosing order and layout mode
+// ==============================================================================================
+
+
+const OrderMenu: React.FC = () => {
+ const { t } = useTranslation();
+ const ref = useRef(null);
+ const order = useContext(OrderContext) ?? bug("order context not defined for videolist block");
+
+ const triggerContent = match(order.eventOrder, {
+ "ORIGINAL": () => t("videolist-block.settings.original"),
+ "NEW_TO_OLD": () => t("videolist-block.settings.new-to-old"),
+ "OLD_TO_NEW": () => t("videolist-block.settings.old-to-new"),
+ "AZ": () => t("videolist-block.settings.a-z"),
+ "ZA": () => t("videolist-block.settings.z-a"),
+ });
+
+ return {triggerContent}>}
+ list={ ref.current?.close()} />}
+ />;
+};
+
+const LayoutMenu: React.FC = () => {
+ const { t } = useTranslation();
+ const state = useContext(LayoutContext);
+ const ref = useRef(null);
+
+ const icon = match(state.layoutState, {
+ SLIDER: () => ,
+ GALLERY: () => ,
+ LIST: () => ,
+ "%future added value": () => unreachable(),
+ });
+
+ const triggerContent = (
+ {icon}
+ );
+
+ return ref.current?.close()} />}
+ />;
+};
+
+
+type ListProps = {
+ type: "layout" | "order";
+ close: () => void;
+};
+
+const List: React.FC = ({ type, close }) => {
+ const { t } = useTranslation();
+ const isDark = useColorScheme().scheme === "dark";
+ const { layoutState, setLayoutState } = useContext(LayoutContext);
+ const { eventOrder, setEventOrder, allowOriginalOrder }
+ = useContext(OrderContext) ?? bug("missing order context");
+ const itemProps = useFloatingItemProps();
+ const itemId = useId();
+
+ const listStyle = {
+ minWidth: 125,
+ div: {
+ cursor: "default",
+ fontSize: 12,
+ padding: "8px 14px 4px 14px",
+ color: COLORS.neutral60,
+ },
+ ul: {
+ listStyle: "none",
+ margin: 0,
+ padding: 0,
+ },
+ };
+
+ const handleBlur = (event: React.FocusEvent) => {
+ if (!event.currentTarget.contains(event.relatedTarget as HTMLUListElement)) {
+ close();
+ }
+ };
+
+ type LayoutTranslationKey = "slider" | "gallery" | "list";
+ const layoutItems: [
+ VideoListLayout,
+ LayoutTranslationKey,
+ IconType
+ ][] = [
+ ["SLIDER", "slider", LuColumns],
+ ["GALLERY", "gallery", LuLayoutGrid],
+ ["LIST", "list", LuList],
+ ];
+
+ type OrderTranslationKey = "original" | "new-to-old" | "old-to-new" | "a-z" | "z-a";
+ const orderItems: [Order, OrderTranslationKey][] = [
+ ["NEW_TO_OLD", "new-to-old"],
+ ["OLD_TO_NEW", "old-to-new"],
+ ["AZ", "a-z"],
+ ["ZA", "z-a"],
+ ];
+ if (allowOriginalOrder) {
+ orderItems.unshift(["ORIGINAL", "original"]);
+ }
+
+ const sharedProps = (key: LayoutTranslationKey | OrderTranslationKey) => ({
+ close: close,
+ label: t(`videolist-block.settings.${key}`),
+ });
+
+ const list = match(type, {
+ layout: () => <>
+ {t("videolist-block.settings.layout")}
+
+ {layoutItems.map(([layout, translationKey, icon], index) => setLayoutState(layout)}
+ />)}
+
+ >,
+ order: () => <>
+ {t("videolist-block.settings.order")}
+
+ {orderItems.map(([order, orderKey], index) => setEventOrder(order)}
+ />)}
+
+ >,
+ });
+
+ return
+ {list}
+ ;
+};
+
+
+type MenuItemProps = {
+ Icon?: IconType;
+ label: string;
+ onClick?: () => void;
+ close: () => void;
+ disabled: boolean;
+};
+
+const MenuItem = React.forwardRef(({
+ Icon, label, onClick, close, disabled,
+}, ref) => {
+ const isDark = useColorScheme().scheme === "dark";
+
+ return (
+
+ {
+ if (onClick) {
+ onClick();
+ }
+ close();
+ }}
+ css={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "8px 14px",
+ width: "100%",
+ svg: { fontSize: 16 },
+ ":hover, :focus": {
+ backgroundColor: isDark ? COLORS.neutral10 : COLORS.neutral15,
+ },
+ ...focusStyle({ inset: true }),
+ "&[disabled]": {
+ fontWeight: "bold",
+ color: COLORS.neutral80,
+ pointerEvents: "none",
+ ...isDark && { backgroundColor: COLORS.neutral10 },
+ },
+ }}
+ >
+ {Icon && }
+ {label}
+
+
+ );
+});
+
+// ==============================================================================================
+// ===== Components for displaying the main part: the video items
+// ==============================================================================================
+
+type ViewProps = {
+ basePath: string;
+ items: {
+ item: VideoListItem;
+ active: boolean;
+ }[];
+};
+
+const Items: React.FC = ({ basePath, items }) => {
+ const { layoutState } = useContext(LayoutContext);
+ return match(layoutState, {
+ SLIDER: () => ,
+ GALLERY: () => ,
+ LIST: () => ,
+ "%future added value": () => unreachable(),
+ });
+};
+
+const ITEM_MIN_SIZE = 250;
+const ITEM_MIN_SIZE_SMALL_SCREENS = 240;
+const ITEM_MAX_SIZE = 330;
+const ITEM_MAX_SIZE_SMALL_SCREENS = 360;
+
+const GalleryView: React.FC = ({ basePath, items }) => (
+ // The following is not exactly what we want, but CSS does not allow us to
+ // do what we want. Let me elaborate. For the sake of this explanation,
+ // let's assume we want the items to be at least 240px and at most 300px
+ // wide.
+ //
+ // What we want is the `repeat(auto-fill)` behavior of the grid. It's nice,
+ // but has one crucial limitation: it is not possible to properly specify a
+ // max-width for items. That's what we want: Fit as many items as possible
+ // given a min item width. With the remaining space, try to grow each item
+ // by the same amount. If you already grew each item as much as possible
+ // (according to a max item width), then align all items in the center of
+ // the container (as if the remaining space was padding-left/right of the
+ // container).
+ //
+ // As you can see below we use `minmax(240px, 1fr)`: a fixed minimum and 1fr
+ // as maximum. The minimum works well, but `1fr` as maximum means that the
+ // track always takes 1fr of the container width, even if thats more than
+ // the `max-width` of the items below. In that case, the `justifySelf`
+ // below gets active and aligns the `Item` inside the track.
+ //
+ // Using `justifySelf: center` means that the remaining space is added
+ // around each item, effectively growing the gap between the items. That
+ // doesn't look that great.
+ //
+ // The obvious idea is to use `minmax(240px, 300px)` right? Except that
+ // doesn't work. I'm still not sure if its intended by the spec or if
+ // browser just implement it incorrectly. But with that, browsers always
+ // make the tracks max (300px) wide.
+ //
+ // One promising solution is to use `minmax(240px, max-content)`. We do need
+ // to add a `
` as child of the grid item to
+ // explicitly state that the max-content is 300px (otherwise videos without
+ // thumbnails break). But the larger problem is that the virtual items that
+ // are imagined by `auto-fill` take the width 240px as they don't have a
+ // defined max-content. At least that's the case if there are not enough
+ // items to completely fill one line. So then the real and virtual items
+ // have different widths, leading to weird alignment problems. These are
+ // particularly apparent if two series blocks are right next to each other
+ // and one of those has few enough videos to not fill a line. So I have not
+ // been able to make this approach work.
+ //
+ // A few other ideas I tried and failed to make work:
+ // - Add left and right padding to the container which we manually
+ // calculate. Can't get it to work because CSS does not yet offer modulo
+ // operations. I haven't found a way to polyfill `mod()` as there isn't
+ // even a way to floor/round a number.
+ // - Add `margin: 0 auto` to the container and/or put it into a flexbox,
+ // both with `inline-grid`. It seems like `auto-fill` just doesn't work
+ // with `inline-grid`. And without `inline-grid`, the container always
+ // fill the whole container.
+ //
+ // What I ended up doing now is just putting a band-aid over the biggest
+ // ugliness, which is the large gap in the worst screen width when not
+ // quite fitting 3 items in a row. That happens inside the screen width
+ // range 650px to 1150px. In that range, we `justifySelf: right` every odd
+ // item(i.e. the left one in a 2 item line). With this alternating
+ // alignment (the default is `left`), it looks as if both items in a line
+ // are centered. Crucially, inside this range, there is never a
+ // non-2-item-line where the alignment matters (i.e. the space is always
+ // filled completely by the items). So this doesn't break anything. There
+ // is still a slightly enlarged gap for a small range of screens sizes with
+ // 3 items per line. But that's not too bad.
+
+ {items.map(({ item, active }, idx) => (
+
+ ))}
+
+);
+
+const ListView: React.FC = ({ basePath, items }) => (
+
+ {items.map(({ item, active }, idx) => (
+ - :first-child": { flex: "0 0 240px" },
+ },
+ }}
+ />
+ ))}
+
+);
+
+const SliderView: React.FC = ({ basePath, items }) => {
+ const { t } = useTranslation();
+ const ref = useRef(null);
+ const scrollDistance = 240;
+
+ const [rightVisible, setRightVisible] = useState(false);
+ const [leftVisible, setLeftVisible] = useState(false);
+
+ /**
+ * This hides the left and/or right scroll buttons if the slider is scrolled almost all
+ * the way to the left or right respectively, or when there is nothing to scroll to.
+ */
+ const setVisibilities = () => {
+ if (ref.current) {
+ const totalSliderWidth = ref.current.scrollWidth;
+ const scrollPositionLeft = ref.current.scrollLeft;
+ const scrollPositionRight = ref.current.scrollLeft + ref.current.offsetWidth;
+ setRightVisible(scrollPositionRight < (totalSliderWidth - 16));
+ setLeftVisible(scrollPositionLeft > 16);
+ }
+ };
+
+ const scroll = (distance: number) => {
+ if (ref.current) {
+ ref.current.scrollLeft += distance;
+ setVisibilities();
+ }
+ };
+
+ useEffect(setVisibilities, []);
+
+ const buttonCss = {
+ position: "absolute",
+ alignSelf: "center",
+ backgroundColor: COLORS.neutral40,
+ borderRadius: 24,
+ padding: 11,
+ transition: "background-color .05s",
+ svg: {
+ color: "white",
+ display: "block",
+ fontSize: 26,
+ },
+ ":hover, :focus": {
+ backgroundColor: COLORS.neutral60,
+ },
+ ...focusStyle({}),
+ } as const;
+
+ return
+
setVisibilities()} ref={ref} css={{
+ display: "flex",
+ marginRight: 5,
+ overflow: "auto",
+ scrollBehavior: "smooth",
+ scrollSnapType: "inline mandatory",
+ ":first-child > :first-child": {
+ scrollMargin: 6,
+ },
+ }}>
+ {items.map(({ item, active }, idx) => (
+
+ ))}
+ {leftVisible &&
scroll(-scrollDistance)}
+ css={{ left: 8, ...buttonCss }}
+ > }
+ {rightVisible &&
scroll(scrollDistance)}
+ css={{ right: 8, ...buttonCss }}
+ > }
+
+
;
+};
+
+
+type UpcomingEventsGridProps = React.PropsWithChildren<{
+ count: number;
+}>;
+
+const UpcomingEventsGrid: React.FC = ({ count, children }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t("videolist-block.upcoming-live-streams", { count })}
+
+
+ {children}
+
+ );
+};
+
+
+type ItemProps = {
+ basePath: string;
+ item: VideoListItem;
+ active: boolean;
+ showDescription?: boolean;
+ className?: string;
+};
+
+const Item: React.FC = ({
+ item,
+ basePath,
+ active,
+ showDescription = false,
+ className,
+}) => {
+ const { t } = useTranslation();
+ const isPlaceholder = item === "missing" || item === "unauthorized";
+
+ const TRANSITION_IN_DURATION = "0.15s";
+ const TRANSITION_OUT_DURATION = "0.3s";
+
+ const thumbnail = isPlaceholder
+ ?
+
+
+
+
+ : <>
+
+
+ >;
+ const title = (() => {
+ const placeholderStyle = {
+ fontWeight: "normal",
+ color: COLORS.neutral80,
+ } as const;
+ if (item === "missing") {
+ return {t("videolist-block.missing-video")} ;
+ } else if (item === "unauthorized") {
+ return {t("videolist-block.unauthorized")} ;
+ } else {
+ return item.title;
+ }
+ })();
+
+ const inner = <>
+ {thumbnail}
+
+
+ {active && }
+ {title}
+
+ {!isPlaceholder && <>
+
span": {
+ display: "inline-block",
+ whiteSpace: "nowrap",
+ },
+ }}>
+ {item.creators.length > 0 && {item.creators.join(", ")} }
+ {/* `new Date` is well defined for our ISO Date strings */}
+
+
+ {showDescription &&
}
+ >}
+
+ >;
+
+ const containerStyle = {
+ position: "relative",
+ display: "block",
+ padding: 6,
+ borderRadius: 12,
+ textDecoration: "none",
+ "& a": { color: COLORS.neutral90, textDecoration: "none" },
+ ...active && { backgroundColor: COLORS.neutral20 },
+ ...!active && !isPlaceholder && {
+ "& > div:first-child": {
+ transition: `transform ${TRANSITION_OUT_DURATION}, `
+ + `box-shadow ${TRANSITION_OUT_DURATION},`
+ + `filter ${TRANSITION_OUT_DURATION}`,
+ },
+ "&:hover > div:first-child, &:focus-visible > div:first-child": {
+ boxShadow: "0 6px 10px rgb(0 0 0 / 40%)",
+ transform: "perspective(500px) rotateX(7deg) scale(1.05)",
+ transitionDuration: TRANSITION_IN_DURATION,
+ "& > div:nth-child(2) > div": {
+ opacity: 0.2,
+ transform: "rotate(30deg)",
+ transitionDuration: TRANSITION_IN_DURATION,
+ },
+ },
+ "&:hover img, &:focus-visible img": {
+ filter: "brightness(100%)",
+ },
+ ...focusStyle({}),
+ },
+ } as const;
+
+ return (active || isPlaceholder)
+ ? {inner}
+ : {inner};
+};
diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx
index 19727601d..3e7ee9b9a 100644
--- a/frontend/src/ui/Video.tsx
+++ b/frontend/src/ui/Video.tsx
@@ -110,12 +110,7 @@ export const ThumbnailReplacement: React.FC = (
// Otherwise we use a generic icon.
const icon = audioOnly ? : ;
- return = (
: "linear-gradient(135deg, #3e3e3e80 50%, transparent 0),"
+ "linear-gradient(-135deg, #3e3e3e80 50%, transparent 0)",
},
- }}>{icon}
;
+ }}>{icon};
};
+type BaseThumbnailReplacementProps = PropsWithChildren<{
+ className?: string;
+}>;
+export const BaseThumbnailReplacement: React.FC = ({
+ children,
+ className,
+}) => (
+ {children}
+);
+
type ThumbnailOverlayProps = PropsWithChildren<{
backgroundColor: string;
}>;
diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts
index ab9cb4699..07e578fc9 100644
--- a/frontend/src/util/index.ts
+++ b/frontend/src/util/index.ts
@@ -26,6 +26,9 @@ export const eventId = (key: string) => `ev${key}`;
/** Constructs series ID for graphQL by adding the "sr" prefix. */
export const seriesId = (key: string) => `sr${key}`;
+/** Constructs series ID for graphQL by adding the "sr" prefix. */
+export const playlistId = (key: string) => `pl${key}`;
+
/**
* Create a comparison function for `Array.prototype.sort` comparing whatever
* the given key function returns as numbers.