diff --git a/lazy_player/__init__.py b/lazy_player/__init__.py
index d24a27c..ff0bf32 100644
--- a/lazy_player/__init__.py
+++ b/lazy_player/__init__.py
@@ -15,7 +15,7 @@ gi.require_version("Gdk", "4.0")
 gi.require_version("Gtk", "4.0")
 gi.require_version("Gst", "1.0")
 gi.require_version("Pango", "1.0")
-from gi.repository import Gdk, Gst, Gtk, Pango  # NOQA: E402
+from gi.repository import Gdk, GLib, Gst, Gtk, Pango  # NOQA: E402
 
 
 class MainWindow(Gtk.ApplicationWindow):
@@ -101,6 +101,15 @@ class MainWindow(Gtk.ApplicationWindow):
         left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
         left_box.set_valign(Gtk.Align.CENTER)
         left_box.set_halign(Gtk.Align.FILL)
+
+        # Create image widget for thumbnail with fixed 16:9 aspect
+        self.thumbnail_image = Gtk.Picture()
+        self.thumbnail_image.set_size_request(384, 216)  # 16:9 aspect ratio
+        self.thumbnail_image.set_can_shrink(True)
+        self.thumbnail_image.set_keep_aspect_ratio(True)
+        self.thumbnail_image.set_resource(None)
+        left_box.append(self.thumbnail_image)
+
         self.file_info_label = Gtk.Label(label="")
         self.file_info_label.set_wrap(True)
         left_box.append(self.file_info_label)
@@ -268,10 +277,17 @@ class MainWindow(Gtk.ApplicationWindow):
         if selection_model.get_selected() == Gtk.INVALID_LIST_POSITION:
             self.file_info_label.set_text("")
         else:
-            selected_item = selection_model.get_selected_item()
-            if selected_item:
-                file_item = cast(FileItem, selected_item)
-                self.file_info_label.set_text(file_item.name)
+            file_item = self.selection
+            self.file_info_label.set_text(file_item.name)
+
+            # Update thumbnail if available
+            if file_item.thumbnail:
+                gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
+                texture = Gdk.Texture.new_from_bytes(gbytes)
+                self.thumbnail_image.set_paintable(texture)
+            else:
+                self.thumbnailer.generate_thumbnail(file_item)
+                self.thumbnail_image.set_paintable(None)
 
     def _toggle_play_pause(self) -> None:
         """Toggle between play and pause states"""
@@ -426,6 +442,13 @@ class MainWindow(Gtk.ApplicationWindow):
             self._save_position()
             self.last_position_save = frame_time
 
+        # Update thumbnail if available
+        file_item = self.selection
+        if file_item.thumbnail and not self.thumbnail_image.get_paintable():
+            gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
+            texture = Gdk.Texture.new_from_bytes(gbytes)
+            self.thumbnail_image.set_paintable(texture)
+
         return True
 
     def _populate_file_list(self) -> None:
@@ -444,8 +467,6 @@ class MainWindow(Gtk.ApplicationWindow):
                 items.append(FileItem(path.name, FileType.DIRECTORY, path.resolve()))
             elif path.suffix in (".mkv", ".mp4", ".avi"):
                 file_item = FileItem(path.name, FileType.VIDEO, path.resolve())
-                if not file_item.saved_thumbnail:
-                    self.thumbnailer.generate_thumbnail(file_item)
                 items.append(file_item)
 
         # Sort directories first, then files, both alphabetically
diff --git a/lazy_player/file_model.py b/lazy_player/file_model.py
index dcff896..85e4105 100644
--- a/lazy_player/file_model.py
+++ b/lazy_player/file_model.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+import sys
 from enum import Enum, auto
 from pathlib import Path
 from typing import Optional, overload
@@ -19,6 +20,8 @@ class FileType(Enum):
 class FileItem(GObject.Object):
     file_type: FileType
     full_path: Path
+    thumbnail: bytes
+    _has_thumbnail: bool
 
     __gtype_name__ = "FileItem"
 
@@ -27,6 +30,8 @@ class FileItem(GObject.Object):
         self.name = name
         self.file_type = file_type
         self.full_path = full_path
+        self.thumbnail = b""
+        self._has_thumbnail = False
 
     @GObject.Property(type=GObject.TYPE_UINT64)
     def saved_position(self) -> int:
