pythontkintertkinter-buttontkinter-photoimage

Tk button is not clickable and image is not loading


I have created a GUI that shows data about videos from YouTube and allows me to select which video I want to download.

The code works well, but for some reason some of the images don't load and I can't select the video.

This is the result I get:

GUI result

it is always the few first images that don't load. And, as I mentioned, I can't click on them at all.

This is my code:

from __future__ import annotations

import contextlib
import dataclasses
import datetime
import tkinter as tk
from io import BytesIO
from typing import Iterable, Any

import requests
from PIL import Image, ImageTk


@dataclasses.dataclass
class VideoData:
    id: str
    title: str
    uploader: str
    duration: int
    thumbnail: str


def _initialize_root(root: tk.Tk):
    root.title('Downloader')
    # root.protocol('WM_DELETE_WINDOW', lambda: ...)
    root.attributes('-topmost', True)
    root.minsize(350, 200)


class Gui:
    def __init__(self, videos: Iterable[VideoData]):
        self._root = tk.Tk()
        _initialize_root(self._root)
        self._frame = tk.Frame(self._root)
        self._frame.grid(row=0, column=0, sticky='news')
        for row, video in enumerate(videos):
            self._create_video_line(video, row)
        self._frame.grid_columnconfigure('all', weight=1)
        self._root.grid_columnconfigure('all', weight=1)
        self.selected_row = -1

    def start(self):
        self._root.mainloop()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()

    def close(self):
        self._root.quit()
        with contextlib.suppress(tk.TclError):
            self._root.destroy()

    def _on_click(self, row: int):
        self.selected_row = row
        self.close()

    def _create_video_line(self, video_data: VideoData, row: int) -> None:
        response = requests.get(video_data.thumbnail)
        image_data = response.content

        # Create a PhotoImage from the image data and resize it
        print(f'Getting thumbnail image from {video_data.thumbnail!r}')
        image = Image.open(BytesIO(image_data)).resize((200, 200))
        # Convert to a PNG image
        with BytesIO() as fp:
            image.save(fp, 'PNG')
            png_image = Image.open(fp)
            thumbnail = ImageTk.PhotoImage(png_image)
        // Create the button
        button = tk.Button(self._frame, image=thumbnail, width=200, height=200, command=lambda: self._on_click(row))
        button.grid(row=row, column=0, columnspan=1, sticky="news")

        duration = datetime.timedelta(seconds=video_data.duration)
        text_label = tk.Label(self._frame, text=f'{video_data.title} by {video_data.uploader} (duration: {duration})'
                              , anchor='w')
        text_label.grid(row=row, column=1, columnspan=1, sticky="news")

How can I fix this issue? I don't mind to add a default image, but I can't find out if the image is loaded or not.


Solution

  • You need to save the reference of the image thumbnail, otherwise the image will be garbage collected after exiting the function _create_video_line():

    def _create_video_line(self, video_data: VideoData, row: int) -> None:
        print(f'Getting thumbnail image from {video_data.thumbnail!r}')
        response = requests.get(video_data.thumbnail)
        image_data = response.content
        # Create a PhotoImage from the image data and resize it
        image = Image.open(BytesIO(image_data)).resize((200,200))
        thumbnail = ImageTk.PhotoImage(image)
        # Create the button
        button = tk.Button(self._frame, image=thumbnail, command=lambda: self._on_click(row))
        button.grid(row=row, column=0, columnspan=1, sticky="news")
        # use an attribute of button to store the reference of image
        # the name of the attribute can be any user-defined name that
        # does not override the built-in attributes of button
        button.thumbnail = thumbnail
    
        duration = datetime.timedelta(seconds=video_data.duration)
        text_label = tk.Label(self._frame, text=f'{video_data.title} by {video_data.uploader} (duration: {duration})'
                              , anchor='w')
        text_label.grid(row=row, column=1, columnspan=1, sticky="news")