diff --git a/src/telephony/client/style.css b/src/telephony/client/style.css index cbf9f52..dac0894 100644 --- a/src/telephony/client/style.css +++ b/src/telephony/client/style.css @@ -261,3 +261,6 @@ viewswitcher button:focus { .blocklist-on { color: #c01c28; } .blocklist-off { color: alpha(@theme_fg_color, 0.25); } + +/* Camera and playback previews: bare rounded surfaces, no card backing. */ +.preview-round { border-radius: 12px; } diff --git a/src/telephony/client/ui/widgets/common_widget.py b/src/telephony/client/ui/widgets/common_widget.py index 75bb66e..083c3a1 100644 --- a/src/telephony/client/ui/widgets/common_widget.py +++ b/src/telephony/client/ui/widgets/common_widget.py @@ -211,7 +211,10 @@ def present_sheet_page(window, page, replace=False): A page going onto an open sheet asks for the height the sheet already has, so a short page does not shrink the sheet around it - and let it spring back when the page is left. + and let it spring back when the page is left. A page that asks for + more keeps its own ask: a capture page opened from a small chooser + would otherwise start small and leap once its viewfinder starts + drawing. The page takes the focus itself so it does not go to whatever the page happens to hold first. A text field taking it brings the @@ -223,7 +226,8 @@ def present_sheet_page(window, page, replace=False): host = window.sheet_host nav = sheet_navigation(host.get_sheet()) if host.get_open() else None if nav is not None: - page.set_size_request(-1, nav.get_height()) + own_height = page.get_size_request()[1] + page.set_size_request(-1, max(nav.get_height(), own_height)) if replace: nav.replace([page]) return diff --git a/src/telephony/client/ui/windows/camera_photo_window.py b/src/telephony/client/ui/windows/camera_photo_window.py index b3118c5..2568335 100644 --- a/src/telephony/client/ui/windows/camera_photo_window.py +++ b/src/telephony/client/ui/windows/camera_photo_window.py @@ -15,7 +15,6 @@ import os import time -import tempfile from gettext import gettext as _ import gi @@ -26,7 +25,7 @@ from telephony.shared.utils.log_utils import logger from telephony.shared.utils.thread_utils import run_in_background -from telephony.shared.constants import (VIEWFINDER_START_DELAY_MS, CAPTURE_SHEET_HEIGHT) +from telephony.shared.constants import VIEWFINDER_START_DELAY_MS from telephony.client.ui.windows.media_window_base import MediaCaptureWindow from telephony.client.ui.widgets.common_widget import close_sheet_page @@ -43,7 +42,7 @@ class CameraPhoto(MediaCaptureWindow): def __init__(self, parent_window, on_attach_callback): super().__init__() - self.set_size_request(-1, CAPTURE_SHEET_HEIGHT) + self.request_capture_height(parent_window) self.on_attach_callback = on_attach_callback self.set_title(_("Take Picture")) @@ -86,9 +85,6 @@ def _setup_ui(self): header.set_show_end_title_buttons(False) content.append(header) - btn_cancel = Gtk.Button(label=_("Cancel")) - btn_cancel.connect("clicked", lambda b: GLib.idle_add(lambda: self._on_cancel_clicked(b) or False)) - header.pack_start(btn_cancel) self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) @@ -98,7 +94,7 @@ def _setup_ui(self): self.page_capture = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) card_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - card_box.add_css_class("card") + card_box.add_css_class("preview-round") card_box.set_hexpand(True) card_box.set_vexpand(True) card_box.set_margin_top(10) @@ -108,12 +104,14 @@ def _setup_ui(self): card_box.set_overflow(Gtk.Overflow.HIDDEN) self.viewfinder_widget = Gtk.Picture() + self.viewfinder_widget.add_css_class("preview-round") + self.viewfinder_widget.set_overflow(Gtk.Overflow.HIDDEN) self.viewfinder_widget.set_can_shrink(True) self.viewfinder_widget.set_hexpand(True) self.viewfinder_widget.set_vexpand(True) self.viewfinder_widget.set_content_fit(Gtk.ContentFit.CONTAIN) - card_box.append(self.viewfinder_widget) + card_box.append(self.letterbox(self.viewfinder_widget)) self.page_capture.append(card_box) ctrl_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) @@ -134,7 +132,7 @@ def _setup_ui(self): self.page_review = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) review_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - review_card.add_css_class("card") + review_card.add_css_class("preview-round") review_card.set_hexpand(True) review_card.set_vexpand(True) review_card.set_margin_top(10) @@ -144,12 +142,14 @@ def _setup_ui(self): review_card.set_overflow(Gtk.Overflow.HIDDEN) self.review_image = Gtk.Picture() + self.review_image.add_css_class("preview-round") + self.review_image.set_overflow(Gtk.Overflow.HIDDEN) self.review_image.set_can_shrink(True) self.review_image.set_hexpand(True) self.review_image.set_vexpand(True) self.review_image.set_content_fit(Gtk.ContentFit.CONTAIN) - review_card.append(self.review_image) + review_card.append(self.letterbox(self.review_image)) self.page_review.append(review_card) act_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20) @@ -194,6 +194,7 @@ def _start_viewfinder(self): if sink: paintable = sink.get_property("paintable") self.viewfinder_widget.set_paintable(paintable) + self.reveal_on_first_frame(self.viewfinder_widget) self.bus, self.bus_handler_id = self._watch_bus(self.pipeline, self._on_viewfinder_message) @@ -238,7 +239,7 @@ def _attempt_capture(self): def _capture_frame(self): """Capture a single frame from the camera.""" - self.temp_capture_path = os.path.join(tempfile.gettempdir(), f"cam_cap_{int(time.time())}.jpg") + self.temp_capture_path = os.path.join(self.capture_dir(), f"cam_cap_{int(time.time())}.jpg") self.frame_count = 0 self._capture_taken = False @@ -379,7 +380,7 @@ def _process_image(self, path): if w > MAX_IMAGE_DIMENSION or h > MAX_IMAGE_DIMENSION: img.thumbnail((MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION)) - output_path = os.path.join(tempfile.gettempdir(), f"photo_{int(time.time())}.jpg") + output_path = os.path.join(self.capture_dir(), f"photo_{int(time.time())}.jpg") img.save(output_path, "JPEG", quality=JPEG_QUALITY) if path != output_path: @@ -415,10 +416,6 @@ def _on_attach_clicked(self, btn): self.on_attach_callback(self.output_path) GLib.idle_add(lambda: close_sheet_page(self.get_root()) or False) - def _on_cancel_clicked(self, btn): - """Handle cancel button click.""" - GLib.idle_add(lambda: close_sheet_page(self.get_root()) or False) - def _on_closed(self, _dialog): """Tear down capture state when the sheet closes.""" self._closed = True diff --git a/src/telephony/client/ui/windows/camera_video_window.py b/src/telephony/client/ui/windows/camera_video_window.py index 2fa6d6e..8390fdd 100644 --- a/src/telephony/client/ui/windows/camera_video_window.py +++ b/src/telephony/client/ui/windows/camera_video_window.py @@ -15,7 +15,6 @@ import os import time -import tempfile from gettext import gettext as _ import gi @@ -25,7 +24,7 @@ from gi.repository import Gtk, Adw, Gst, GLib from telephony.shared.utils.log_utils import logger -from telephony.shared.constants import (CAPTURE_SHEET_HEIGHT, VIEWFINDER_START_DELAY_MS, PLAYBACK_PROGRESS_INTERVAL_MS, EOS_TIMEOUT_MS, PROGRESS_BAR_WIDTH) +from telephony.shared.constants import (VIEWFINDER_START_DELAY_MS, PLAYBACK_PROGRESS_INTERVAL_MS, EOS_TIMEOUT_MS, PROGRESS_BAR_WIDTH) from telephony.client.ui.windows.media_window_base import MediaCaptureWindow from telephony.client.ui.widgets.common_widget import close_sheet_page @@ -43,7 +42,7 @@ class CameraVideo(MediaCaptureWindow): def __init__(self, parent_window, on_attach_callback): super().__init__() - self.set_size_request(-1, CAPTURE_SHEET_HEIGHT) + self.request_capture_height(parent_window) registry = Gst.Registry.get() droidvdec = registry.lookup_feature("droidvdec") @@ -92,9 +91,6 @@ def _setup_ui(self): header.set_show_end_title_buttons(False) content.append(header) - btn_cancel = Gtk.Button(label=_("Cancel")) - btn_cancel.connect("clicked", lambda b: GLib.idle_add(lambda: self._on_cancel_clicked(b) or False)) - header.pack_start(btn_cancel) self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) @@ -104,7 +100,7 @@ def _setup_ui(self): self.page_capture = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) card_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - card_box.add_css_class("card") + card_box.add_css_class("preview-round") card_box.set_hexpand(True) card_box.set_vexpand(True) card_box.set_margin_top(10) @@ -114,12 +110,14 @@ def _setup_ui(self): card_box.set_overflow(Gtk.Overflow.HIDDEN) self.viewfinder_widget = Gtk.Picture() + self.viewfinder_widget.add_css_class("preview-round") + self.viewfinder_widget.set_overflow(Gtk.Overflow.HIDDEN) self.viewfinder_widget.set_can_shrink(True) self.viewfinder_widget.set_hexpand(True) self.viewfinder_widget.set_vexpand(True) self.viewfinder_widget.set_content_fit(Gtk.ContentFit.CONTAIN) - card_box.append(self.viewfinder_widget) + card_box.append(self.letterbox(self.viewfinder_widget)) self.page_capture.append(card_box) ctrl_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) @@ -148,7 +146,7 @@ def _setup_ui(self): self.page_review = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) review_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - review_card.add_css_class("card") + review_card.add_css_class("preview-round") review_card.set_hexpand(True) review_card.set_vexpand(True) review_card.set_margin_top(10) @@ -158,12 +156,14 @@ def _setup_ui(self): review_card.set_overflow(Gtk.Overflow.HIDDEN) self.review_widget = Gtk.Picture() + self.review_widget.add_css_class("preview-round") + self.review_widget.set_overflow(Gtk.Overflow.HIDDEN) self.review_widget.set_can_shrink(True) self.review_widget.set_hexpand(True) self.review_widget.set_vexpand(True) self.review_widget.set_content_fit(Gtk.ContentFit.CONTAIN) - review_card.append(self.review_widget) + review_card.append(self.letterbox(self.review_widget)) self.page_review.append(review_card) act_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) @@ -228,6 +228,7 @@ def _start_viewfinder(self): if sink: paintable = sink.get_property("paintable") self.viewfinder_widget.set_paintable(paintable) + self.reveal_on_first_frame(self.viewfinder_widget) self.bus, self.bus_handler_id = self._watch_bus(self.pipeline, self._on_viewfinder_message) @@ -280,7 +281,7 @@ def _attempt_recording(self): def _start_recording(self): """Initialize and start the GStreamer recording pipeline.""" - self.output_path = os.path.join(tempfile.gettempdir(), f"video_{int(time.time())}.mkv") + self.output_path = os.path.join(self.capture_dir(), f"video_{int(time.time())}.mkv") pipeline_str = ( f"matroskamux name=mux ! filesink location={self.output_path} " @@ -299,6 +300,7 @@ def _start_recording(self): if sink: paintable = sink.get_property("paintable") self.viewfinder_widget.set_paintable(paintable) + self.reveal_on_first_frame(self.viewfinder_widget) self.bus, self.bus_handler_id = self._watch_bus(self.pipeline, self._on_record_message) @@ -444,6 +446,7 @@ def _start_playback(self): paintable = sink.get_property("paintable") self.review_widget.set_paintable(paintable) + self.reveal_on_first_frame(self.review_widget) self.player_bus, self.player_bus_handler_id = self._watch_bus(self.player, self._on_player_message) @@ -471,15 +474,27 @@ def _stop_playback(self): if self.player: self.player.set_state(Gst.State.NULL) self.player = None + self.review_widget.set_visible(False) self.btn_play.set_icon_name("media-playback-start-symbolic") self.lbl_progress.set_label(self._playback_progress_text(0, 0)) self.progress_bar.set_fraction(0.0) def _on_player_message(self, bus, message): - """Handle playback messages.""" + """Handle playback messages. + + The end of the video is not the end of the player: tearing it + down leaves the picture holding a dead paintable, which paints + black. Rewound and paused it keeps the first frame up as a + poster, and play starts it again from the top. + """ t = message.type if t == Gst.MessageType.EOS: - self._stop_playback() + self.player.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH, 0) + self.player.set_state(Gst.State.PAUSED) + if self.progress_timer_id: + self._cancel_timeout(self.progress_timer_id) + self.progress_timer_id = None + self.btn_play.set_icon_name("media-playback-start-symbolic") elif t == Gst.MessageType.ERROR: err, debug = message.parse_error() logger.error(f"[Camera-Video] Playback error: {err} : {debug}") @@ -516,10 +531,6 @@ def _on_attach_clicked(self, btn): self.on_attach_callback(self.output_path) GLib.idle_add(lambda: close_sheet_page(self.get_root()) or False) - def _on_cancel_clicked(self, btn): - """Handle cancel button click.""" - GLib.idle_add(lambda: close_sheet_page(self.get_root()) or False) - def _on_closed(self, _dialog): """Tear down capture state when the sheet closes.""" self._closed = True diff --git a/src/telephony/client/ui/windows/media_window_base.py b/src/telephony/client/ui/windows/media_window_base.py index 8d67ef3..168c214 100644 --- a/src/telephony/client/ui/windows/media_window_base.py +++ b/src/telephony/client/ui/windows/media_window_base.py @@ -13,13 +13,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import os import time import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') gi.require_version('Gst', '1.0') -from gi.repository import Adw, Gst, GLib +from gi.repository import Gtk, Adw, Gst, GLib, Gsk, Graphene + +from telephony.shared.constants import CAPTURE_SHEET_HEIGHT +from telephony.shared.utils.log_utils import logger @@ -29,9 +33,154 @@ def _format_duration(seconds): return f"{total // 60}:{total % 60:02d}" +CAPTURE_SWEEP_AGE_SECONDS = 24 * 3600 + + +class PreviewStage(Gtk.Widget): + """A box that sizes its child instead of asking the child. + + A widget subclass with no layout manager, because a manager would + supply the measurement this class exists to withhold. + """ + + def __init__(self, child): + """Adopt the child; the stage owns it until disposed.""" + super().__init__() + self._child = child + self._child.set_parent(self) + self.set_hexpand(True) + self.set_vexpand(True) + self._child.connect("notify::paintable", self._on_paintable_replaced) + self._paintable_watch = None + self._watch_paintable() + + def _on_paintable_replaced(self, _picture, _pspec): + """Follow the new paintable and reshape around it.""" + self._watch_paintable() + self.queue_allocate() + + def _watch_paintable(self): + """Reallocate when the current paintable learns its size.""" + if self._paintable_watch is not None: + obj, handler = self._paintable_watch + obj.disconnect(handler) + self._paintable_watch = None + paintable = self._child.get_paintable() + if paintable is not None: + self._paintable_watch = (paintable, paintable.connect( + "invalidate-size", lambda _p: self.queue_allocate())) + + def do_measure(self, orientation, for_size): + return (0, 0, -1, -1) + + def do_size_allocate(self, width, height, baseline): + """Give the child the shape of what it shows, centered. + + Allocated the whole box, a contained frame floats inset from + the widget's corners, so a rounded clip on the child trims + only the empty letterbox bars and the visible frame stays + square. Shaped to the frame's aspect, the child's corners are + the frame's corners and the rounding lands on pixels. + """ + paintable = self._child.get_paintable() + pw = paintable.get_intrinsic_width() if paintable else 0 + ph = paintable.get_intrinsic_height() if paintable else 0 + if pw > 0 and ph > 0 and width > 0 and height > 0: + scale = min(width / pw, height / ph) + cw, ch = max(1, round(pw * scale)), max(1, round(ph * scale)) + transform = Gsk.Transform.new().translate( + Graphene.Point().init((width - cw) / 2, (height - ch) / 2)) + self._child.allocate(cw, ch, baseline, transform) + return + self._child.allocate(width, height, baseline, None) + + def do_dispose(self): + """Release the child and its paintable watch before the widget goes.""" + if self._paintable_watch is not None: + obj, handler = self._paintable_watch + obj.disconnect(handler) + self._paintable_watch = None + if self._child is not None: + self._child.unparent() + self._child = None + Gtk.Widget.do_dispose(self) + + class MediaCaptureWindow(Adw.NavigationPage): """Base capture sheet providing shared timeout, bus watch and progress scaffolding.""" + def capture_dir(self): + """Return the on-disk directory captures are written to. + + The system temp directory is tmpfs on this platform, so a + video recording written there streams straight into RAM for + as long as it runs. The cache directory is real storage; + leftovers from interrupted sessions are swept on the next + capture rather than on boot, which tmpfs used to do for free. + Blocking, call from a worker or accept the mkdir on first use. + """ + directory = os.path.join(GLib.get_user_cache_dir(), "telephony", "captures") + os.makedirs(directory, exist_ok=True) + now = time.time() + for name in os.listdir(directory): + path = os.path.join(directory, name) + try: + if now - os.path.getmtime(path) > CAPTURE_SWEEP_AGE_SECONDS: + os.remove(path) + except OSError as e: + logger.debug(f"[Capture] Sweep skipped {name}: {e}") + return directory + + def reveal_on_first_frame(self, picture): + """Keep a picture hidden until its paintable actually draws. + + The stage gives the picture its full box before the camera is + awake, and the sink paints black until frames arrive; hidden, + the card shows instead, and the live image appears in place. + """ + paintable = picture.get_paintable() + if paintable is None: + picture.set_visible(True) + return + picture.set_visible(False) + state = {"handler": None} + + def first_frame(_paintable): + picture.set_visible(True) + if state["handler"] is not None: + paintable.disconnect(state["handler"]) + state["handler"] = None + + state["handler"] = paintable.connect("invalidate-contents", first_frame) + + def letterbox(self, picture): + """Keep a picture from dictating the size of its box. + + A picture asks for the natural size of whatever it shows, so + the box jumps when the camera starts, the camera flips or the + review appears, each with its own dimensions. The stage + reports no size of its own and hands the picture exactly the + room it was given, so the picture scales into it; a scroll + area would break the request too, but it allocates its child + at natural size, which had every camera frame painted at full + resolution into a box showing a fraction of it. + """ + return PreviewStage(picture) + + def request_capture_height(self, parent_window): + """Ask for the capture height, capped to the window it opens in. + + The fixed ask overflowed short screens, pushing the controls + off the bottom, and a preview that shrinks to fit is more use + than one that covers the window. The fraction leaves the sheet + visibly a sheet rather than a takeover. + """ + available = parent_window.get_height() if parent_window else 0 + if available > 0: + self.set_size_request(-1, min(CAPTURE_SHEET_HEIGHT, int(available * 0.8))) + else: + self.set_size_request(-1, CAPTURE_SHEET_HEIGHT) + def __init__(self, **kwargs): super().__init__(**kwargs) self._closed = False diff --git a/src/telephony/client/ui/windows/sound_recorder_window.py b/src/telephony/client/ui/windows/sound_recorder_window.py index 0c23d2d..01c9fbd 100644 --- a/src/telephony/client/ui/windows/sound_recorder_window.py +++ b/src/telephony/client/ui/windows/sound_recorder_window.py @@ -15,7 +15,6 @@ import os import time -import tempfile from gettext import gettext as _ import gi @@ -25,7 +24,7 @@ from gi.repository import Gtk, Adw, Gst, GLib from telephony.shared.utils.log_utils import logger -from telephony.shared.constants import (CAPTURE_SHEET_HEIGHT, PLAYBACK_PROGRESS_INTERVAL_MS, EOS_TIMEOUT_MS, PROGRESS_BAR_WIDTH) +from telephony.shared.constants import (PLAYBACK_PROGRESS_INTERVAL_MS, EOS_TIMEOUT_MS, PROGRESS_BAR_WIDTH) from telephony.client.ui.windows.media_window_base import MediaCaptureWindow, _format_duration from telephony.client.ui.widgets.common_widget import close_sheet_page @@ -48,7 +47,7 @@ class SoundRecorder(MediaCaptureWindow): def __init__(self, parent_window, on_attach_callback, max_bytes=MAX_MMS_AUDIO_BYTES): super().__init__() - self.set_size_request(-1, CAPTURE_SHEET_HEIGHT) + self.request_capture_height(parent_window) self.on_attach_callback = on_attach_callback self.max_bytes = max_bytes self._attached = False @@ -89,10 +88,6 @@ def _setup_ui(self): header.set_show_end_title_buttons(False) content.append(header) - btn_cancel = Gtk.Button(label=_("Cancel")) - btn_cancel.connect("clicked", lambda b: GLib.idle_add( - lambda: self._on_cancel_clicked(b) or False)) - header.pack_start(btn_cancel) self.stack = Gtk.Stack() self.stack.set_transition_type( @@ -194,7 +189,7 @@ def _on_record_toggle(self, btn): def _start_recording(self): """Initialize and start the GStreamer recording pipeline.""" self.output_path = os.path.join( - tempfile.gettempdir(), f"voice_{int(time.time())}.mp3") + self.capture_dir(), f"voice_{int(time.time())}.mp3") pipeline_str = ( "autoaudiosrc ! audioconvert ! audioresample ! " @@ -382,10 +377,6 @@ def _on_attach_clicked(self, btn): self.on_attach_callback(self.output_path) GLib.idle_add(lambda: close_sheet_page(self.get_root()) or False) - def _on_cancel_clicked(self, btn): - """Handle cancel button click.""" - GLib.idle_add(lambda: close_sheet_page(self.get_root()) or False) - def _on_closed(self, _dialog): """Tear down capture state when the sheet closes.""" self._closed = True