シンプルな配信用フォルダ指定スライドショーアプリ

# はじめに
とある絵師の方の配信を拝見していた際に、ちょうど使い勝手の良いシンプルなスライドショーアプリが欲しいとおっしゃっていたのでChatGPT o3-miniに作ってもらい共有しようと思います。

# 機能

- **画像フォルダの選択**  
  - 設定ウィンドウからフォルダを選択可能  

- **サブフォルダの画像を含めるオプション**  
  - チェックボックスでサブフォルダの画像も対象にするか選択可能  

- **スライドショーの再生順**  
  - 連番 / ランダム をラジオボタンで切り替え  

- **表示間隔の設定**  
  - インターバル（秒単位）を入力してスライドの切り替え速度を変更  

- **ウィンドウサイズに応じた画像表示**  
  - 画像は縦または横をウィンドウサイズに合わせ、余白部分には暗くフェードした背景画像を表示  

- **ウィンドウサイズ変更対応**  
  - ウィンドウサイズを変更すると、それに合わせて画像をリサイズ  



# 置き場
以下で実行ファイル化したものを公開しています。

https://github.com/Cartelet0423/SimpleSlideshowApp/releases/tag/v1.0

# プログラム
```python
import os
import glob
import random
import tkinter as tk
import tkinter.filedialog as filedialog
import tkinter.messagebox as messagebox
from PIL import Image, ImageTk, ImageEnhance, ImageOps

class SlideShowApp:
    def __init__(self, master, image_folder, interval, shuffle_mode, include_subfolders):
        self.master = master
        self.master.title("スライドショー")
        self.interval = interval  # ミリ秒単位
        self.image_folder = image_folder
        self.shuffle_mode = shuffle_mode  # True: ランダム, False: 連番
        self.include_subfolders = include_subfolders  # サブフォルダ内も含むか

        self.canvas = tk.Canvas(master, highlightthickness=0)
        self.canvas.pack(fill="both", expand=True)
        self.master.bind("<Configure>", self.on_resize)

        self.load_images()
        if not self.image_paths:
            messagebox.showerror("エラー", "指定フォルダに画像がありません")
            self.master.destroy()
            return

        self.current_index = 0
        self.bg_imgtk = None
        self.fg_imgtk = None
        self.current_img = None

        self.show_image()

    def load_images(self):
        """指定フォルダから画像パスを取得（サブフォルダを含むかは設定に依存）"""
        self.image_paths = []
        exts = ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif")
        for ext in exts:
            if self.include_subfolders:
                # 再帰的に検索（Python3.5以降）
                pattern = os.path.join(self.image_folder, '**', ext)
                self.image_paths.extend(glob.glob(pattern, recursive=True))
            else:
                pattern = os.path.join(self.image_folder, ext)
                self.image_paths.extend(glob.glob(pattern))
        self.image_paths.sort()
        if self.shuffle_mode:
            random.shuffle(self.image_paths)

    def show_image(self):
        if not self.image_paths:
            return

        path = self.image_paths[self.current_index]
        try:
            img = Image.open(path).convert("RGB")
        except Exception as e:
            print(f"画像読み込みエラー: {path} ({e})")
            self.next_image()
            return

        self.current_img = img
        self.update_images()
        self.master.after(self.interval, self.next_image)

    def update_images(self):
        if self.current_img is None:
            return

        win_w = self.master.winfo_width()
        win_h = self.master.winfo_height()
        if win_w < 10 or win_h < 10:
            return

        # 背景画像：ウィンドウサイズに合わせて切り抜き、暗くフェードさせる
        bg_img = ImageOps.fit(self.current_img, (win_w, win_h), method=Image.LANCZOS)
        bg_img = ImageEnhance.Brightness(bg_img).enhance(0.3)
        self.bg_imgtk = ImageTk.PhotoImage(bg_img)

        # 前景画像：アスペクト比を維持してウィンドウに収める
        orig_w, orig_h = self.current_img.size
        ratio = orig_w / orig_h
        win_ratio = win_w / win_h
        if ratio > win_ratio:
            new_w = win_w
            new_h = int(win_w / ratio)
        else:
            new_h = win_h
            new_w = int(win_h * ratio)
        fg_img = self.current_img.resize((new_w, new_h), Image.LANCZOS)
        self.fg_imgtk = ImageTk.PhotoImage(fg_img)

        self.canvas.delete("all")
        self.canvas.create_image(win_w // 2, win_h // 2, image=self.bg_imgtk)
        self.canvas.create_image(win_w // 2, win_h // 2, image=self.fg_imgtk)

    def on_resize(self, event):
        try:
            if self.current_img:
                self.update_images()
        except tk.TclError:
            pass

    def next_image(self):
        if not self.image_paths:
            return
        self.current_index = (self.current_index + 1) % len(self.image_paths)
        self.show_image()

    def update_config(self, new_folder, new_interval, new_shuffle_mode, new_include_subfolders):
        """設定更新：画像フォルダ、インターバル、再生順、サブフォルダ含有の更新"""
        folder_changed = new_folder != self.image_folder
        mode_changed = new_shuffle_mode != self.shuffle_mode
        include_changed = new_include_subfolders != self.include_subfolders

        self.image_folder = new_folder
        self.interval = new_interval
        self.shuffle_mode = new_shuffle_mode
        self.include_subfolders = new_include_subfolders

        if folder_changed or mode_changed or include_changed:
            self.load_images()
            self.current_index = 0

        if self.current_img:
            self.update_images()


class ConfigWindow:
    def __init__(self, root):
        self.root = root
        self.root.title("スライドショー設定")

        # 画像フォルダ
        tk.Label(root, text="画像フォルダ:").grid(row=0, column=0, padx=10, pady=10, sticky="e")
        self.folder_entry = tk.Entry(root, width=40)
        self.folder_entry.grid(row=0, column=1, padx=10, pady=10)
        self.browse_button = tk.Button(root, text="参照", command=self.browse_folder)
        self.browse_button.grid(row=0, column=2, padx=10, pady=10)

        # インターバル
        tk.Label(root, text="インターバル (秒):").grid(row=1, column=0, padx=10, pady=10, sticky="e")
        self.interval_entry = tk.Entry(root, width=10)
        self.interval_entry.grid(row=1, column=1, padx=10, pady=10, sticky="w")

        # 再生順ラジオボタン
        tk.Label(root, text="再生順:").grid(row=2, column=0, padx=10, pady=10, sticky="e")
        self.shuffle_mode = tk.BooleanVar(value=False)
        self.radio_seq = tk.Radiobutton(root, text="連番", variable=self.shuffle_mode, value=False)
        self.radio_rand = tk.Radiobutton(root, text="ランダム", variable=self.shuffle_mode, value=True)
        self.radio_seq.grid(row=2, column=1, padx=5, pady=10, sticky="w")
        self.radio_rand.grid(row=2, column=2, padx=5, pady=10, sticky="w")

        # サブフォルダを含むかチェックボックス
        self.include_subfolders = tk.BooleanVar(value=False)
        self.subfolder_check = tk.Checkbutton(root, text="サブフォルダを含む", variable=self.include_subfolders)
        self.subfolder_check.grid(row=3, column=1, padx=10, pady=10, sticky="w")

        # ボタン：開始・更新設定
        self.start_button = tk.Button(root, text="開始", command=self.start_slideshow)
        self.start_button.grid(row=4, column=0, padx=10, pady=20)
        self.update_button = tk.Button(root, text="更新設定", command=self.update_slideshow)
        self.update_button.grid(row=4, column=1, padx=10, pady=20)

        self.slideshow_app = None

    def browse_folder(self):
        folder = filedialog.askdirectory()
        if folder:
            self.folder_entry.delete(0, tk.END)
            self.folder_entry.insert(0, folder)

    def start_slideshow(self):
        folder = self.folder_entry.get().strip()
        if not folder or not os.path.isdir(folder):
            messagebox.showerror("エラー", "有効なフォルダを指定してください")
            return

        try:
            interval = int(float(self.interval_entry.get()) * 1000)
        except ValueError:
            messagebox.showerror("エラー", "インターバルは数値で指定してください")
            return

        slideshow_window = tk.Toplevel(self.root)
        slideshow_window.geometry("800x600")
        self.slideshow_app = SlideShowApp(
            slideshow_window,
            folder,
            interval,
            self.shuffle_mode.get(),
            self.include_subfolders.get()
        )

    def update_slideshow(self):
        if self.slideshow_app and self.slideshow_app.master.winfo_exists():
            folder = self.folder_entry.get().strip()
            if not folder or not os.path.isdir(folder):
                messagebox.showerror("エラー", "有効なフォルダを指定してください")
                return

            try:
                interval = int(float(self.interval_entry.get()) * 1000)
            except ValueError:
                messagebox.showerror("エラー", "インターバルは数値で指定してください")
                return

            self.slideshow_app.update_config(
                folder,
                interval,
                self.shuffle_mode.get(),
                self.include_subfolders.get()
            )

if __name__ == "__main__":
    root = tk.Tk()
    root.geometry("500x250")
    ConfigWindow(root)
    root.mainloop()
```