Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/app-frontend/src/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export async function highlightInFolder(path) {
return await invoke('plugin:utils|highlight_in_folder', { path })
}

export async function openVideoOverlay(videoId) {
return await invoke('plugin:utils|open_video_overlay', { videoId })
}

export async function closeVideoOverlay() {
return await invoke('plugin:utils|close_video_overlay')
}

export async function showLauncherLogsFolder() {
return await invoke('plugin:utils|show_launcher_logs_folder', {})
}
Expand Down
220 changes: 217 additions & 3 deletions apps/app-frontend/src/pages/project/Description.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,236 @@
<template>
<Card>
<ProjectPageDescription :description="project.body" />
<!-- eslint-disable-next-line vue/no-v-html -->
<div ref="container" class="markdown-body" @click="onClick" v-html="renderedBody" />
</Card>

<Teleport to="body">
<Transition name="video-overlay-fade">
<div
v-if="videoOpen"
class="video-overlay-backdrop"
@click="closeOverlay"
@keydown.esc="closeOverlay"
>
<!-- The native video webview is positioned by Rust centered in this
backdrop. This region is just a sizing placeholder + close button. -->
<div class="video-overlay-frame" @click.stop>
<button
type="button"
class="video-overlay-close"
aria-label="Close video"
@click="closeOverlay"
>
<XIcon />
</button>
</div>
</div>
</Transition>
</Teleport>
</template>

<script setup>
import { Card, ProjectPageDescription } from '@modrinth/ui'
import { XIcon } from '@modrinth/assets'
import { Card } from '@modrinth/ui'
import { renderHighlightedString } from '@modrinth/utils'
import { computed, onBeforeUnmount, ref, watch } from 'vue'

import { closeVideoOverlay, openVideoOverlay } from '@/helpers/utils.js'

defineProps({
const props = defineProps({
project: {
type: Object,
default: () => {},
},
})

const YOUTUBE_EMBED = /^https?:\/\/(?:www\.)?youtube(?:-nocookie)?\.com\/embed\/([a-zA-Z0-9_-]{11})/

const videoOpen = ref(false)

// Inline YouTube iframes fail with Error 153 in the app (the tauri:// webview
// sends no Referer to the cross-origin frame). Swap each embed for a thumbnail
// facade that opens the video as a centered in-app overlay webview.
function facadeForIframe(iframe) {
const src = iframe.getAttribute('src') ?? ''
const match = src.match(YOUTUBE_EMBED)
if (!match) {
return null
}

const videoId = match[1]
const facade = document.createElement('button')
facade.type = 'button'
facade.className = 'video-facade'
facade.dataset.videoId = videoId
facade.setAttribute('aria-label', 'Play YouTube video')
facade.innerHTML =
`<img loading="lazy" alt="" src="https://i.ytimg.com/vi/${videoId}/hqdefault.jpg" />` +
`<span class="video-facade__play" aria-hidden="true"></span>`
return facade
}

const renderedBody = computed(() => {
const html = renderHighlightedString(props.project?.body ?? '')
if (typeof document === 'undefined') {
return html
}

const template = document.createElement('template')
template.innerHTML = html
for (const iframe of template.content.querySelectorAll('iframe')) {
const facade = facadeForIframe(iframe)
if (facade) {
iframe.replaceWith(facade)
}
}
return template.innerHTML
})

function onClick(event) {
const facade = event.target.closest('.video-facade')
if (!facade) {
return
}
event.preventDefault()
videoOpen.value = true
openVideoOverlay(facade.dataset.videoId)
}

function closeOverlay() {
videoOpen.value = false
closeVideoOverlay()
}

// Keep the native webview in sync if the overlay is dismissed by navigation, etc.
watch(videoOpen, (open) => {
if (!open) {
closeVideoOverlay()
}
})

function onKeydown(event) {
if (event.key === 'Escape' && videoOpen.value) {
closeOverlay()
}
}
window.addEventListener('keydown', onKeydown)
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
if (videoOpen.value) {
closeVideoOverlay()
}
})
</script>

<script>
export default {
name: 'Description',
}
</script>

<style scoped lang="scss">
.markdown-body :deep(.video-facade) {
position: relative;
display: block;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
padding: 0;
border: none;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
background: var(--color-button-bg);

img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}

.video-facade__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 68px;
height: 48px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.7);
transition: background 0.15s ease-in-out;

&::after {
content: '';
position: absolute;
top: 50%;
left: 52%;
transform: translate(-50%, -50%);
border-style: solid;
border-width: 11px 0 11px 19px;
border-color: transparent transparent transparent #fff;
}
}

&:hover .video-facade__play,
&:focus-visible .video-facade__play {
background: var(--color-brand);
}
}

.video-overlay-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
}

// Matches the 80%/16:9 rect the Rust side uses for the native video webview,
// so the close button sits just outside its top-right corner.
.video-overlay-frame {
position: relative;
width: 80%;
aspect-ratio: 16 / 9;
max-height: 80%;
}

