OK with some hacking I got the 16:9 detection to work reliably. On my
current set of testfiles it works a treat. So I am now sharing the file (to go into video/plugins) for others to play with. So please provide feedback if you find a case where it does not work as expected. Paul On 26-01-10 12:02, John Molohan wrote: Interesting, thanks for the update. -- Paul Sijben tel 0334557522 |
# -*- coding: iso-8859-1 -*- # ----------------------------------------------------------------------- # Freevo video module for MPlayer # ----------------------------------------------------------------------- # $Id: mplayer.py 11479 2009-05-07 17:34:38Z duncan $ # ----------------------------------------------------------------------- # Freevo - A Home Theater PC framework # Copyright (C) 2002 Krister Lagerstrom, et al. # Please see the file freevo/Docs/CREDITS for a complete list of authors. # # 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 MER- # CHANTABILITY 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 # # ----------------------------------------------------------------------- """ Freevo video module for MPlayer """ import os, re import threading import popen2 import kaa.metadata as mmpython import config # Configuration handler. reads config file. import util # Various utilities import childapp # Handle child applications import rc # The RemoteControl class. import plugin import dialog from dialog.display import AppTextDisplay from event import * class PluginInterface(plugin.Plugin): """ Mplayer plugin for the video player. With this plugin Freevo can play all video files defined in VIDEO_MPLAYER_SUFFIX. This is the default video player for Freevo. """ def __init__(self): # create plugin structure plugin.Plugin.__init__(self) # register mplayer as the object to play video plugin.register(MPlayer(), plugin.VIDEO_PLAYER, True) class MPlayer: """ the main class to control mplayer """ def __init__(self): """ init the mplayer object """ self.name = 'mplayer' self.app_mode = 'video' self.seek = 0 self.seek_timer = threading.Timer(0, self.reset_seek) self.app = None self.plugins = [] self.paused = False self.stored_time_info = None self.audio_media = None self.subtitle_media = None def rate(self, item): """ How good can this player play the file: 2 = good 1 = possible, but not good 0 = unplayable """ if not item.url: return 0 # this seems strange that it is 'possible' for dvd:// and 'good' for dvd # possible because dvd:// should be played with xine when available! if item.url[:6] in ('dvd://', 'vcd://') and item.url.endswith('/'): _debug_('mplayer rating: %r possible' % (item.url), 2) return 1 if item.mode in ('dvd', 'vcd'): _debug_('mplayer rating: %r good' % (item.url), 2) return 2 if item.mode in ('http') and not item.filename and not item.media: _debug_('mplayer rating: %r good' % (item.url), 2) return 2 if item.mimetype in config.VIDEO_MPLAYER_SUFFIX: _debug_('mplayer rating: %r good' % (item.url), 2) return 2 if item.network_play: _debug_('mplayer rating: %r possible' % (item.url), 2) return 1 _debug_('mplayer rating: %r unplayable' % (item.url), 2) return 0 def play(self, options, item): """ play a videoitem with mplayer """ _debug_('options=%r' % (options,), 2) for k, v in item.__dict__.items(): _debug_('item[%s]=%r' % (k, v), 2) mode = item.mode url = item.url self.options = options self.item = item self.item_info = None self.item_length = -1 self.item.elapsed = 0 if mode == 'file': url = item.url[6:] self.item_info = mmpython.parse(url) if hasattr(self.item_info, 'get_length'): self.item_length = self.item_info.get_endpos() self.dynamic_seek_control = True if url.startswith('dvd://') and url[-1] == '/': url += '1' if url == 'vcd://': c_len = 0 for i in range(len(item.info.tracks)): if item.info.tracks[i].length > c_len: c_len = item.info.tracks[i].length url = item.url + str(i+1) url=Unicode(url) try: _debug_('MPlayer.play(): mode=%s, url=%s' % (mode, url)) except UnicodeError: _debug_('MPlayer.play(): [non-ASCII data]') if mode == 'file' and not os.path.isfile(url): # This event allows the videoitem which contains subitems to # try to play the next subitem return '%s\nnot found' % os.path.basename(url) set_vcodec = False if item['xvmc'] and item['type'][:6] in ['MPEG-1', 'MPEG-2', 'MPEG-T']: set_vcodec = True mode = item.mimetype if not config.MPLAYER_ARGS.has_key(mode): _debug_('MPLAYER_ARGS not defined for %r, using default' % mode, DINFO) mode = 'default' _debug_('mode=%s args=%s'% (mode, config.MPLAYER_ARGS[mode])) # Build the MPlayer command args = { 'nice': config.MPLAYER_NICE, 'cmd': config.MPLAYER_CMD, 'vo': '-vo %s' % config.MPLAYER_VO_DEV, 'vo_opts': config.MPLAYER_VO_DEV_OPTS, 'vc': '', 'ao': '-ao %s' % config.MPLAYER_AO_DEV, 'ao_opts': config.MPLAYER_AO_DEV_OPTS, 'default_args': config.MPLAYER_ARGS_DEF, 'mode_args': config.MPLAYER_ARGS[mode], 'fxd_args': ' '.join(options), 'geometry': '', 'verbose': '', 'dvd-device': '', 'cdrom-device': '', 'alang': '', 'aid': '', 'slang': '', 'sid': '', 'playlist': '', 'field-dominance': '', 'edl': '', 'mc': '', 'delay': '', 'sub': '', 'audiofile': '', 'af': [], 'vf': [], 'url': url, 'disable_osd': False, } if config.CONF.x or config.CONF.y: args['geometry'] = '-geometry %d:%d' % (config.CONF.x, config.CONF.y) if config.DEBUG_CHILDAPP: args['verbose'] = '-v' if mode == 'dvd': if config.DVD_LANG_PREF: # There are some bad mastered DVDs out there. E.g. the specials on # the German Babylon 5 Season 2 disc claim they have more than one # audio track, even more then on en. But only the second en works, # mplayer needs to be started without -alang to find the track if hasattr(item, 'mplayer_audio_broken') and item.mplayer_audio_broken: print '*** dvd audio broken, try without alang ***' else: args['alang'] = '-alang %s' % config.DVD_LANG_PREF if config.DVD_SUBTITLE_PREF: # Only use if defined since it will always turn on subtitles when defined args['slang'] = '-slang %s' % config.DVD_SUBTITLE_PREF if mode == 'dvd': # dvd on harddisc args['dvd-device'] = '%s' % item.filename args['url'] = url[:6] + url[url.rfind('/')+1:] elif mode != 'file' and hasattr(item.media, 'devicename'): args['dvd-device'] = '%s' % item.media.devicename if item.media and hasattr(item.media, 'devicename'): args['cdrom-device'] = '%s' % item.media.devicename if item.selected_subtitle == -1: args['sid'] = '-noautosub' elif item.selected_subtitle is not None: if mode == 'file': if os.path.isfile(os.path.splitext(item.filename)[0]+'.idx'): args['sid'] = '-vobsubid %s' % str(item.selected_subtitle) else: args['sid'] = '-sid %s' % str(item.selected_subtitle) else: args['sid'] = '-sid %s' % str(item.selected_subtitle) if item.selected_audio is not None: args['aid'] = '-aid %s' % str(item.selected_audio) # This comes from the bilingual language selection menu if hasattr(item, 'selected_language') and item.selected_language: if item.selected_language == 'left': args['af'].append('pan=2:1:1:0:0') elif item.selected_language == 'right': args['af'].append('pan=2:0:0:1:1') if not set_vcodec: if item['deinterlace'] and config.MPLAYER_VF_INTERLACED: args['vf'].append(config.MPLAYER_VF_INTERLACED) elif config.MPLAYER_VF_PROGRESSIVE: args['vf'].append(config.MPLAYER_VF_PROGRESSIVE) if config.VIDEO_FIELD_DOMINANCE is not None: args['field-dominance'] = '-field-dominance %d' % int(item['field-dominance']) if os.path.isfile(os.path.splitext(item.filename)[0]+'.edl'): args['edl'] = '-edl %s' % str(os.path.splitext(item.filename)[0]+'.edl') if dialog.overlay_display_supports_dialogs: # Disable the mplayer OSD if we have a better option. args['disable_osd'] = True # Mplayer command and standard arguments if set_vcodec: if item['deinterlace']: bobdeint='bobdeint' else: bobdeint='nobobdeint' args['vo'] = '-vo xvmc:%s' % bobdeint args['vc'] = '-vc ffmpeg12mc' if hasattr(item, 'is_playlist') and item.is_playlist: args['playlist'] = '-playlist' # correct avi delay based on kaa.metadata settings if config.MPLAYER_SET_AUDIO_DELAY and item.info.has_key('delay') and item.info['delay'] > 0: args['mc'] = '-mc %s' % str(int(item.info['delay'])+1) args['delay'] = '-delay -%s' % str(item.info['delay']) # autocrop if config.MPLAYER_AUTOCROP and not item.network_play and args['fxd_args'].find('crop=') == -1: _debug_('starting autocrop points=%s'%config.MPLAYER_AUTOCROP_START) (x1, y1, x2, y2) = (1000, 1000, 0, 0) #new approach; detect if this is 16:9 or 4:3 crop_point=config.TV_RECORD_PADDING_PRE+60 (x1, y1, x2, y2) = self.get_crop(crop_point, x1, y1, x2, y2) _debug_('set=%s'%(str((x1, y1, x2, y2)))) if y1==0 and float(x2-x1)/max(float(y2-y1),1)<1.5: #ah 4:3, let's investigate further #now we have the max size (720x576 for tv recordings) #however the upper and lower bars of a TV recording are not black enough count_16by9 = 0 count_4by3 = 0 for ii in range(0,4): if ii: crop_point+=3*60 #jump through the movie with 3 min intervals (x11, y11, x21, y21) = (1000, 1000, 0, 0) isit,x11,y11,x21,y21= self.is16by9(crop_point,x11,y11,x21,y21) if isit: count_16by9+=1 else: count_4by3+=1 if count_16by9>count_4by3: #ah adapt the crop params height=y2-y1 w=x2-x1 if w==720: w=768 factor = float(config.CONF.width)/float(w) cropheight = int(.5+(config.CONF.height/factor)) _debug_('height=%s w=%s factor=%s cropheight=%s'%(height,w,factor,cropheight)) #OK cut top and bottom in equal amounts y1=int((height-cropheight)/2) y2=y1+cropheight _debug_('y1=%s y2=%s'%(y1,y2)) if x1 < 1000 and x2 < 1000: args['vf'].append('crop=%s:%s:%s:%s' % (x2-x1, y2-y1, x1, y1)) _debug_('crop=%s:%s:%s:%s' % (x2-x1, y2-y1, x1, y1)) if item.subtitle_file: self.subtitle_media, f = util.resolve_media_mountdir(item.subtitle_file) if self.subtitle_media: self.subtitle_media.mount() args['sub'] = '-sub %s' % f if item.audio_file: self.audio_media, f = util.resolve_media_mountdir(item.audio_file) if self.audio_media: self.audio_media.mount() args['audiofile'] = '-audiofile %s' % f self.plugins = plugin.get('mplayer_video') for p in self.plugins: command = p.play(command, self) vo = ['%(vo)s' % args, '%(vo_opts)s' % args] vo = filter(len, vo) vo = ':'.join(vo) ao = ['%(ao)s' % args, '%(ao_opts)s' % args] ao = filter(len, ao) ao = ':'.join(ao) # process the mplayer options extracting video and audio filters args['default_args'], args = self.find_filters(args['default_args'], args) args['mode_args'], args = self.find_filters(args['mode_args'], args) args['fxd_args'], args = self.find_filters(args['fxd_args'], args) command = ['--prio=%(nice)s' % args] command += ['%(cmd)s' % args] command += ['-slave'] command += str('%(verbose)s' % args).split() command += str('%(geometry)s' % args).split() command += vo.split() command += str('%(vc)s' % args).split() command += ao.split() command += args['dvd-device'] and ['-dvd-device', '%(dvd-device)s' % args] or [] command += args['cdrom-device'] and ['-cdrom-device', '%(cdrom-device)s' % args] or [] command += str('%(alang)s' % args).split() command += str('%(aid)s' % args).split() command += str('%(audiofile)s' % args).split() command += str('%(slang)s' % args).split() command += str('%(sid)s' % args).split() command += str('%(sub)s' % args).split() command += str('%(field-dominance)s' % args).split() command += str('%(edl)s' % args).split() command += str('%(mc)s' % args).split() command += str('%(delay)s' % args).split() command += args['default_args'].split() command += args['mode_args'].split() command += args['fxd_args'].split() command += args['af'] and ['-af', '%s' % ','.join(args['af'])] or [] command += args['vf'] and ['-vf', '%s' % ','.join(args['vf'])] or [] command += args['disable_osd'] and ['-osdlevel', '0'] or [] # use software scaler? #XXX these need to be in the arg list as the scaler will add vf args if '-nosws' in command: command.remove('-nosws') elif '-framedrop' not in command: command += config.MPLAYER_SOFTWARE_SCALER.split() command = filter(len, command) command += str('%(playlist)s' % args).split() command += ['%(url)s' % args] _debug_(' '.join(command[1:])) #if plugin.getbyname('MIXER'): #plugin.getbyname('MIXER').reset() self.paused = False rc.app(self) self.app = MPlayerApp(command, self) dialog.enable_overlay_display(AppTextDisplay(self.show_message)) return None def stop(self): """ Stop mplayer """ for p in self.plugins: command = p.stop() if not self.app: return self.app.stop('quit\n') rc.app(None) dialog.disable_overlay_display() self.app = None if self.subtitle_media: self.subtitle_media.mount() self.subtitle_media = None if self.audio_media: self.audio_media.mount() self.audio_media = None def eventhandler(self, event, menuw=None): """ eventhandler for mplayer control. If an event is not bound in this function it will be passed over to the items eventhandler """ if not self.app: return self.item.eventhandler(event) for p in self.plugins: if p.eventhandler(event): return True if event == VIDEO_MANUAL_SEEK: self.seek = 0 rc.set_context('input') dialog.show_message("input") return True if event.context == 'input': if event in INPUT_ALL_NUMBERS: self.reset_seek_timeout() self.seek = self.seek * 10 + int(event); return True elif event == INPUT_ENTER: self.seek_timer.cancel() self.seek *= 60 self.app.write('seek ' + str(self.seek) + ' 2\n') _debug_("seek "+str(self.seek)+" 2\n") self.seek = 0 rc.set_context('video') return True elif event == INPUT_EXIT: _debug_('seek stopped') #self.app.write('seek stopped\n') self.seek_timer.cancel() self.seek = 0 rc.set_context('video') return True if event == STOP: self.stop() return self.item.eventhandler(event) if event == 'AUDIO_ERROR_START_AGAIN': self.stop() self.play(self.options, self.item) return True if event in (PLAY_END, USER_END): self.stop() return self.item.eventhandler(event) if event == VIDEO_SEND_MPLAYER_CMD: self.app.write('%s\n' % event.arg) return True if event == TOGGLE_OSD: if dialog.is_dialog_supported(): if self.paused: dialog.show_play_state(dialog.PLAY_STATE_PAUSE , self.get_stored_time_info) else: dialog.show_play_state(dialog.PLAY_STATE_PLAY, self.get_time_info) else: self.app.write('osd\n') return True if event == PAUSE or event == PLAY: self.paused = not self.paused # We have to store the current time before displaying the dialog # otherwise the act of requesting the current position resumes playback! if self.paused: self.stored_time_info = self.get_time_info() dialog.show_play_state(dialog.PLAY_STATE_PAUSE, self.get_time_info) self.app.write('pause\n') else: self.app.write('pause\n') dialog.show_play_state(dialog.PLAY_STATE_PLAY, self.get_time_info) return True if event == SEEK: if event.arg > 0 and self.item_length != -1 and self.dynamic_seek_control: # check if the file is growing if self.item_info.get_endpos() == self.item_length: # not growing, deactivate this self.item_length = -1 self.dynamic_seek_control = False if event.arg > 0 and self.item_length != -1: # safety time for bad mplayer seeking seek_safety_time = 20 if self.item_info['type'] in ('MPEG-PES', 'MPEG-TS'): seek_safety_time = 500 # check if seek is allowed if self.item_length <= self.item.elapsed + event.arg + seek_safety_time: # get new length self.item_length = self.item_info.get_endpos() # check again if seek is allowed if self.item_length <= self.item.elapsed + event.arg + seek_safety_time: _debug_('unable to seek %s secs at time %s, length %s' % \ (event.arg, self.item.elapsed, self.item_length)) dialog.show_message(_('Seeking not possible')) return False if event.arg > 0: dialog.show_play_state(dialog.PLAY_STATE_SEEK_FORWARD, self.get_time_info) else: dialog.show_play_state(dialog.PLAY_STATE_SEEK_BACK, self.get_time_info) self.app.write('seek %s\n' % event.arg) return True if event == VIDEO_AVSYNC: self.app.write('audio_delay %g\n' % event.arg); return True if event == VIDEO_NEXT_AUDIOLANG: self.app.write('switch_audio\n') return True if event == VIDEO_NEXT_SUBTITLE: self.app.write('sub_select\n') return True if event == OSD_MESSAGE: self.show_message(event.arg) return True # nothing found? Try the eventhandler of the object who called us return self.item.eventhandler(event) def show_message(self, message): self.app.write('osd_show_text "%s"\n' % message); def reset_seek(self): _debug_('seek timeout') self.seek = 0 rc.set_context('video') def reset_seek_timeout(self): self.seek_timer.cancel() self.seek_timer = threading.Timer(config.MPLAYER_SEEK_TIMEOUT, self.reset_seek) self.seek_timer.start() def find_filters(self, arg, args): old_options = arg.split('-') new_options = [] for i in range(len(old_options)): pair = old_options[i].split() if len(pair) == 2: if pair[0] == 'vf': args['vf'].append(pair[1]) continue elif pair[0] == 'af': args['af'].append(pair[1]) continue new_options.append(old_options[i]) arg = '-'.join(new_options) return arg, args def sort_filter(self, command): """ Change a mplayer command to support more than one -vf parameter. This function will grep all -vf parameter from the command and add it at the end as one vf argument """ ret = [] vf = '' next_is_vf = False for arg in command: if next_is_vf: vf += ',%s' % arg next_is_vf = False elif (arg == '-vf'): next_is_vf=True else: ret += [arg] if vf: return ret + ['-vf', vf[1:]] return ret def get_crop(self, pos, x1, y1, x2, y2, threshold=None): if threshold: crop_cmd = [config.MPLAYER_CMD, '-ao', 'null', '-vo', 'null', '-slave', '-nolirc', '-ss', '%s' % pos, '-frames', '6', '-vf', 'cropdetect=%s'%threshold] else: crop_cmd = [config.MPLAYER_CMD, '-ao', 'null', '-vo', 'null', '-slave', '-nolirc', '-ss', '%s' % pos, '-frames', '6', '-vf', 'cropdetect'] crop_cmd.append(self.item.url) child = popen2.Popen3(self.sort_filter(crop_cmd), 1, 100) exp = re.compile('^.*-vf crop=([0-9]*):([0-9]*):([0-9]*):([0-9]*).*') gotone=False while(1): data = child.fromchild.readline() if not data: if not gotone: return self.get_crop(0,x1,y1,x2,y2,threshold) break m = exp.match(data) if m: x1 = min(x1, int(m.group(3))) y1 = min(y1, int(m.group(4))) x2 = max(x2, int(m.group(1)) + int(m.group(3))) y2 = max(y2, int(m.group(2)) + int(m.group(4))) _debug_('x1=%s x2=%s y1=%s y2=%s' % (x1, x2, y1, y2)) gotone=True child.wait() return (x1, y1, x2, y2) def is16by9(self,crop_point,x1,y1,x2,y2,threshold=54): _debug_('croppoint=%s thresh=%s ' % (crop_point,threshold)) (x12, y12, x22, y22) = self.get_crop(crop_point, x1,y1,x2,y2,threshold) if y22==0: y22=1 _debug_('x1=%s x2=%s y1=%s y2=%s x2/y2=%s' % (x12, x22, y12, y22,float(x22)/float(y22))) return float(x22)/float(y22)>1.27,x12,y12,x22,y22 def get_stored_time_info(self): return self.stored_time_info def get_time_info(self): time_pos = self.app.get_property('time_pos') length = self.app.get_property('length') percent_pos = self.app.get_property('percent_pos') return (int(float(time_pos)), int(float(length)), int(percent_pos) / 100.0) # ====================================================================== class MPlayerApp(childapp.ChildApp2): """ class controlling the in and output from the mplayer process """ def __init__(self, command, mplayer): self.RE_TIME = re.compile("^[AV]: *([0-9]+)").match self.RE_START = re.compile("^Starting playback\.\.\.").match self.RE_EXIT = re.compile("^Exiting\.\.\. \((.*)\)$").match self.item = mplayer.item self.mplayer = mplayer self.exit_type = None # DVD items also store mplayer_audio_broken to check if you can # start them with -alang or not if hasattr(self.item, 'mplayer_audio_broken') or self.item.mode != 'dvd': self.check_audio = 0 else: self.check_audio = 1 import osd self.osd = osd.get_singleton() self.osdfont = self.osd.getfont(config.OSD_DEFAULT_FONTNAME, config.OSD_DEFAULT_FONTSIZE) # check for mplayer plugins self.stdout_plugins = [] self.elapsed_plugins = [] for p in plugin.get('mplayer_video'): if hasattr(p, 'stdout'): self.stdout_plugins.append(p) if hasattr(p, 'elapsed'): self.elapsed_plugins.append(p) self.output_event = threading.Event() self.get_property_ans = None # init the child (== start the threads) childapp.ChildApp2.__init__(self, command, callback_use_rc=False) def get_property(self, property): self.get_property_ans = None self.output_event.clear() self.write('get_property %s\n' % property) self.output_event.wait() return self.get_property_ans def stop_event(self): """ return the stop event send through the eventhandler """ if self.exit_type == "End of file": return PLAY_END elif self.exit_type == "Quit": return USER_END else: return PLAY_END def stdout_cb(self, line): """ parse the stdout of the mplayer process """ # show connection status for network play if self.item.network_play: if line.find('Opening audio decoder') == 0: self.osd.clearscreen(self.osd.COL_BLACK) self.osd.update() elif (line.startswith('Resolving ') or \ line.startswith('Connecting to server') or \ line.startswith('Cache fill:')) and \ line.find('Resolving reference to') == -1: if line.startswith('Connecting to server'): line = 'Connecting to server' self.osd.clearscreen(self.osd.COL_BLACK) self.osd.drawstringframed(line, config.OSD_OVERSCAN_LEFT+10, config.OSD_OVERSCAN_TOP+10, self.osd.width - (config.OSD_OVERSCAN_LEFT+10 + config.OSD_OVERSCAN_RIGHT+10), -1, self.osdfont, self.osd.COL_WHITE) self.osd.update() # current elapsed time if line.startswith("A:") or line.startswith("V:"): m = self.RE_TIME(line) if hasattr(m, 'group') and self.item.elapsed != int(m.group(1))+1: self.item.elapsed = int(m.group(1))+1 for p in self.elapsed_plugins: p.elapsed(self.item.elapsed) # exit status elif line.find("Exiting...") == 0: m = self.RE_EXIT(line) if m: self.exit_type = m.group(1) if self.output_event.isSet(): self.output_event.set() elif line.startswith('ANS_'): prop,ans = line.split('=') self.get_property_ans = ans.strip() self.output_event.set() # this is the first start of the movie, parse info elif not self.item.elapsed: for p in self.stdout_plugins: p.stdout(line) if self.check_audio: if line.find('MPEG: No audio stream found -> no sound') == 0: # OK, audio is broken, restart without -alang self.check_audio = 2 self.item.mplayer_audio_broken = True rc.post_event(Event('AUDIO_ERROR_START_AGAIN')) if self.RE_START(line): if self.check_audio == 1: # audio seems to be ok self.item.mplayer_audio_broken = False self.check_audio = 0 def stderr_cb(self, line): """ parse the stderr of the mplayer process """ if line.startswith('Failed to get value of property '): self.output_event.set() for p in self.stdout_plugins: p.stdout(line)
------------------------------------------------------------------------------ The Planet: dedicated and managed hosting, cloud storage, colocation Stay online with enterprise data centers and the best network in the business Choose flexible plans and management services without long-term contracts Personal 24x7 support from experience hosting pros just a phone call away. http://p.sf.net/sfu/theplanet-com
_______________________________________________ Freevo-users mailing list Freevo-users@lists.sourceforge.net https://lists.sourceforge.net/lists/listinfo/freevo-users