@@ -55,36 +60,44 @@ class FileItem(GObject.Object):
         self._save_attribute("subtitle_track", value if value >= -1 else None)
         self.notify("saved-subtitle-track")
 
-    @GObject.Property(type=str)
-    def saved_thumbnail(self) -> str:
-        return self._load_attribute("thumbnail", "")
+    @GObject.Property(type=bool, default=False)
+    def has_thumbnail(self):
+        return self._has_thumbnail
 
-    @saved_thumbnail.setter
-    def set_saved_thumbnail(self, value: str) -> None:
-        self._save_attribute("thumbnail", value)
-        self.notify("saved-thumbnail")
+    @has_thumbnail.setter
+    def set_has_thumbnail(self, value: bool):
+        self._has_thumbnail = value
+        self.notify("has-thumbnail")
 
     @overload
     def _load_attribute(self, name: str, dfl: str) -> str: ...
 
+    @overload
+    def _load_attribute(self, name: str, dfl: bytes) -> bytes: ...
+
     @overload
     def _load_attribute(self, name: str, dfl: int) -> int: ...
 
-    def _load_attribute(self, name: str, dfl: str | int) -> str | int:
+    def _load_attribute(self, name: str, dfl: str | bytes | int) -> str | bytes | int:
         try:
             strval = os.getxattr(self.full_path, f"user.lazy_player.{name}")
             return type(dfl)(strval)
         except OSError:
             return dfl
 
-    def _save_attribute(self, name: str, value: str | float | int | None) -> None:
+    def _save_attribute(self, name: str, value: str | bytes | float | int | None) -> None:
         try:
             if value is None:
                 os.removexattr(self.full_path, f"user.lazy_player.{name}")
             else:
-                os.setxattr(self.full_path, f"user.lazy_player.{name}", str(value).encode("utf8"))
-        except OSError:
-            pass
+                if isinstance(value, bytes):
+                    os.setxattr(self.full_path, f"user.lazy_player.{name}", value)
+                else:
+                    os.setxattr(
+                        self.full_path, f"user.lazy_player.{name}", str(value).encode("utf8")
+                    )
+        except OSError as err:
+            print(err, file=sys.stderr)
 
 
 class FileListModel(GObject.Object, Gio.ListModel):
diff --git a/lazy_player/thumbnailer.py b/lazy_player/thumbnailer.py
index 6d70614..20649f9 100644
--- a/lazy_player/thumbnailer.py
+++ b/lazy_player/thumbnailer.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-import base64
 import sys
+from enum import Enum, auto
 
 import gi
 
@@ -11,23 +11,34 @@ gi.require_version("Gst", "1.0")
 from gi.repository import Gst  # NOQA: E402
 
 
+class State(Enum):
+    IDLE = auto()
+    INITIALIZING = auto()
+    SEEKING = auto()
+    CAPTURING = auto()
+    CLEANING_UP = auto()
+
+
 class Thumbnailer:
     pipeline: Gst.Pipeline
     uridecodebin: Gst.Element
     sink: Gst.Element
     queue: list[FileItem]
     current_item: FileItem | None
+    state: State
 
     def __init__(self):
         self.queue = []
         self.current_item = None
+        self.state = State.IDLE
 
         pipeline_str = (
             "uridecodebin name=uridecodebin ! "
             "videoconvert ! "
-            "videoscale ! video/x-raw,width=480,height=270 ! "
-            "videobox name=box ! "
-            "jpegenc ! "
+            "videoscale ! "
+            "videobox name=box,autocrop=true ! "
+            "video/x-raw,width=384,height=216 ! "
+            "jpegenc quality=85 ! "
             "appsink name=sink"
         )
 
@@ -40,14 +51,16 @@ class Thumbnailer:
         assert uridecodebin is not None
         self.uridecodebin = uridecodebin
 
-        # Get elements
-        box = self.pipeline.get_by_name("box")
-        assert box is not None
-
         sink = self.pipeline.get_by_name("sink")
         assert sink is not None
         self.sink = sink
 
+        # Configure appsink for better reliability
+        self.sink.set_property("emit-signals", True)
+        self.sink.set_property("max-buffers", 1)
+        self.sink.set_property("drop", True)
+        self.sink.connect("new-sample", self._on_new_sample)
+
         # Set up bus message handler
         bus = self.pipeline.get_bus()
         bus.add_signal_watch()
