a lot of changes to make videos a bit more robust

I still have a problem in lagging while moving the mouse though.
This commit is contained in:
Chris Cochrun 2024-12-16 22:25:17 -06:00
parent 2bcf421e48
commit 7e7d27ecff
13 changed files with 300 additions and 96 deletions

2
Cargo.lock generated
View file

@ -3580,6 +3580,8 @@ dependencies = [
"clap", "clap",
"crisp", "crisp",
"dirs", "dirs",
"gstreamer",
"gstreamer-app",
"iced_video_player", "iced_video_player",
"lexpr", "lexpr",
"libcosmic", "libcosmic",

View file

@ -25,4 +25,6 @@ dirs = "5.0.1"
tokio = "1.41.1" tokio = "1.41.1"
crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" } crisp = { git = "https://git.tfcconnection.org/chris/crisp", version = "0.1.3" }
rodio = { version = "0.20.1", features = ["symphonia-all", "tracing"] } rodio = { version = "0.20.1", features = ["symphonia-all", "tracing"] }
gstreamer = "0.23.3"
gstreamer-app = "0.23.3"

View file

@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::Slide; use crate::Slide;
use super::{ use super::{
images::Image, images::Image, presentations::Presentation, songs::Song,
presentations::Presentation,
songs::Song,
videos::Video, videos::Video,
}; };

View file

@ -6,4 +6,5 @@ pub mod presentations;
pub mod service_items; pub mod service_items;
pub mod slide; pub mod slide;
pub mod songs; pub mod songs;
pub mod thumbnail;
pub mod videos; pub mod videos;

View file

@ -5,8 +5,7 @@ use crisp::types::{Keyword, Symbol, Value};
use miette::{miette, IntoDiagnostic, Result}; use miette::{miette, IntoDiagnostic, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
query, sqlite::SqliteRow, FromRow, Row, query, sqlite::SqliteRow, FromRow, Row, SqliteConnection,
SqliteConnection,
}; };
use tracing::{debug, error}; use tracing::{debug, error};
@ -256,7 +255,9 @@ pub fn lisp_to_song(list: Vec<Value>) -> Song {
None None
}; };
let first_text_postiion = list.iter().position(|v| match v { let first_text_postiion = list
.iter()
.position(|v| match v {
Value::List(inner) => { Value::List(inner) => {
(match &inner[0] { (match &inner[0] {
Value::Symbol(Symbol(text)) => { Value::Symbol(Symbol(text)) => {
@ -272,7 +273,8 @@ pub fn lisp_to_song(list: Vec<Value>) -> Song {
}) })
} }
_ => false, _ => false,
}).unwrap_or(1); })
.unwrap_or(1);
let lyric_elements = &list[first_text_postiion..]; let lyric_elements = &list[first_text_postiion..];

123
src/core/thumbnail.rs Normal file
View file

@ -0,0 +1,123 @@
use dirs;
use std::error::Error;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str;
use tracing::debug;
pub fn bg_from_video(
video: &Path,
screenshot: &Path,
) -> Result<(), Box<dyn Error>> {
if !screenshot.exists() {
let output_duration = Command::new("ffprobe")
.args(["-i", &video.to_string_lossy()])
.output()
.expect("failed to execute ffprobe");
io::stderr().write_all(&output_duration.stderr).unwrap();
let mut at_second = 5;
let mut log = str::from_utf8(&output_duration.stderr)
.expect("Using non UTF-8 characters")
.to_string();
debug!(log);
if let Some(duration_index) = log.find("Duration") {
let mut duration = log.split_off(duration_index + 10);
duration.truncate(11);
// debug!("rust-duration-is: {duration}");
let mut hours = String::from("");
let mut minutes = String::from("");
let mut seconds = String::from("");
for (i, c) in duration.chars().enumerate() {
if i <= 1 {
hours.push(c);
} else if i > 2 && i <= 4 {
minutes.push(c);
} else if i > 5 && i <= 7 {
seconds.push(c);
}
}
let hours: i32 = hours.parse().unwrap_or_default();
let mut minutes: i32 =
minutes.parse().unwrap_or_default();
let mut seconds: i32 =
seconds.parse().unwrap_or_default();
minutes += hours * 60;
seconds += minutes * 60;
at_second = seconds / 5;
debug!(hours, minutes, seconds, at_second);
}
let _output = Command::new("ffmpeg")
.args([
"-i",
&video.to_string_lossy(),
"-ss",
&at_second.to_string(),
"-vframes",
"1",
"-y",
&screenshot.to_string_lossy(),
])
.output()
.expect("failed to execute ffmpeg");
// io::stdout().write_all(&output.stdout).unwrap();
// io::stderr().write_all(&output.stderr).unwrap();
} else {
debug!("Screenshot already exists");
}
Ok(())
}
pub fn bg_path_from_video(video: &Path) -> PathBuf {
let video = PathBuf::from(video);
debug!(?video);
let mut data_dir = dirs::data_local_dir().unwrap();
data_dir.push("lumina");
data_dir.push("thumbnails");
if !data_dir.exists() {
fs::create_dir(&data_dir)
.expect("Could not create thumbnails dir");
}
let mut screenshot = data_dir.clone();
screenshot.push(video.file_name().unwrap());
screenshot.set_extension("png");
screenshot
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_bg_video_creation() {
let video = Path::new("/home/chris/vids/moms-funeral.mp4");
let screenshot = bg_path_from_video(video);
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_eq!(screenshot_string, "/home/chris/.local/share/lumina/thumbnails/moms-funeral.png");
// let runtime = tokio::runtime::Runtime::new().unwrap();
let result = bg_from_video(video, &screenshot);
// let result = runtime.block_on(future);
match result {
Ok(_o) => assert!(screenshot.exists()),
Err(e) => debug_assert!(
false,
"There was an error in the runtime future. {:?}",
e
),
}
}
#[test]
fn test_bg_not_same() {
let video = Path::new(
"/home/chris/vids/All WebDev Sucks and you know it.webm",
);
let screenshot = bg_path_from_video(video);
let screenshot_string =
screenshot.to_str().expect("Should be thing");
assert_ne!(screenshot_string, "/home/chris/.local/share/lumina/thumbnails/All WebDev Sucks and you know it.webm");
}
}

View file

@ -82,9 +82,7 @@ impl From<&Value> for Video {
}) { }) {
let pos = loop_pos + 1; let pos = loop_pos + 1;
list.get(pos) list.get(pos)
.map(|l| { .map(|l| String::from(l) == *"true")
String::from(l) == *"true"
})
.unwrap_or_default() .unwrap_or_default()
} else { } else {
false false

View file

@ -4,18 +4,14 @@ use cosmic::app::context_drawer::ContextDrawer;
use cosmic::app::{Core, Settings, Task}; use cosmic::app::{Core, Settings, Task};
use cosmic::iced::keyboard::{Key, Modifiers}; use cosmic::iced::keyboard::{Key, Modifiers};
use cosmic::iced::window::{Mode, Position}; use cosmic::iced::window::{Mode, Position};
use cosmic::iced::{ use cosmic::iced::{self, event, window, Length, Padding, Point};
self, event, window, Length, Padding, Point,
};
use cosmic::iced_futures::Subscription; use cosmic::iced_futures::Subscription;
use cosmic::iced_widget::{column, row}; use cosmic::iced_widget::{column, row};
use cosmic::prelude::ElementExt; use cosmic::prelude::ElementExt;
use cosmic::prelude::*; use cosmic::prelude::*;
use cosmic::widget::nav_bar::nav_bar_style; use cosmic::widget::nav_bar::nav_bar_style;
use cosmic::widget::tooltip::Position as TPosition; use cosmic::widget::tooltip::Position as TPosition;
use cosmic::widget::{ use cosmic::widget::{button, nav_bar, text, tooltip, Space};
button, nav_bar, text, tooltip, Space,
};
use cosmic::widget::{icon, slider}; use cosmic::widget::{icon, slider};
use cosmic::{executor, Application, ApplicationExt, Element}; use cosmic::{executor, Application, ApplicationExt, Element};
use cosmic::{widget::Container, Theme}; use cosmic::{widget::Container, Theme};
@ -281,9 +277,7 @@ impl cosmic::Application for App {
.class(cosmic::theme::style::Button::HeaderBar) .class(cosmic::theme::style::Button::HeaderBar)
.on_press({ .on_press({
if self.presentation_open { if self.presentation_open {
Message::CloseWindow( Message::CloseWindow(presenter_window.copied())
presenter_window.copied(),
)
} else { } else {
Message::OpenWindow Message::OpenWindow
} }
@ -384,24 +378,20 @@ impl cosmic::Application for App {
presenter::Message::NextSlide, presenter::Message::NextSlide,
)), )),
(Key::Character(k), _) (Key::Character(k), _)
if k == *"j" if k == *"j" || k == *"l" =>
|| k == *"l" =>
{ {
self.update(Message::Present( self.update(Message::Present(
presenter::Message::NextSlide, presenter::Message::NextSlide,
)) ))
} }
(Key::Character(k), _) (Key::Character(k), _)
if k == *"k" if k == *"k" || k == *"h" =>
|| k == *"h" =>
{ {
self.update(Message::Present( self.update(Message::Present(
presenter::Message::PrevSlide, presenter::Message::PrevSlide,
)) ))
} }
(Key::Character(k), _) (Key::Character(k), _) if k == *"q" => {
if k == *"q" =>
{
self.update(Message::Quit) self.update(Message::Quit)
} }
_ => Task::none(), _ => Task::none(),
@ -456,13 +446,12 @@ impl cosmic::Application for App {
Message::WindowOpened(id, _) => { Message::WindowOpened(id, _) => {
debug!(?id, "Window opened"); debug!(?id, "Window opened");
if self.cli_mode if self.cli_mode
|| id > self.core.main_window_id().unwrap() || id > self.core.main_window_id().expect("Cosmic core seems to be missing a main window, was this started in cli mode?")
{ {
self.presentation_open = true; self.presentation_open = true;
if let Some(video) = &mut self.presenter.video { if let Some(video) = &mut self.presenter.video {
video.set_muted(false); video.set_muted(false);
} }
warn!(self.presentation_open);
window::change_mode(id, Mode::Fullscreen) window::change_mode(id, Mode::Fullscreen)
} else { } else {
Task::none() Task::none()
@ -500,29 +489,23 @@ impl cosmic::Application for App {
let icon_left = icon::from_name("arrow-left"); let icon_left = icon::from_name("arrow-left");
let icon_right = icon::from_name("arrow-right"); let icon_right = icon::from_name("arrow-right");
let video_range: f32 = let video_range = self.presenter.video.as_ref().map_or_else(
if let Some(video) = &self.presenter.video { || 0.0,
let duration = video.duration(); |video| video.duration().as_secs_f32(),
duration.as_secs_f32() );
} else {
0.0
};
let video_button_icon = let video_button_icon =
if let Some(video) = &self.presenter.video { if let Some(video) = &self.presenter.video {
if video.paused() { let (icon_name, tooltip) = if video.paused() {
button::icon(icon::from_name("media-play")) ("media-play", "Play")
.tooltip("Play")
.on_press(Message::Present(
presenter::Message::StartVideo,
))
} else { } else {
button::icon(icon::from_name("media-pause")) ("media-pause", "Pause")
.tooltip("Pause") };
.on_press(Message::Present( button::icon(icon::from_name(icon_name))
presenter::Message::StartVideo, .tooltip(tooltip)
)) .on_press(Message::Present(
} presenter::Message::StartVideo,
))
} else { } else {
button::icon(icon::from_name("media-play")) button::icon(icon::from_name("media-play"))
.tooltip("Play") .tooltip("Play")
@ -534,9 +517,7 @@ impl cosmic::Application for App {
let slide_preview = column![ let slide_preview = column![
Space::with_height(Length::Fill), Space::with_height(Length::Fill),
Container::new( Container::new(
self.presenter self.presenter.view_preview().map(Message::Present),
.view_preview()
.map(Message::Present),
) )
.align_bottom(Length::Fill), .align_bottom(Length::Fill),
Container::new(if self.presenter.video.is_some() { Container::new(if self.presenter.video.is_some() {
@ -610,9 +591,7 @@ impl cosmic::Application for App {
let column = column![ let column = column![
Container::new(row).center_y(Length::Fill), Container::new(row).center_y(Length::Fill),
Container::new( Container::new(
self.presenter self.presenter.preview_bar().map(Message::Present)
.preview_bar()
.map(Message::Present)
) )
.clip(true) .clip(true)
.width(Length::Fill) .width(Length::Fill)
@ -625,7 +604,6 @@ impl cosmic::Application for App {
// View for presentation // View for presentation
fn view_window(&self, _id: window::Id) -> Element<Message> { fn view_window(&self, _id: window::Id) -> Element<Message> {
debug!("window");
self.presenter.view().map(Message::Present) self.presenter.view().map(Message::Present)
} }
} }
@ -634,22 +612,21 @@ impl App
where where
Self: cosmic::Application, Self: cosmic::Application,
{ {
fn active_page_title(&mut self) -> &str { fn active_page_title(&self) -> &str {
// self.nav_model let Some(label) =
// .text(self.nav_model.active()) self.nav_model.text(self.nav_model.active())
// .unwrap_or("Unknown Page") else {
"Lumina" return "Lumina";
};
label
} }
fn update_title(&mut self) -> Task<Message> { fn update_title(&mut self) -> Task<Message> {
let header_title = self.active_page_title().to_owned(); let header_title = self.active_page_title().to_owned();
let window_title = format!("{header_title} — Lumina"); let window_title = format!("{header_title} — Lumina");
// self.set_header_title(header_title); self.core.main_window_id().map_or_else(Task::none, |id| {
if let Some(id) = self.core.main_window_id() {
self.set_window_title(window_title, id) self.set_window_title(window_title, id)
} else { })
Task::none()
}
} }
fn show_window(&mut self) -> Task<Message> { fn show_window(&mut self) -> Task<Message> {

View file

@ -1 +1,2 @@
pub mod presenter; pub mod presenter;
pub mod video;

View file

@ -1,3 +1,4 @@
use miette::{miette, IntoDiagnostic, Result};
use std::{fs::File, io::BufReader, path::PathBuf, sync::Arc}; use std::{fs::File, io::BufReader, path::PathBuf, sync::Arc};
use cosmic::{ use cosmic::{
@ -21,9 +22,9 @@ use cosmic::{
}, },
Task, Task,
}; };
use iced_video_player::{Position, Video, VideoPlayer}; use iced_video_player::{gst_pbutils, Position, Video, VideoPlayer};
use rodio::{Decoder, OutputStream, Sink}; use rodio::{Decoder, OutputStream, Sink};
use tracing::{debug, error}; use tracing::{debug, error, info, warn};
use crate::{ use crate::{
core::{service_items::ServiceItemModel, slide::Slide}, core::{service_items::ServiceItemModel, slide::Slide},
@ -45,7 +46,7 @@ pub(crate) struct Presenter {
current_font: Font, current_font: Font,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone)]
pub(crate) enum Message { pub(crate) enum Message {
NextSlide, NextSlide,
PrevSlide, PrevSlide,
@ -56,42 +57,82 @@ pub(crate) enum Message {
EndAudio, EndAudio,
VideoPos(f32), VideoPos(f32),
VideoFrame, VideoFrame,
MissingPlugin(gstreamer::Message),
HoveredSlide(i32), HoveredSlide(i32),
ChangeFont(String), ChangeFont(String),
Error(String),
None, None,
} }
impl Presenter { impl Presenter {
fn create_video(url: Url) -> Result<Video> {
// Based on `iced_video_player::Video::new`,
// but without a text sink so that the built-in subtitle functionality triggers.
use gstreamer as gst;
use gstreamer_app as gst_app;
use gstreamer_app::prelude::*;
gst::init().into_diagnostic()?;
let pipeline = format!(
r#"playbin uri="{}" video-sink="videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1""#,
url.as_str()
);
let pipeline = gst::parse::launch(pipeline.as_ref())
.into_diagnostic()?;
let pipeline = pipeline
.downcast::<gst::Pipeline>()
.map_err(|_| iced_video_player::Error::Cast)
.into_diagnostic()?;
let video_sink: gst::Element =
pipeline.property("video-sink");
let pad = video_sink.pads().first().cloned().unwrap();
let pad = pad.dynamic_cast::<gst::GhostPad>().unwrap();
let bin = pad
.parent_element()
.unwrap()
.downcast::<gst::Bin>()
.unwrap();
let video_sink = bin.by_name("iced_video").unwrap();
let video_sink =
video_sink.downcast::<gst_app::AppSink>().unwrap();
let result =
Video::from_gst_pipeline(pipeline, video_sink, None);
result.into_diagnostic()
}
pub fn with_items(items: ServiceItemModel) -> Self { pub fn with_items(items: ServiceItemModel) -> Self {
let slides = items.to_slides().unwrap_or_default(); let slides = items.to_slides().unwrap_or_default();
let video = {
if let Some(slide) = slides.first() {
let path = slide.background().path.clone();
if path.exists() {
let url = Url::from_file_path(path).unwrap();
let result = Self::create_video(url);
match result {
Ok(mut v) => {
v.set_paused(true);
Some(v)
}
Err(e) => {
error!("Had an error creating the video object: {e}, likely the first slide isn't a video");
None
}
}
} else {
None
}
} else {
None
}
};
Self { Self {
slides: slides.clone(), slides: slides.clone(),
items, items,
current_slide: slides[0].clone(), current_slide: slides[0].clone(),
current_slide_index: 0, current_slide_index: 0,
video: { video,
if let Some(slide) = slides.first() {
let path = slide.background().path.clone();
if path.exists() {
let url = Url::from_file_path(path).unwrap();
let result = Video::new(&url);
match result {
Ok(mut v) => {
v.set_paused(true);
Some(v)
}
Err(e) => {
error!("Had an error creating the video object: {e}");
None
}
}
} else {
None
}
} else {
None
}
},
audio: slides[0].audio(), audio: slides[0].audio(),
video_position: 0.0, video_position: 0.0,
hovered_slide: -1, hovered_slide: -1,
@ -134,14 +175,20 @@ impl Presenter {
} }
Message::SlideChange(id) => { Message::SlideChange(id) => {
debug!(id, "slide changed"); debug!(id, "slide changed");
let old_background =
self.current_slide.background().clone();
self.current_slide_index = id; self.current_slide_index = id;
if let Some(slide) = self.slides.get(id as usize) { if let Some(slide) = self.slides.get(id as usize) {
self.current_slide = slide.clone(); self.current_slide = slide.clone();
let _ = self let _ = self
.update(Message::ChangeFont(slide.font())); .update(Message::ChangeFont(slide.font()));
} }
if let Some(video) = &mut self.video { if self.current_slide.background() != &old_background
let _ = video.restart_stream(); {
if let Some(video) = &mut self.video {
let _ = video.restart_stream();
}
self.reset_video();
} }
let offset = AbsoluteOffset { let offset = AbsoluteOffset {
@ -158,7 +205,6 @@ impl Presenter {
let mut tasks = vec![]; let mut tasks = vec![];
tasks.push(scroll_to(self.scroll_id.clone(), offset)); tasks.push(scroll_to(self.scroll_id.clone(), offset));
self.reset_video();
if let Some(audio) = &mut self.current_slide.audio() { if let Some(audio) = &mut self.current_slide.audio() {
let audio = audio.to_str().unwrap().to_string(); let audio = audio.to_str().unwrap().to_string();
let audio = if let Some(audio) = let audio = if let Some(audio) =
@ -253,6 +299,42 @@ impl Presenter {
} }
Task::none() Task::none()
} }
Message::MissingPlugin(element) => {
if let Some(video) = &mut self.video {
video.set_paused(true);
}
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
match gst_pbutils::MissingPluginMessage::parse(&element) {
Ok(missing_plugin) => {
let mut install_ctx = gst_pbutils::InstallPluginsContext::new();
install_ctx
.set_desktop_id(&format!("{}.desktop", "org.chriscochrun.lumina"));
let install_detail = missing_plugin.installer_detail();
println!("installing plugins: {}", install_detail);
let status = gst_pbutils::missing_plugins::install_plugins_sync(
&[&install_detail],
Some(&install_ctx),
);
info!("plugin install status: {}", status);
info!(
"gstreamer registry update: {:?}",
gstreamer::Registry::update()
);
}
Err(err) => {
warn!("failed to parse missing plugin message: {err}");
}
}
Message::None
})
.await
.unwrap()
},
|x| x,
)
}
Message::HoveredSlide(slide) => { Message::HoveredSlide(slide) => {
self.hovered_slide = slide; self.hovered_slide = slide;
Task::none() Task::none()
@ -273,6 +355,10 @@ impl Presenter {
Task::none() Task::none()
} }
Message::None => Task::none(), Message::None => Task::none(),
Message::Error(error) => {
error!(error);
Task::none()
}
} }
} }
@ -348,6 +434,15 @@ impl Presenter {
.height(size.width * 9.0 / 16.0) .height(size.width * 9.0 / 16.0)
.on_end_of_stream(Message::EndVideo) .on_end_of_stream(Message::EndVideo)
.on_new_frame(Message::VideoFrame) .on_new_frame(Message::VideoFrame)
.on_missing_plugin(
Message::MissingPlugin,
)
.on_warning(|w| {
Message::Error(w.to_string())
})
.on_error(|e| {
Message::Error(e.to_string())
})
.content_fit(ContentFit::Cover), .content_fit(ContentFit::Cover),
) )
} else { } else {
@ -554,7 +649,7 @@ impl Presenter {
let path = slide.background().path.clone(); let path = slide.background().path.clone();
if path.exists() { if path.exists() {
let url = Url::from_file_path(path).unwrap(); let url = Url::from_file_path(path).unwrap();
let result = Video::new(&url); let result = Self::create_video(url);
match result { match result {
Ok(v) => self.video = Some(v), Ok(v) => self.video = Some(v),
Err(e) => { Err(e) => {
@ -571,6 +666,7 @@ impl Presenter {
} }
} }
#[allow(clippy::unused_async)]
async fn start_audio(sink: Arc<Sink>, audio: PathBuf) { async fn start_audio(sink: Arc<Sink>, audio: PathBuf) {
let file = BufReader::new(File::open(audio).unwrap()); let file = BufReader::new(File::open(audio).unwrap());
debug!(?file); debug!(?file);

3
src/ui/video.rs Normal file
View file

@ -0,0 +1,3 @@
// use iced_video_player::Video;
// fn video_player(video: &Video) -> Element<Message> {}

View file

@ -1,6 +1,7 @@
(slide :background (image :source "~/pics/frodo.jpg" :fit fill) (slide :background (image :source "~/pics/frodo.jpg" :fit fill)
(text "This is frodo" :font-size 70)) (text "This is frodo" :font-size 70))
(slide (video :source "~/vids/test/camprules2024.mp4" :fit contain)) (slide (video :source "~/vids/test/camprules2024.mp4" :fit contain))
(slide (video :source "~/vids/Tree-of-Life.mp4" :fit contain))
(song :id 7 :author "North Point Worship" (song :id 7 :author "North Point Worship"
:font "Allura" :font-size 60 :font "Allura" :font-size 60
:title "Death Was Arrested" :title "Death Was Arrested"
@ -98,7 +99,7 @@ And my life began"))
(song :author "Jordan Feliz" :ccli 97987 (song :author "Jordan Feliz" :ccli 97987
:font "Quicksand" :font-size 80 :font "Quicksand" :font-size 80
:title "The River" :title "The River"
:background (video :source "~/vids/test/camprules2024theo.mp4" :fit cover) :background (video :source "~/nc/tfc/openlp/Flood/motions/Brook_HD.mp4" :fit cover)
:verse-order (v1 c1 v2 c1) :verse-order (v1 c1 v2 c1)
(v1 "I'm going down to the river") (v1 "I'm going down to the river")
(c1 "Down to the river") (c1 "Down to the river")

View file

@ -1,7 +1,7 @@
(song :id 7 :author "North Point Worship" (song :id 7 :author "North Point Worship"
:font "Quicksand Bold" :font-size 60 :font "Quicksand Bold" :font-size 60
:title "Death Was Arrested" :title "Death Was Arrested"
:background (image :source "file:///home/chris/nc/tfc/openlp/CMG - Bright Mountains 01.jpg" :fit cover) :background (video :source "~/nc/tfc/openlp/Flood/motions/Brook_HD.mp4" :fit cover)
:text-alignment center :text-alignment center
:audio "file:///home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3" :audio "file:///home/chris/music/North Point InsideOut/Nothing Ordinary, Pt. 1 (Live)/05 Death Was Arrested (feat. Seth Condrey).mp3"
:verse-order (i1 v1 v2 c1 v3 c1 v4 c1 b1 b1 e1 e2) :verse-order (i1 v1 v2 c1 v3 c1 v4 c1 b1 b1 e1 e2)