#!/usr/bin/env python
# -*- python -*-

__version__ = "cplay 1.45"

"""
cplay - A curses front-end for various audio players
Copyright (C) 1998-2001 Ulf Betlehem <flu@iki.fi>

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 2
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, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""

# ------------------------------------------
from types import *

import os
import sys
import time
import getopt
import signal
import string
import select
import re

try: from ncurses import curses
except ImportError: import curses

try: import tty
except ImportError: tty = None

try: import locale; locale.setlocale(locale.LC_ALL, "")
except: pass

# ------------------------------------------
_locale_domain = "cplay"
_locale_dir = "/usr/local/share/locale"

try:
    import gettext # python 2.0
    gettext.install(_locale_domain, _locale_dir)
except ImportError:
    try:
        import fintl
        fintl.bindtextdomain(_locale_domain, _locale_dir)
        fintl.textdomain(_locale_domain)
        _ = fintl.gettext
    except ImportError:
        def _(s): return s
except:
    def _(s): return s

# ------------------------------------------
XTERM = re.search("rxvt|xterm", os.environ["TERM"]) and 1 or 0
RETRY = 2.0

SOUND_MIXER_WRITE_VOLUME = 0xc0044d00
SOUND_MIXER_READ_VOLUME = 0x80044d00
SOUND_MIXER_WRITE_PCM = 0xc0044d04
SOUND_MIXER_READ_PCM = 0x80044d04

for DSP in ["/dev/sound/dsp", "/dev/dsp"]:
    if os.path.exists(DSP): break
for MIXER in ["/dev/sound/mixer", "/dev/mixer"]:
    if os.path.exists(MIXER): break

# ------------------------------------------
def which(program):
    for path in string.split(os.environ["PATH"], ":"):
        if os.path.exists(os.path.join(path, program)):
            return os.path.join(path, program)

# ------------------------------------------
class Stack:
    def __init__(self):
        self.items = ()

    def push(self, item):
        self.items = (item,) + self.items

    def pop(self):
        self.items, item = self.items[1:], self.items[0]
        return item

# ------------------------------------------
class KeymapStack(Stack):
    def process(self, code):
        for keymap in self.items:
            if keymap and keymap.process(code):
                break

# ------------------------------------------
class Keymap:
    def __init__(self):
        self.methods = [None] * curses.KEY_MAX

    def bind(self, key, method, args=None):
        if type(key) in (TupleType, ListType):
            for i in key: self.bind(i, method, args)
            return
        if type(key) is StringType:
            key = ord(key)
        self.methods[key] = (method, args)

    def process(self, key):
        if self.methods[key] is None: return 0
        method, args = self.methods[key]
        if args is None:
            apply(method, (key,))
        else:
            apply(method, args)
        return 1

# ------------------------------------------
class Window:

    t = ['?'] * 256
    for i in range(0x20, 0x7f): t[i] = chr(i)
    for c in string.letters: t[ord(c)] = c
    translationTable = string.join(t, "")

    def __init__(self, parent):
        self.parent = parent
        self.children = []
        self.name = None
        self.keymap = None
        self.visible = 1
        self.resize()
        if parent: parent.children.append(self)

    def insstr(self, s):
        if not s: return
        self.w.addstr(s[:-1])
        self.w.hline(ord(s[-1]), 1)  # insch() work-around

    def __getattr__(self, name):
        return getattr(self.w, name)

    def getmaxyx(self):
        y, x = self.w.getmaxyx()
        try: curses.version # tested with '1.2' and '1.6'
        except AttributeError:
            # pyncurses - emulate traditional (silly) behavior
            y, x = y+1, x+1
        return y, x

    def touchwin(self):
        try: self.w.touchwin()
        except AttributeError: self.touchln(0, self.getmaxyx()[0])

    def attron(self, attr):
        try: self.w.attron(attr)
        except AttributeError: self.w.attr_on(attr)

    def attroff(self, attr):
        try: self.w.attroff(attr)
        except AttributeError: self.w.attr_off(attr)

    def newwin(self):
        return curses.newwin(0, 0, 0, 0)

    def resize(self):
        ## todo - delwin?
        self.w = self.newwin()
        self.ypos, self.xpos = self.getbegyx()
        self.rows, self.cols = self.getmaxyx()
        self.keypad(1)
        self.leaveok(0)
        self.scrollok(0)
        for child in self.children:
            child.resize()

    def update(self):
        self.clear()
        self.refresh()
        for child in self.children:
            child.update()

# ------------------------------------------
class HelpWindow(Window):
    text = _("""\

  Global                               Filelist
  ------                               --------
  Up, C-p, k, Down, C-n, j,            Space, a  : add file/dir to playlist
  PgUp, K, PgDown, J                   Enter     : chdir or play
  Home, g, End, G                      o         : open path
              : movement               Backspace : parent dir
  Tab         : filelist/playlist      
  n, p        : next/prev track        Playlist
  z, x        : toggle pause/stop      --------
  Left, Right,                         Enter : play track
  C-b, C-f    : seek                   Space : toggle mark
  t           : toggle counter mode    a, A  : mark all/regex
  C-s, C-r, / : isearch                c, C  : clear all/regex
  C-g, Esc    : cancel                 d, D  : delete marked/current tracks
  1..9, +, -  : volume control         m, M  : move marked tracks after/before
  v, V        : PCM or MASTER volume   r, R  : toggle repeat/Random mode
  C-l         : refresh screen         s, S  : shuffle/Sort playlist
  h, q        : help, quit             w     : write playlist (.m3u file)""")

    def __init__(self, parent):
        Window.__init__(self, parent)
        self.name = _("Help")
        self.keymap = Keymap()
        self.keymap.bind('q', self.parent.help, ())

    def newwin(self):
        return curses.newwin(self.parent.rows-2, self.parent.cols,
                             self.parent.ypos+2, self.parent.xpos)

    def update(self):
        self.move(0, 0)
        self.insstr(self.text)
        self.touchwin()
        self.refresh()

# ------------------------------------------
class ProgressWindow(Window):
    def __init__(self, parent):
        Window.__init__(self, parent)
        self.value = 0

    def newwin(self):
        return curses.newwin(1, self.parent.cols, self.parent.rows-2, 0)

    def update(self):
        self.move(0, 0)
        self.hline(ord('-'), self.cols)
        if self.value > 0:
            self.move(0, 0)
            x = int(self.value * self.cols)  # 0 to cols-1
            x and self.hline(ord('='), x)
            self.move(0, x)
            self.insstr('|')
        self.touchwin()
        self.refresh()

    def progress(self, value):
        self.value = min(value, 0.99)
        self.update()

# ------------------------------------------
class StatusWindow(Window):
    def __init__(self, parent):
        Window.__init__(self, parent)
        self.default_message = ''
        self.current_message = ''
        self.timeout_tag = None

    def newwin(self):
        return curses.newwin(1, self.parent.cols-12, self.parent.rows-1, 0)

    def update(self):
        msg = string.translate(self.current_message, Window.translationTable)
        if len(msg) > self.cols: msg = "%s>" % msg[:self.cols-1]
        self.move(0, 0)
        self.clrtoeol()
        self.insstr(msg)
        self.touchwin()
        self.refresh()

    def status(self, message, duration = 0):
        self.current_message = message
        if duration > 0:
            if self.timeout_tag: app.timeout.remove(self.timeout_tag)
            self.timeout_tag = app.timeout.add(duration, self.timeout)
        self.update()

    def timeout(self):
        self.timeout_tag = None
        self.restore_default_status()

    def set_default_status(self, message):
        self.default_message = message
        self.status(message)
        XTERM and sys.stderr.write("\033]0;%s\a" % (message or "cplay"))

    def restore_default_status(self):
        self.status(self.default_message)

# ------------------------------------------
class CounterWindow(Window):
    def __init__(self, parent):
        Window.__init__(self, parent)
        self.values = [0, 0]
        self.mode = 1

    def newwin(self):
        return curses.newwin(1, 11, self.parent.rows-1, self.parent.cols-11)

    def update(self):
        h, s = divmod(self.values[self.mode], 3600)
        m, s = divmod(s, 60)
        self.move(0, 0)
        self.attron(curses.A_BOLD)
        self.insstr("%02dh %02dm %02ds" % (h, m, s))
        self.attroff(curses.A_BOLD)
        self.touchwin()
        self.refresh()

    def counter(self, values):
        self.values = values
        self.update()

    def toggle_mode(self):
        self.mode = not self.mode
        self.update()

# ------------------------------------------
class RootWindow(Window):
    def __init__(self, parent):
        Window.__init__(self, parent)
        keymap = Keymap()
        keymap.bind(12, self.update, ()) # C-l
        keymap.bind([curses.KEY_LEFT, 2], app.seek, (-1,)) # Left, C-b
        keymap.bind([curses.KEY_RIGHT, 6], app.seek, (1,)) # Right, C-f
        keymap.bind(range(48,58), app.key_volume) # 1234567890
        keymap.bind('+', app.inc_volume, ())
        keymap.bind('-', app.dec_volume, ())
        keymap.bind('n', app.next_song, ())
        keymap.bind('p', app.prev_song, ())
        keymap.bind('z', app.toggle_pause, ())
        keymap.bind('x', app.toggle_stop, ())
        keymap.bind('t', app.toggle_counter_mode, ())
        keymap.bind('q', app.quit, ())
        keymap.bind('v', app.use_pcm_volume, ())
        keymap.bind('V', app.use_master_volume, ())
        app.keymapstack.push(keymap)

        self.win_progress = ProgressWindow(self)
        self.win_status = StatusWindow(self)
        self.win_counter = CounterWindow(self)
        self.win_tab = TabWindow(self)

# ------------------------------------------
class TabWindow(Window):
    def __init__(self, parent):
        Window.__init__(self, parent)
        self.active_child = 0

        self.win_filelist = self.add(FilelistWindow)
        self.win_playlist = self.add(PlaylistWindow)
        self.win_help     = self.add(HelpWindow)

        self.keymap = Keymap()
        self.keymap.bind('\t', self.change_window, ()) # Tab
        self.keymap.bind('h', self.help, ())
        app.keymapstack.push(self.keymap)
        app.keymapstack.push(self.children[self.active_child].keymap)

    def newwin(self):
        return curses.newwin(self.parent.rows-2, self.parent.cols, 0, 0)

    def update(self):
        self.update_title()
        self.move(1, 0)
        self.hline(ord('-'), self.cols)
        self.move(2, 0)
        self.clrtobot()
        self.refresh()
        child = self.children[self.active_child]
        child.visible = 1
        child.update()

    def update_title(self, refresh = 1):
        child = self.children[self.active_child]
        try: child.update_name()
        except AttributeError: pass
        self.move(0, 0)
        self.clrtoeol()
        self.attron(curses.A_BOLD)
        self.insstr(str(child.name))
        self.attroff(curses.A_BOLD)
        if refresh: self.refresh()

    def add(self, Class):
        win = Class(self)
        win.visible = 0
        return win

    def change_window(self, window = None):
        app.keymapstack.pop()
        self.children[self.active_child].visible = 0
        if window:
            self.active_child = self.children.index(window)
        else:
            # toggle windows 0 and 1
            self.active_child = not self.active_child
        app.keymapstack.push(self.children[self.active_child].keymap)
        self.update()

    def help(self):
        if self.children[self.active_child] == self.win_help:
            self.change_window(self.win_last)
        else:
            self.win_last = self.children[self.active_child]
            self.change_window(self.win_help)
            app.status(__version__, 2)

# ------------------------------------------
class ListWindow(Window):
    def __init__(self, parent):
        Window.__init__(self, parent)
        self.buffer = []
        self.bufptr = self.scrptr = 0
        self.search_direction = 0

        self.input_mode = 0
        self.input_prompt = ""
        self.input_string = ""
        self.do_input_hook = None
        self.stop_input_hook = None

        self.keymap = Keymap()
        self.keymap.bind(['k', curses.KEY_UP, 16], self.cursor_move, (-1,))
        self.keymap.bind(['j', curses.KEY_DOWN, 14], self.cursor_move, (1,))
        self.keymap.bind(['K', curses.KEY_PPAGE], self.cursor_ppage, ())
        self.keymap.bind(['J', curses.KEY_NPAGE], self.cursor_npage, ())
        self.keymap.bind(['g', curses.KEY_HOME], self.cursor_home, ())
        self.keymap.bind(['G', curses.KEY_END], self.cursor_end, ())
        self.keymap.bind(['?', 18], self.start_search,
                         (_("backward-isearch"), -1))
        self.keymap.bind(['/', 19], self.start_search,
                         (_("forward-isearch"), 1))
        self.input_keymap = Keymap()
        self.input_keymap.bind(range(32, 128), self.do_input)
        self.input_keymap.bind('\t', self.do_input)
        self.input_keymap.bind(curses.KEY_BACKSPACE, self.do_input, (8,))
        self.input_keymap.bind(['\a', 27], self.stop_input, (_("cancel"),))
        self.input_keymap.bind('\n', self.stop_input, (_("ok"),))

    def newwin(self):
        return curses.newwin(self.parent.rows-2, self.parent.cols,
                             self.parent.ypos+2, self.parent.xpos)

    def update(self, force = 1):
        self.bufptr = max(0, min(self.bufptr, len(self.buffer) - 1))
        scrptr = (self.bufptr / self.rows) * self.rows
        if force or self.scrptr != scrptr:
            self.scrptr = scrptr
            self.move(0, 0)
            self.clrtobot()
            i = 0
            for entry in self.buffer[self.scrptr:]:
                self.move(i, 0)
                i = i + 1
                self.putstr(entry)
                if self.getyx()[0] == self.rows - 1: break
            if self.visible:
                self.refresh()
                self.parent.update_title()
        self.update_line(curses.A_REVERSE)

    def update_line(self, attr = None, refresh = 1):
        if not self.buffer: return
        ypos = self.bufptr - self.scrptr
        if attr: self.attron(attr)
        self.move(ypos, 0)
        self.hline(ord(' '), self.cols)
        entry = self.current()
        self.putstr(entry)
        if attr: self.attroff(attr)
        if self.visible and refresh: self.refresh()

    def start_input(self, prompt="", data=""):
        self.input_mode = 1
        app.keymapstack.push(self.input_keymap)
        self.input_prompt = prompt
        self.input_string = data

    def do_input(self, *args):
        if self.do_input_hook:
            return apply(self.do_input_hook, args)
        ch = args and args[0] or None
        if ch in [8, 127]: # backspace
            self.input_string = self.input_string[:-1]
        elif ch:
            self.input_string = "%s%c" % (self.input_string, ch)
        app.status("%s: %s" % (self.input_prompt, self.input_string))

    ## We have the result in self.input_string
    def stop_input(self, *args):
        self.input_mode = 0
        app.keymapstack.pop()
        if self.stop_input_hook:
            return apply(self.stop_input_hook, args)

    def putstr(self, entry, *pos):
        s = string.translate(str(entry), Window.translationTable)
        s = "%s%s" % (
            (len(s) > self.cols) and (s[:self.cols-1], ">") or (s, ""))
        pos and apply(self.move, pos)
        self.insstr(s)

    def current(self):
        if self.bufptr >= len(self.buffer): self.bufptr = len(self.buffer) - 1
        return self.buffer[self.bufptr]

    def cursor_move(self, ydiff):
        if self.input_mode: self.stop_input(_("cancel"))
        if not self.buffer: return
        self.update_line(refresh = 0)
        self.bufptr = (self.bufptr + ydiff) % len(self.buffer)
        self.update(force = 0)

    def cursor_ppage(self):
        if self.rows > len(self.buffer): return
        tmp = self.bufptr % self.rows
        if tmp == self.bufptr:
            self.cursor_move(-(tmp + (len(self.buffer) % self.rows) or self.rows))
        else:
            self.cursor_move(-(tmp + self.rows))

    def cursor_npage(self):
        if self.rows > len(self.buffer): return
        tmp = self.rows - self.bufptr % self.rows
        if self.bufptr + tmp > len(self.buffer):
            self.cursor_move(len(self.buffer) - self.bufptr)
        else:
            self.cursor_move(tmp)

    def cursor_home(self): self.cursor_move(-self.bufptr)

    def cursor_end(self): self.cursor_move(-self.bufptr - 1)

    def is_searching(self): return abs(self.search_direction)

    def start_search(self, type, direction):
        if not self.is_searching():
            self.start_input()
            self.do_input_hook = self.do_search
            self.stop_input_hook = self.stop_search
        if self.search_direction != direction:
            self.search_direction = direction
            self.input_prompt = type
            self.do_search()
        else:
            self.do_search(advance = direction)

    def stop_search(self, reason = ""):
        self.search_direction = 0
        app.status(reason, 1)

    def do_search(self, ch = None, advance = 0):
        direction = self.search_direction
        if ch in [8, 127]: # backspace
            direction = -direction
            self.input_string = self.input_string[:-1]
        elif ch:
            self.input_string = "%s%c" % (self.input_string, ch)
        index = self.bufptr + advance
        while 1:
            if index >= len(self.buffer) or index < 0:
                app.status(_("Not found: %s") % self.input_string)
                break
            line = "%s" % self.buffer[index]
            if string.find(string.lower(line),
                           string.lower(self.input_string)) != -1:
                app.status("%s: %s" % (self.input_prompt, self.input_string))
                self.update_line(refresh = 0)
                self.bufptr = index
                self.update(force = 0)
                break
            index = index + direction

# ------------------------------------------
class FilelistWindow(ListWindow):
    def __init__(self, parent):
        ListWindow.__init__(self, parent)
        self.oldposition = {}
        try: self.chdir(os.getcwd())
        except OSError: self.chdir(os.environ['HOME'])
        self.mtime_when = 0
        self.mtime = None
        self.keymap.bind('\n', self.command_chdir_or_play, ())
        self.keymap.bind(['.', curses.KEY_BACKSPACE],
                         self.command_chparentdir, ())
        self.keymap.bind(' ', self.command_add, ())
        self.keymap.bind('a', self.command_add_recursively, ())
        self.keymap.bind('o', self.command_goto, ())

    def update_name(self):
        pos = "%s-%s/%s" % (self.scrptr,
                            min(self.scrptr+self.rows, len(self.buffer)),
                            len(self.buffer))
        self.name = _("Filelist: ")
        width = self.cols-len(pos)-2
        diff = len(self.name) + len(self.cwd) - width
        if diff > 0:
            self.name = "%s<%s" % (self.name, self.cwd[diff+1:])
        else:
            self.name = "%s%s" % (self.name, self.cwd)
        self.name = "%-*s  %s" % (width, self.name, pos)

    def listdir_maybe(self, now=0):
        if now < self.mtime_when+2: return
        self.mtime_when = now
        try:
            mtime = os.stat(self.cwd)[8]
            self.mtime == mtime or self.listdir()
            self.mtime = mtime
        except os.error: pass

    def listdir(self):
        app.status(_("Reading directory..."))
        dirs = []
        files = []
        try:
            self.mtime = os.stat(self.cwd)[8]
            self.mtime_when = time.time()
            for entry in os.listdir(self.cwd):
                if entry[0] == ".":
                    continue
                if os.path.isdir(self.cwd + entry):
                    dirs.append("%s/" % entry)
                elif VALID_SONG(entry):
                    files.append("%s" % entry)
                elif VALID_PLAYLIST(entry):
                    files.append("%s" % entry)
        except os.error: pass
        self.buffer = [["../"], []][self.cwd == "/"]
        dirs.sort()
        files.sort()
        self.buffer = self.buffer + dirs + files
        if self.oldposition.has_key(self.cwd):
            self.bufptr = self.oldposition[self.cwd]
        else:
            self.bufptr = 0
        self.parent.update_title()
        self.update(force = 1)
        app.restore_default_status()

    def normpath(self, dir):
        dir = dir and dir + '/'
        match = 1
        while match: dir, match = re.subn("/+(\.|[^/]*/*\.\.)/+", "/", dir, 1)
        match = 1
        while match: dir, match = re.subn("//+", "/", dir, 1)
        return dir

    def chdir(self, dir):
        if hasattr(self, "cwd"): self.oldposition[self.cwd] = self.bufptr
        self.cwd = self.normpath(dir)

    def command_chdir_or_play(self):
        if os.path.isdir(self.cwd + self.current()):
            self.chdir(self.cwd + self.current())
            self.listdir()
        elif VALID_SONG(self.current()):
            app.play(self.cwd + self.current())

    def command_chparentdir(self):
        self.chdir(self.cwd + "..")
        self.listdir()

    def command_goto(self):
        self.start_input(_("goto"))
        self.do_input_hook = None
        self.stop_input_hook = self.stop_goto
        self.do_input()

    def stop_goto(self, reason):
        if reason == _("cancel") or not self.input_string:
            app.status(_("cancel"), 1)
            return
        dir = os.path.expanduser(self.input_string)
        if dir[0] != '/': dir = "%s%s" % (self.cwd, dir)
        if not os.path.isdir(dir):
            app.status(_("Not a directory!"), 1)
            return
        self.chdir(dir)
        self.listdir()

    def command_add(self):
        if (os.path.isfile(self.cwd + self.current()) and
            VALID_SONG(self.current())):
            app.win_playlist.append(self.cwd + self.current())
            self.cursor_move(+1)

    def command_add_recursively(self):
        app.win_playlist.append(self.cwd + self.current())
        self.cursor_move(+1)

# ------------------------------------------
class PlaylistEntry:
    def __init__(self, pathname):
        self.pathname = pathname
        self.filename = os.path.basename(pathname)
        self.marked = 0
        self.active = 0
        self.attrib = curses.A_BOLD

    def set_marked(self, value):
        self.marked = value

    def toggle_marked(self):
        self.marked = not self.marked

    def is_marked(self):
        return self.marked == 1

    def set_active(self, value):
        self.active = value

    def is_active(self):
        return self.active == 1

    def __str__(self):
        return "%s %s" % (self.is_marked() and "#" or " ", self.filename)

# ------------------------------------------
class PlaylistWindow(ListWindow):
    def __init__(self, parent):
        ListWindow.__init__(self, parent)
        self.name = _("Playlist")
        self.repeat = 0
        self.random = 0
        self.random_buffer = []
        self.keymap.bind('\n', self.command_play, ())
        self.keymap.bind(' ', self.command_mark, ())
        self.keymap.bind('d', self.command_delete, ())
        self.keymap.bind('D', self.command_delete_current, ())
        self.keymap.bind('m', self.command_move, (1,))
        self.keymap.bind('M', self.command_move, (0,))
        self.keymap.bind('s', self.command_shuffle, ())
        self.keymap.bind('S', self.command_sort, ())
        self.keymap.bind('r', self.command_toggle_repeat, ())
        self.keymap.bind('R', self.command_toggle_random, ())
        self.keymap.bind('w', self.command_save_playlist, ())
        self.keymap.bind('a', self.command_mark_all, ())
        self.keymap.bind('c', self.command_clear_all, ())
        self.keymap.bind('A', self.command_mark_regexp, ())
        self.keymap.bind('C', self.command_clear_regexp, ())

    def update_name(self):
        pos = "%s-%s/%s" % (self.scrptr,
                            min(self.scrptr+self.rows, len(self.buffer)),
                            len(self.buffer))
        self.name = _("Playlist %s %s") % (
            self.repeat and _("[repeat]") or " " * len(_("[repeat]")),
            self.random and _("[random]") or " " * len(_("[random]")))
        self.name = "%-*s  %s" % (self.cols-len(pos)-2, self.name, pos)

    def add_dir(self, dir):
        if os.path.isdir(dir):
            try:
                entries = []
                for entry in os.listdir(dir):
                    entries.append(os.path.join(dir, entry))
            except os.error: return
            songs = filter(VALID_SONG, entries)
            dirs = filter(os.path.isdir, entries)
            songs.sort()
            for song in songs:
                entry = PlaylistEntry(song)
                self.buffer.append(entry)
            dirs.sort()
            for subdir in dirs:
                self.add_dir(subdir)

    def append(self, pathname):
        if os.path.isdir(pathname):
            app.status(_("Adding dir: %s") % pathname)
            self.add_dir(pathname)
            app.restore_default_status()
        elif VALID_PLAYLIST(pathname):
            try:
                file = open(pathname)
                for filename in map(string.strip, file.readlines()):
                    if re.match("^(#.*)?$", filename): continue
                    entry = None
                    if not re.match("^(/|http://)", filename):
                        entry = PlaylistEntry(os.path.join(os.path.dirname(pathname), filename))
                    else:
                        entry = PlaylistEntry(filename)
                    app.status(_("Added: %s") % entry.filename, 1)
                    self.buffer.append(entry)
                file.close()
            except IOError:
                app.status(_("IOError"), 1)
        else:
            entry = PlaylistEntry(pathname)
            app.status(_("Added: %s") % entry.filename, 1)
            self.buffer.append(entry)
        self.update(force = 1)

    def clear(self):
        self.buffer = []

    def putstr(self, entry, *pos):
        if entry.is_active(): self.attron(curses.A_BOLD)
        apply(ListWindow.putstr, (self, entry) + pos)
        if entry.is_active(): self.attroff(curses.A_BOLD)

    def next_song(self):
        entry = self.change_active_entry(1)
        return entry and entry.pathname or None

    def prev_song(self):
        entry = self.change_active_entry(-1)
        return entry and entry.pathname or None

    def get_remaining_entries(self):
        l = []
        for i in self.buffer:
            if not i in self.random_buffer:
                l.append(i)
        return l

    def change_active_entry(self, direction):
        if not self.buffer: return
        entry = self.get_active_entry()
        if self.random:
            entries = self.get_remaining_entries()
            if entries == [] and not self.repeat:
                return None
            if entries == [] and self.repeat:
                self.random_buffer = []
                entries = self.buffer
            if entry: entry.set_active(0)
            import whrandom
            entry = whrandom.choice(entries)
        elif entry:
            index = self.buffer.index(entry)+direction
            if not (index in range(len(self.buffer)) or self.repeat):
                return None
            entry.set_active(0)
            entry = self.buffer[index % len(self.buffer)]
        else:
            entry = self.buffer[0]
        entry.set_active(1)
        ## todo - hmm
        if entry in self.random_buffer: self.random_buffer = [entry]
        else: self.random_buffer.append(entry)
        self.update(force = 1)
        return entry

    def get_active_entry(self):
        for entry in self.buffer:
            if entry.is_active(): return entry

    def command_play(self):
        if not self.buffer: return
        entry = self.get_active_entry()
        if entry: entry.set_active(0)
        entry = self.current()
        entry.set_active(1)
        ## todo - hmm
        if entry in self.random_buffer: self.random_buffer = [entry]
        else: self.random_buffer.append(entry)
        self.update(force = 1)
        app.play(entry.pathname)

    def command_mark(self):
        if not self.buffer: return
        self.buffer[self.bufptr].toggle_marked()
        self.cursor_move(1)

    def command_mark_all(self):
        for entry in self.buffer:
            entry.set_marked(1)
        app.status(_("Almost there..."), 1)
        self.update(force = 1)

    def command_clear_all(self):
        for entry in self.buffer:
            entry.set_marked(0)
        app.status(_("Cleared all marks..."), 1)
        self.update(force = 1)

    def command_delete(self):
        if not self.buffer: return
        current_entry = self.current()
        for entry in self.buffer[:]:
            if entry.is_marked():
                self.buffer.remove(entry)
        try: self.bufptr = self.buffer.index(current_entry)
        except ValueError: self.bufptr = 0
        self.update(force = 1)

    def command_delete_current(self):
        if not self.buffer: return
        self.buffer.remove(self.current())
        self.update(force = 1)

    def command_move(self, after):
        if not self.buffer: return
        current_entry = self.current()
        if current_entry.is_marked(): return    # sanity check
        l = []
        for entry in self.buffer[:]:
            if entry.is_marked():
                l.append(entry)
                self.buffer.remove(entry)
        self.bufptr = self.buffer.index(current_entry)+after
        self.buffer[self.bufptr:self.bufptr] = l
        self.update(force = 1)

    def command_shuffle(self):
        import whrandom
        l = []
        n = len(self.buffer)
        while n > 0:
            n = n-1
            r = whrandom.randint(0, n)
            l.append(self.buffer[r])
            del self.buffer[r]
        self.buffer = l
        self.bufptr = 0
        self.update(force = 1)
        app.status(_("Shuffled playlist... Oops?"), 1)

    def command_sort(self):
        self.buffer.sort(lambda x, y: x.filename > y.filename or -1)
        self.bufptr = 0
        self.update(force = 1)
        app.status(_("Sorted playlist..."), 1)

    def command_toggle_repeat(self):
        self.repeat = not self.repeat
        app.status(_("Repeat %s") % (self.repeat and _("on") or _("off")), 1)
        self.parent.update_title()

    def command_toggle_random(self):
        self.random = not self.random
        app.status(_("Random %s") % (self.random and _("on") or _("off")), 1)
        self.parent.update_title()

    def command_mark_regexp(self):
        self.mark_value = 1
        self.start_input(_("Mark regexp"))
        self.do_input_hook = None
        self.stop_input_hook = self.stop_mark_regexp
        self.do_input()

    def command_clear_regexp(self):
        self.mark_value = 0
        self.start_input(_("Clear regexp"))
        self.do_input_hook = None
        self.stop_input_hook = self.stop_mark_regexp
        self.do_input()

    def stop_mark_regexp(self, reason):
        if reason == _("cancel") or not self.input_string:
            app.status(_("cancel"), 1)
            return
        try:
            r = re.compile(self.input_string)
            for entry in self.buffer:
                if r.search(entry.filename):
                    entry.set_marked(self.mark_value)
            self.update(force = 1)
            app.status(_("ok"), 1)
        except re.error, e:
            app.status(str(e), 2)

    def command_save_playlist(self):
        self.start_input(_("Save playlist"), app.win_filelist.cwd)
        self.do_input_hook = None
        self.stop_input_hook = self.stop_save_playlist
        self.do_input()

    def stop_save_playlist(self, reason):
        if reason == _("cancel") or not self.input_string:
            app.status(_("cancel"), 1)
            return
        filename = self.input_string
        if filename[0] != '/':
            filename = "%s%s" % (app.win_filelist.cwd, filename)
        if not VALID_PLAYLIST(filename):
            filename = "%s%s" % (filename, ".m3u")
        try:
            file = open(filename, "w")
            for entry in self.buffer:
                file.write("%s\n" % entry.pathname)
            file.close()
            app.status(_("ok"), 1)
        except IOError:
            app.status(_("Cannot write playlist!"), 1)

# ------------------------------------------
class Player:
    def __init__(self, commandline, files, fps=1):
        self.commandline = commandline
        self.re_files = re.compile(files, re.I)
        self.fps = fps
        self.stdout_r, self.stdout_w = os.pipe()
        self.stderr_r, self.stderr_w = os.pipe()
        self.pathname = None
        self.filename = None
        self.stopped = 0
        self.paused = 0
        self.time_setup = None
        self.offset = 0

    def is_stopped(self):
        return self.stopped

    def is_paused(self):
        return self.paused

    def setup(self, pathname, offset):
        self.argv = string.split(self.commandline)
        self.argv[0] = which(self.argv[0])
        for i in range(len(self.argv)):
            if self.argv[i] == '%s': self.argv[i] = str(pathname)
            if self.argv[i] == '%d': self.argv[i] = str(offset*self.fps)
        self.pathname = pathname
        if offset == 0:
            app.progress(0)
            self.offset = 0
            self.length = 0
            self.values = 0
        self.time_setup = time.time()
        return self.argv[0]

    def play(self):
        self.filename = os.path.basename(self.pathname)
        app.set_default_status(_("Playing: %s") % self.filename)
        self.pid = os.fork()
        if self.pid == 0:
            signal.signal(signal.SIGTERM, signal.SIG_DFL)
            os.dup2(self.stdout_w, sys.stdout.fileno())
            os.dup2(self.stderr_w, sys.stderr.fileno())
            os.setpgrp()
            try: os.execv(self.argv[0], self.argv)
            except: time.sleep(1); os._exit(1)
        self.stopped = 0
        self.paused = 0
        self.step = 0

    def pause(self, quiet=0):
        self.paused = 1
        try: os.kill(-self.pid, signal.SIGSTOP)
        except os.error: pass
        quiet or app.set_default_status(_("Paused: %s") % self.filename)

    def unpause(self, quiet=0):
        self.paused = 0
        try: os.kill(-self.pid, signal.SIGCONT)
        except os.error: pass
        quiet or app.set_default_status(_("Playing: %s") % self.filename)

    def stop(self, quiet=0):
        self.stopped = 1
        self.is_paused() and self.unpause(quiet)
        try:
            while 1:
                try: os.kill(-self.pid, signal.SIGTERM)
                except os.error: pass
                os.waitpid(self.pid, os.WNOHANG)
        except Exception: pass
        quiet or app.set_default_status(_("Stopped: %s") % self.filename)

    def parse_fd(self, fd):
        os.read(fd, 256)

    def parse_stderr(self):
        self.parse_fd(self.stderr_r)

    def parse_stdout(self):
        self.parse_fd(self.stdout_r)

    def poll(self):
        try: os.waitpid(self.pid, os.WNOHANG)
        except:
            # broken player? try again
            if self.time_setup and (time.time() - self.time_setup) < RETRY:
                self.play()
                return 0
            app.set_default_status("")
            app.counter([0,0])
            app.progress(0)
            return 1

    def seek(self, direction):
        d = direction * self.length * 0.002
        self.step = self.step * (self.step * d > 0) + d
        offset = min(self.length, max(0, self.offset+self.step))
        self.set_position(offset)

    def set_position(self, offset=None, length=None, values=None):
        self.offset = offset or self.offset
        self.length = length or self.length
        self.values = values or self.values
        self.show_position()

    def show_position(self):
        app.counter(self.values)
        app.progress(self.length and (float(self.offset) / self.length))

# ------------------------------------------
class FrameOffsetPlayer(Player):
    re_progress = re.compile("Time.*\s(\d+):(\d+).*\[(\d+):(\d+)")

    def parse_fd(self, fd):
        match = self.re_progress.search(os.read(fd, 256))
        if match and not self.step:
            m1, s1, m2, s2 = map(string.atoi, match.groups())
            head, tail = m1*60+s1, m2*60+s2
            self.set_position(head, head+tail, [head, tail])

# ------------------------------------------
class NoOffsetPlayer(Player):
    re_progress = re.compile(
        "\s*(\d+):(\d+):(\d+)")

    def parse_fd(self, fd):
        match = self.re_progress.search(os.read(fd, 256))
        if match and not self.step:
            h, m, s = map(string.atoi, match.groups())
            head = tail = h*3600+m*60+s
            self.set_position(0, 0, [head, tail])

    def seek(self, *dummy):
        return 1

# ------------------------------------------
class Timeout:
    def __init__(self):
        self.next = 0
        self.dict = {}

    def add(self, timeout, func, args=()):
        tag = self.next = self.next + 1
        self.dict[tag] = (func, args, time.time() + timeout)
        return tag

    def remove(self, tag):
        del self.dict[tag]

    def check(self, now):
        for tag, (func, args, timeout) in self.dict.items():
            if now >= timeout:
                self.remove(tag)
                apply(func, args)
        return len(self.dict) and 0.2 or None

# ------------------------------------------
class Application:
    def __init__(self):
        self.keymapstack = KeymapStack()
	self.mixer_read = SOUND_MIXER_READ_VOLUME
	self.mixer_write = SOUND_MIXER_WRITE_VOLUME

    def setup(self):
        if tty:
            self.tcattr = tty.tcgetattr(sys.stdin.fileno())
            tcattr = tty.tcgetattr(sys.stdin.fileno())
            tcattr[0] = tcattr[0] & ~(tty.IXON)
            tty.tcsetattr(sys.stdin.fileno(), tty.TCSANOW, tcattr)
        self.w = curses.initscr()
        curses.cbreak()
        curses.noecho()
        try: curses.meta(1)
        except: pass
        try: curses.curs_set(0)
        except: pass
        signal.signal(signal.SIGCHLD, signal.SIG_IGN)
        signal.signal(signal.SIGHUP, self.handler_quit)
        signal.signal(signal.SIGINT, self.handler_quit)
        signal.signal(signal.SIGTERM, self.handler_quit)
        signal.signal(signal.SIGWINCH, self.handler_resize)
        self.win_root = RootWindow(None)
        self.win_root.update()
        self.win_tab = self.win_root.win_tab
        self.win_filelist = self.win_root.win_tab.win_filelist
        self.win_playlist = self.win_root.win_tab.win_playlist
        self.status = self.win_root.win_status.status
        self.set_default_status = self.win_root.win_status.set_default_status
        self.restore_default_status = self.win_root.win_status.restore_default_status
        self.counter = self.win_root.win_counter.counter
        self.progress = self.win_root.win_progress.progress
        self.player = PLAYERS[0]
        self.timeout = Timeout()
        self.win_filelist.listdir_maybe(time.time())
        self.set_default_status("")
        self.seek_tag = None
        self.start_tag = None

    def cleanup(self):
        curses.endwin()
        XTERM and sys.stderr.write("\033]0;%s\a" % "xterm")
        tty and tty.tcsetattr(sys.stdin.fileno(), tty.TCSADRAIN, self.tcattr)

    def run(self):
        while 1:
            now = time.time()
            timeout = self.timeout.check(now)
            self.win_filelist.listdir_maybe(now)
            if not self.player.is_stopped():
                timeout = 0.5
                if self.player.poll():
                    pathname = self.win_playlist.next_song()
                    if pathname: self.play(pathname)
                    else: self.player.stopped = 1 # end of playlist hack
            R = [sys.stdin, self.player.stdout_r, self.player.stderr_r]
            try: r, w, e = select.select(R, [], [], timeout)
            except select.error: continue
            ## user input
            if sys.stdin in r:
                c = self.win_root.getch()
                self.keymapstack.process(c)
            ## player input
            if self.player.stderr_r in r:
                self.player.parse_stderr()
            ## player input
            if self.player.stdout_r in r:
                self.player.parse_stdout()

    def play(self, pathname, offset = 0):
        self.seek_tag = None
        self.start_tag = None
        if pathname is None or offset is None: return
        if not self.player.is_stopped(): self.player.stop(quiet=1)
        for self.player in PLAYERS:
            if self.player.re_files.match(pathname):
                if self.player.setup(pathname, offset): break
        else:
            app.status(_("Player not found!"), 1)
            return
        self.player.play()

    def next_song(self):
        if self.start_tag: self.timeout.remove(self.start_tag)
        args = (self.win_playlist.next_song(), 0)
        self.start_tag = self.timeout.add(0.5, self.play, args)

    def prev_song(self):
        if self.start_tag: self.timeout.remove(self.start_tag)
        args = (self.win_playlist.prev_song(), 0)
        self.start_tag = self.timeout.add(0.5, self.play, args)

    def toggle_pause(self):
        if not self.player.is_stopped():
            if self.player.is_paused(): self.player.unpause()
            else: self.player.pause()

    def toggle_stop(self):
        if not self.player.is_stopped(): self.player.stop()
        else: self.play(self.player.pathname, self.player.offset)

    def toggle_counter_mode(self):
        self.win_root.win_counter.toggle_mode()

    ## todo - support seeking when stopped?
    def seek(self, direction):
        if self.player.is_stopped(): return
        if self.seek_tag: self.timeout.remove(self.seek_tag)
        if self.player.seek(direction): return
        args = (self.player.pathname, self.player.offset)
        self.seek_tag = self.timeout.add(0.5, self.play, args)

    def inc_volume(self):
        self.get_volume() and self.set_volume(min(100, self.volume+3))

    def dec_volume(self):
        self.get_volume() and self.set_volume(max(0, self.volume-3))

    def key_volume(self, ch):
        self.set_volume((ch & 0x0f) * 10)

    def get_volume(self):
        try:
            import fcntl
            fd = os.open(MIXER, 0)
            self.volume = ord(fcntl.ioctl(fd, self.mixer_read, "."))
	    # 80044d04
            os.close(fd)
            return 1
        except:
            app.status(_("Cannot open mixer device %s") % MIXER, 1)

    def set_volume(self, v):
        try:
            import fcntl
            fd = os.open(MIXER, 1)
            fcntl.ioctl(fd, self.mixer_write, "%c%c" % (v, v))
            os.close(fd)
            app.status(_("Volume %d%%") % v, 1)
        except:
            app.status(_("Cannot open mixer device %s") % MIXER, 1)

    def use_pcm_volume(self):
	self.mixer_read = SOUND_MIXER_READ_PCM
	self.mixer_write = SOUND_MIXER_WRITE_PCM
	app.status(_("Using PCM volume"), 1)

    def use_master_volume(self):
	self.mixer_read = SOUND_MIXER_READ_VOLUME
	self.mixer_write = SOUND_MIXER_WRITE_VOLUME
	app.status(_("Using MASTER volume"), 1)

    def quit(self):
        if not self.player.is_stopped(): self.player.stop(quiet=1)
        sys.exit(0)

    def handler_resize(self, sig, frame):
        ## curses trickery
        curses.endwin()
        self.w.refresh()
        self.win_root.resize()
        self.win_root.update()

    def handler_quit(self, sig, frame):
        self.quit()

# ------------------------------------------
def main():
    try:
        opts, args = getopt.getopt(sys.argv[1:], "rRvV")
    except:
        usage = _("Usage: %s [-rRvV] [ file | dir | playlist.m3u ] ...\n")
        sys.stderr.write(usage % sys.argv[0])
        sys.exit(1)

    global app
    app = Application()

    playlist = []
    if not sys.stdin.isatty():
        playlist = map(string.strip, sys.stdin.readlines())
        os.close(0)
        os.open("/dev/tty", 0)
    try:
        app.setup()
        for opt, optarg in opts:
            if opt == '-r': app.win_playlist.command_toggle_repeat()
            if opt == '-R': app.win_playlist.command_toggle_random()
            if opt == '-v': app.use_pcm_volume()
            if opt == '-V': app.use_master_volume()
        if args or playlist:
            for item in args or playlist:
                app.win_playlist.append(item)
            app.win_tab.change_window()
        app.run()
    except SystemExit:
        app.cleanup()
    except Exception:
        app.cleanup()
        import traceback
        traceback.print_exc()

# ------------------------------------------

PLAYERS = [
    FrameOffsetPlayer("mpg321 -q -v -k %d %s", ".*\.mp[123]$", 38.28),
    FrameOffsetPlayer("ogg123 -q -v -k %d %s", ".*\.ogg$"),
    FrameOffsetPlayer("splay -f -k %d %s", ".*\.mp[123]$", 38.28),
    FrameOffsetPlayer("mpg123 -q -v -k %d %s", ".*\.mp[123]$", 38.28),
    NoOffsetPlayer("madplay -q -v --no-tty-control %s", ".*\.mp[123]$"),
    NoOffsetPlayer("mikmod -p1 -t %s", "(^mod\.|.*\.(mod|xm|fm|s3m|med))$")
    ]

def VALID_SONG(name):
    for player in PLAYERS:
        if player.re_files.match(name):
            return 1

RE_PLAYLIST = re.compile(".*\.m3u$", re.I)

def VALID_PLAYLIST(name):
    if RE_PLAYLIST.match(name):
        return 1

# ------------------------------------------
if __name__ == "__main__": main()