@@ -72,15 +85,28 @@ class Thumbnailer:
             return
 
         if message.type == Gst.MessageType.STATE_CHANGED:
-            if self.pipeline and message.src == self.pipeline:
-                _, new_state, _ = message.parse_state_changed()
-                if new_state == Gst.State.PAUSED:
-                    self._on_pipeline_ready()
+            if message.src != self.pipeline:
+                return
+
+            _, new_state, _ = message.parse_state_changed()
+
+            if new_state == Gst.State.PAUSED and self.state == State.INITIALIZING:
+                self.state = State.SEEKING
+                self._on_pipeline_ready()
+            elif new_state == Gst.State.PLAYING:
+                self.state = State.CAPTURING
+            elif new_state == Gst.State.NULL:
+                if self.state == State.CAPTURING:
+                    self._on_capture_complete()
+                self.state = State.IDLE
+
             return
 
         if message.type == Gst.MessageType.ASYNC_DONE:
-            self._on_seek_complete()
-            return
+            if self.state == State.SEEKING:
+                # Let the pipeline run to capture the frame
+                self.pipeline.set_state(Gst.State.PLAYING)
+                return
 
         if message.type == Gst.MessageType.EOS:
             self._on_capture_complete()
@@ -95,56 +121,75 @@ class Thumbnailer:
             return
 
         seek_pos = duration // 3
-        self.pipeline.seek_simple(
+        success = self.pipeline.seek_simple(
             Gst.Format.TIME,
             Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
             seek_pos,
         )
 
-    def _on_seek_complete(self) -> None:
-        """Called when seek operation completes"""
-
-        # Let the pipeline run to capture the frame
-        self.pipeline.set_state(Gst.State.PLAYING)
-
-    def _on_capture_complete(self) -> None:
-        """Called when capture is complete"""
-
-        if not (self.sink and self.current_item):
+        if not success:
             self._cleanup()
             return
 
-        sample = self.sink.emit("pull-sample")
-        if sample:
-            buffer = sample.get_buffer()
-            success, map_info = buffer.map(Gst.MapFlags.READ)
-            if success:
-                try:
-                    jpeg_bytes = bytes(map_info.data)
-                    base64_data = base64.b64encode(jpeg_bytes).decode("utf-8")
-                    data_url = f"data:image/jpeg;base64,{base64_data}"
-                    self.current_item.saved_thumbnail = data_url
-                finally:
-                    buffer.unmap(map_info)
+    def _on_new_sample(self, sink: Gst.Element) -> Gst.FlowReturn:
+        """Handle new sample from appsink"""
 
+        if self.state != State.CAPTURING or not self.current_item:
+            return Gst.FlowReturn.OK
+
+        sample = sink.emit("pull-sample")
+        if not sample:
+            self._cleanup()
+            return Gst.FlowReturn.ERROR
+
+        buffer = sample.get_buffer()
+        if not buffer:
+            self._cleanup()
+            return Gst.FlowReturn.ERROR
+
+        success, map_info = buffer.map(Gst.MapFlags.READ)
+        if not success:
+            self._cleanup()
+            return Gst.FlowReturn.ERROR
+
+        try:
+            self.current_item.thumbnail = bytes(map_info.data)
+            self.current_item.has_thumbnail = True
+        finally:
+            buffer.unmap(map_info)
+
+        # We got our sample, clean up
+        self._cleanup()
+        return Gst.FlowReturn.OK
+
+    def _on_capture_complete(self) -> None:
+        """Called when capture is complete"""
         self._cleanup()
 
     def _cleanup(self) -> None:
         """Clean up resources and process next item"""
 
+        # Ensure pipeline is stopped
         self.pipeline.set_state(Gst.State.NULL)
+
+        # Reset state
+        self.state = State.IDLE
         self.current_item = None
+
+        # Process next item
         self._process_next()
 
     def _process_next(self) -> None:
         """Start processing the next item in the queue"""
 
         if not self.queue:
+            self.state = State.IDLE
             return
 
+        self.state = State.INITIALIZING
         self.current_item = self.queue.pop(0)
 
         # Update URI and start pipeline
-        video_uri = Gst.filename_to_uri(str(self.current_item.full_path.resolve()))
+        video_uri = Gst.filename_to_uri(str(self.current_item.full_path))
         self.uridecodebin.set_property("uri", video_uri)
         self.pipeline.set_state(Gst.State.PLAYING)