diff --git a/AGENTS.md b/AGENTS.md index fc216a0..e9e2021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,7 +315,11 @@ discovery and is the preferred way to isolate catalogue tests. manual-selection precedence when changing search or cache behavior. - Relm4 initializes GTK; do not call `gtk::init()`. - The application is a Wayland layer-shell overlay and is not expected to run on - X11 or without compositor layer-shell support. + X11. When `gtk4_layer_shell::is_supported()` is false (GNOME's Mutter), the + overlay falls back to `DragMode::ToplevelWindow`: a plain undecorated window + the compositor moves, with no click-through input region, edge snapping, or + remembered position. Keep layer-shell calls and margin updates behind + `uses_layer_shell()` / `uses_layer_margins()` so that path never reaches them. - Before GTK initialization, `src/lib.rs` supplies default `GSK_RENDERER=gl` and `GTK_A11Y=none` values when unset. Preserve caller-provided values. diff --git a/README.md b/README.md index 331ae59..96f29d7 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ FloatLyrics 是一款面向 Linux Wayland 的 MPRIS 悬浮歌词应用。它会 | 组件 | 要求 | |---|---| -| 桌面会话 | Linux Wayland,合成器支持 layer-shell | +| 桌面会话 | Linux Wayland;悬浮覆盖层需要合成器支持 layer-shell,否则退化为普通窗口 | | 播放器 | 提供 `PlaybackStatus`、`Position` 和含标题 `Metadata` 的 MPRIS 播放器 | | 运行库 | GTK 4.12 或更高版本、gtk4-layer-shell、WebKitGTK 6.0 | -FloatLyrics 依赖 layer-shell,目前不支持 X11。可运行 `echo "$XDG_SESSION_TYPE"` 检查当前会话类型。 +FloatLyrics 依赖 layer-shell 实现悬浮覆盖层,目前不支持 X11;X11 下同样会退化为普通窗口,但不在支持范围内。可运行 `echo "$XDG_SESSION_TYPE"` 检查当前会话类型。 -已知兼容的合成器包括 GNOME(Mutter)、KDE(KWin)、Hyprland 和 Sway;其他支持 layer-shell 的合成器也可能正常工作。 +KDE(KWin)、Hyprland 和 Sway 等合成器原生支持 layer-shell;GNOME(Mutter)不支持该协议,浮窗会退化为可拖拽的普通窗口,点击穿透、屏幕吸附与记住窗口位置不可用;可在窗口上按 `Alt+Space` 打开“置顶”与“在所有工作区显示” ## 安装 @@ -269,7 +269,7 @@ enabled = true ## 常见问题与限制 -- **浮窗没有出现:** 确认当前是 Wayland 会话、合成器支持 layer-shell,并且播放器正在通过 MPRIS 暴露播放状态。 +- **浮窗没有出现:** 确认当前是 Wayland 会话,并且播放器正在通过 MPRIS 暴露播放状态。 - **播放器没有被识别:** 运行 `busctl --user list` 检查 `org.mpris.MediaPlayer2.*` 名称,必要时调整 `player.preferred_players` 或 `player.ignored_players`。 - **歌词时间不准:** 使用浮窗按钮校准当前歌曲,或在设置中调整全局偏移 `lyrics.offset_ms`。播放器未正确更新 `Position` 或 `Metadata` 时可能无法可靠同步。 - **逐词高亮与演唱不完全一致:** 逐词时间只取自歌词源自己的逐字数据(AMLL TTML DB 的逐词 TTML、QQ 音乐 QRC、网易云逐字、汽水 KRC);只提供整行时间的歌词源(例如 LRC)不做逐词高亮,整行一起点亮。网易云的逐字歌词是它单独一份时间表,FloatLyrics 只取其中的时间、文字仍用它的整行版本,对不上的行(例如逐字版给某个词打了码)同样退回整行高亮。 diff --git a/data/locale/en.json b/data/locale/en.json index 605c0a6..ce60aaf 100644 --- a/data/locale/en.json +++ b/data/locale/en.json @@ -120,6 +120,7 @@ "SearchingProviders": "Searching lyrics sources…", "SelectCandidatePreview": "Select a candidate to preview its lyrics", "SettingsWindowTitle": "FloatLyrics Settings", + "OverlayWindowTitle": "FloatLyrics Lyrics", "ShowRomanization": "Show romanization", "ShowRomanizationDescription": "Generated locally from the original lyrics", "ChineseRomanization": "Chinese pronunciation", diff --git a/data/locale/zh-CN.json b/data/locale/zh-CN.json index 1af4a1e..0044506 100644 --- a/data/locale/zh-CN.json +++ b/data/locale/zh-CN.json @@ -120,6 +120,7 @@ "SearchingProviders": "正在搜索歌词源…", "SelectCandidatePreview": "选择候选歌词后将在这里预览", "SettingsWindowTitle": "FloatLyrics 设置", + "OverlayWindowTitle": "FloatLyrics 歌词", "ShowRomanization": "显示罗马音", "ShowRomanizationDescription": "始终根据原歌词在本地生成", "ChineseRomanization": "中文罗马音", diff --git a/data/locale/zh-TW.json b/data/locale/zh-TW.json index 2950566..83227df 100644 --- a/data/locale/zh-TW.json +++ b/data/locale/zh-TW.json @@ -120,6 +120,7 @@ "SearchingProviders": "正在搜尋歌詞來源…", "SelectCandidatePreview": "選擇候選歌詞後會在這裡預覽", "SettingsWindowTitle": "FloatLyrics 設定", + "OverlayWindowTitle": "FloatLyrics 歌詞", "ShowRomanization": "顯示羅馬拼音", "ShowRomanizationDescription": "一律根據原歌詞在本機產生", "ChineseRomanization": "中文羅馬拼音", diff --git a/floatlyrics-core/src/i18n.rs b/floatlyrics-core/src/i18n.rs index 1c62a50..f3c41b5 100644 --- a/floatlyrics-core/src/i18n.rs +++ b/floatlyrics-core/src/i18n.rs @@ -208,6 +208,7 @@ macro_rules! define_text_keys { define_text_keys!( SettingsWindowTitle, + OverlayWindowTitle, ChangesSavedAutomatically, Saved, SaveFailed, diff --git a/src/frontend/view.rs b/src/frontend/view.rs index 55cbb42..03f4f05 100644 --- a/src/frontend/view.rs +++ b/src/frontend/view.rs @@ -98,7 +98,17 @@ pub(super) fn build( about: Rc, sender: relm4::Sender, ) -> OverlayView { - let drag_mode = desktop_drag_mode(std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref()); + let layer_shell_supported = gtk4_layer_shell::is_supported(); + if !layer_shell_supported { + tracing::warn!( + "the compositor does not support the layer shell protocol; \ + falling back to a plain draggable window" + ); + } + let drag_mode = overlay_drag_mode( + layer_shell_supported, + std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(), + ); let panel_width = compact_panel_width(config.window.width); let initial_placement = if config.window.remember_position { config.window.position.map(WindowPlacement::from_position) @@ -113,38 +123,45 @@ pub(super) fn build( config.lyrics.apple_music_style, ); let fallback_height = fallback_panel_height(viewport_h); - window.set_title(Some("FloatLyrics Overlay")); + window.set_title(Some(i18n.text(Text::OverlayWindowTitle))); window.set_decorated(false); window.set_resizable(false); - window.init_layer_shell(); - window.set_namespace(Some("floatlyrics")); - window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::None); - window.set_anchor(Edge::Bottom, true); - window.set_anchor(Edge::Left, true); - // KWin can apply desktop effects whenever a layer surface moves. On KDE, - // keep the surface fixed to the output and drag only its child panel. - window.set_anchor(Edge::Right, drag_mode.is_internal()); - window.set_anchor(Edge::Top, drag_mode.is_internal()); - if !drag_mode.is_internal() { - window.set_margin( - Edge::Left, - initial_placement - .and_then(|placement| left_position_for_width(window, &placement, panel_width)) - .or_else(|| initial_x(panel_width)) - .unwrap_or_default(), - ); - window.set_margin( - Edge::Bottom, - initial_placement - .and_then(|placement| { - bottom_margin_from_placement(window, &placement, panel_width, fallback_height) - }) - .unwrap_or_else(|| effective_bottom_margin(config)), - ); + if drag_mode.uses_layer_shell() { + window.init_layer_shell(); + window.set_namespace(Some("floatlyrics")); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::None); + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + // KWin can apply desktop effects whenever a layer surface moves. On KDE, + // keep the surface fixed to the output and drag only its child panel. + window.set_anchor(Edge::Right, drag_mode.is_internal()); + window.set_anchor(Edge::Top, drag_mode.is_internal()); + if !drag_mode.is_internal() { + window.set_margin( + Edge::Left, + initial_placement + .and_then(|placement| left_position_for_width(window, &placement, panel_width)) + .or_else(|| initial_x(panel_width)) + .unwrap_or_default(), + ); + window.set_margin( + Edge::Bottom, + initial_placement + .and_then(|placement| { + bottom_margin_from_placement( + window, + &placement, + panel_width, + fallback_height, + ) + }) + .unwrap_or_else(|| effective_bottom_margin(config)), + ); + } + window.set_exclusive_zone(-1); } - window.set_exclusive_zone(-1); window.add_css_class("floating-window"); super::style::install( @@ -198,6 +215,8 @@ pub(super) fn build( mode: drag_mode, }, move |placement| placement_view.set_overlay_placement(snap_classes(&placement)), + // The plain-window fallback never calls this: a compositor-driven move + // reports no position to persist. move |position| { let _ = sender.send(AppMsg::WindowMoved(position)); if drag_mode.is_internal() { @@ -235,7 +254,9 @@ pub(super) fn build( panel_width, fallback_height, ); - setup_input_region(window, &content); + if !placement.uses_native_window() { + setup_input_region(window, &content); + } gtk::glib::idle_add_local_once(move || { let picked = web_stack.pick(16.0, 16.0, gtk::PickFlags::DEFAULT); if picked @@ -269,6 +290,10 @@ pub(super) fn build( { let overlay = overlay.clone(); i18n.subscribe(move |language| { + // Only the plain-window fallback shows this title to the user. + overlay + .window + .set_title(Some(language.text(Text::OverlayWindowTitle))); overlay.render_overlay_state(language); let static_status = overlay.state.static_status(); if let Some(key) = static_status { @@ -290,7 +315,9 @@ fn apply_panel_width( content.set_width_request(width); lyrics_viewport.set_width_request(width); reposition_for_width(window, stage, content, placement, width); - setup_input_region(window, content); + if !placement.uses_native_window() { + setup_input_region(window, content); + } } fn set_status_lyrics(floating: &OverlayView, message: &str, key: Text) { @@ -371,7 +398,7 @@ impl OverlayView { width, fallback_height, ); - } else { + } else if self.placement.uses_layer_margins() { self.window.set_margin( Edge::Bottom, bottom_margin_from_placement( @@ -413,12 +440,19 @@ impl OverlayView { width, fallback_height, ); - } else if let Some(bottom_margin) = - bottom_margin_from_placement(&window, &placement.current(), width, fallback_height) + } else if placement.uses_layer_margins() + && let Some(bottom_margin) = bottom_margin_from_placement( + &window, + &placement.current(), + width, + fallback_height, + ) { window.set_margin(Edge::Bottom, bottom_margin); } - setup_input_region(&window, &content); + if !placement.uses_native_window() { + setup_input_region(&window, &content); + } }); } @@ -540,6 +574,13 @@ fn track_offset_label(offset_ms: i64, unit: &str) -> String { } } +fn overlay_drag_mode(layer_shell_supported: bool, desktop: Option<&str>) -> DragMode { + if !layer_shell_supported { + return DragMode::ToplevelWindow; + } + desktop_drag_mode(desktop) +} + fn desktop_drag_mode(desktop: Option<&str>) -> DragMode { if desktop.is_some_and(|desktop| { desktop diff --git a/src/frontend/view/positioning.rs b/src/frontend/view/positioning.rs index 9a8ecc0..fea0f10 100644 --- a/src/frontend/view/positioning.rs +++ b/src/frontend/view/positioning.rs @@ -35,12 +35,18 @@ struct PlacedContent { pub(super) enum DragMode { LayerSurface, InternalPanel, + /// No layer shell protocol: a plain toplevel the compositor moves. + ToplevelWindow, } impl DragMode { pub(super) fn is_internal(self) -> bool { self == Self::InternalPanel } + + pub(super) fn uses_layer_shell(self) -> bool { + self != Self::ToplevelWindow + } } impl PlacementState { @@ -68,6 +74,14 @@ impl PlacementState { self.mode.is_internal() } + pub(super) fn uses_layer_margins(&self) -> bool { + self.mode == DragMode::LayerSurface + } + + pub(super) fn uses_native_window(&self) -> bool { + self.mode == DragMode::ToplevelWindow + } + fn set(&self, placement: WindowPlacement, left: i32, top: i32) { *self.content.borrow_mut() = PlacedContent { placement, @@ -94,6 +108,11 @@ pub(super) fn attach_floating_drag( on_placement_changed: impl Fn(WindowPlacement) + 'static, on_drag_end: impl Fn(WindowPosition) + 'static, ) -> PlacementState { + if layout.mode == DragMode::ToplevelWindow { + // The layer modes mount the panel below; the plain window needs it too. + layout.stage.put(content, 0.0, 0.0); + return attach_toplevel_move(window, content, drag_handle, on_placement_changed); + } let FloatingDragLayout { stage, fallback_width, @@ -213,23 +232,11 @@ pub(super) fn attach_floating_drag( { let placement = placement.clone(); - let drag_handle = drag_handle.downgrade(); gesture.connect_drag_end(move |_, _, _| { - if let Some(drag_handle) = drag_handle.upgrade() { - drag_handle.set_cursor_from_name(Some("grab")); - } on_drag_end(placement.current().position()); }); } - - { - let drag_handle = drag_handle.downgrade(); - gesture.connect_cancel(move |_, _| { - if let Some(drag_handle) = drag_handle.upgrade() { - drag_handle.set_cursor_from_name(Some("grab")); - } - }); - } + restore_grab_cursor(&gesture, drag_handle); if mode.is_internal() { // GestureDrag reports offsets in the controller widget's coordinate @@ -243,6 +250,71 @@ pub(super) fn attach_floating_drag( placement } +/// Plain-window fallback: the compositor moves the toplevel, so the drag only +/// hands the pointer to it and there is no placement to track or persist. +fn attach_toplevel_move( + window: >k::ApplicationWindow, + content: >k::Box, + drag_handle: >k::Box, + on_placement_changed: impl Fn(WindowPlacement) + 'static, +) -> PlacementState { + let placement = WindowPlacement::default(); + on_placement_changed(placement); + let gesture = gtk::GestureDrag::new(); + drag_handle.set_cursor_from_name(Some("grab")); + { + let window = window.downgrade(); + let drag_handle = drag_handle.downgrade(); + gesture.connect_drag_begin(move |gesture, _, _| { + let (Some(window), Some(drag_handle)) = (window.upgrade(), drag_handle.upgrade()) + else { + return; + }; + drag_handle.set_cursor_from_name(Some("grabbing")); + let Some(surface) = window.surface() else { + return; + }; + let Some(device) = gesture.current_event_device() else { + return; + }; + let Some((x, y, _)) = surface.device_position(&device) else { + return; + }; + let Ok(toplevel) = surface.downcast::() else { + return; + }; + toplevel.begin_move( + &device, + gesture.current_button() as i32, + x, + y, + gesture.current_event_time(), + ); + }); + } + restore_grab_cursor(&gesture, drag_handle); + content.add_controller(gesture); + PlacementState::new(placement, 0, 0, DragMode::ToplevelWindow) +} + +/// Puts the open-hand cursor back on the drag handle when a drag ends or the +/// gesture is cancelled. +fn restore_grab_cursor(gesture: >k::GestureDrag, drag_handle: >k::Box) { + let restore = { + let drag_handle = drag_handle.downgrade(); + move || { + if let Some(drag_handle) = drag_handle.upgrade() { + drag_handle.set_cursor_from_name(Some("grab")); + } + } + }; + { + let restore = restore.clone(); + gesture.connect_drag_end(move |_, _, _| restore()); + } + gesture.connect_cancel(move |_, _| restore()); +} + pub(super) fn initial_x(window_width: i32) -> Option { let monitor = first_monitor()?; Some(centered_position(monitor.geometry().width(), window_width)) @@ -290,7 +362,7 @@ pub(super) fn reposition_for_width( } stage.move_(content, left as f64, top as f64); placement.set(current, left, top); - } else { + } else if placement.uses_layer_margins() { window.set_margin(Edge::Left, left); } } diff --git a/src/test/overlay_view_test.rs b/src/test/overlay_view_test.rs index cb8c059..98185b7 100644 --- a/src/test/overlay_view_test.rs +++ b/src/test/overlay_view_test.rs @@ -17,3 +17,24 @@ fn kde_uses_internal_drag_to_bypass_compositor_move_effects() { assert_eq!(desktop_drag_mode(Some("GNOME")), DragMode::LayerSurface); assert_eq!(desktop_drag_mode(None), DragMode::LayerSurface); } + +#[test] +fn compositor_without_layer_shell_falls_back_to_a_plain_window() { + assert_eq!( + overlay_drag_mode(false, Some("KDE")), + DragMode::ToplevelWindow + ); + assert_eq!( + overlay_drag_mode(false, Some("GNOME")), + DragMode::ToplevelWindow + ); + assert_eq!(overlay_drag_mode(false, None), DragMode::ToplevelWindow); + assert_eq!( + overlay_drag_mode(true, Some("KDE")), + DragMode::InternalPanel + ); + assert_eq!( + overlay_drag_mode(true, Some("GNOME")), + DragMode::LayerSurface + ); +}