.video-overlay-close {
position: absolute;
top: -2.75rem;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: var(--color-button-bg);
color: var(--color-contrast);
cursor: pointer;

svg {
width: 1.25rem;
height: 1.25rem;
}

&:hover {
filter: brightness(1.25);
}
}

.video-overlay-fade-enter-active,
.video-overlay-fade-leave-active {
transition: opacity 0.15s ease-in-out;
}

.video-overlay-fade-enter-from,
.video-overlay-fade-leave-to {
opacity: 0;
}
</style>
2 changes: 2 additions & 0 deletions apps/app/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ fn main() {
"should_disable_mouseover",
"highlight_in_folder",
"open_path",
"open_video_overlay",
"close_video_overlay",
"show_launcher_logs_folder",
"progress_bars_list",
"get_opening_command",
Expand Down
116 changes: 115 additions & 1 deletion apps/app/src/api/utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use tauri::Runtime;
use tauri::webview::{NewWindowResponse, WebviewBuilder};
use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime, WebviewUrl};
use tauri_plugin_opener::OpenerExt;
use theseus::{
handler,
Expand All @@ -20,6 +21,8 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
should_disable_mouseover,
highlight_in_folder,
open_path,
open_video_overlay,
close_video_overlay,
show_launcher_logs_folder,
progress_bars_list,
get_opening_command
Expand Down Expand Up @@ -109,6 +112,117 @@ pub async fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
.ok();
}

const VIDEO_WEBVIEW_LABEL: &str = "video-overlay";

fn is_valid_video_id(video_id: &str) -> bool {
!video_id.is_empty()
&& video_id.len() <= 16
&& video_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}

/// Computes the centered video rect (16:9) within the main window, leaving a
/// margin so the surrounding backdrop and the close button stay visible.
fn video_overlay_rect(
window_size: PhysicalSize<u32>,
) -> (PhysicalPosition<i32>, PhysicalSize<u32>) {
let win_w = window_size.width as f32;
let win_h = window_size.height as f32;

// Use at most 80% of each dimension, keeping a 16:9 aspect ratio.
let max_w = win_w * 0.8;
let max_h = win_h * 0.8;
let mut width = max_w;
let mut height = width * 9.0 / 16.0;
if height > max_h {
height = max_h;
width = height * 16.0 / 9.0;
}

let x = ((win_w - width) / 2.0).max(0.0);
let y = ((win_h - height) / 2.0).max(0.0);

(
PhysicalPosition::new(x as i32, y as i32),
PhysicalSize::new(width as u32, height as u32),
)
}

/// Opens a YouTube video as an in-app overlay webview centered over the main
/// window.
///
/// Inline YouTube `/embed` iframes fail with "Video player configuration error"
/// (Error 153) inside the app, because the main webview loads from a custom
/// `tauri://localhost` scheme and macOS WKWebView refuses to attach a `Referer`
/// to the cross-origin subframe. Loading the standard `watch?v=` page in a
/// separate child webview gives YouTube a real origin, so playback works. The
/// frontend draws a dimmed backdrop and close button behind/around it.
#[tauri::command]
pub async fn open_video_overlay<R: Runtime>(
app: tauri::AppHandle<R>,
video_id: String,
) -> Result<()> {
if !is_valid_video_id(&video_id) {
tracing::error!(
"Refusing to open invalid YouTube video id: {video_id}"
);
return Ok(());
}

let Some(window) = app.get_window("main") else {
return Ok(());
};

let url: Url = format!("https://www.youtube.com/watch?v={video_id}")
.parse()
.map_err(|_| {
TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError(
"Failed to parse video URL".to_string(),
)
.as_error(),
)
})?;

let (position, size) = video_overlay_rect(window.inner_size()?);

if let Some(webview) = app.webviews().get(VIDEO_WEBVIEW_LABEL) {
webview.navigate(url)?;
webview.set_size(size)?;
webview.set_position(position)?;
webview.show().ok();
} else {
window.add_child(
WebviewBuilder::new(VIDEO_WEBVIEW_LABEL, WebviewUrl::External(url))
.initialization_script_for_all_frames(include_str!(
"youtube-theater.js"
))
.zoom_hotkeys_enabled(false)
.on_new_window(|_, _| NewWindowResponse::Deny),
position,
size,
)?;
}

Ok(())
}

/// Hides the in-app video overlay webview (moved offscreen and hidden).
#[tauri::command]
pub async fn close_video_overlay<R: Runtime>(app: tauri::AppHandle<R>) {
if let Some(webview) = app.webviews().get(VIDEO_WEBVIEW_LABEL) {
// Navigate away first so audio stops, then hide offscreen.
if let Ok(url) = "about:blank".parse() {
let _ = webview.navigate(url);
}
webview
.set_position(PhysicalPosition::new(-10000, -10000))
.ok();
webview.hide().ok();
}
}

#[tauri::command]
pub async fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
if let Some(d) = DirectoryInfo::global_handle_if_ready() {
Expand Down
Loading