#! /usr/bin/python3
# Simple countdown timer
# Copyright (C) 2021 Joey Schulze
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import os
import math
import time
import signal
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib, Gdk
import multiprocessing
from pydub import AudioSegment
from pydub.playback import play
from argparse import ArgumentParser
from configparser import ConfigParser, NoSectionError, NoOptionError
class NotificationWindow(Gtk.Window):
def __init__(self, audio=False, noise=None):
super().__init__(title="Countdown")
self.process = None
self.noise = noise
if audio:
self.make_noise()
self.build()
def build(self):
self.set_resizable(False)
vbox = Gtk.VBox()
vbox.set_margin_bottom(10)
self.lbl_destination = Gtk.Label()
self.lbl_destination.set_markup('{}'.format(time.strftime('%H:%M:%S', time.localtime(time.time()))))
vbox.add(self.lbl_destination)
self.lbl_destination = Gtk.Label()
self.lbl_destination.set_markup('Countdown finished')
vbox.add(self.lbl_destination)
vbox.add(Gtk.Label(label=''))
hbox = Gtk.HBox()
self.btn_dismiss = Gtk.Button(label="Dismiss")
self.btn_dismiss.connect("clicked", self.dismiss_on_clicked)
hbox.set_center_widget(self.btn_dismiss)
vbox.add(hbox)
self.add(vbox)
self.show_all()
def dismiss_on_clicked(self, widget):
if self.process:
self.process.terminate()
self.process = None
else:
self.destroy()
def play_noise(self):
if os.path.exists(self.noise):
song = AudioSegment.from_file(self.noise)
play(song)
def make_noise(self):
if self.noise and os.path.exists(self.noise):
self.process = multiprocessing.Process(target=self.play_noise)
self.process.start()
class CountdownWindow(Gtk.Window):
def __init__(self, noise=None):
super().__init__(title="Countdown Timer")
self.noise = noise
self.started = False
self.countdown = 0
self.range_max = 0
self.build()
self.update()
def build(self):
self.connect("delete-event", Gtk.main_quit)
self.set_resizable(False)
vbox = Gtk.VBox()
vbox.set_margin_bottom(10)
vbox.set_margin_start(5)
vbox.set_margin_end(5)
self.lbl_destination = Gtk.Label()
self.lbl_destination.set_markup('00:00:00')
vbox.add(self.lbl_destination)
vbox.add(Gtk.Label(label=''))
self.lbl_countdown = Gtk.Label()
self.lbl_countdown.set_markup('00:00:00')
vbox.add(self.lbl_countdown)
self.scale = Gtk.HScale()
self.scale.set_draw_value(False)
self.scale.connect("value-changed", self.scale_on_change)
self.scale.connect("key-press-event", self.scale_on_keypress)
vbox.add(self.scale)
hbox = Gtk.HBox()
self.btn_min = Gtk.RadioButton(label="Min")
self.btn_min.set_group()
self.btn_min.connect("toggled", lambda w: self.period_on_toggle(w, 'min'))
hbox.add(self.btn_min)
self.btn_hour = Gtk.RadioButton(label="Hour")
self.btn_hour.join_group(self.btn_min)
self.btn_hour.set_active(True)
self.btn_hour.connect("toggled", lambda w: self.period_on_toggle(w, 'hour'))
hbox.add(self.btn_hour)
self.btn_day = Gtk.RadioButton(label="Day")
self.btn_day.join_group(self.btn_min)
self.btn_day.connect("toggled", lambda w: self.period_on_toggle(w, 'day'))
hbox.add(self.btn_day)
self.set_period('hour')
vbox.add(hbox)
self.btn_audio = Gtk.CheckButton(label="Play audio file")
vbox.add(self.btn_audio)
self.controls = Gtk.HBox()
self.controls.set_spacing(100)
self.btn_start = Gtk.Button(label="Start")
self.btn_start.connect("clicked", self.start_on_clicked)
self.btn_cancel = Gtk.Button(label="Cancel")
self.btn_cancel.connect("clicked", self.cancel_on_clicked)
vbox.add(self.controls)
self.toggle_controls(True)
self.add(vbox)
self.show_all()
self.btn_cancel.hide()
def get_scale_value(self):
try:
return self.step * int(self.scale.get_value() // self.step)
except:
return 0.0
def notify(self):
NotificationWindow(self.btn_audio.get_active(), self.noise)
def update(self):
self.update_destination()
if self.started:
value = math.floor(self.countdown - time.time())
if value <= 0:
self.notify()
value = 0
self.started = False
self.toggle_controls(True)
GLib.timeout_add_seconds(5, lambda:self.update_countdown(math.floor(self.get_scale_value())))
self.update_countdown(value)
GLib.timeout_add_seconds(1, self.update)
def update_destination(self):
if not self.started:
dest = time.time() + self.get_scale_value()
self.lbl_destination.set_markup('{}'.format(
time.strftime('%H:%M:%S', time.localtime(dest))))
def update_countdown(self, value):
hours = math.floor(value / (60*60)); value -= hours * (60*60)
mins = math.floor(value / 60); value -= mins * 60
secs = value
self.lbl_countdown.set_markup('{:02d}:{:02d}:{:02d}'.format(hours, mins, secs))
def period_on_toggle(self, widget, period):
if widget.get_active():
self.set_period(period)
def set_period(self, period):
old_range_max = self.range_max
old_value = self.get_scale_value()
if period == 'min':
self.range_max = 10*60
self.step = 5
elif period == 'hour':
self.range_max = 2*60*60
self.step = 60
elif period == 'day':
self.range_max = 24*60*60
self.step = 5*60
else:
raise Exception("Unsupported period {}".format(perood))
self.scale.set_range(0, self.range_max)
if old_range_max:
new_value = (old_value * self.range_max) / old_range_max
self.scale.set_value(new_value)
self.update_destination()
def toggle_controls(self, show_start):
widget = self.controls.get_center_widget()
if widget is not None:
widget.hide()
self.controls.remove(widget)
widget = self.btn_start if show_start else self.btn_cancel
widget.show()
self.controls.set_center_widget(widget)
if show_start:
self.scale.set_sensitive(True)
self.btn_min.set_sensitive(True)
self.btn_hour.set_sensitive(True)
self.btn_day.set_sensitive(True)
else:
self.scale.set_sensitive(False)
self.btn_min.set_sensitive(False)
self.btn_hour.set_sensitive(False)
self.btn_day.set_sensitive(False)
def start_on_clicked(self, widget):
self.toggle_controls(False)
if not self.started:
self.started = True
self.countdown = time.time() + self.get_scale_value()
def cancel_on_clicked(self, widget):
self.toggle_controls(True)
if self.started:
self.started = False
def scale_on_change(self, widget):
self.update_countdown(math.floor(self.get_scale_value()))
self.update_destination()
def scale_on_keypress(self, widget, event):
key = Gdk.keyval_name(event.keyval)
if key == 'Left':
value = self.scale.get_value() - (5 if 'control-mask' in event.get_state().value_nicks else 1) * self.step
if value < 0:
value = 0.0
self.scale.set_value(value)
elif key == 'Right':
value = self.scale.get_value() + (5 if 'control-mask' in event.get_state().value_nicks else 1) * self.step
if value > self.range_max:
value = self.range_max
self.scale.set_value(value)
def build_window(args):
if os.path.exists(args.configfile):
config = ConfigParser()
config.read(args.configfile, encoding='UTF-8')
else:
config = False
noise = None
if config:
try:
noise = os.path.expanduser(config.get('countdown', 'alarm'))
except (NoOptionError, NoSectionError):
pass
if args.alarmfile:
noise = args.alarmfile
window = CountdownWindow(noise=noise)
return window
def main():
parser = ArgumentParser(description='Simple Countdown Timer')
parser.add_argument('-c', '--config',
dest='configfile',
metavar='file',
default=os.path.expanduser('~/.countdown.conf'),
action='store',
help='config file')
parser.add_argument('-a', '--alarm',
dest='alarmfile',
metavar='file',
action='store',
help='alarm sound file')
args = parser.parse_args()
window = build_window(args)
Gtk.main()
if __name__ == '__main__':
main()