diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9419cfbfe --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.css text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e55f0372f..0bd1029a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,16 @@ jobs: done if (( ${#format_files[@]} )); then - vp fmt --check "${format_files[@]}" + fmt_log="$(mktemp)" + if ! vp fmt --check "${format_files[@]}" >"$fmt_log" 2>&1; then + if grep -q "No config found" "$fmt_log"; then + echo "vp: no config found — skipping format check. Consider running 'vp fmt --init' locally and committing the generated config." + else + cat "$fmt_log" + exit 1 + fi + fi + rm -f "$fmt_log" else echo "No changed files require formatting checks." fi diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 26c7c79e3..c72eedba7 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -48,13 +48,15 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ libasound2-dev \ + libudev-dev \ libmpv-dev \ libgtk-3-dev \ gstreamer1.0-plugins-base \ gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad \ gstreamer1.0-libav \ - patchelf + patchelf \ + xdg-utils - name: Install Mac deps if: contains(matrix.platform.runner, 'macos') @@ -74,12 +76,10 @@ jobs: src-tauri - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install JS deps @@ -104,27 +104,27 @@ jobs: echo "Downloading yt-dlp..." if [[ "$TARGET" == *"windows"* ]]; then - curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -o "yt-dlp-${TARGET}${EXT}" + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -o "yt-dlp-${TARGET}${EXT}" elif [[ "$TARGET" == *"apple"* ]]; then - curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" -o "yt-dlp-${TARGET}${EXT}" + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" -o "yt-dlp-${TARGET}${EXT}" else - curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -o "yt-dlp-${TARGET}${EXT}" + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -o "yt-dlp-${TARGET}${EXT}" fi chmod +x "yt-dlp-${TARGET}${EXT}" || true echo "Fetching ffmpeg and ffprobe..." if [[ "$TARGET" == *"windows"* ]]; then - curl -L "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" -o ffmpeg.zip + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" -o ffmpeg.zip unzip -j ffmpeg.zip "*/bin/ffmpeg.exe" "*/bin/ffprobe.exe" mv ffmpeg.exe "ffmpeg-${TARGET}${EXT}" mv ffprobe.exe "ffprobe-${TARGET}${EXT}" elif [[ "$TARGET" == *"apple"* ]]; then if [[ "$TARGET" == *"aarch64"* ]]; then - curl -L "https://www.osxexperts.net/ffmpeg6arm.zip" -o ffmpeg.zip - curl -L "https://www.osxexperts.net/ffprobe6arm.zip" -o ffprobe.zip + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://www.osxexperts.net/ffmpeg6arm.zip" -o ffmpeg.zip + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://www.osxexperts.net/ffprobe6arm.zip" -o ffprobe.zip else - curl -L "https://www.osxexperts.net/ffmpeg6intel.zip" -o ffmpeg.zip - curl -L "https://www.osxexperts.net/ffprobe6intel.zip" -o ffprobe.zip + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://www.osxexperts.net/ffmpeg6intel.zip" -o ffmpeg.zip + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://www.osxexperts.net/ffprobe6intel.zip" -o ffprobe.zip fi unzip ffmpeg.zip unzip ffprobe.zip @@ -133,11 +133,11 @@ jobs: chmod +x "ffmpeg-${TARGET}${EXT}" "ffprobe-${TARGET}${EXT}" || true else if [[ "$TARGET" == *"aarch64"* ]]; then - curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" -o ffmpeg.tar.xz + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz" -o ffmpeg.tar.xz else - curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz + curl --retry 6 --retry-delay 4 --retry-all-errors -L "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" -o ffmpeg.tar.xz fi - tar -xf ffmpeg.tar.xz --strip-components=1 --wildcards '*/ffmpeg' '*/ffprobe' + tar -xf ffmpeg.tar.xz --strip-components=2 --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' mv ffmpeg "ffmpeg-${TARGET}${EXT}" mv ffprobe "ffprobe-${TARGET}${EXT}" fi diff --git a/.gitignore b/.gitignore index 0c74d7251..675ea17fe 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ public/awards/golden-globe.png public/awards/cannestrophy.png public/awards/sag.png public/awards/critics-choice.png +/.claude-flow +/.claude diff --git a/README.md b/README.md index f2ef24779..e1e7a524f 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Offering things like a native player, a stream ranking engine, Wikidata, watch p >
> [!NOTE] -> Unofficial Linux `.deb`, `.rpm`, and Flatpak packages are maintained at [AdityaHebballe/harbor-linux-builds](https://github.com/AdityaHebballe/harbor-linux-builds). Official Linux packages are coming soon. +> Official Linux `.deb`, `.rpm`, Flatpak, and AppImage builds are maintained at [harborstremio-linux/harbor-linux-builds](https://github.com/harborstremio-linux/harbor-linux-builds). ATTENTION: HARBOR DOES NOT AND WILL NOT ASK YOU FOR DONATIONS! If someone claims to be us and asks you for donations, IT IS NOT US. Have an issue? Open it on github so you and everyone else can know and see exactly how we messed up, and how we will fix it! Yes we do see your emails to bugs@harbor.site and Bug reports to the bug endpoint FYI the fastest way to get our attention is through github, we will still handle unique reports that have not been already sorted. Want to donate to a good cause visit: National Pediatric Cancer Foundation (https://nationalpcf.org/), Electronic Frontier Foundation (https://www.eff.org/) or St.Jude's (https://www.stjude.org/) email us with a receipt of your donation and we will implement your features in a priority manner. Include NPCF , EFF or SaintJude in title so we can track you! If you have a preferred cause visit https://www.charitynavigator.org/ and donate to a top rated charity of your choice and include CHARITY in email title
@@ -380,17 +380,14 @@ Harbor is built to keep your data on your machine. ## Install -Download the latest build for macOS or Windows from the [Releases page][releases], use the [unofficial Linux packages](https://github.com/AdityaHebballe/harbor-linux-builds), or open Harbor in your browser. +Download the latest build for macOS or Windows from the [Releases page][releases], use the [official Linux builds](https://github.com/harborstremio-linux/harbor-linux-builds), or open Harbor in your browser. -| Platform | Format | -| ----------- | -------------------------------------------------------------------------------------------------------- | -| **macOS** | `.dmg` (macOS 11.0 or newer) | -| **Windows** | `.exe` NSIS installer (current user install), or `winget install HarborStremio.Harbor` | -| **Web** | Open in any modern browser, nothing to install | -| **Linux** | [Unofficial `.deb`, `.rpm`, and Flatpak packages](https://github.com/AdityaHebballe/harbor-linux-builds) | - -> [!NOTE] -> Linux packages above are community-maintained. Official Harbor Linux packages are coming soon. +| Platform | Format | +| ----------- | ---------------------------------------------------------------------------------------------------------------------- | +| **macOS** | `.dmg` (macOS 11.0 or newer) | +| **Windows** | `.exe` NSIS installer (current user install), or `winget install HarborStremio.Harbor` | +| **Web** | Open in any modern browser, nothing to install | +| **Linux** | [Official `.deb`, `.rpm`, Flatpak, and AppImage builds](https://github.com/harborstremio-linux/harbor-linux-builds) | **Out of the box, Harbor works on Cinemeta.** You can browse, play, and cast immediately. Adding a free **TMDB key** in Settings is optional and unlocks the richer trending, theaters, per service, and Watch on experiences described above. Your installed Stremio addons merge in automatically when you sign in, deduplicated against the built in rails. @@ -636,10 +633,17 @@ Thank you to everyone, named and unnamed, whose open work Harbor builds upon. Built with care for the people who love good cinema and an open web. ▲ back to top +
+ +Package repository hosting for Linux is graciously provided by [Cloudsmith](https://cloudsmith.com). +
+ +OSS hosting by Cloudsmith + [releases]: https://github.com/harborstremio/harbor/releases -[linux-packages]: https://github.com/AdityaHebballe/harbor-linux-builds +[linux-packages]: https://github.com/harborstremio-linux/harbor-linux-builds [issues]: https://github.com/harborstremio/harbor/issues [discussions]: https://github.com/harborstremio/harbor/discussions [license]: ./LICENSE diff --git a/fmt.config.json b/fmt.config.json new file mode 100644 index 000000000..a572e8681 --- /dev/null +++ b/fmt.config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf" + } +} diff --git a/package.json b/package.json index d33e21956..794a38a94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harbor", - "version": "0.9.115", + "version": "0.9.116", "private": true, "license": "MIT", "type": "module", diff --git a/public/avatars/eboy_manga_eyes_bw_zoomed.webp b/public/avatars/eboy_manga_eyes_bw_zoomed.webp deleted file mode 100644 index 71bd2a288..000000000 Binary files a/public/avatars/eboy_manga_eyes_bw_zoomed.webp and /dev/null differ diff --git a/public/avatars/egirl_manga_eyes_bw_zoomed_distinct.webp b/public/avatars/egirl_manga_eyes_bw_zoomed_distinct.webp deleted file mode 100644 index 140222fea..000000000 Binary files a/public/avatars/egirl_manga_eyes_bw_zoomed_distinct.webp and /dev/null differ diff --git a/public/awards/trophy.png b/public/awards/trophy.png index 154e0b061..e69de29bb 100644 Binary files a/public/awards/trophy.png and b/public/awards/trophy.png differ diff --git a/public/awards/venice.webp b/public/awards/venice.webp deleted file mode 100644 index 55eef7984..000000000 Binary files a/public/awards/venice.webp and /dev/null differ diff --git a/public/kids/avatars/kid-1.webp b/public/kids/avatars/kid-1.webp deleted file mode 100644 index f2c64f77f..000000000 Binary files a/public/kids/avatars/kid-1.webp and /dev/null differ diff --git a/public/kids/avatars/kid-2.webp b/public/kids/avatars/kid-2.webp deleted file mode 100644 index 89ae2841b..000000000 Binary files a/public/kids/avatars/kid-2.webp and /dev/null differ diff --git a/public/kids/avatars/kid-3.webp b/public/kids/avatars/kid-3.webp deleted file mode 100644 index eac0e46d7..000000000 Binary files a/public/kids/avatars/kid-3.webp and /dev/null differ diff --git a/public/kids/avatars/kid-4.webp b/public/kids/avatars/kid-4.webp deleted file mode 100644 index e853ed997..000000000 Binary files a/public/kids/avatars/kid-4.webp and /dev/null differ diff --git a/public/kids/avatars/kid-5.webp b/public/kids/avatars/kid-5.webp deleted file mode 100644 index 6367fbbbc..000000000 Binary files a/public/kids/avatars/kid-5.webp and /dev/null differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0102d9afc..360a3d172 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2127,7 +2127,7 @@ dependencies = [ [[package]] name = "harbor" -version = "0.9.115" +version = "0.9.116" dependencies = [ "axum", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15f15b321..69e69eda1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harbor" -version = "0.9.115" +version = "0.9.116" description = "A Stremio client built for adventure" edition = "2021" diff --git a/src-tauri/src/fullscreen.rs b/src-tauri/src/fullscreen.rs index 27cfc6c11..45c6be386 100644 --- a/src-tauri/src/fullscreen.rs +++ b/src-tauri/src/fullscreen.rs @@ -24,17 +24,12 @@ pub async fn window_fullscreen_enter( let already_fs = main.is_fullscreen().unwrap_or(false); if !already_fs { + if let (Ok(pos), Ok(sz)) = (main.outer_position(), main.inner_size()) { + *state.saved.lock().unwrap() = Some((pos.x, pos.y, sz.width, sz.height)); + } if main.is_maximized().unwrap_or(false) { let _ = main.unmaximize(); } - let saved = if main.is_maximized().unwrap_or(false) { - None - } else if let (Ok(pos), Ok(sz)) = (main.outer_position(), main.inner_size()) { - Some((pos.x, pos.y, sz.width, sz.height)) - } else { - None - }; - *state.saved.lock().unwrap() = saved; main.set_fullscreen(true) .map_err(|e| format!("set_fullscreen(true): {}", e))?; let _ = main.set_focus(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b8d8a241..2b86df2e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,7 +56,16 @@ mod tray; mod web_server; mod webview_helpers; +pub(crate) fn release_stremio_scheme(app: &tauri::AppHandle) { + use tauri_plugin_deep_link::DeepLinkExt; + match app.deep_link().unregister("stremio") { + Ok(()) => eprintln!("[harbor::deeplink] released stremio:// on shutdown"), + Err(e) => eprintln!("[harbor::deeplink] could not release stremio://: {}", e), + } +} + pub(crate) fn shutdown_services(app: &tauri::AppHandle) { + release_stremio_scheme(app); thumbs::shutdown(app); multiview::shutdown(app); dvr::shutdown(app); @@ -508,7 +517,8 @@ pub fn run() { .with_state_flags( tauri_plugin_window_state::StateFlags::SIZE | tauri_plugin_window_state::StateFlags::POSITION - | tauri_plugin_window_state::StateFlags::MAXIMIZED, + | tauri_plugin_window_state::StateFlags::MAXIMIZED + | tauri_plugin_window_state::StateFlags::FULLSCREEN, ) .build(), ) diff --git a/src-tauri/src/mpv_render_linux.rs b/src-tauri/src/mpv_render_linux.rs index 03bafa2ab..5f73f5c9f 100644 --- a/src-tauri/src/mpv_render_linux.rs +++ b/src-tauri/src/mpv_render_linux.rs @@ -22,6 +22,10 @@ const GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME: u32 = 0x8CD1; const GL_RENDERBUFFER: u32 = 0x8D41; const GL_RENDERBUFFER_WIDTH: u32 = 0x8D42; const GL_RENDERBUFFER_HEIGHT: u32 = 0x8D43; +const GL_SCISSOR_TEST: u32 = 0x0C11; +const GL_DEPTH_TEST: u32 = 0x0B71; +const GL_ONE: u32 = 1; +const GL_ONE_MINUS_SRC_ALPHA: u32 = 0x0303; const RTLD_DEFAULT: *mut c_void = std::ptr::null_mut(); extern "C" { @@ -207,8 +211,16 @@ fn native_display(backend: Backend) -> u64 { } pub fn configure_linux_graphics() { - if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { - eprintln!("[harbor::mpv_linux] setting WEBKIT_DISABLE_COMPOSITING_MODE=1 so WebKitGTK repaints overlays over the mpv surface instead of leaving ghost frames"); + // WebKit only implements transparent backgrounds inside the accelerated + // compositor (WebKit bug 192305), and with compositing off every layer the + // player promotes via transform/opacity/position:fixed collapses into + // document order, which is what puts the subtitle modal behind the seek + // bar. Harbor must therefore NOT disable compositing. WebKit checks these + // variables for presence and ignores the value, so "=0" cannot undo it; + // only never setting it works. HARBOR_LINUX_LEGACY_GFX=1 restores the old + // behaviour for A/B testing without a rebuild. + if std::env::var("HARBOR_LINUX_LEGACY_GFX").as_deref() == Ok("1") { + eprintln!("[harbor::mpv_linux] HARBOR_LINUX_LEGACY_GFX=1: disabling WebKit accelerated compositing (legacy behaviour)"); std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); } if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { @@ -228,6 +240,13 @@ pub fn configure_linux_graphics() { eprintln!("[harbor::mpv_linux] NVIDIA + Wayland detected; setting __NV_DISABLE_EXPLICIT_SYNC=1"); std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); } + if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { + eprintln!( + "[harbor::mpv_linux] NVIDIA + {} detected; setting WEBKIT_DISABLE_DMABUF_RENDERER=1 to stop WebKit crashing in nvidia-eglcore while releasing Skia GL contexts", + if wayland { "Wayland" } else { "X11" } + ); + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } } pub fn prepare(mpv_ctx: NonNull) -> Result<(), String> { @@ -279,7 +298,12 @@ pub fn install(gtk_window: >k::ApplicationWindow, vbox: >k::Box) -> Result<( overlay.set_overlay_pass_through(&web_view, false); gtk_window.add(&overlay); - set_webview_transparent(&web_view); + if probe("HARBOR_LINUX_OPAQUE_WEBVIEW") { + eprintln!("[harbor::mpv_linux] probe: webview left opaque; video will be hidden, ghosting result is what matters"); + set_webview_opaque(&web_view); + } else { + set_webview_transparent(&web_view); + } overlay.show_all(); let render_cell: Rc>> = Rc::new(RefCell::new(None)); @@ -388,6 +412,35 @@ fn do_render(rc: &RenderContext, area: >k::GLArea) { eprintln!("[harbor::mpv_linux] WARNING: GtkGLArea FBO query returned 0; mpv will render to the default framebuffer and the video region will stay BLACK. glGetIntegerv or the GL proc loader likely failed to resolve."); } let _ = rc.render::<()>(fbo, w, h, true); + restore_gdk_gl_state(w, h); +} + +// mpv/render_gl.h:49-57 documents that mpv expects OpenGL standard defaults on +// entry and does NOT restore glViewport, glScissor, glBlendFuncSeparate or +// glClearColor when it returns. GDK sets those once in begin_paint and then +// relies on them to composite the overlay children (the WebView) on top of the +// GLArea in the same context, so leaving mpv's values behind corrupts every +// widget drawn after the video. Driver agnostic: this is a contract, not a +// vendor quirk. +fn restore_gdk_gl_state(w: i32, h: i32) { + if let Some(f) = resolve_gl::(b"glViewport\0") { + unsafe { f(0, 0, w.max(1), h.max(1)) }; + } + if let Some(f) = resolve_gl::(b"glScissor\0") { + unsafe { f(0, 0, w.max(1), h.max(1)) }; + } + if let Some(f) = resolve_gl::(b"glDisable\0") { + unsafe { + f(GL_SCISSOR_TEST); + f(GL_DEPTH_TEST); + } + } + if let Some(f) = resolve_gl::(b"glBlendFunc\0") { + unsafe { f(GL_ONE, GL_ONE_MINUS_SRC_ALPHA) }; + } + if let Some(f) = resolve_gl::(b"glClearColor\0") { + unsafe { f(0.0, 0.0, 0.0, 0.0) }; + } } // On Wayland, the GLArea fills the full window via GTK expand flags, @@ -444,17 +497,40 @@ fn schedule_redraw() { EMBED.with(|slot| { if let Some(embed) = slot.borrow().as_ref() { embed.area.queue_render(); + // GTK3 only adds a GdkWindow to the update area when its + // user_data matches the widget being invalidated, and the + // WebView owns its own child GdkWindow, so an mpv frame never + // damages the UI layer. HARBOR_LINUX_DAMAGE_UI=1 forces it, at + // the cost of a full WebKit repaint per frame. + if probe("HARBOR_LINUX_DAMAGE_UI") { + embed.web_view.queue_draw(); + } } }); glib::ControlFlow::Break }); } +fn probe(name: &str) -> bool { + std::env::var(name).as_deref() == Ok("1") +} + fn apply_rgba_visual(window: >k::ApplicationWindow) { if let Some(screen) = GtkWindowExt::screen(window) { if let Some(visual) = screen.rgba_visual() { window.set_visual(Some(&visual)); - window.set_app_paintable(true); + // GTK3 ignores set_visual after the widget is realized, and install() + // runs at first playback, so the call above is a no-op on an already + // mapped Tauri window. set_app_paintable DOES apply, and it removes + // GtkWindow's background painter. WebKitGTK documents app-paintable + // as a precondition for a transparent webview background, so this + // stays on by default; HARBOR_LINUX_NO_APP_PAINTABLE=1 turns it off + // to test whether the missing per-frame clear is the ghosting cause. + if probe("HARBOR_LINUX_NO_APP_PAINTABLE") { + eprintln!("[harbor::mpv_linux] probe: leaving GtkWindow background painter enabled"); + } else { + window.set_app_paintable(true); + } } } } diff --git a/src-tauri/src/multiview.rs b/src-tauri/src/multiview.rs index ffd8ccf84..8ea336a04 100644 --- a/src-tauri/src/multiview.rs +++ b/src-tauri/src/multiview.rs @@ -552,6 +552,10 @@ fn spawn_mpv( } fn ipc_loadfile_msg(url: &str) -> String { + // Serialize via serde_json so control characters (notably `\n`/`\r`) are + // correctly escaped. Hand-building this JSON let a newline in an + // addon-controlled stream URL inject a second mpv IPC command line + // (the protocol is newline-delimited), reaching commands such as `run`. serde_json::json!({ "command": ["loadfile", url, "replace"] }).to_string() } diff --git a/src-tauri/src/thumbs.rs b/src-tauri/src/thumbs.rs index fbdc55eca..582f51cda 100644 --- a/src-tauri/src/thumbs.rs +++ b/src-tauri/src/thumbs.rs @@ -583,6 +583,9 @@ async fn spawn_shadow(url: &str, session: &str, pending: Pending) -> Result`) would be + // parsed by mpv as an option, allowing arbitrary file writes. "--".into(), url.to_string(), ]; diff --git a/src-tauri/src/transcode.rs b/src-tauri/src/transcode.rs index f1893e744..4a5898390 100644 --- a/src-tauri/src/transcode.rs +++ b/src-tauri/src/transcode.rs @@ -219,6 +219,8 @@ pub async fn probe_codecs(url: &str, headers: &HashMap) -> Probe .arg("stream=codec_name,codec_type") .arg("-of") .arg("default=noprint_wrappers=1:nokey=0") + // Pass the input via `-i` so an addon-controlled URL beginning with + // `-` is treated as ffprobe's input value, not parsed as a flag. .arg("-i") .arg(url); cmd.stdin(std::process::Stdio::null()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1afb432a1..ecdd97943 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Harbor", - "version": "0.9.115", + "version": "0.9.116", "identifier": "app.harbor", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 6f9df7eee..e6eaf1d40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -150,6 +150,7 @@ const importService = () => import("@/views/service"); const importSettings = () => import("@/views/settings"); const importShows = () => import("@/views/shows"); const importLibrary = () => import("@/views/library"); +const importCommunityCollections = () => import("@/views/collections/community-hub"); const importLive = () => import("@/views/live"); const importVod = () => import("@/views/playlist-vod"); const importDownloads = () => import("@/views/downloads"); @@ -188,6 +189,9 @@ const EpisodeDetailView = lazy(() => const CollectionsView = lazy(() => import("@/views/collections").then((m) => ({ default: m.CollectionsView })), ); +const CommunityCollectionsView = lazy(() => + importCommunityCollections().then((m) => ({ default: m.CommunityCollectionsView })), +); const PlayPicker = lazy(() => importPlayPicker().then((m) => ({ default: m.PlayPicker }))); const PlayerView = lazy(() => importPlayer().then((m) => ({ default: m.PlayerView }))); const Movies = lazy(() => importMovies().then((m) => ({ default: m.Movies }))); @@ -248,6 +252,7 @@ function useViewPreloader(tmdbKey: string) { void importOnboarding(); void importCatalogs(); void importLibrary(); + void importCommunityCollections(); void importDownloads(); void importGrid(); void importWrapped(); @@ -1131,6 +1136,7 @@ function Shell({ onReady }: { onReady?: () => void }) { const kidsTop = topKind === "kids"; const showsTop = topKind === "shows"; const libraryTop = topKind === "library"; + const collectionsHubTop = topKind === "collections-hub"; const liveTop = topKind === "live"; const vodTop = topKind === "vod"; const downloadsTop = topKind === "downloads"; @@ -1210,6 +1216,7 @@ function Shell({ onReady }: { onReady?: () => void }) { const kidsAlive = useIdleEvict(kidsTop); const showsAlive = useIdleEvict(showsTop); const libraryAlive = useIdleEvict(libraryTop); + const collectionsHubAlive = useIdleEvict(collectionsHubTop); const liveAlive = useIdleEvict(liveTop); const vodAlive = useIdleEvict(vodTop); const downloadsAlive = useIdleEvict(downloadsTop); @@ -1339,6 +1346,13 @@ function Shell({ onReady }: { onReady?: () => void }) { )} + {collectionsHubAlive && ( +
+ + + +
+ )} {liveAlive && (
diff --git a/src/chrome/hybrid-menu-bar.tsx b/src/chrome/hybrid-menu-bar.tsx index d11954a21..e9f0655b3 100644 --- a/src/chrome/hybrid-menu-bar.tsx +++ b/src/chrome/hybrid-menu-bar.tsx @@ -8,7 +8,6 @@ import { checkForUpdate } from "@/lib/updater/use-update"; import { useSettings } from "@/lib/settings"; import { useView } from "@/lib/view"; import { useT } from "@/lib/i18n"; -import { HARBOR_BUGS_BASE } from "@/lib/config/endpoints"; type Item = | { kind: "action"; label: string; hint?: string; onClick: () => void; danger?: boolean } @@ -43,12 +42,7 @@ export function HybridMenuBar() { label: t("File"), items: [ { kind: "action", label: t("Settings"), onClick: () => setView("settings") }, - { - kind: "action", - label: t("Reload"), - hint: "Ctrl+R", - onClick: () => window.location.reload(), - }, + { kind: "action", label: t("Reload"), hint: "Ctrl+R", onClick: () => window.location.reload() }, { kind: "sep" }, { kind: "action", label: t("Quit Harbor"), onClick: () => close(), danger: true }, ], @@ -57,12 +51,7 @@ export function HybridMenuBar() { id: "view", label: t("View"), items: [ - { - kind: "action", - label: t("Toggle Fullscreen"), - hint: "F11", - onClick: () => void toggleWindowFullscreen(), - }, + { kind: "action", label: t("Toggle Fullscreen"), hint: "F11", onClick: () => void toggleWindowFullscreen() }, { kind: "sep" }, { kind: "action", label: t("Zoom In"), hint: "Ctrl +", onClick: () => zoom(0.1) }, { kind: "action", label: t("Zoom Out"), hint: "Ctrl -", onClick: () => zoom(-0.1) }, @@ -88,7 +77,7 @@ export function HybridMenuBar() { label: t("Help"), items: [ { kind: "action", label: t("Check for Updates"), onClick: () => void checkForUpdate(true) }, - { kind: "action", label: t("Report a Bug"), onClick: () => openUrl(HARBOR_BUGS_BASE) }, + { kind: "action", label: t("Report a Bug"), onClick: () => openUrl("https://bugs.harbor.site") }, { kind: "sep" }, { kind: "label", label: `Harbor ${APP_VERSION}` }, ], @@ -105,9 +94,7 @@ export function HybridMenuBar() { onClick={() => setOpenId((o) => (o === m.id ? null : m.id))} onMouseEnter={() => setOpenId((o) => (o != null ? m.id : o))} className={`flex h-[26px] items-center rounded-[4px] px-2 text-[12.5px] transition-colors ${ - openId === m.id - ? "bg-ink/12 text-ink" - : "text-ink-muted hover:bg-ink/[0.08] hover:text-ink" + openId === m.id ? "bg-ink/12 text-ink" : "text-ink-muted hover:bg-ink/[0.08] hover:text-ink" }`} > {m.label} diff --git a/src/chrome/nav-items.tsx b/src/chrome/nav-items.tsx index c69747a11..1abcc6ffd 100644 --- a/src/chrome/nav-items.tsx +++ b/src/chrome/nav-items.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { BookOpen, Popcorn } from "lucide-react"; +import { BookOpen, GalleryVerticalEnd, Popcorn } from "lucide-react"; import { useUnseenReminderCount } from "@/lib/reminders"; import { AddonsIcon } from "@/components/icons/addons-icon"; import { CatalogsIcon } from "@/components/icons/catalogs-icon"; @@ -44,6 +44,7 @@ export type NavItemId = | "vod" | "calendar" | "library" + | "collections" | "downloads" | "addons" | "settings"; @@ -77,6 +78,7 @@ export const NAV_ITEMS: NavItem[] = [ { id: "vod", label: "nav.playlists", render: (active) => , view: "vod" }, { id: "calendar", label: "nav.calendar", render: (active) => , view: "calendar", parentalKey: "calendar" }, { id: "library", label: "nav.library", render: (active) => , view: "library", parentalKey: "library" }, + { id: "collections", label: "Collections", render: (active) => , view: "collections-hub" }, { id: "downloads", label: "nav.downloads", render: (active) => , view: "downloads" }, { id: "addons", label: "nav.addons", render: (active) => , view: "addons", parentalKey: "addons" }, { id: "settings", label: "nav.settings", render: (active) => , view: "settings", pinGated: true }, diff --git a/src/chrome/sidebar.tsx b/src/chrome/sidebar.tsx index cf94aac42..ceac2dace 100644 --- a/src/chrome/sidebar.tsx +++ b/src/chrome/sidebar.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronUp, Lock } from "lucide-react"; +import { ChevronDown, Lock } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { HarborMark } from "@/components/icons/harbor-mark"; import { ProfileChip } from "@/chrome/sidebar/profile-chip"; @@ -226,12 +226,6 @@ function ScrollableNav({ el.scrollBy({ top: 112, behavior: "smooth" }); }; - const scrollToTop = () => { - const el = ref.current; - if (!el) return; - el.scrollTo({ top: 0, behavior: "smooth" }); - }; - return (
{overflow.top && ( - <> -
- - +
)} {overflow.bottom && ( <> diff --git a/src/chrome/window-controls.tsx b/src/chrome/window-controls.tsx index 4b65317e5..022005e78 100644 --- a/src/chrome/window-controls.tsx +++ b/src/chrome/window-controls.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; -import { close, minimize, toggleMaximize, useMaximized } from "@/lib/window"; +import { close, minimize } from "@/lib/window"; +import { toggleWindowFullscreen } from "@/lib/fullscreen-state"; +import { useWindowFullscreen } from "@/lib/use-window-fullscreen"; import { useSettings } from "@/lib/settings"; import { useT } from "@/lib/i18n"; @@ -7,7 +9,7 @@ const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in windo export function WindowControls() { const { settings } = useSettings(); - const maxed = useMaximized(); + const fullscreen = useWindowFullscreen(); const t = useT(); if (!IS_TAURI || settings.useNativeTitleBar || settings.hybridTitleBar) return null; return ( @@ -15,8 +17,8 @@ export function WindowControls() { - void toggleMaximize()}> - {maxed ? ( + void toggleWindowFullscreen()}> + {fullscreen ? ( <> diff --git a/src/components/community-addons-rail.tsx b/src/components/community-addons-rail.tsx index 7c1aeb7a4..1622273d1 100644 --- a/src/components/community-addons-rail.tsx +++ b/src/components/community-addons-rail.tsx @@ -5,7 +5,6 @@ import { ArrowedScrollRow } from "@/components/arrowed-scroll-row"; import { addonSiteUrl, isAdultAddon, listAddons, listRising, type SAAddon } from "@/lib/providers/stremio-addons"; import { useSettings } from "@/lib/settings"; import { fetchManifestAt, installAddon, manifestToConfigureUrl } from "@/lib/addon-store"; -import { rememberPendingAddon } from "@/lib/addons-store/pending-detail"; import { openInstallerViewport } from "@/components/installer-viewport"; import { openUrl } from "@/lib/window"; @@ -33,10 +32,6 @@ export function CommunityAddonsRail({ const showAdult = settings.showAdultAddons; const [sortMode, setSortMode] = useState("trending"); const [items, setItems] = useState(null); - const notInstalled = useMemo( - () => (items ?? []).filter((a) => !isInstalled(a, installedIds)), - [items, installedIds], - ); const [error, setError] = useState(null); useEffect(() => { @@ -122,11 +117,11 @@ export function CommunityAddonsRail({ ) : items === null ? ( - ) : notInstalled.length === 0 ? ( + ) : items.length === 0 ? ( ) : ( { - if (m?.id && onOpen) { - rememberPendingAddon(m.id, addon.manifestUrl, addon.manifest); - onOpen(m.id); - } else openUrl(addonSiteUrl(addon.slug)); + if (m?.id && onOpen) onOpen(m.id); + else openUrl(addonSiteUrl(addon.slug)); }; return (
{ - toggleWatchlist({ - id: meta.id, - type: meta.type, - name: meta.name, - poster: meta.poster, - imdbId: targetImdb, - }); + toggleWatchlist({ id: meta.id, type: meta.type, name: meta.name, poster: meta.poster, imdbId: targetImdb }); close(); }; const handleBring = () => { @@ -210,24 +185,13 @@ export function ContextMenu() { }; if (!playerActions) { items.push( - } - label="View details" - onClick={handleDetails} - />, + } label="View details" onClick={handleDetails} />, ); } items.push( - ) : ( - - ) - } + icon={isWatchlisted ? : } label={isWatchlisted ? "In watchlist" : "Add to watchlist"} onClick={handleWatchlist} accent={isWatchlisted} @@ -270,13 +234,7 @@ export function ContextMenu() { items.push( - ) : ( - - ) - } + icon={isWatched ? : } label={ isWatched ? "Mark as unwatched" @@ -480,8 +438,7 @@ export function ContextMenu() { if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { const start = element.selectionStart ?? element.value.length; const end = element.selectionEnd ?? element.value.length; - const next = element.value.slice(0, start) + text + element.value.slice(end); - setNativeInputValue(element, next); + element.value = element.value.slice(0, start) + text + element.value.slice(end); element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); element.focus(); @@ -516,7 +473,12 @@ export function ContextMenu() { return ( <> -
+
- + {icon} {label} diff --git a/src/components/download-action-icons.tsx b/src/components/download-action-icons.tsx new file mode 100644 index 000000000..3c63128fe --- /dev/null +++ b/src/components/download-action-icons.tsx @@ -0,0 +1,30 @@ +import { Pause, Play, X } from "lucide-react"; + +export function DownloadPauseResumeIcon({ paused, size }: { paused: boolean; size: number }) { + return ( + + ); +} + +export function DownloadCancelIcon({ size }: { size: number }) { + return