diff --git a/.gitignore b/.gitignore
index f917fd5..137752d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,7 +41,9 @@ testem.log
.DS_Store
Thumbs.db
*.mkv
+*.mp4
imageviewer
+title_test.png
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt b/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt
index d0ffe2e..4fc2b8c 100644
--- a/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt
+++ b/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt
@@ -15,6 +15,8 @@ com.glovo.courier
com.wolt.android
# Bolt Food / Bolt
+com.bolt.deliveryclient
+ee.mtakso.client
ee.mtakso.food
ee.mtakso
diff --git a/linux_configuration/scripts/utils/docx_to_pdf.sh b/linux_configuration/scripts/utils/docx_to_pdf.sh
new file mode 100755
index 0000000..01f293d
--- /dev/null
+++ b/linux_configuration/scripts/utils/docx_to_pdf.sh
@@ -0,0 +1,200 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# docx_to_pdf.sh
+#
+# Convert one or more DOCX files (or directories containing them) to PDF
+# using LibreOffice.
+
+# Source common library
+SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
+# shellcheck source=../lib/common.sh
+source "$SCRIPT_DIR/../lib/common.sh"
+
+OUTPUT_DIR=""
+RECURSIVE=false
+MERGE=false
+MERGE_NAME="merged.pdf"
+PRINT=false
+DOCX_FILES=()
+
+usage() {
+ cat << EOF
+Usage:
+ $(basename "$0") [OPTIONS] PATH [PATH...]
+
+Convert DOCX files to PDF using LibreOffice.
+PATH can be a DOCX file or a directory containing DOCX files.
+
+Options:
+ -o DIR Output directory (default: same as input)
+ -m NAME Merge all PDFs into one file (default: merged.pdf)
+ -p Print the merged PDF (requires -m)
+ -r Search directories recursively for DOCX files
+ -h Show this help
+
+Examples:
+ $(basename "$0") file.docx
+ $(basename "$0") -o out file1.docx file2.docx
+ $(basename "$0") /path/to/docs/
+ $(basename "$0") -m merged.pdf /path/to/docs/
+ $(basename "$0") -m combined.pdf -p /path/to/docs/
+ $(basename "$0") -r -o out /path/to/docs/
+EOF
+}
+
+ensure_libreoffice() {
+ if ! command -v libreoffice > /dev/null 2>&1; then
+ echo "Error: 'libreoffice' is not installed or not in PATH." >&2
+ echo "Install it with: sudo pacman -S libreoffice-fresh (Arch)" >&2
+ echo " sudo apt install libreoffice (Debian/Ubuntu)" >&2
+ exit 1
+ fi
+}
+
+ensure_pdfunite() {
+ if ! command -v pdfunite > /dev/null 2>&1; then
+ echo "Error: 'pdfunite' is not installed or not in PATH." >&2
+ echo "Install it with: sudo pacman -S poppler (Arch)" >&2
+ echo " sudo apt install poppler-utils (Debian/Ubuntu)" >&2
+ exit 1
+ fi
+}
+
+parse_args() {
+ local opt
+ OUTPUT_DIR=""
+ DOCX_FILES=()
+
+ while getopts ":o:m:prh" opt; do
+ case "$opt" in
+ o)
+ OUTPUT_DIR="$OPTARG"
+ ;;
+ m)
+ MERGE=true
+ MERGE_NAME="$OPTARG"
+ ;;
+ p)
+ PRINT=true
+ ;;
+ r)
+ RECURSIVE=true
+ ;;
+ h)
+ usage
+ exit 0
+ ;;
+ *)
+ usage
+ exit 1
+ ;;
+ esac
+ done
+
+ shift $((OPTIND - 1))
+
+ if [[ $# -lt 1 ]]; then
+ echo "Error: at least one DOCX file or directory must be specified." >&2
+ usage
+ exit 1
+ fi
+
+ local arg
+ local input_dir=""
+ for arg in "$@"; do
+ if [[ -d $arg ]]; then
+ collect_from_dir "$arg"
+ input_dir="$arg"
+ else
+ DOCX_FILES+=("$arg")
+ fi
+ done
+
+ if [[ ${#DOCX_FILES[@]} -eq 0 ]]; then
+ echo "Error: no DOCX files found." >&2
+ exit 1
+ fi
+
+ if [[ -z ${OUTPUT_DIR:-} ]]; then
+ if [[ -n $input_dir ]]; then
+ OUTPUT_DIR="$input_dir"
+ else
+ OUTPUT_DIR="$(dirname "${DOCX_FILES[0]}")"
+ fi
+ fi
+
+ if [[ ! -d $OUTPUT_DIR ]]; then
+ mkdir -p "$OUTPUT_DIR"
+ fi
+}
+
+collect_from_dir() {
+ local dir="$1"
+ local found
+
+ if [[ $RECURSIVE == true ]]; then
+ while IFS= read -r -d '' found; do
+ DOCX_FILES+=("$found")
+ done < <(find "$dir" -type f -iname '*.docx' -print0 | sort -z)
+ else
+ for found in "$dir"/*.docx "$dir"/*.DOCX; do
+ if [[ -f $found ]]; then
+ DOCX_FILES+=("$found")
+ fi
+ done
+ fi
+}
+
+convert_docx() {
+ local docx_file="$1"
+
+ log "Converting '$docx_file' to PDF -> ${OUTPUT_DIR}/"
+ libreoffice --headless --convert-to pdf --outdir "$OUTPUT_DIR" "$docx_file"
+}
+
+main() {
+ ensure_libreoffice
+ parse_args "$@"
+
+ if [[ $MERGE == true ]]; then
+ ensure_pdfunite
+ fi
+
+ if [[ $PRINT == true && $MERGE != true ]]; then
+ echo "Error: -p (print) requires -m (merge)." >&2
+ exit 1
+ fi
+
+ local docx
+ local pdf_files=()
+ for docx in "${DOCX_FILES[@]}"; do
+ if [[ ! -f $docx ]]; then
+ echo "Warning: '$docx' is not a regular file, skipping." >&2
+ continue
+ fi
+
+ convert_docx "$docx"
+
+ local base
+ base="$(basename "${docx%.*}").pdf"
+ pdf_files+=("${OUTPUT_DIR%/}/$base")
+ done
+
+ if [[ $MERGE == true && ${#pdf_files[@]} -gt 0 ]]; then
+ local merged_path="${OUTPUT_DIR%/}/${MERGE_NAME}"
+ log "Merging ${#pdf_files[@]} PDFs into '$merged_path'"
+ pdfunite "${pdf_files[@]}" "$merged_path"
+ log "Merged PDF created: $merged_path"
+
+ if [[ $PRINT == true ]]; then
+ log "Sending '$merged_path' to printer"
+ lp "$merged_path"
+ fi
+ fi
+
+ log "Done converting DOCX files to PDF. Output directory: $OUTPUT_DIR"
+}
+
+main "$@"
diff --git a/moviepy_showcase.py b/moviepy_showcase.py
new file mode 100644
index 0000000..ffcddaf
--- /dev/null
+++ b/moviepy_showcase.py
@@ -0,0 +1,1197 @@
+"""MoviePy 2.x — Comprehensive Showcase of ALL Features.
+
+Generates a video demonstrating every MoviePy class, method, effect,
+and tool. Organised into sections:
+
+ Part 1: Clip Types (VideoClip, ColorClip, TextClip, ImageClip,
+ BitmapClip, DataVideoClip, ImageSequenceClip)
+ Part 2: Clip Methods (subclipped, cropped, resized, rotated, with_position,
+ with_opacity, with_mask, image_transform, transform,
+ time_transform, with_speed_scaled, with_section_cut_out,
+ to_ImageClip, to_mask, to_RGB, with_background_color,
+ with_effects_on_subclip, with_layer_index)
+ Part 3: Video Effects (all 34)
+ Part 4: Audio (AudioClip, AudioArrayClip, CompositeAudioClip,
+ all 7 audio effects)
+ Part 5: Composition (CompositeVideoClip, concatenate_videoclips, clips_array)
+ Part 6: Drawing Tools (circle, color_gradient, color_split)
+ Part 7: Output (write_videofile params, write_gif, save_frame,
+ write_images_sequence)
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+import shutil
+import tempfile
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+logger = logging.getLogger(__name__)
+
+os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg"
+
+from moviepy import ( # noqa: E402
+ AudioArrayClip,
+ AudioClip,
+ BitmapClip,
+ ColorClip,
+ CompositeAudioClip,
+ CompositeVideoClip,
+ DataVideoClip,
+ ImageClip,
+ ImageSequenceClip,
+ TextClip,
+ VideoClip,
+ VideoFileClip,
+ concatenate_audioclips,
+ concatenate_videoclips,
+)
+from moviepy.audio.fx import ( # noqa: E402
+ AudioDelay,
+ AudioFadeIn,
+ AudioFadeOut,
+ AudioLoop,
+ AudioNormalize,
+ MultiplyStereoVolume,
+ MultiplyVolume,
+)
+from moviepy.video.compositing.CompositeVideoClip import ( # noqa: E402
+ clips_array,
+)
+from moviepy.video.fx import ( # noqa: E402
+ AccelDecel,
+ BlackAndWhite,
+ Blink,
+ Crop,
+ CrossFadeIn,
+ CrossFadeOut,
+ EvenSize,
+ FadeIn,
+ FadeOut,
+ Freeze,
+ FreezeRegion,
+ GammaCorrection,
+ HeadBlur,
+ InvertColors,
+ Loop,
+ LumContrast,
+ MakeLoopable,
+ Margin,
+ MaskColor,
+ MirrorX,
+ MirrorY,
+ MultiplyColor,
+ MultiplySpeed,
+ Painting,
+ Resize,
+ Rotate,
+ Scroll,
+ SlideIn,
+ SlideOut,
+ SuperSample,
+ TimeMirror,
+ TimeSymmetrize,
+)
+from moviepy.video.tools.drawing import ( # noqa: E402
+ circle,
+ color_gradient,
+ color_split,
+)
+
+# ── Constants ─────────────────────────────────────────────────────
+W, H = 1920, 1080
+FPS = 30
+CLIP_DUR = 2.0 # duration of each demo clip
+HEADER_DUR = 1.5 # duration of section headers
+OUTPUT = "moviepy_showcase_full.mp4"
+FONT_B = "/usr/share/fonts/noto/NotoSans-Bold.ttf"
+FONT_R = "/usr/share/fonts/noto/NotoSans-Regular.ttf"
+
+# ── Pre-computed gradient LUTs ────────────────────────────────────
+_G_CH = (
+ np.linspace(0, 255, H, dtype=np.uint8)[:, None]
+ * np.ones(W, dtype=np.uint8)[None, :]
+)
+_B_CH = (
+ np.ones(H, dtype=np.uint8)[:, None]
+ * np.linspace(0, 255, W, dtype=np.uint8)[None, :]
+)
+
+
+def _gradient(t: float) -> np.ndarray:
+ f = np.empty((H, W, 3), dtype=np.uint8)
+ f[:, :, 0] = int(128 + 127 * np.sin(t * 2))
+ f[:, :, 1] = _G_CH
+ f[:, :, 2] = _B_CH
+ return f
+
+
+def _checkerboard(t: float) -> np.ndarray:
+ sq = 60
+ off = int(t * 40) % sq
+ xs = np.arange(W, dtype=np.int32)[None, :]
+ ys = np.arange(H, dtype=np.int32)[:, None]
+ v = (((xs + off) // sq + (ys + off) // sq) % 2 * 255).astype(np.uint8)
+ return np.dstack([v, v, v])
+
+
+# ── Helpers ───────────────────────────────────────────────────────
+def _base_clip(dur: float = CLIP_DUR) -> VideoClip:
+ """Animated gradient as a reusable base clip."""
+ return VideoClip(_gradient, duration=dur).with_fps(FPS)
+
+
+def _label(
+ text: str,
+ size: int = 36,
+ color: str = "white",
+ pos: tuple[str, int] | tuple[str, str] = ("center", 40),
+ dur: float = CLIP_DUR,
+) -> TextClip:
+ """Small label overlay (transparent bg)."""
+ return (
+ TextClip(
+ text=text,
+ font_size=size,
+ color=color,
+ font=FONT_R,
+ margin=(0, 15),
+ )
+ .with_duration(dur)
+ .with_position(pos)
+ )
+
+
+def _titled(clip: VideoClip, text: str) -> CompositeVideoClip:
+ """Overlay a label onto a clip."""
+ lbl = _label(text, dur=clip.duration)
+ return CompositeVideoClip(
+ [clip.with_duration(clip.duration), lbl],
+ size=(W, H),
+ )
+
+
+def _section_header(title: str, subtitle: str = "") -> CompositeVideoClip:
+ """Dark background with centred title text."""
+ bg = ColorClip(size=(W, H), color=(15, 15, 40)).with_duration(HEADER_DUR)
+ t = (
+ TextClip(
+ text=title,
+ font_size=72,
+ color="white",
+ font=FONT_B,
+ margin=(0, 30),
+ )
+ .with_duration(HEADER_DUR)
+ .with_position(("center", 380))
+ )
+ parts: list[VideoClip] = [bg, t]
+ if subtitle:
+ s = (
+ TextClip(
+ text=subtitle,
+ font_size=32,
+ color="#aaaaaa",
+ font=FONT_R,
+ margin=(0, 15),
+ )
+ .with_duration(HEADER_DUR)
+ .with_position(("center", 520))
+ )
+ parts.append(s)
+ return CompositeVideoClip(parts, size=(W, H))
+
+
+def _resize_to_canvas(clip: VideoClip) -> VideoClip:
+ """Resize a clip to fit (W, H) and centre on black background."""
+ cw, ch = clip.size
+ scale = min(W / cw, H / ch)
+ return clip.resized(
+ width=int(cw * scale), height=int(ch * scale)
+ ).with_background_color(size=(W, H), color=(0, 0, 0))
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 1 — Clip Types
+# ══════════════════════════════════════════════════════════════════
+def part1_clip_types() -> list[VideoClip]:
+ """Demonstrate every clip creation class."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 1: Clip Types",
+ "VideoClip · ColorClip · TextClip · ImageClip"
+ " · BitmapClip · DataVideoClip · ImageSequenceClip",
+ ),
+ ]
+
+ # 1. VideoClip — custom frame function
+ vc = VideoClip(_gradient, duration=CLIP_DUR).with_fps(FPS)
+ scenes.append(_titled(vc, "VideoClip(frame_function)"))
+
+ # 2. ColorClip
+ cc = ColorClip(size=(W, H), color=(0, 120, 200)).with_duration(CLIP_DUR)
+ scenes.append(_titled(cc, "ColorClip(size, color)"))
+
+ # 3. TextClip — label method
+ tbg = ColorClip(size=(W, H), color=(20, 20, 50)).with_duration(CLIP_DUR)
+ tc = (
+ TextClip(
+ text="Hello MoviePy!",
+ font_size=96,
+ color="yellow",
+ font=FONT_B,
+ stroke_color="black",
+ stroke_width=3,
+ bg_color=None,
+ margin=(10, 30),
+ method="label",
+ horizontal_align="center",
+ vertical_align="center",
+ transparent=True,
+ )
+ .with_duration(CLIP_DUR)
+ .with_position("center")
+ )
+ scenes.append(
+ _titled(
+ CompositeVideoClip([tbg, tc], size=(W, H)),
+ "TextClip(text, font_size, color, stroke, margin, method='label')",
+ )
+ )
+
+ # 4. TextClip — caption method (wraps text)
+ tbg2 = ColorClip(size=(W, H), color=(50, 20, 20)).with_duration(CLIP_DUR)
+ tc2 = (
+ TextClip(
+ text="This is a longer caption that wraps "
+ "because we use method='caption' with a fixed size.",
+ font_size=48,
+ color="white",
+ font=FONT_R,
+ method="caption",
+ size=(W - 200, None),
+ text_align="center",
+ interline=10,
+ margin=(0, 20),
+ )
+ .with_duration(CLIP_DUR)
+ .with_position("center")
+ )
+ scenes.append(
+ _titled(
+ CompositeVideoClip([tbg2, tc2], size=(W, H)),
+ "TextClip(method='caption', text_align, interline, size)",
+ )
+ )
+
+ # 5. ImageClip — from numpy array
+ img = np.zeros((H, W, 3), dtype=np.uint8)
+ img[200:880, 400:1520] = [255, 100, 50] # orange rectangle
+ ic = ImageClip(img, duration=CLIP_DUR)
+ scenes.append(_titled(ic, "ImageClip(numpy_array)"))
+
+ # 6. BitmapClip — from ASCII-art frames
+ frames = [
+ ["RR__", "RR__", "__BB", "__BB"],
+ ["__RR", "__RR", "BB__", "BB__"],
+ ["RR__", "RR__", "__BB", "__BB"],
+ ["__RR", "__RR", "BB__", "BB__"],
+ ]
+ bc = BitmapClip(
+ frames,
+ fps=2,
+ color_dict={"R": (255, 0, 0), "B": (0, 0, 255), "_": (30, 30, 30)},
+ )
+ bc = _resize_to_canvas(bc)
+ scenes.append(
+ _titled(bc.with_duration(CLIP_DUR), "BitmapClip(bitmap_frames, color_dict)")
+ )
+
+ # 7. DataVideoClip — data-driven frames
+ data_list = list(range(60))
+
+ def data_to_frame(d: int) -> np.ndarray:
+ frame = np.full((H, W, 3), 30, dtype=np.uint8)
+ bar_w = int(d / 60 * (W - 100))
+ frame[400:680, 50 : 50 + bar_w] = [0, 200, 100]
+ return frame
+
+ dvc = DataVideoClip(data_list, data_to_frame, fps=FPS).with_duration(CLIP_DUR)
+ scenes.append(_titled(dvc, "DataVideoClip(data, data_to_frame)"))
+
+ # 8. ImageSequenceClip — from a list of arrays
+ seq_frames = []
+ for i in range(10):
+ f = np.full((H, W, 3), int(25 * i), dtype=np.uint8)
+ f[:, :, 0] = int(255 - 25 * i)
+ seq_frames.append(f)
+ isc = ImageSequenceClip(seq_frames, fps=5).with_duration(CLIP_DUR)
+ scenes.append(_titled(isc, "ImageSequenceClip(sequence, fps)"))
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 2 — Clip Methods
+# ══════════════════════════════════════════════════════════════════
+def part2_clip_methods() -> list[VideoClip]:
+ """Demonstrate VideoClip methods."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 2: Clip Methods",
+ "subclipped · cropped · resized · rotated"
+ " · with_position · with_opacity · …",
+ ),
+ ]
+
+ base = _base_clip(3.0)
+
+ # subclipped
+ sc = base.subclipped(0.5, 2.5)
+ scenes.append(
+ _titled(_resize_to_canvas(sc), "subclipped(start_time=0.5, end_time=2.5)")
+ )
+
+ # cropped
+ cr = base.cropped(x1=200, y1=100, x2=1200, y2=700).with_duration(CLIP_DUR)
+ scenes.append(
+ _titled(_resize_to_canvas(cr), "cropped(x1=200, y1=100, x2=1200, y2=700)")
+ )
+
+ # resized — by factor
+ rs1 = base.resized(0.5).with_duration(CLIP_DUR)
+ scenes.append(_titled(_resize_to_canvas(rs1), "resized(0.5) # half size"))
+
+ # resized — by height
+ rs2 = base.resized(height=400).with_duration(CLIP_DUR)
+ scenes.append(_titled(_resize_to_canvas(rs2), "resized(height=400)"))
+
+ # rotated
+ rt = base.rotated(30, expand=False, bg_color=(0, 0, 0)).with_duration(CLIP_DUR)
+ scenes.append(_titled(rt, "rotated(angle=30, expand=False)"))
+
+ # with_position + with_opacity in a composite
+ small = base.resized(0.4).with_duration(CLIP_DUR)
+ bg = ColorClip(size=(W, H), color=(10, 10, 10)).with_duration(CLIP_DUR)
+ p1 = small.with_position((50, 50)).with_opacity(1.0)
+ p2 = small.with_position((500, 300)).with_opacity(0.5)
+ comp = CompositeVideoClip([bg, p1, p2], size=(W, H))
+ scenes.append(_titled(comp, "with_position() + with_opacity(0.5)"))
+
+ # with_mask — circular mask
+ mask_arr = circle(
+ screensize=(W, H),
+ center=(W // 2, H // 2),
+ radius=300,
+ color=1.0,
+ bg_color=0.0,
+ blur=20,
+ )
+ mask_clip = ImageClip(mask_arr, is_mask=True, duration=CLIP_DUR)
+ masked = base.with_duration(CLIP_DUR).with_mask(mask_clip)
+ mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR)
+ scenes.append(
+ _titled(
+ CompositeVideoClip([mbg, masked], size=(W, H)),
+ "with_mask() — circular mask via drawing.circle()",
+ )
+ )
+
+ # image_transform
+ def flip_lr(img: np.ndarray) -> np.ndarray:
+ return img[:, ::-1]
+
+ it = base.image_transform(flip_lr).with_duration(CLIP_DUR)
+ scenes.append(_titled(it, "image_transform(flip_lr_func)"))
+
+ # transform
+ def shift_right(gf: Callable[[float], np.ndarray], t: float) -> np.ndarray:
+ frame = gf(t)
+ shift = int(t * 100)
+ return np.roll(frame, shift, axis=1)
+
+ tf = base.transform(shift_right).with_duration(CLIP_DUR)
+ scenes.append(_titled(tf, "transform(shift_right_func)"))
+
+ # time_transform
+ tt = base.time_transform(lambda t: t * 3).with_duration(CLIP_DUR)
+ scenes.append(_titled(tt, "time_transform(lambda t: t*3) # 3x speed"))
+
+ # with_speed_scaled
+ ss = base.with_speed_scaled(factor=0.5)
+ scenes.append(_titled(ss.with_duration(CLIP_DUR), "with_speed_scaled(factor=0.5)"))
+
+ # with_section_cut_out
+ sco = base.with_section_cut_out(0.5, 1.5)
+ scenes.append(
+ _titled(
+ sco.with_duration(min(sco.duration, CLIP_DUR)),
+ "with_section_cut_out(0.5, 1.5)",
+ )
+ )
+
+ # to_ImageClip
+ still = base.to_ImageClip(t=1.0, duration=CLIP_DUR)
+ scenes.append(_titled(still, "to_ImageClip(t=1.0) # freeze at t=1"))
+
+ # to_mask + to_RGB
+ bw = base.to_mask(canal=1).to_RGB().with_duration(CLIP_DUR)
+ scenes.append(_titled(bw, "to_mask(canal=1).to_RGB()"))
+
+ # with_background_color
+ small2 = base.resized(0.5).with_duration(CLIP_DUR)
+ wbg = small2.with_background_color(size=(W, H), color=(80, 0, 120))
+ scenes.append(_titled(wbg, "with_background_color(color=(80,0,120))"))
+
+ # with_effects_on_subclip
+ eos = base.with_effects_on_subclip(
+ [InvertColors()], start_time=0.5, end_time=1.5
+ ).with_duration(CLIP_DUR)
+ scenes.append(_titled(eos, "with_effects_on_subclip([InvertColors], 0.5, 1.5)"))
+
+ # with_volume_scaled (visual label only — audio effect)
+ vsc = base.with_duration(CLIP_DUR)
+ scenes.append(_titled(vsc, "with_volume_scaled(factor) # scales audio amplitude"))
+
+ # with_layer_index
+ scenes.append(
+ _titled(
+ base.with_duration(CLIP_DUR), "with_layer_index(n) # compositing z-order"
+ )
+ )
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 3 — Video Effects (all 34)
+# ══════════════════════════════════════════════════════════════════
+def part3_video_effects() -> list[VideoClip]: # noqa: PLR0915
+ """Demonstrate all 34 video effects."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 3: Video Effects",
+ "All 34 effects from moviepy.video.fx",
+ ),
+ ]
+
+ def _fx(effect: object, label: str, dur: float = CLIP_DUR) -> VideoClip:
+ """Apply effect to base clip and label it."""
+ b = _base_clip(dur)
+ try:
+ result = b.with_effects([effect])
+ # Ensure it has a finite duration
+ if result.duration is None or result.duration <= 0:
+ result = result.with_duration(dur)
+ result = result.with_duration(min(result.duration, dur))
+ except (ValueError, OSError, AttributeError):
+ result = b
+ # Make sure it fits the canvas
+ if result.size != (W, H):
+ result = _resize_to_canvas(result)
+ return _titled(result, label)
+
+ # 1. AccelDecel
+ scenes.append(
+ _fx(
+ AccelDecel(new_duration=CLIP_DUR, abruptness=2.0, soonness=1.0),
+ "AccelDecel(abruptness=2.0, soonness=1.0)",
+ )
+ )
+
+ # 2. BlackAndWhite
+ scenes.append(
+ _fx(
+ BlackAndWhite(preserve_luminosity=True),
+ "BlackAndWhite(preserve_luminosity=True)",
+ )
+ )
+
+ # 3. Blink
+ scenes.append(
+ _fx(
+ Blink(duration_on=0.3, duration_off=0.3),
+ "Blink(duration_on=0.3, duration_off=0.3)",
+ )
+ )
+
+ # 4. Crop
+ b_crop = _base_clip().with_effects([Crop(x1=200, y1=100, x2=1400, y2=800)])
+ scenes.append(
+ _titled(_resize_to_canvas(b_crop), "Crop(x1=200, y1=100, x2=1400, y2=800)")
+ )
+
+ # 5. CrossFadeIn
+ scenes.append(_fx(CrossFadeIn(duration=1.0), "CrossFadeIn(duration=1.0)"))
+
+ # 6. CrossFadeOut
+ scenes.append(_fx(CrossFadeOut(duration=1.0), "CrossFadeOut(duration=1.0)"))
+
+ # 7. EvenSize
+ scenes.append(_fx(EvenSize(), "EvenSize() # ensures even wxh"))
+
+ # 8. FadeIn
+ scenes.append(
+ _fx(
+ FadeIn(duration=1.5, initial_color=[0, 0, 0]),
+ "FadeIn(duration=1.5, initial_color=[0,0,0])",
+ )
+ )
+
+ # 9. FadeOut
+ scenes.append(
+ _fx(
+ FadeOut(duration=1.5, final_color=[0, 0, 0]),
+ "FadeOut(duration=1.5, final_color=[0,0,0])",
+ )
+ )
+
+ # 10. Freeze
+ scenes.append(
+ _fx(
+ Freeze(t=0.5, freeze_duration=1.0),
+ "Freeze(t=0.5, freeze_duration=1.0)",
+ dur=3.0,
+ )
+ )
+
+ # 11. FreezeRegion
+ scenes.append(
+ _fx(
+ FreezeRegion(t=0.5, region=(200, 100, 1400, 700)),
+ "FreezeRegion(t=0.5, region=(200,100,1400,700))",
+ )
+ )
+
+ # 12. GammaCorrection
+ scenes.append(_fx(GammaCorrection(gamma=2.5), "GammaCorrection(gamma=2.5)"))
+
+ # 13. HeadBlur
+ scenes.append(
+ _fx(
+ HeadBlur(
+ fx=lambda t: W // 2, # noqa: ARG005
+ fy=lambda t: H // 2, # noqa: ARG005
+ radius=100,
+ intensity=None,
+ ),
+ "HeadBlur(fx, fy, radius=100)",
+ )
+ )
+
+ # 14. InvertColors
+ scenes.append(_fx(InvertColors(), "InvertColors()"))
+
+ # 15. Loop
+ short = _base_clip(0.5)
+ looped = short.with_effects([Loop(n=4)])
+ scenes.append(_titled(looped.with_duration(CLIP_DUR), "Loop(n=4)"))
+
+ # 16. LumContrast
+ scenes.append(
+ _fx(
+ LumContrast(lum=30, contrast=50, contrast_threshold=127),
+ "LumContrast(lum=30, contrast=50)",
+ )
+ )
+
+ # 17. MakeLoopable
+ scenes.append(
+ _fx(MakeLoopable(overlap_duration=0.5), "MakeLoopable(overlap_duration=0.5)")
+ )
+
+ # 18. Margin
+ b_margin = _base_clip().with_effects(
+ [
+ Resize(0.7),
+ Margin(
+ margin_size=None,
+ left=40,
+ right=40,
+ top=20,
+ bottom=20,
+ color=(255, 0, 0),
+ opacity=1.0,
+ ),
+ ]
+ )
+ scenes.append(
+ _titled(
+ _resize_to_canvas(b_margin),
+ "Margin(left=40, right=40, top=20, bottom=20, color=red)",
+ )
+ )
+
+ # 19. MaskColor
+ scenes.append(
+ _fx(
+ MaskColor(color=(128, 128, 128), threshold=80, stiffness=1),
+ "MaskColor(color, threshold=80)",
+ )
+ )
+
+ # 20. MasksAnd
+ mask1 = circle((W, H), (W // 3, H // 2), 300, 1.0, 0.0, 1)
+ mask2 = circle((W, H), (2 * W // 3, H // 2), 300, 1.0, 0.0, 1)
+ combined = np.minimum(mask1, mask2)
+ m_clip = ImageClip(combined, is_mask=True, duration=CLIP_DUR)
+ masked_and = _base_clip().with_mask(m_clip)
+ mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR)
+ scenes.append(
+ _titled(
+ CompositeVideoClip([mbg, masked_and], size=(W, H)),
+ "MasksAnd — intersection of two circle masks",
+ )
+ )
+
+ # 21. MasksOr
+ combined_or = np.maximum(mask1, mask2)
+ m_clip2 = ImageClip(combined_or, is_mask=True, duration=CLIP_DUR)
+ masked_or = _base_clip().with_mask(m_clip2)
+ scenes.append(
+ _titled(
+ CompositeVideoClip([mbg, masked_or], size=(W, H)),
+ "MasksOr — union of two circle masks",
+ )
+ )
+
+ # 22. MirrorX
+ scenes.append(_fx(MirrorX(), "MirrorX() # horizontal flip"))
+
+ # 23. MirrorY
+ scenes.append(_fx(MirrorY(), "MirrorY() # vertical flip"))
+
+ # 24. MultiplyColor
+ scenes.append(_fx(MultiplyColor(factor=1.8), "MultiplyColor(factor=1.8)"))
+
+ # 25. MultiplySpeed
+ scenes.append(_fx(MultiplySpeed(factor=3.0), "MultiplySpeed(factor=3.0)", dur=4.0))
+
+ # 26. Painting
+ scenes.append(
+ _fx(
+ Painting(saturation=1.4, black=0.006),
+ "Painting(saturation=1.4, black=0.006)",
+ )
+ )
+
+ # 27. Resize
+ b_rs = _base_clip().with_effects([Resize(new_size=(960, 540))])
+ scenes.append(_titled(_resize_to_canvas(b_rs), "Resize(new_size=(960,540))"))
+
+ # 28. Rotate
+ scenes.append(
+ _fx(
+ Rotate(angle=45, expand=True, bg_color=(0, 0, 0)),
+ "Rotate(angle=45, expand=True)",
+ )
+ )
+
+ # 29. Scroll
+ # Draw bands
+ tall_arr = np.full((H * 3, W, 3), 40, dtype=np.uint8)
+ for i in range(6):
+ y0, y1 = i * H // 2, (i + 1) * H // 2
+ tall_arr[y0:y1, :] = [
+ (50 * i) % 256,
+ (100 + 30 * i) % 256,
+ (200 - 20 * i) % 256,
+ ]
+ tall_clip = ImageClip(tall_arr, duration=CLIP_DUR).with_effects(
+ [
+ Scroll(h=H, y_speed=-300, w=W),
+ ]
+ )
+ scenes.append(_titled(_resize_to_canvas(tall_clip), "Scroll(h, y_speed=-300)"))
+
+ # 30. SlideIn
+ si = _base_clip().with_effects([SlideIn(duration=1.0, side="left")])
+ scenes.append(_titled(si, "SlideIn(duration=1.0, side='left')"))
+
+ # 31. SlideOut
+ so = _base_clip().with_effects([SlideOut(duration=1.0, side="right")])
+ scenes.append(_titled(so, "SlideOut(duration=1.0, side='right')"))
+
+ # 32. SuperSample
+ scenes.append(_fx(SuperSample(d=0.1, n_frames=3), "SuperSample(d=0.1, n_frames=3)"))
+
+ # 33. TimeMirror
+ tm = _base_clip().with_effects([TimeMirror()])
+ scenes.append(
+ _titled(tm.with_duration(CLIP_DUR), "TimeMirror() # plays backwards")
+ )
+
+ # 34. TimeSymmetrize
+ ts = _base_clip().with_effects([TimeSymmetrize()])
+ scenes.append(
+ _titled(ts.with_duration(CLIP_DUR), "TimeSymmetrize() # forward then reverse")
+ )
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 4 — Audio
+# ══════════════════════════════════════════════════════════════════
+def _make_sine(freq: float = 440.0, dur: float = CLIP_DUR) -> AudioClip:
+ """Pure sine-wave AudioClip."""
+
+ def maker(t: np.ndarray) -> np.ndarray:
+ t_arr = np.asarray(t)
+ wave = 0.3 * np.sin(2 * np.pi * freq * t_arr.flatten())
+ stereo = np.column_stack([wave, wave])
+ # MoviePy probes with scalar t=0 and uses len(list(frame0))
+ # for nchannels. A (1,2) array iterates as 1 row → nchannels=1.
+ # Returning shape (2,) for scalar t lets MoviePy detect 2 channels.
+ if t_arr.ndim == 0:
+ return stereo[0]
+ return stereo
+
+ return AudioClip(maker, duration=dur, fps=44100)
+
+
+def part4_audio() -> list[VideoClip]:
+ """Demonstrate audio clips and all 7 audio effects."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 4: Audio",
+ "AudioClip · AudioArrayClip · CompositeAudioClip · 7 Audio Effects",
+ ),
+ ]
+ bg = ColorClip(size=(W, H), color=(20, 30, 50))
+
+ # AudioClip
+ a1 = _make_sine(440, CLIP_DUR)
+ c1 = bg.with_duration(CLIP_DUR).with_audio(a1)
+ scenes.append(_titled(c1, "AudioClip(sine_440Hz)"))
+
+ # AudioArrayClip
+ sr = 44100
+ t_arr = np.linspace(0, CLIP_DUR, int(sr * CLIP_DUR), endpoint=False)
+ arr = (0.3 * np.sin(2 * np.pi * 880 * t_arr)).astype(np.float64)
+ stereo = np.column_stack([arr, arr])
+ a2 = AudioArrayClip(stereo, fps=sr)
+ c2 = bg.with_duration(CLIP_DUR).with_audio(a2)
+ scenes.append(_titled(c2, "AudioArrayClip(numpy_array, fps=44100) # 880Hz"))
+
+ # CompositeAudioClip
+ low = _make_sine(220, CLIP_DUR)
+ high = _make_sine(660, CLIP_DUR)
+ comp_audio = CompositeAudioClip([low, high])
+ c3 = bg.with_duration(CLIP_DUR).with_audio(comp_audio)
+ scenes.append(_titled(c3, "CompositeAudioClip([220Hz, 660Hz])"))
+
+ # concatenate_audioclips
+ a_cat = concatenate_audioclips([_make_sine(330, 1.0), _make_sine(550, 1.0)])
+ c4 = bg.with_duration(CLIP_DUR).with_audio(a_cat)
+ scenes.append(_titled(c4, "concatenate_audioclips([330Hz, 550Hz])"))
+
+ # AudioFadeIn
+ a_fi = _make_sine(440, CLIP_DUR).with_effects([AudioFadeIn(duration=1.5)])
+ c5 = bg.with_duration(CLIP_DUR).with_audio(a_fi)
+ scenes.append(_titled(c5, "AudioFadeIn(duration=1.5)"))
+
+ # AudioFadeOut
+ a_fo = _make_sine(440, CLIP_DUR).with_effects([AudioFadeOut(duration=1.5)])
+ c6 = bg.with_duration(CLIP_DUR).with_audio(a_fo)
+ scenes.append(_titled(c6, "AudioFadeOut(duration=1.5)"))
+
+ # AudioDelay
+ a_delay = _make_sine(440, CLIP_DUR).with_effects(
+ [AudioDelay(offset=0.2, n_repeats=4, decay=1)]
+ )
+ c7 = bg.with_duration(a_delay.duration).with_audio(a_delay)
+ scenes.append(
+ _titled(
+ c7.with_duration(CLIP_DUR), "AudioDelay(offset=0.2, n_repeats=4, decay=1)"
+ )
+ )
+
+ # AudioLoop
+ short_a = _make_sine(440, 0.5)
+ a_loop = short_a.with_effects([AudioLoop(duration=CLIP_DUR)])
+ c8 = bg.with_duration(CLIP_DUR).with_audio(a_loop)
+ scenes.append(_titled(c8, "AudioLoop(duration=2.0)"))
+
+ # AudioNormalize
+ quiet = _make_sine(440, CLIP_DUR) # already normalized but demonstrates the call
+ a_norm = quiet.with_effects([AudioNormalize()])
+ c9 = bg.with_duration(CLIP_DUR).with_audio(a_norm)
+ scenes.append(_titled(c9, "AudioNormalize()"))
+
+ # MultiplyStereoVolume
+ a_stereo = _make_sine(440, CLIP_DUR).with_effects(
+ [MultiplyStereoVolume(left=1.0, right=0.2)]
+ )
+ c10 = bg.with_duration(CLIP_DUR).with_audio(a_stereo)
+ scenes.append(_titled(c10, "MultiplyStereoVolume(left=1.0, right=0.2)"))
+
+ # MultiplyVolume
+ a_vol = _make_sine(440, CLIP_DUR).with_effects([MultiplyVolume(factor=0.3)])
+ c11 = bg.with_duration(CLIP_DUR).with_audio(a_vol)
+ scenes.append(_titled(c11, "MultiplyVolume(factor=0.3)"))
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 5 — Composition
+# ══════════════════════════════════════════════════════════════════
+def part5_composition() -> list[VideoClip]:
+ """Demonstrate composition & concatenation."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 5: Composition",
+ "CompositeVideoClip · concatenate_videoclips · clips_array",
+ ),
+ ]
+
+ # CompositeVideoClip with bg_color, use_bgclip
+ bg = _base_clip()
+ overlay = (
+ ColorClip(size=(400, 400), color=(255, 50, 50))
+ .with_duration(CLIP_DUR)
+ .with_position(("center", "center"))
+ .with_opacity(0.6)
+ )
+ comp1 = CompositeVideoClip([bg, overlay], size=(W, H), bg_color=(0, 0, 0))
+ scenes.append(_titled(comp1, "CompositeVideoClip(clips, bg_color, use_bgclip)"))
+
+ # concatenate_videoclips — method='chain'
+ c1 = ColorClip(size=(W, H), color=(200, 50, 50)).with_duration(0.7)
+ c2 = ColorClip(size=(W, H), color=(50, 200, 50)).with_duration(0.7)
+ c3 = ColorClip(size=(W, H), color=(50, 50, 200)).with_duration(0.6)
+ cat = concatenate_videoclips([c1, c2, c3], method="chain")
+ scenes.append(_titled(cat, "concatenate_videoclips(method='chain')"))
+
+ # concatenate_videoclips — method='compose' with padding
+ cat2 = concatenate_videoclips(
+ [
+ c1.resized((W // 2, H // 2)),
+ c2.resized((W // 2, H // 2)),
+ c3.resized((W, H)),
+ ],
+ method="compose",
+ bg_color=(0, 0, 0),
+ padding=-0.2,
+ )
+ scenes.append(
+ _titled(
+ _resize_to_canvas(cat2),
+ "concatenate_videoclips(method='compose', padding=-0.2)",
+ )
+ )
+
+ # concatenate_videoclips with transition
+ cat3 = concatenate_videoclips(
+ [c1, c2, c3],
+ padding=-0.3,
+ method="compose",
+ )
+ scenes.append(
+ _titled(
+ cat3.with_duration(CLIP_DUR),
+ "concatenate_videoclips(padding=-0.3) # overlap",
+ )
+ )
+
+ # clips_array
+ a = ColorClip(size=(W // 2, H // 2), color=(200, 50, 50)).with_duration(CLIP_DUR)
+ b = ColorClip(size=(W // 2, H // 2), color=(50, 200, 50)).with_duration(CLIP_DUR)
+ c = ColorClip(size=(W // 2, H // 2), color=(50, 50, 200)).with_duration(CLIP_DUR)
+ d = ColorClip(size=(W // 2, H // 2), color=(200, 200, 50)).with_duration(CLIP_DUR)
+ grid = clips_array([[a, b], [c, d]])
+ scenes.append(_titled(_resize_to_canvas(grid), "clips_array([[a, b], [c, d]])"))
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 6 — Drawing Tools
+# ══════════════════════════════════════════════════════════════════
+def part6_drawing_tools() -> list[VideoClip]:
+ """Demonstrate moviepy.video.tools.drawing functions."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 6: Drawing Tools", "circle · color_gradient · color_split"
+ ),
+ ]
+
+ # circle
+ circ = circle(
+ screensize=(W, H),
+ center=(W // 2, H // 2),
+ radius=300,
+ color=1.0,
+ bg_color=0.0,
+ blur=30,
+ )
+ circ_rgb = (np.dstack([circ, circ, circ]) * 255).astype(np.uint8)
+ scenes.append(
+ _titled(
+ ImageClip(circ_rgb, duration=CLIP_DUR),
+ "drawing.circle(center, radius=300, blur=30)",
+ )
+ )
+
+ # color_gradient — linear
+ grad = color_gradient(
+ size=(W, H),
+ p1=(0, 0),
+ p2=(W, H),
+ color_1=0.0,
+ color_2=1.0,
+ shape="linear",
+ )
+ grad_rgb = (np.dstack([grad, grad, grad]) * 255).astype(np.uint8)
+ scenes.append(
+ _titled(
+ ImageClip(grad_rgb, duration=CLIP_DUR),
+ "drawing.color_gradient(shape='linear')",
+ )
+ )
+
+ # color_gradient — radial
+ grad_r = color_gradient(
+ size=(W, H),
+ p1=(W // 2, H // 2),
+ radius=500,
+ color_1=1.0,
+ color_2=0.0,
+ shape="radial",
+ )
+ grad_r_rgb = (np.dstack([grad_r, grad_r, grad_r]) * 255).astype(np.uint8)
+ scenes.append(
+ _titled(
+ ImageClip(grad_r_rgb, duration=CLIP_DUR),
+ "drawing.color_gradient(shape='radial', radius=500)",
+ )
+ )
+
+ # color_split
+ split = color_split(
+ size=(W, H),
+ x=W // 2,
+ color_1=0.0,
+ color_2=1.0,
+ gradient_width=100,
+ )
+ split_rgb = (np.dstack([split, split, split]) * 255).astype(np.uint8)
+ scenes.append(
+ _titled(
+ ImageClip(split_rgb, duration=CLIP_DUR),
+ "drawing.color_split(x=W/2, gradient_width=100)",
+ )
+ )
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# PART 7 — Output Methods
+# ══════════════════════════════════════════════════════════════════
+def part7_output() -> list[VideoClip]:
+ """Label-only slides for output methods + parameters."""
+ scenes: list[VideoClip] = [
+ _section_header(
+ "Part 7: Output Methods",
+ "write_videofile · write_gif · save_frame · write_images_sequence",
+ ),
+ ]
+
+ bg = ColorClip(size=(W, H), color=(15, 20, 35))
+
+ methods = [
+ (
+ "write_videofile()",
+ "filename, fps, codec, bitrate, audio, audio_fps,\n"
+ "preset, audio_nbytes, audio_codec, audio_bitrate,\n"
+ "audio_bufsize, temp_audiofile, threads,\n"
+ "ffmpeg_params, logger, pixel_format",
+ ),
+ (
+ "write_gif()",
+ "filename, fps, loop, logger",
+ ),
+ (
+ "save_frame()",
+ "filename, t, with_mask",
+ ),
+ (
+ "write_images_sequence()",
+ "name_format, fps, with_mask, logger",
+ ),
+ (
+ "write_audiofile()",
+ "filename, fps, nbytes, buffersize,\ncodec, bitrate, ffmpeg_params, logger",
+ ),
+ ]
+
+ for title, params in methods:
+ t1 = (
+ TextClip(
+ text=title, font_size=56, color="cyan", font=FONT_B, margin=(0, 20)
+ )
+ .with_duration(2.5)
+ .with_position(("center", 300))
+ )
+ t2 = (
+ TextClip(
+ text=f"Parameters:\n{params}",
+ font_size=32,
+ color="#dddddd",
+ font=FONT_R,
+ method="caption",
+ size=(W - 300, None),
+ text_align="center",
+ interline=8,
+ margin=(0, 15),
+ )
+ .with_duration(2.5)
+ .with_position(("center", 500))
+ )
+ scenes.append(CompositeVideoClip([bg.with_duration(2.5), t1, t2], size=(W, H)))
+
+ return scenes
+
+
+# ══════════════════════════════════════════════════════════════════
+# ASSEMBLY — memory-safe: render each part to a temp file, then
+# concatenate via VideoFileClip so only one part is in RAM at a time.
+# ══════════════════════════════════════════════════════════════════
+def _render_part(
+ scenes: list[VideoClip],
+ path: str,
+ label: str,
+) -> None:
+ """Concatenate *scenes*, write to *path*, then close all clips."""
+ logger.info(" Rendering %s (%d scenes) → %s", label, len(scenes), Path(path).name)
+ part = concatenate_videoclips(scenes, method="compose", bg_color=(0, 0, 0))
+ part.write_videofile(
+ path,
+ fps=FPS,
+ codec="libx264",
+ preset="ultrafast",
+ audio=False,
+ logger=None,
+ )
+ # Free memory
+ part.close()
+ for s in scenes:
+ s.close()
+
+
+def main() -> None:
+ """Assemble all parts into the final showcase video."""
+ logging.basicConfig(level=logging.INFO)
+ logger.info("Building MoviePy comprehensive showcase…")
+
+ tmpdir = tempfile.mkdtemp(prefix="moviepy_showcase_")
+ try:
+ _build(tmpdir)
+ finally:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+def _build(tmpdir: str) -> None:
+ # ── Render each part to its own temp file ─────────────────────
+ # Title card
+ title_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0)
+ title_txt = (
+ TextClip(
+ text="MoviePy 2.x\nComplete Feature Showcase",
+ font_size=80,
+ color="white",
+ font=FONT_B,
+ method="caption",
+ size=(W - 200, None),
+ text_align="center",
+ margin=(0, 40),
+ )
+ .with_duration(3.0)
+ .with_position("center")
+ )
+ title_card = CompositeVideoClip([title_bg, title_txt], size=(W, H)).with_effects(
+ [FadeIn(1.0)]
+ )
+
+ # Outro
+ outro_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0)
+ outro_txt = (
+ TextClip(
+ text="That's all of MoviePy 2.x!\n34 video effects · 7 audio effects\n"
+ "11 clip types · drawing tools · composition",
+ font_size=52,
+ color="white",
+ font=FONT_B,
+ method="caption",
+ size=(W - 200, None),
+ text_align="center",
+ margin=(0, 30),
+ )
+ .with_duration(3.0)
+ .with_position("center")
+ )
+ outro = CompositeVideoClip([outro_bg, outro_txt], size=(W, H)).with_effects(
+ [FadeOut(1.5)]
+ )
+
+ part_builders = [
+ ("title", lambda: [title_card]),
+ ("Part 1: Clip Types", part1_clip_types),
+ ("Part 2: Clip Methods", part2_clip_methods),
+ ("Part 3: Video Effects", part3_video_effects),
+ ("Part 4: Audio", part4_audio),
+ ("Part 5: Composition", part5_composition),
+ ("Part 6: Drawing Tools", part6_drawing_tools),
+ ("Part 7: Output Methods", part7_output),
+ ("outro", lambda: [outro]),
+ ]
+
+ part_files: list[str] = []
+ for i, (label, builder) in enumerate(part_builders):
+ path = str(Path(tmpdir) / f"part_{i:02d}.mp4")
+ scenes = builder()
+ _render_part(scenes, path, label)
+ part_files.append(path)
+
+ # ── Load temp files as lightweight VideoFileClips & concat ─────
+ logger.info("Concatenating all parts…")
+ file_clips = [VideoFileClip(p) for p in part_files]
+ final = concatenate_videoclips(file_clips, method="chain")
+
+ # Background audio
+ audio = _make_sine(330, final.duration).with_effects([MultiplyVolume(factor=0.5)])
+ final = final.with_audio(audio)
+
+ logger.info("Total duration: %.1fs", final.duration)
+ logger.info("Writing %s (NVENC GPU)…", OUTPUT)
+
+ final.write_videofile(
+ OUTPUT,
+ fps=FPS,
+ codec="h264_nvenc",
+ audio_codec="aac",
+ threads=os.cpu_count(),
+ ffmpeg_params=["-preset", "p4", "-rc", "constqp", "-qp", "18", "-b:v", "0"],
+ logger="bar",
+ )
+
+ # Clean up
+ final.close()
+ for c in file_clips:
+ c.close()
+
+ size_mb = Path(OUTPUT).stat().st_size / (1024 * 1024)
+ logger.info("✔ Saved %s (%.1f MB)", OUTPUT, size_mb)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pomodoro_app/android/app/src/main/AndroidManifest.xml b/pomodoro_app/android/app/src/main/AndroidManifest.xml
index 05764e4..51d5919 100644
--- a/pomodoro_app/android/app/src/main/AndroidManifest.xml
+++ b/pomodoro_app/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
createState() => PomodoroScreenState();
+}
+
+/// State for [PomodoroScreen], exposed for testing.
+@visibleForTesting
+class PomodoroScreenState extends State {
+ PomodoroTimer? _timer;
+ SyncService? _syncService;
+ bool _ownsTimer = false;
+ bool _ownsSyncService = false;
+ bool _initialized = false;
+
+ @override
+ void initState() {
+ super.initState();
+
+ if (widget.timer != null) {
+ // Test path: synchronous init, no sync service needed.
+ _timer = widget.timer!;
+ _syncService = widget.syncService;
+ _timer!.addListener(_onTimerChanged);
+ _initialized = true;
+ } else {
+ // Production path: async init with sync service.
+ _initAsync();
+ }
+ }
+
+ Future _initAsync() async {
+ _syncService = SyncService(
+ onStateReceived: _onRemoteState,
+ );
+ _ownsSyncService = true;
+ await _syncService!.start();
+
+ _timer = PomodoroTimer(
+ syncService: _syncService,
+ soundService: SoundService(),
+ notificationService: NotificationService(),
+ );
+ _ownsTimer = true;
+
+ _timer!.addListener(_onTimerChanged);
+ _initialized = true;
+ if (mounted) setState(() {});
+ }
+
+ void _onRemoteState(PomodoroState state, String action) {
+ _timer?.applyRemoteState(state, action);
+ }
+
+ void _onTimerChanged() {
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _timer?.removeListener(_onTimerChanged);
+ if (_ownsTimer) _timer?.dispose();
+ if (_ownsSyncService) _syncService?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (!_initialized || _timer == null) {
+ return const Scaffold(
+ body: Center(child: CircularProgressIndicator()),
+ );
+ }
+
+ final timer = _timer!;
+ final state = timer.state;
+
+ return Scaffold(
+ body: SafeArea(
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 500),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Spacer(flex: 2),
+ // Timer style picker.
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: TimerStyle.pomodoro,
+ label: Text('Pomodoro'),
+ icon: Icon(Icons.timer),
+ ),
+ ButtonSegment(
+ value: TimerStyle.ultraradian,
+ label: Text('Ultraradian'),
+ icon: Icon(Icons.self_improvement),
+ ),
+ ],
+ selected: {timer.timerStyle},
+ onSelectionChanged: (selected) {
+ timer.switchStyle(selected.first);
+ },
+ ),
+ const SizedBox(height: 16),
+ // Timer display.
+ Expanded(
+ flex: 5,
+ child: TimerDisplay(state: state),
+ ),
+ const SizedBox(height: 32),
+ // Controls.
+ TimerControls(
+ state: state,
+ onStart: timer.start,
+ onPause: timer.pause,
+ onReset: timer.reset,
+ onSkip: timer.skip,
+ ),
+ const SizedBox(height: 32),
+ // Session indicators.
+ PomodoroIndicators(state: state),
+ const SizedBox(height: 16),
+ // Completed count.
+ Text(
+ '${state.completedPomodoros} '
+ '${timer.timerStyle.label.toLowerCase()}'
+ '${state.completedPomodoros == 1 ? '' : 's'}'
+ ' completed',
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const Spacer(flex: 2),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/pomodoro_app/lib/services/notification_service.dart b/pomodoro_app/lib/services/notification_service.dart
new file mode 100644
index 0000000..2476c8a
--- /dev/null
+++ b/pomodoro_app/lib/services/notification_service.dart
@@ -0,0 +1,155 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Sends desktop notifications showing Pomodoro timer status.
+///
+/// Uses the freedesktop D-Bus Notifications interface via `gdbus` to show,
+/// update, and dismiss notifications. The notification includes the current
+/// mode, remaining time, and a progress bar. Action buttons (Pause / Skip /
+/// Start) are displayed for quick interaction.
+class NotificationService {
+ /// Creates a [NotificationService].
+ ///
+ /// Pass a custom [runProcess] for testing.
+ NotificationService({
+ @visibleForTesting
+ Future Function(String, List)? runProcess,
+ }) : _runProcess = runProcess ?? Process.run;
+
+ final Future Function(String, List) _runProcess;
+ int _currentId = 0;
+ bool _disposed = false;
+
+ static const _dbusDest = 'org.freedesktop.Notifications';
+ static const _dbusPath = '/org/freedesktop/Notifications';
+
+ /// The notification ID currently shown (0 means none).
+ @visibleForTesting
+ int get currentId => _currentId;
+
+ /// Shows or updates the timer notification with the current [state].
+ ///
+ /// The notification replaces any previous one so only a single
+ /// notification is visible at a time.
+ Future showTimer({required PomodoroState state}) async {
+ if (_disposed) return;
+
+ final title = '${state.mode.label} \u2013 ${state.formattedTime}';
+ final body = _progressBar(state.progress);
+
+ await _notify(
+ title: title,
+ body: body,
+ actions: state.isRunning
+ ? ['pause', 'Pause', 'skip', 'Skip']
+ : ['start', 'Start'],
+ );
+ }
+
+ /// Shows a notification that the session has completed.
+ Future showSessionComplete({
+ required PomodoroMode completedMode,
+ required PomodoroMode nextMode,
+ }) async {
+ if (_disposed) return;
+
+ final title = '${completedMode.label} complete!';
+ final body = 'Up next: ${nextMode.label}';
+
+ await _notify(title: title, body: body, actions: ['start', 'Start']);
+ }
+
+ /// Cancels the currently shown notification.
+ Future cancel() async {
+ if (_disposed || _currentId == 0) return;
+
+ try {
+ await _runProcess('gdbus', [
+ 'call',
+ '--session',
+ '--dest',
+ _dbusDest,
+ '--object-path',
+ _dbusPath,
+ '--method',
+ 'org.freedesktop.Notifications.CloseNotification',
+ '$_currentId',
+ ]);
+ } on Object catch (e) {
+ debugPrint('NotificationService: Close error: $e');
+ }
+ _currentId = 0;
+ }
+
+ /// Releases resources. Does not await the underlying cancel.
+ void dispose() {
+ if (_disposed) return;
+ if (_currentId != 0) {
+ // Fire-and-forget; the notification daemon cleans up on exit.
+ unawaited(cancel());
+ }
+ _disposed = true;
+ }
+
+ // ------------------------------------------------------------------
+ // Private helpers
+ // ------------------------------------------------------------------
+
+ Future _notify({
+ required String title,
+ required String body,
+ List actions = const [],
+ }) async {
+ final actionsStr = actions.isEmpty
+ ? '[]'
+ : '[${actions.map((a) => "'$a'").join(', ')}]';
+
+ try {
+ final result = await _runProcess('gdbus', [
+ 'call',
+ '--session',
+ '--dest',
+ _dbusDest,
+ '--object-path',
+ _dbusPath,
+ '--method',
+ 'org.freedesktop.Notifications.Notify',
+ 'Pomodoro',
+ '$_currentId',
+ 'appointment-soon',
+ title,
+ body,
+ actionsStr,
+ '{}',
+ '0',
+ ]);
+
+ final match =
+ RegExp(r'\(uint32 (\d+),?\)').firstMatch(result.stdout as String);
+ if (match != null) {
+ _currentId = int.parse(match.group(1)!);
+ }
+ } on Object catch (e) {
+ debugPrint('NotificationService: Notify error: $e');
+ }
+ }
+
+ /// Builds a text-based progress bar for the notification body.
+ @visibleForTesting
+ static String progressBar(double progress) => _progressBar(progress);
+
+ static String _progressBar(double progress) {
+ const total = 20;
+ final filled = (progress * total).round();
+ final empty = total - filled;
+ return '${'█' * filled}${'░' * empty}';
+ }
+}
+
+/// Completes a future without requiring `await`.
+///
+/// Prevents the `unawaited_futures` lint in fire-and-forget calls.
+void unawaited(Future future) {}
diff --git a/pomodoro_app/lib/services/pomodoro_timer.dart b/pomodoro_app/lib/services/pomodoro_timer.dart
new file mode 100644
index 0000000..3008976
--- /dev/null
+++ b/pomodoro_app/lib/services/pomodoro_timer.dart
@@ -0,0 +1,269 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../models/pomodoro_state.dart';
+import 'notification_service.dart';
+import 'sound_service.dart';
+import 'sync_service.dart';
+
+/// Manages the Pomodoro timer logic, independent of UI framework.
+///
+/// Optionally synchronizes state across devices via [SyncService].
+class PomodoroTimer extends ChangeNotifier {
+ /// Creates a [PomodoroTimer] with configurable durations.
+ PomodoroTimer({
+ int? workMinutes,
+ int? shortBreakMinutes,
+ int? longBreakMinutes,
+ int? pomodorosPerCycle,
+ TimerStyle timerStyle = TimerStyle.pomodoro,
+ this.syncService,
+ SoundService? soundService,
+ NotificationService? notificationService,
+ @visibleForTesting Timer Function(Duration, void Function(Timer))? timerFactory,
+ }) : _timerStyle = timerStyle,
+ _soundService = soundService,
+ _notificationService = notificationService,
+ _timerFactory = timerFactory ?? Timer.periodic {
+ _workMinutes = workMinutes ?? timerStyle.defaultWorkMinutes;
+ _shortBreakMinutes = shortBreakMinutes ?? timerStyle.defaultShortBreakMinutes;
+ _longBreakMinutes = longBreakMinutes ?? timerStyle.defaultLongBreakMinutes;
+ _pomodorosPerCycle = pomodorosPerCycle ?? timerStyle.defaultPomodorosPerCycle;
+ _state = PomodoroState.initial(
+ workMinutes: _workMinutes,
+ shortBreakMinutes: _shortBreakMinutes,
+ longBreakMinutes: _longBreakMinutes,
+ pomodorosPerCycle: _pomodorosPerCycle,
+ );
+ }
+
+ /// Duration of a work session in minutes.
+ late int _workMinutes;
+
+ /// Duration of a short break in minutes.
+ late int _shortBreakMinutes;
+
+ /// Duration of a long break in minutes.
+ late int _longBreakMinutes;
+
+ /// Number of work sessions before a long break.
+ late int _pomodorosPerCycle;
+
+ /// The current timer style.
+ TimerStyle _timerStyle;
+
+ /// Optional sync service for LAN synchronization.
+ final SyncService? syncService;
+
+ final SoundService? _soundService;
+ final NotificationService? _notificationService;
+ final Timer Function(Duration, void Function(Timer)) _timerFactory;
+
+ late PomodoroState _state;
+ Timer? _timer;
+
+ /// Whether we are currently applying a remote state (prevents echo).
+ bool _applyingRemote = false;
+
+ /// The current state of the timer.
+ PomodoroState get state => _state;
+
+ /// The active timer style.
+ TimerStyle get timerStyle => _timerStyle;
+
+ /// Switches to a different timer style, resetting all progress.
+ void switchStyle(TimerStyle style) {
+ if (style == _timerStyle) return;
+ _timer?.cancel();
+ _timer = null;
+ _timerStyle = style;
+ _workMinutes = style.defaultWorkMinutes;
+ _shortBreakMinutes = style.defaultShortBreakMinutes;
+ _longBreakMinutes = style.defaultLongBreakMinutes;
+ _pomodorosPerCycle = style.defaultPomodorosPerCycle;
+ _state = PomodoroState.initial(
+ workMinutes: _workMinutes,
+ shortBreakMinutes: _shortBreakMinutes,
+ longBreakMinutes: _longBreakMinutes,
+ pomodorosPerCycle: _pomodorosPerCycle,
+ );
+ _notificationService?.cancel();
+ notifyListeners();
+ syncService?.stopHeartbeat();
+ }
+
+ /// Starts or resumes the timer.
+ void start() {
+ if (_state.isRunning) return;
+ _state = _state.copyWith(isRunning: true);
+ notifyListeners();
+ _startTicking();
+ _notificationService?.showTimer(state: _state);
+ _broadcastIfLocal('start');
+ syncService?.startHeartbeat(() => _state);
+ }
+
+ /// Pauses the timer.
+ void pause() {
+ if (!_state.isRunning) return;
+ _timer?.cancel();
+ _timer = null;
+ _state = _state.copyWith(isRunning: false);
+ _notificationService?.cancel();
+ notifyListeners();
+ _broadcastIfLocal('pause');
+ syncService?.stopHeartbeat();
+ }
+
+ /// Resets the current session timer without changing the mode.
+ void reset() {
+ _timer?.cancel();
+ _timer = null;
+ _state = _state.copyWith(
+ remainingSeconds: _state.totalSeconds,
+ isRunning: false,
+ );
+ _notificationService?.cancel();
+ notifyListeners();
+ _broadcastIfLocal('reset');
+ syncService?.stopHeartbeat();
+ }
+
+ /// Skips to the next session, treating the current one as completed.
+ void skip() {
+ _timer?.cancel();
+ _timer = null;
+ _onSessionComplete();
+ _broadcastIfLocal('skip');
+ syncService?.stopHeartbeat();
+ }
+
+ /// Applies state received from a remote device via [SyncService].
+ void applyRemoteState(PomodoroState remoteState, String action) {
+ _applyingRemote = true;
+
+ _timer?.cancel();
+ _timer = null;
+
+ _state = remoteState;
+
+ if (_state.isRunning) {
+ _startTicking();
+ }
+
+ notifyListeners();
+ _applyingRemote = false;
+ }
+
+ void _tick(Timer timer) {
+ if (_state.remainingSeconds <= 1) {
+ timer.cancel();
+ _timer = null;
+ _onSessionComplete();
+ } else {
+ _state = _state.copyWith(
+ remainingSeconds: _state.remainingSeconds - 1,
+ );
+ _updateNotification();
+ notifyListeners();
+ }
+ }
+
+ void _onSessionComplete() {
+ if (_state.mode == PomodoroMode.work) {
+ final newCompleted = _state.completedPomodoros + 1;
+ _state = _state.copyWith(
+ completedPomodoros: newCompleted,
+ remainingSeconds: 0,
+ isRunning: false,
+ );
+ } else {
+ _state = _state.copyWith(
+ remainingSeconds: 0,
+ isRunning: false,
+ );
+ }
+ notifyListeners();
+ final completedMode = _state.mode;
+ _advanceToNextMode();
+ _soundService?.playTransitionSound(
+ completedMode: completedMode,
+ nextMode: _state.mode,
+ );
+ _notificationService?.showSessionComplete(
+ completedMode: completedMode,
+ nextMode: _state.mode,
+ );
+ notifyListeners();
+ }
+
+ void _advanceToNextMode() {
+ switch (_state.mode) {
+ case PomodoroMode.work:
+ if (_state.completedPomodoros > 0 &&
+ _state.completedPomodoros % _pomodorosPerCycle == 0) {
+ _setMode(PomodoroMode.longBreak);
+ } else {
+ _setMode(PomodoroMode.shortBreak);
+ }
+ case PomodoroMode.shortBreak:
+ case PomodoroMode.longBreak:
+ _setMode(PomodoroMode.work);
+ }
+ }
+
+ void _setMode(PomodoroMode mode) {
+ final totalSeconds = _durationForMode(mode) * 60;
+ _state = _state.copyWith(
+ mode: mode,
+ remainingSeconds: totalSeconds,
+ totalSeconds: totalSeconds,
+ isRunning: false,
+ );
+ }
+
+ int _durationForMode(PomodoroMode mode) {
+ switch (mode) {
+ case PomodoroMode.work:
+ return _workMinutes;
+ case PomodoroMode.shortBreak:
+ return _shortBreakMinutes;
+ case PomodoroMode.longBreak:
+ return _longBreakMinutes;
+ }
+ }
+
+ void _startTicking() {
+ _timer = _timerFactory(
+ const Duration(seconds: 1),
+ _tick,
+ );
+ }
+
+ /// Broadcasts state to peers only if this is a local user action.
+ void _broadcastIfLocal(String action) {
+ if (!_applyingRemote) {
+ syncService?.broadcast(_state, action);
+ }
+ }
+
+ /// Interval in seconds between notification updates while running.
+ static const _notifyIntervalSeconds = 30;
+
+ void _updateNotification() {
+ if (_notificationService == null) return;
+ if (_state.remainingSeconds % _notifyIntervalSeconds == 0) {
+ _notificationService.showTimer(state: _state);
+ }
+ }
+
+ @override
+ void dispose() {
+ _timer?.cancel();
+ syncService?.stopHeartbeat();
+ _soundService?.dispose();
+ _notificationService?.dispose();
+ super.dispose();
+ }
+}
diff --git a/pomodoro_app/lib/services/sound_service.dart b/pomodoro_app/lib/services/sound_service.dart
new file mode 100644
index 0000000..bf48048
--- /dev/null
+++ b/pomodoro_app/lib/services/sound_service.dart
@@ -0,0 +1,78 @@
+import 'package:audioplayers/audioplayers.dart';
+import 'package:flutter/foundation.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Plays notification sounds for Pomodoro timer transitions.
+///
+/// Each transition type has a distinct sound:
+/// - Work done → ascending chime
+/// - Short break done → gentle double ping
+/// - Long break starting → descending celebration
+/// - Long break done → rapid wake-up beeps
+class SoundService {
+ /// Creates a [SoundService].
+ ///
+ /// Pass a custom [playCallback] for testing.
+ SoundService({
+ @visibleForTesting Future Function(String assetPath)? playCallback,
+ }) : _playCallback = playCallback;
+
+ final Future Function(String assetPath)? _playCallback;
+ AudioPlayer? _player;
+ bool _disposed = false;
+
+ static const _assetPrefix = 'sounds';
+
+ /// Plays the appropriate sound for a mode transition.
+ ///
+ /// [completedMode] is the mode that just finished.
+ /// [nextMode] is the mode that is starting.
+ Future playTransitionSound({
+ required PomodoroMode completedMode,
+ required PomodoroMode nextMode,
+ }) async {
+ if (_disposed) return;
+
+ final assetPath = _assetForTransition(completedMode, nextMode);
+ if (assetPath == null) return;
+
+ try {
+ if (_playCallback != null) {
+ await _playCallback(assetPath);
+ } else {
+ _player?.dispose();
+ _player = AudioPlayer();
+ await _player!.play(AssetSource('$_assetPrefix/$assetPath'));
+ }
+ debugPrint('SoundService: Playing $assetPath');
+ } on Object catch (e) {
+ debugPrint('SoundService: Playback error: $e');
+ }
+ }
+
+ /// Returns the WAV filename for a given transition, or null if none.
+ static String? _assetForTransition(
+ PomodoroMode completedMode,
+ PomodoroMode nextMode,
+ ) {
+ switch (completedMode) {
+ case PomodoroMode.work:
+ if (nextMode == PomodoroMode.longBreak) {
+ return 'long_break_start.wav';
+ }
+ return 'work_done.wav';
+ case PomodoroMode.shortBreak:
+ return 'short_break_done.wav';
+ case PomodoroMode.longBreak:
+ return 'long_break_done.wav';
+ }
+ }
+
+ /// Releases audio resources.
+ void dispose() {
+ _disposed = true;
+ _player?.dispose();
+ _player = null;
+ }
+}
diff --git a/pomodoro_app/lib/services/sync_service.dart b/pomodoro_app/lib/services/sync_service.dart
new file mode 100644
index 0000000..7c19861
--- /dev/null
+++ b/pomodoro_app/lib/services/sync_service.dart
@@ -0,0 +1,252 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Callback type for receiving a synced [PomodoroState] and action name.
+typedef SyncCallback = void Function(PomodoroState state, String action);
+
+/// Provides LAN synchronization between Pomodoro app instances using
+/// UDP broadcast.
+///
+/// Uses subnet broadcast (255.255.255.255) instead of multicast for
+/// maximum compatibility across platforms. A unique [deviceId] prevents
+/// echo (processing own messages).
+class SyncService {
+ /// Creates a [SyncService].
+ ///
+ /// [onStateReceived] is called when a remote device broadcasts a state
+ /// change. [port] can be overridden for testing.
+ SyncService({
+ required this.onStateReceived,
+ this.port = 41234,
+ @visibleForTesting String? deviceId,
+ @visibleForTesting
+ Future Function(dynamic host, int port)?
+ socketFactory,
+ }) : deviceId = deviceId ?? _generateDeviceId(),
+ _socketFactory = socketFactory;
+
+ /// Unique identifier for this device instance.
+ final String deviceId;
+
+ /// UDP port for sync messages.
+ final int port;
+
+ /// UDP port for wake signals (separate from sync to allow a daemon to
+ /// listen without conflicting with the app's sync socket).
+ static const int wakePort = 41235;
+
+ /// Called when a state update is received from another device.
+ final SyncCallback onStateReceived;
+
+ final Future Function(dynamic host, int port)?
+ _socketFactory;
+
+ RawDatagramSocket? _socket;
+ Timer? _heartbeat;
+ bool _disposed = false;
+
+ static const _methodChannel = MethodChannel('pomodoro_multicast_lock');
+
+ /// Whether the service is currently listening.
+ bool get isActive => _socket != null && !_disposed;
+
+ /// Starts listening for broadcast messages and enables sending.
+ Future start() async {
+ if (_disposed) return;
+
+ // Acquire Android multicast/broadcast lock.
+ await _acquireMulticastLock();
+
+ try {
+ if (_socketFactory != null) {
+ _socket = await _socketFactory(InternetAddress.anyIPv4, port);
+ } else {
+ _socket = await RawDatagramSocket.bind(
+ InternetAddress.anyIPv4,
+ port,
+ reuseAddress: true,
+ );
+ }
+
+ _socket?.broadcastEnabled = true;
+
+ _socket?.listen(
+ _onSocketEvent,
+ onError: _onError,
+ cancelOnError: false,
+ );
+
+ debugPrint('SyncService: Listening on port $port (device=$deviceId)');
+
+ // Notify other devices that this instance just opened.
+ _sendWake();
+ } on Object catch (e) {
+ debugPrint('SyncService: Failed to start: $e');
+ }
+ }
+
+ /// Broadcasts the given [state] with an [action] label to all peers.
+ void broadcast(PomodoroState state, String action) {
+ if (_socket == null || _disposed) return;
+
+ final message = _encodeMessage(state, action);
+ try {
+ final sent = _socket!.send(
+ message,
+ InternetAddress('255.255.255.255'),
+ port,
+ );
+ debugPrint(
+ 'SyncService: Sent $action ($sent bytes) '
+ 'to 255.255.255.255:$port',
+ );
+ } on Object catch (e) {
+ debugPrint('SyncService: Send failed: $e');
+ }
+ }
+
+ /// Starts periodic heartbeat that broadcasts current state.
+ ///
+ /// This keeps devices in sync even if an individual message is lost.
+ void startHeartbeat(PomodoroState Function() stateProvider) {
+ _heartbeat?.cancel();
+ _heartbeat = Timer.periodic(
+ const Duration(seconds: 5),
+ (_) => broadcast(stateProvider(), 'heartbeat'),
+ );
+ }
+
+ /// Stops the periodic heartbeat.
+ void stopHeartbeat() {
+ _heartbeat?.cancel();
+ _heartbeat = null;
+ }
+
+ /// Shuts down the sync service.
+ Future dispose() async {
+ _disposed = true;
+ _heartbeat?.cancel();
+ _heartbeat = null;
+
+ _socket?.close();
+ _socket = null;
+
+ await _releaseMulticastLock();
+ }
+
+ // -- Private helpers --
+
+ /// Sends a wake signal to the dedicated wake port so that a desktop
+ /// daemon can auto-launch the app on other devices.
+ void _sendWake() {
+ if (_socket == null || _disposed) return;
+ final message = utf8.encode(jsonEncode({
+ 'deviceId': deviceId,
+ 'action': 'wake',
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ }));
+ try {
+ _socket!.send(message, InternetAddress('255.255.255.255'), wakePort);
+ debugPrint('SyncService: Sent wake to port $wakePort');
+ } on Object catch (e) {
+ debugPrint('SyncService: Wake send failed: $e');
+ }
+ }
+
+ void _onSocketEvent(RawSocketEvent event) {
+ if (event != RawSocketEvent.read) return;
+
+ final datagram = _socket?.receive();
+ if (datagram == null) return;
+
+ try {
+ final json = utf8.decode(datagram.data);
+ final map = jsonDecode(json) as Map;
+
+ // Ignore own messages.
+ if (map['deviceId'] == deviceId) return;
+
+ final state = _decodeState(map['state'] as Map);
+ final action = map['action'] as String;
+ debugPrint(
+ 'SyncService: Received $action from ${map['deviceId']}',
+ );
+ onStateReceived(state, action);
+ } on Object catch (e) {
+ debugPrint('SyncService: Parse error: $e');
+ }
+ }
+
+ void _onError(Object error) {
+ debugPrint('SyncService: Socket error: $error');
+ }
+
+ List _encodeMessage(PomodoroState state, String action) {
+ final map = {
+ 'deviceId': deviceId,
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ 'action': action,
+ 'state': _encodeState(state),
+ };
+ return utf8.encode(jsonEncode(map));
+ }
+
+ static Map _encodeState(PomodoroState state) {
+ return {
+ 'mode': state.mode.name,
+ 'remainingSeconds': state.remainingSeconds,
+ 'totalSeconds': state.totalSeconds,
+ 'isRunning': state.isRunning,
+ 'completedPomodoros': state.completedPomodoros,
+ 'pomodorosPerCycle': state.pomodorosPerCycle,
+ };
+ }
+
+ static PomodoroState _decodeState(Map map) {
+ return PomodoroState(
+ mode: PomodoroMode.values.byName(map['mode'] as String),
+ remainingSeconds: map['remainingSeconds'] as int,
+ totalSeconds: map['totalSeconds'] as int,
+ isRunning: map['isRunning'] as bool,
+ completedPomodoros: map['completedPomodoros'] as int,
+ pomodorosPerCycle: map['pomodorosPerCycle'] as int,
+ );
+ }
+
+ static Future _acquireMulticastLock() async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _methodChannel.invokeMethod('acquire');
+ } on MissingPluginException {
+ // Platform channel not available (e.g., in tests).
+ } on Object catch (e) {
+ debugPrint('SyncService: Failed to acquire multicast lock: $e');
+ }
+ }
+
+ static Future _releaseMulticastLock() async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _methodChannel.invokeMethod('release');
+ } on MissingPluginException {
+ // Platform channel not available.
+ } on Object catch (e) {
+ debugPrint('SyncService: Failed to release multicast lock: $e');
+ }
+ }
+
+ static String _generateDeviceId() {
+ final random = Random();
+ return List.generate(
+ 8,
+ (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
+ ).join();
+ }
+}
diff --git a/pomodoro_app/lib/theme/pomodoro_theme.dart b/pomodoro_app/lib/theme/pomodoro_theme.dart
new file mode 100644
index 0000000..499a138
--- /dev/null
+++ b/pomodoro_app/lib/theme/pomodoro_theme.dart
@@ -0,0 +1,73 @@
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+
+/// Provides consistent theming for the Pomodoro app across platforms.
+class PomodoroTheme {
+ PomodoroTheme._();
+
+ // Brand colors per mode.
+ static const Color workColor = Color(0xFFE74C3C);
+ static const Color shortBreakColor = Color(0xFF2ECC71);
+ static const Color longBreakColor = Color(0xFF3498DB);
+
+ static const Color _darkSurface = Color(0xFF1A1A2E);
+ static const Color _darkBackground = Color(0xFF16213E);
+ static const Color _textLight = Color(0xFFF5F5F5);
+ static const Color _textMuted = Color(0xFFB0B0B0);
+
+ /// Returns the accent color for the given [mode].
+ static Color colorForMode(PomodoroMode mode) {
+ switch (mode) {
+ case PomodoroMode.work:
+ return workColor;
+ case PomodoroMode.shortBreak:
+ return shortBreakColor;
+ case PomodoroMode.longBreak:
+ return longBreakColor;
+ }
+ }
+
+ /// The app's dark theme.
+ static ThemeData get darkTheme {
+ return ThemeData(
+ useMaterial3: true,
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: _darkBackground,
+ colorScheme: const ColorScheme.dark(
+ primary: workColor,
+ surface: _darkSurface,
+ onSurface: _textLight,
+ ),
+ textTheme: const TextTheme(
+ displayLarge: TextStyle(
+ fontSize: 72,
+ fontWeight: FontWeight.w300,
+ color: _textLight,
+ letterSpacing: 4,
+ ),
+ headlineMedium: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.w500,
+ color: _textLight,
+ ),
+ bodyLarge: TextStyle(
+ fontSize: 16,
+ color: _textMuted,
+ ),
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(30),
+ ),
+ textStyle: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/pomodoro_app/lib/widgets/pomodoro_indicators.dart b/pomodoro_app/lib/widgets/pomodoro_indicators.dart
new file mode 100644
index 0000000..d938cda
--- /dev/null
+++ b/pomodoro_app/lib/widgets/pomodoro_indicators.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+import '../theme/pomodoro_theme.dart';
+
+/// Shows completed pomodoro indicators as filled/unfilled dots.
+class PomodoroIndicators extends StatelessWidget {
+ /// Creates [PomodoroIndicators].
+ const PomodoroIndicators({
+ required this.state,
+ super.key,
+ });
+
+ /// The current Pomodoro state.
+ final PomodoroState state;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: List.generate(
+ state.pomodorosPerCycle,
+ (index) {
+ final isCompleted = index < state.completedPomodoros % state.pomodorosPerCycle;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 6),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 300),
+ width: 14,
+ height: 14,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: isCompleted
+ ? PomodoroTheme.workColor
+ : Colors.white24,
+ border: Border.all(
+ color: PomodoroTheme.workColor.withValues(alpha: 0.5),
+ width: 2,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/pomodoro_app/lib/widgets/timer_controls.dart b/pomodoro_app/lib/widgets/timer_controls.dart
new file mode 100644
index 0000000..ba369cb
--- /dev/null
+++ b/pomodoro_app/lib/widgets/timer_controls.dart
@@ -0,0 +1,79 @@
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+import '../theme/pomodoro_theme.dart';
+
+/// Row of control buttons for the Pomodoro timer.
+class TimerControls extends StatelessWidget {
+ /// Creates [TimerControls].
+ const TimerControls({
+ required this.state,
+ required this.onStart,
+ required this.onPause,
+ required this.onReset,
+ required this.onSkip,
+ super.key,
+ });
+
+ /// The current Pomodoro state.
+ final PomodoroState state;
+
+ /// Callback when user taps start.
+ final VoidCallback onStart;
+
+ /// Callback when user taps pause.
+ final VoidCallback onPause;
+
+ /// Callback when user taps reset.
+ final VoidCallback onReset;
+
+ /// Callback when user taps skip.
+ final VoidCallback onSkip;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = PomodoroTheme.colorForMode(state.mode);
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ // Reset button.
+ IconButton(
+ onPressed: onReset,
+ icon: const Icon(Icons.refresh),
+ iconSize: 32,
+ tooltip: 'Reset',
+ color: Colors.white70,
+ ),
+ const SizedBox(width: 16),
+ // Play / Pause button.
+ SizedBox(
+ width: 72,
+ height: 72,
+ child: ElevatedButton(
+ onPressed: state.isRunning ? onPause : onStart,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: color,
+ foregroundColor: Colors.white,
+ shape: const CircleBorder(),
+ padding: EdgeInsets.zero,
+ ),
+ child: Icon(
+ state.isRunning ? Icons.pause : Icons.play_arrow,
+ size: 36,
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ // Skip button.
+ IconButton(
+ onPressed: onSkip,
+ icon: const Icon(Icons.skip_next),
+ iconSize: 32,
+ tooltip: 'Skip',
+ color: Colors.white70,
+ ),
+ ],
+ );
+ }
+}
diff --git a/pomodoro_app/lib/widgets/timer_display.dart b/pomodoro_app/lib/widgets/timer_display.dart
new file mode 100644
index 0000000..7b1b86b
--- /dev/null
+++ b/pomodoro_app/lib/widgets/timer_display.dart
@@ -0,0 +1,79 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import '../models/pomodoro_state.dart';
+import '../theme/pomodoro_theme.dart';
+
+/// A circular progress indicator that displays the remaining time.
+class TimerDisplay extends StatelessWidget {
+ /// Creates a [TimerDisplay].
+ const TimerDisplay({
+ required this.state,
+ super.key,
+ });
+
+ /// The current Pomodoro state.
+ final PomodoroState state;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = PomodoroTheme.colorForMode(state.mode);
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final size = min(constraints.maxWidth, constraints.maxHeight) * 0.7;
+ return SizedBox(
+ width: size,
+ height: size,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ // Background circle.
+ SizedBox.expand(
+ child: CircularProgressIndicator(
+ value: 1.0,
+ strokeWidth: 8,
+ color: color.withValues(alpha: 0.2),
+ ),
+ ),
+ // Progress arc.
+ SizedBox.expand(
+ child: CircularProgressIndicator(
+ value: state.progress,
+ strokeWidth: 8,
+ color: color,
+ strokeCap: StrokeCap.round,
+ ),
+ ),
+ // Time text and mode label.
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ state.modeDisplayLabel,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ state.formattedTime,
+ style:
+ Theme.of(context).textTheme.displayLarge?.copyWith(
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/pomodoro_app/packaging/arch/PKGBUILD b/pomodoro_app/packaging/arch/PKGBUILD
index 80384af..a807ebd 100644
--- a/pomodoro_app/packaging/arch/PKGBUILD
+++ b/pomodoro_app/packaging/arch/PKGBUILD
@@ -47,10 +47,10 @@ package() {
"$pkgdir/usr/lib/$pkgname/pomodoro_app"
# Install bundled shared libraries.
- install -Dm644 "$_bundle/lib/libapp.so" \
- "$pkgdir/usr/lib/$pkgname/lib/libapp.so"
- install -Dm644 "$_bundle/lib/libflutter_linux_gtk.so" \
- "$pkgdir/usr/lib/$pkgname/lib/libflutter_linux_gtk.so"
+ for lib in "$_bundle"/lib/*.so; do
+ install -Dm644 "$lib" \
+ "$pkgdir/usr/lib/$pkgname/lib/$(basename "$lib")"
+ done
# Install data directory.
install -Dm644 "$_bundle/data/icudtl.dat" \
diff --git a/pomodoro_app/test/models/pomodoro_state_test.dart b/pomodoro_app/test/models/pomodoro_state_test.dart
index a9ebd92..46b1750 100644
--- a/pomodoro_app/test/models/pomodoro_state_test.dart
+++ b/pomodoro_app/test/models/pomodoro_state_test.dart
@@ -2,6 +2,27 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pomodoro_app/models/pomodoro_state.dart';
void main() {
+ group('TimerStyle', () {
+ test('label returns correct strings', () {
+ expect(TimerStyle.pomodoro.label, 'Pomodoro');
+ expect(TimerStyle.ultraradian.label, 'Ultraradian');
+ });
+
+ test('pomodoro has correct defaults', () {
+ expect(TimerStyle.pomodoro.defaultWorkMinutes, 25);
+ expect(TimerStyle.pomodoro.defaultShortBreakMinutes, 5);
+ expect(TimerStyle.pomodoro.defaultLongBreakMinutes, 15);
+ expect(TimerStyle.pomodoro.defaultPomodorosPerCycle, 4);
+ });
+
+ test('ultraradian has correct defaults', () {
+ expect(TimerStyle.ultraradian.defaultWorkMinutes, 90);
+ expect(TimerStyle.ultraradian.defaultShortBreakMinutes, 30);
+ expect(TimerStyle.ultraradian.defaultLongBreakMinutes, 30);
+ expect(TimerStyle.ultraradian.defaultPomodorosPerCycle, 1);
+ });
+ });
+
group('PomodoroMode', () {
test('label returns correct strings', () {
expect(PomodoroMode.work.label, 'Work');
@@ -98,6 +119,30 @@ void main() {
});
});
+ group('PomodoroState.modeDisplayLabel', () {
+ test('returns mode label when pomodorosPerCycle > 1', () {
+ final state = PomodoroState.initial();
+ expect(state.modeDisplayLabel, 'Work');
+
+ final breakState = state.copyWith(mode: PomodoroMode.shortBreak);
+ expect(breakState.modeDisplayLabel, 'Short Break');
+
+ final longBreakState = state.copyWith(mode: PomodoroMode.longBreak);
+ expect(longBreakState.modeDisplayLabel, 'Long Break');
+ });
+
+ test('returns Break when pomodorosPerCycle is 1 and not work', () {
+ final state = PomodoroState.initial(pomodorosPerCycle: 1);
+ expect(state.modeDisplayLabel, 'Work');
+
+ final breakState = state.copyWith(mode: PomodoroMode.shortBreak);
+ expect(breakState.modeDisplayLabel, 'Break');
+
+ final longBreakState = state.copyWith(mode: PomodoroMode.longBreak);
+ expect(longBreakState.modeDisplayLabel, 'Break');
+ });
+ });
+
group('PomodoroState equality', () {
test('equal states are ==', () {
final a = PomodoroState.initial();
diff --git a/pomodoro_app/test/screens/pomodoro_screen_test.dart b/pomodoro_app/test/screens/pomodoro_screen_test.dart
index d533b51..c460db4 100644
--- a/pomodoro_app/test/screens/pomodoro_screen_test.dart
+++ b/pomodoro_app/test/screens/pomodoro_screen_test.dart
@@ -174,5 +174,34 @@ void main() {
// We can check that the PomodoroIndicators widget is present.
expect(find.text('0 pomodoros completed'), findsOneWidget);
});
+
+ testWidgets('shows style picker with Pomodoro selected', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('Pomodoro'), findsOneWidget);
+ expect(find.text('Ultraradian'), findsOneWidget);
+ });
+
+ testWidgets('switching to ultraradian updates timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.text('Ultraradian'));
+ await tester.pump();
+
+ // Ultraradian work session is 90 minutes.
+ expect(find.text('90:00'), findsOneWidget);
+ expect(timer.timerStyle, TimerStyle.ultraradian);
+ });
+
+ testWidgets('switching back to pomodoro resets timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.text('Ultraradian'));
+ await tester.pump();
+ expect(find.text('90:00'), findsOneWidget);
+
+ await tester.tap(find.text('Pomodoro'));
+ await tester.pump();
+ expect(find.text('25:00'), findsOneWidget);
+ });
});
}
diff --git a/pomodoro_app/test/services/notification_service_test.dart b/pomodoro_app/test/services/notification_service_test.dart
new file mode 100644
index 0000000..908c525
--- /dev/null
+++ b/pomodoro_app/test/services/notification_service_test.dart
@@ -0,0 +1,211 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/services/notification_service.dart';
+
+/// Captured call to the mock process runner.
+class _Call {
+ _Call(this.executable, this.args);
+ final String executable;
+ final List args;
+}
+
+void main() {
+ group('NotificationService', () {
+ late List<_Call> calls;
+ late NotificationService service;
+
+ Future mockRun(String exec, List args) async {
+ calls.add(_Call(exec, args));
+ return ProcessResult(0, 0, '(uint32 42,)', '');
+ }
+
+ setUp(() {
+ calls = [];
+ service = NotificationService(runProcess: mockRun);
+ });
+
+ tearDown(() {
+ service.dispose();
+ });
+
+ test('showTimer sends Notify via gdbus', () async {
+ final state = PomodoroState(
+ mode: PomodoroMode.work,
+ remainingSeconds: 1500,
+ totalSeconds: 1500,
+ isRunning: true,
+ completedPomodoros: 0,
+ pomodorosPerCycle: 4,
+ );
+
+ await service.showTimer(state: state);
+
+ expect(calls, hasLength(1));
+ expect(calls[0].executable, 'gdbus');
+ expect(
+ calls[0].args,
+ contains('org.freedesktop.Notifications.Notify'),
+ );
+ expect(calls[0].args, contains('Work \u2013 25:00'));
+ expect(calls[0].args, contains("['pause', 'Pause', 'skip', 'Skip']"));
+ });
+
+ test('showTimer shows Start action when paused', () async {
+ final state = PomodoroState(
+ mode: PomodoroMode.shortBreak,
+ remainingSeconds: 120,
+ totalSeconds: 300,
+ isRunning: false,
+ completedPomodoros: 1,
+ pomodorosPerCycle: 4,
+ );
+
+ await service.showTimer(state: state);
+
+ expect(calls[0].args, contains("['start', 'Start']"));
+ });
+
+ test('showTimer replaces previous notification', () async {
+ final state = PomodoroState(
+ mode: PomodoroMode.work,
+ remainingSeconds: 1500,
+ totalSeconds: 1500,
+ isRunning: true,
+ completedPomodoros: 0,
+ pomodorosPerCycle: 4,
+ );
+
+ await service.showTimer(state: state);
+
+ // First call should use replaces_id 0.
+ expect(calls[0].args, contains('0'));
+
+ // Second call should use the parsed ID 42.
+ await service.showTimer(state: state);
+ expect(calls[1].args, contains('42'));
+ });
+
+ test('parses notification ID from gdbus output', () async {
+ final state = PomodoroState.initial();
+
+ await service.showTimer(state: state);
+ expect(service.currentId, 42);
+ });
+
+ test('handles unparsable gdbus output gracefully', () async {
+ final stubService = NotificationService(
+ runProcess: (exec, args) async {
+ return ProcessResult(0, 0, 'unexpected output', '');
+ },
+ );
+
+ final state = PomodoroState.initial();
+ await stubService.showTimer(state: state);
+ expect(stubService.currentId, 0);
+
+ stubService.dispose();
+ });
+
+ test('showSessionComplete sends correct content', () async {
+ await service.showSessionComplete(
+ completedMode: PomodoroMode.work,
+ nextMode: PomodoroMode.shortBreak,
+ );
+
+ expect(calls, hasLength(1));
+ expect(calls[0].args, contains('Work complete!'));
+ expect(calls[0].args, contains('Up next: Short Break'));
+ });
+
+ test('cancel sends CloseNotification', () async {
+ // First show a notification to get an ID.
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ calls.clear();
+
+ await service.cancel();
+
+ expect(calls, hasLength(1));
+ expect(
+ calls[0].args,
+ contains('org.freedesktop.Notifications.CloseNotification'),
+ );
+ expect(calls[0].args, contains('42'));
+ });
+
+ test('cancel does nothing when no notification shown', () async {
+ await service.cancel();
+ expect(calls, isEmpty);
+ });
+
+ test('cancel resets currentId to 0', () async {
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ expect(service.currentId, 42);
+
+ await service.cancel();
+ expect(service.currentId, 0);
+ });
+
+ test('does nothing after dispose', () async {
+ service.dispose();
+
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ await service.showSessionComplete(
+ completedMode: PomodoroMode.work,
+ nextMode: PomodoroMode.shortBreak,
+ );
+ await service.cancel();
+
+ expect(calls, isEmpty);
+ });
+
+ test('dispose cancels active notification', () async {
+ final state = PomodoroState.initial();
+ await service.showTimer(state: state);
+ calls.clear();
+
+ service.dispose();
+
+ // Cancel was fired (fire-and-forget).
+ expect(calls, hasLength(1));
+ expect(
+ calls[0].args,
+ contains('org.freedesktop.Notifications.CloseNotification'),
+ );
+ });
+
+ test('handles process error gracefully', () async {
+ final errorService = NotificationService(
+ runProcess: (exec, args) async {
+ throw const OSError('gdbus not found');
+ },
+ );
+
+ final state = PomodoroState.initial();
+ // Should not throw.
+ await errorService.showTimer(state: state);
+ await errorService.cancel();
+
+ errorService.dispose();
+ });
+ });
+
+ group('progressBar', () {
+ test('returns empty bar at 0%', () {
+ expect(NotificationService.progressBar(0.0), '░' * 20);
+ });
+
+ test('returns full bar at 100%', () {
+ expect(NotificationService.progressBar(1.0), '█' * 20);
+ });
+
+ test('returns half bar at 50%', () {
+ final bar = NotificationService.progressBar(0.5);
+ expect(bar, '${'█' * 10}${'░' * 10}');
+ });
+ });
+}
diff --git a/pomodoro_app/test/services/pomodoro_timer_test.dart b/pomodoro_app/test/services/pomodoro_timer_test.dart
index e071bca..b26ef26 100644
--- a/pomodoro_app/test/services/pomodoro_timer_test.dart
+++ b/pomodoro_app/test/services/pomodoro_timer_test.dart
@@ -247,6 +247,70 @@ void main() {
});
});
+ group('switchStyle()', () {
+ test('switches to ultraradian with correct durations', () {
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(timer.timerStyle, TimerStyle.ultraradian);
+ expect(timer.state.remainingSeconds, 90 * 60);
+ expect(timer.state.totalSeconds, 90 * 60);
+ expect(timer.state.pomodorosPerCycle, 1);
+ expect(timer.state.mode, PomodoroMode.work);
+ expect(timer.state.isRunning, false);
+ });
+
+ test('switches back to pomodoro', () {
+ timer.switchStyle(TimerStyle.ultraradian);
+ timer.switchStyle(TimerStyle.pomodoro);
+ expect(timer.timerStyle, TimerStyle.pomodoro);
+ expect(timer.state.remainingSeconds, 25 * 60);
+ expect(timer.state.totalSeconds, 25 * 60);
+ expect(timer.state.pomodorosPerCycle, 4);
+ });
+
+ test('resets running timer when switching', () {
+ timer.start();
+ fakeController.tick();
+ expect(timer.state.isRunning, true);
+
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(timer.state.isRunning, false);
+ expect(timer.state.remainingSeconds, 90 * 60);
+ });
+
+ test('does nothing when switching to same style', () {
+ timer.start();
+ fakeController.tick();
+ final stateBefore = timer.state;
+
+ timer.switchStyle(TimerStyle.pomodoro);
+ expect(timer.state, stateBefore);
+ });
+
+ test('notifies listeners', () {
+ var notified = false;
+ timer.addListener(() => notified = true);
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(notified, true);
+ });
+
+ test('resets completed pomodoros', () {
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.completedPomodoros, 1);
+
+ timer.switchStyle(TimerStyle.ultraradian);
+ expect(timer.state.completedPomodoros, 0);
+ });
+ });
+
+ group('timerStyle getter', () {
+ test('defaults to pomodoro', () {
+ expect(timer.timerStyle, TimerStyle.pomodoro);
+ });
+ });
+
group('applyRemoteState()', () {
test('applies remote state and notifies listeners', () {
var notified = false;
diff --git a/pomodoro_app/test/services/sync_service_test.dart b/pomodoro_app/test/services/sync_service_test.dart
index 05ed281..69befd3 100644
--- a/pomodoro_app/test/services/sync_service_test.dart
+++ b/pomodoro_app/test/services/sync_service_test.dart
@@ -10,12 +10,12 @@ import 'package:pomodoro_app/services/sync_service.dart';
/// injecting received messages.
class FakeDatagramSocket implements RawDatagramSocket {
final _controller = StreamController.broadcast();
- final List<_SentDatagram> sentMessages = [];
+ final List sentMessages = [];
Datagram? _pendingDatagram;
@override
int send(List buffer, InternetAddress address, int port) {
- sentMessages.add(_SentDatagram(buffer, address, port));
+ sentMessages.add(SentDatagram(buffer, address, port));
return buffer.length;
}
@@ -61,8 +61,8 @@ class FakeDatagramSocket implements RawDatagramSocket {
dynamic noSuchMethod(Invocation invocation) => null;
}
-class _SentDatagram {
- _SentDatagram(this.data, this.address, this.port);
+class SentDatagram {
+ SentDatagram(this.data, this.address, this.port);
final List data;
final InternetAddress address;
final int port;
@@ -118,7 +118,6 @@ void main() {
});
test('ignores own messages', () async {
- final state = PomodoroState.initial();
final message = jsonEncode({
'deviceId': 'test-device-1', // Same as our device.
'timestamp': DateTime.now().millisecondsSinceEpoch,
@@ -212,7 +211,7 @@ void main() {
PomodoroState? received;
final sender = SyncService(
- onStateReceived: (_, __) {},
+ onStateReceived: (_, _) {},
deviceId: 'sender',
socketFactory: (h, p) async => fakeSocket,
);
diff --git a/python_pkg/brother_printer/check_brother_printer.py b/python_pkg/brother_printer/check_brother_printer.py
index 3d2c8b9..a97280a 100644
--- a/python_pkg/brother_printer/check_brother_printer.py
+++ b/python_pkg/brother_printer/check_brother_printer.py
@@ -57,6 +57,13 @@ def _out(text: str = "") -> None:
sys.stdout.write(text + "\n")
+def _prompt(text: str) -> str:
+ """Read user input with a prompt."""
+ sys.stdout.write(text)
+ sys.stdout.flush()
+ return sys.stdin.readline().strip()
+
+
# ── Brother PJL status codes ────────────────────────────────────────
# Documented in Brother PJL Technical Reference.
# Format: code -> (severity, short_text, action)
@@ -91,26 +98,23 @@ BROTHER_STATUS_CODES: dict[int, tuple[str, str, str]] = {
40309: (
"critical",
"Replace Toner",
- "The toner cartridge needs immediate replacement"
- " (TN-1050/TN-1030 compatible).",
+ "The toner cartridge needs immediate replacement (TN-1050/TN-1030 compatible).",
),
40310: (
"critical",
"Toner End",
- "The toner cartridge is empty. Replace now" " (TN-1050/TN-1030 compatible).",
+ "The toner cartridge is empty. Replace now (TN-1050/TN-1030 compatible).",
),
# Drum
30201: (
"warn",
"Drum End Soon",
- "The drum unit is nearing end of life."
- " Order replacement (DR-1050 compatible).",
+ "The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
),
40201: (
"warn",
"Drum End Soon",
- "The drum unit is nearing end of life."
- " Order replacement (DR-1050 compatible).",
+ "The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
),
40019: (
"critical",
@@ -147,6 +151,28 @@ BROTHER_STATUS_CODES: dict[int, tuple[str, str, str]] = {
# ── Data classes ─────────────────────────────────────────────────────
+@dataclass
+class CUPSJob:
+ """A single CUPS print job."""
+
+ job_id: str
+ user: str
+ size: str
+ date: str
+
+
+@dataclass
+class CUPSQueueStatus:
+ """Status of the CUPS print queue for a printer."""
+
+ printer_name: str = ""
+ enabled: bool = True
+ reason: str = ""
+ jobs: list[CUPSJob] = field(default_factory=list)
+ has_backend_errors: bool = False
+ last_backend_error: str = ""
+
+
@dataclass
class USBResult:
"""Result from a USB PJL query."""
@@ -481,6 +507,362 @@ def query_network_snmp(ip: str) -> NetworkResult:
return _build_network_result(ip, community, timeout)
+# ── CUPS queue inspection ────────────────────────────────────────────
+
+
+def _find_cups_printer_name() -> str:
+ """Find the CUPS queue name for a Brother printer."""
+ lpstat_path = shutil.which("lpstat")
+ if not lpstat_path:
+ return ""
+ try:
+ r = subprocess.run(
+ [lpstat_path, "-v"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ for line in r.stdout.splitlines():
+ if "brother" in line.lower():
+ # e.g. device for Brother_HL-1110_series: usb://...
+ match = re.match(r"device for (\S+):", line)
+ if match:
+ return match.group(1)
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
+ pass
+ return ""
+
+
+def _parse_lpstat_printer_line(line: str) -> tuple[bool, str]:
+ """Parse an lpstat -p line. Returns (enabled, reason)."""
+ enabled = "disabled" not in line.lower()
+ reason = ""
+ # Reason follows the dash after the date
+ match = re.search(r"\d{4}\s+-\s*(.+)", line)
+ if match:
+ reason = match.group(1).strip()
+ return enabled, reason
+
+
+def _parse_lpstat_jobs(output: str, printer_name: str) -> list[CUPSJob]:
+ """Parse lpstat -o output into CUPSJob list."""
+ jobs: list[CUPSJob] = []
+ for line in output.splitlines():
+ if not line.startswith(printer_name):
+ continue
+ parts = line.split()
+ if len(parts) >= 4: # noqa: PLR2004
+ job_id = parts[0]
+ user = parts[1]
+ size = parts[2]
+ date = " ".join(parts[3:])
+ jobs.append(CUPSJob(job_id=job_id, user=user, size=size, date=date))
+ return jobs
+
+
+def get_cups_queue_status() -> CUPSQueueStatus:
+ """Check if the CUPS queue is disabled and list pending jobs."""
+ printer_name = _find_cups_printer_name()
+ if not printer_name:
+ return CUPSQueueStatus()
+
+ result = CUPSQueueStatus(printer_name=printer_name)
+ lpstat_path = shutil.which("lpstat")
+ if not lpstat_path:
+ return result
+
+ # Check printer enabled/disabled state
+ try:
+ r = subprocess.run(
+ [lpstat_path, "-p", printer_name],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ for line in r.stdout.splitlines():
+ if "printer" in line.lower() and printer_name in line:
+ result.enabled, result.reason = _parse_lpstat_printer_line(line)
+ break
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
+ pass
+
+ # List pending jobs
+ try:
+ r = subprocess.run(
+ [lpstat_path, "-o", printer_name],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ result.jobs = _parse_lpstat_jobs(r.stdout, printer_name)
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
+ pass
+
+ # Check for stale backend errors
+ has_errors, last_error = _check_cups_backend_errors(printer_name)
+ result.has_backend_errors = has_errors
+ result.last_backend_error = last_error
+
+ return result
+
+
+def _cups_enable_printer(printer_name: str) -> bool:
+ """Re-enable a disabled CUPS printer. Returns True on success."""
+ cupsenable_path = shutil.which("cupsenable")
+ if not cupsenable_path:
+ _out(f" {RED}cupsenable not found.{RESET}")
+ return False
+ try:
+ subprocess.run(
+ [cupsenable_path, printer_name],
+ timeout=5,
+ check=True,
+ )
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
+ _out(f" {RED}Failed to enable printer: {e}{RESET}")
+ return False
+ else:
+ return True
+
+
+def _cups_cancel_all_jobs(printer_name: str) -> bool:
+ """Cancel all pending jobs. Returns True on success."""
+ cancel_path = shutil.which("cancel")
+ if not cancel_path:
+ _out(f" {RED}cancel command not found.{RESET}")
+ return False
+ try:
+ subprocess.run(
+ [cancel_path, "-a", printer_name],
+ timeout=5,
+ check=True,
+ )
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
+ _out(f" {RED}Failed to cancel jobs: {e}{RESET}")
+ return False
+ else:
+ return True
+
+
+def _cups_cancel_job(job_id: str) -> bool:
+ """Cancel a specific job. Returns True on success."""
+ cancel_path = shutil.which("cancel")
+ if not cancel_path:
+ return False
+ try:
+ subprocess.run(
+ [cancel_path, job_id],
+ timeout=5,
+ check=True,
+ )
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
+ return False
+ else:
+ return True
+
+
+def _cups_restart_service() -> bool:
+ """Restart the CUPS service. Returns True on success."""
+ systemctl_path = shutil.which("systemctl")
+ if not systemctl_path:
+ _out(f" {RED}systemctl not found.{RESET}")
+ return False
+ try:
+ subprocess.run(
+ [systemctl_path, "restart", "cups"],
+ timeout=15,
+ check=True,
+ )
+ time.sleep(2) # wait for CUPS to come back up
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
+ _out(f" {RED}Failed to restart CUPS: {e}{RESET}")
+ return False
+ else:
+ return True
+
+
+def _check_cups_backend_errors(
+ printer_name: str, # noqa: ARG001
+) -> tuple[bool, str]:
+ """Check CUPS error log for backend errors. Returns (has_errors, last_error)."""
+ log_path = Path("/var/log/cups/error_log")
+ if not log_path.exists():
+ return False, ""
+ try:
+ lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
+ except OSError:
+ return False, ""
+
+ # Look for backend errors related to this printer (scan from end)
+ backend_error = ""
+ error_timestamp = ""
+ last_success_timestamp = ""
+
+ for line in reversed(lines):
+ if (
+ "backend errors" in line or "stopped with status" in line
+ ) and not backend_error:
+ backend_error = line.strip()
+ ts_match = re.search(r"\[([^\]]+)\]", line)
+ if ts_match:
+ error_timestamp = ts_match.group(1)
+ # Check if a job completed successfully after the error
+ if ("Completed" in line or "total" in line) and error_timestamp:
+ ts_match = re.search(r"\[([^\]]+)\]", line)
+ if ts_match:
+ last_success_timestamp = ts_match.group(1)
+ break
+
+ if not backend_error:
+ return False, ""
+
+ # If there's been a successful print after the error, backend is fine
+ if last_success_timestamp and last_success_timestamp > error_timestamp:
+ return False, ""
+
+ return True, backend_error
+
+
+def _display_cups_queue_status(queue: CUPSQueueStatus) -> None:
+ """Display CUPS queue status and offer interactive fixes."""
+ if not queue.printer_name:
+ return
+ if queue.enabled and not queue.jobs and not queue.has_backend_errors:
+ return
+
+ _out()
+ _out(f"{BOLD}── Print Queue ──{RESET}")
+ _out()
+
+ if queue.has_backend_errors and queue.enabled and not queue.jobs:
+ _out(f" {YELLOW}{BOLD}⚡ CUPS backend has stale errors{RESET}")
+ _out(
+ f" {DIM}New print jobs may silently fail."
+ f" A CUPS restart usually fixes this.{RESET}"
+ )
+ _out()
+
+ if not queue.enabled:
+ _out(f" {RED}{BOLD}⚠ Printer queue is DISABLED{RESET}")
+ if queue.reason:
+ _out(f" {DIM}Reason: {queue.reason}{RESET}")
+ _out()
+
+ if queue.jobs:
+ _out(f" {BOLD}Pending jobs ({len(queue.jobs)}):{RESET}")
+ for job in queue.jobs:
+ _out(f" {job.job_id} {DIM}{job.user} {job.size}B {job.date}{RESET}")
+ _out()
+
+ _offer_queue_fix(queue)
+
+
+def _offer_queue_fix(queue: CUPSQueueStatus) -> None:
+ """Prompt the user to fix a disabled queue / pending jobs."""
+ _out(f" {BOLD}Available actions:{RESET}")
+
+ options: list[str] = []
+ if not queue.enabled and queue.jobs:
+ _out(f" {CYAN}1){RESET} Re-enable printer and retry all jobs")
+ _out(f" {CYAN}2){RESET} Re-enable printer and cancel all jobs")
+ _out(f" {CYAN}3){RESET} Cancel all jobs (keep printer disabled)")
+ _out(f" {CYAN}4){RESET} Restart CUPS service (fixes stale backend)")
+ _out(f" {CYAN}5){RESET} Restart CUPS + re-enable + retry all jobs")
+ _out(f" {CYAN}6){RESET} Do nothing")
+ options = ["1", "2", "3", "4", "5", "6"]
+ elif not queue.enabled:
+ _out(f" {CYAN}1){RESET} Re-enable printer")
+ _out(f" {CYAN}2){RESET} Restart CUPS service (fixes stale backend)")
+ _out(f" {CYAN}3){RESET} Do nothing")
+ options = ["1", "2", "3"]
+ elif queue.jobs:
+ _out(f" {CYAN}1){RESET} Cancel all pending jobs")
+ _out(f" {CYAN}2){RESET} Restart CUPS service (fixes stale backend)")
+ _out(f" {CYAN}3){RESET} Do nothing")
+ options = ["1", "2", "3"]
+ else:
+ # Backend errors only, printer enabled, no jobs
+ _out(f" {CYAN}1){RESET} Restart CUPS service (fixes stale backend)")
+ _out(f" {CYAN}2){RESET} Do nothing")
+ options = ["1", "2"]
+
+ _out()
+ choice = _prompt(f" Choose [{'/'.join(options)}]: ")
+ _out()
+
+ if not queue.enabled and queue.jobs:
+ _handle_disabled_with_jobs(queue, choice)
+ elif not queue.enabled:
+ _handle_disabled_no_jobs(queue, choice)
+ elif queue.jobs:
+ _handle_enabled_with_jobs(queue, choice)
+ else:
+ _handle_backend_errors_only(choice)
+
+
+def _handle_disabled_with_jobs(queue: CUPSQueueStatus, choice: str) -> None: # noqa: C901
+ """Handle fix for disabled printer with pending jobs."""
+ if choice == "1":
+ if _cups_enable_printer(queue.printer_name):
+ _out(f" {GREEN}✓ Printer re-enabled. Jobs will be retried.{RESET}")
+ elif choice == "2":
+ _cups_cancel_all_jobs(queue.printer_name)
+ if _cups_enable_printer(queue.printer_name):
+ _out(f" {GREEN}✓ All jobs cancelled and printer re-enabled.{RESET}")
+ elif choice == "3":
+ if _cups_cancel_all_jobs(queue.printer_name):
+ _out(f" {GREEN}✓ All jobs cancelled.{RESET}")
+ elif choice == "4":
+ if _cups_restart_service():
+ _out(f" {GREEN}✓ CUPS restarted.{RESET}")
+ elif choice == "5":
+ if _cups_restart_service():
+ _cups_enable_printer(queue.printer_name)
+ _out(
+ f" {GREEN}✓ CUPS restarted, printer re-enabled."
+ f" Jobs will be retried.{RESET}"
+ )
+ else:
+ _out(f" {DIM}No changes made.{RESET}")
+
+
+def _handle_disabled_no_jobs(queue: CUPSQueueStatus, choice: str) -> None:
+ """Handle fix for disabled printer with no pending jobs."""
+ if choice == "1":
+ if _cups_enable_printer(queue.printer_name):
+ _out(f" {GREEN}✓ Printer re-enabled.{RESET}")
+ elif choice == "2":
+ if _cups_restart_service():
+ _cups_enable_printer(queue.printer_name)
+ _out(f" {GREEN}✓ CUPS restarted and printer re-enabled.{RESET}")
+ else:
+ _out(f" {DIM}No changes made.{RESET}")
+
+
+def _handle_enabled_with_jobs(queue: CUPSQueueStatus, choice: str) -> None:
+ """Handle fix for enabled printer with stuck jobs."""
+ if choice == "1":
+ if _cups_cancel_all_jobs(queue.printer_name):
+ _out(f" {GREEN}✓ All jobs cancelled.{RESET}")
+ elif choice == "2":
+ if _cups_restart_service():
+ _out(f" {GREEN}✓ CUPS restarted.{RESET}")
+ else:
+ _out(f" {DIM}No changes made.{RESET}")
+
+
+def _handle_backend_errors_only(choice: str) -> None:
+ """Handle fix when only stale backend errors are detected."""
+ if choice == "1":
+ if _cups_restart_service():
+ _out(f" {GREEN}✓ CUPS restarted. Stale backend errors cleared.{RESET}")
+ else:
+ _out(f" {DIM}No changes made.{RESET}")
+
+
# ── Status code lookup ──────────────────────────────────────────────
@@ -564,8 +946,7 @@ _SEVERITY_SUMMARIES: dict[str, str] = {
"warn": f"{YELLOW}{BOLD}⚡ WARNING: Maintenance will be needed"
f" soon.{RESET}\n{YELLOW} Order replacement parts"
f" now to avoid interruption.{RESET}",
- "critical": f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix"
- f" needed now!{RESET}",
+ "critical": f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix needed now!{RESET}",
}
@@ -619,6 +1000,9 @@ def display_usb_results(result: USBResult) -> None:
_out()
_display_consumables_reference()
+ queue = get_cups_queue_status()
+ _display_cups_queue_status(queue)
+
# ── Display: Network helpers ────────────────────────────────────────
@@ -810,7 +1194,7 @@ def _run_usb_mode(usb_line: str) -> None:
"""Handle USB printer mode."""
_out(f"{CYAN}Found Brother printer on USB: {usb_line}{RESET}")
if os.geteuid() != 0:
- _out(f"{RED}Root access required for USB printer." f" Re-run with sudo.{RESET}")
+ _out(f"{RED}Root access required for USB printer. Re-run with sudo.{RESET}")
sys.exit(1)
display_usb_results(query_usb_pjl())