Initial commit
[fillmore] / src / marina / project.vala
diff --git a/src/marina/project.vala b/src/marina/project.vala
new file mode 100644 (file)
index 0000000..22b1d93
--- /dev/null
@@ -0,0 +1,1143 @@
+/* Copyright 2009-2010 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution. 
+ */
+
+using Logging;
+
+extern const string _VERSION;
+
+namespace Model {
+public class MediaLoaderHandler : LoaderHandler {
+    protected weak Project the_project;
+    protected Track current_track;
+
+    Gee.ArrayList<ClipFetcher> clipfetchers = new Gee.ArrayList<ClipFetcher>();
+    int num_clipfiles_complete;
+
+    public MediaLoaderHandler(Project the_project) {
+        this.the_project = the_project;
+        current_track = null;
+    }
+
+    public override bool commit_marina(string[] attr_names, string[] attr_values) {
+        int number_of_attributes = attr_names.length;
+        if (number_of_attributes != 1 ||
+            attr_names[0] != "version") {
+            load_error("Missing version information");
+            return false;
+        }
+
+        if (the_project.get_file_version() < attr_values[0].to_int()) {
+            load_error("Version mismatch! (File Version: %d, App Version: %d)".printf(
+                the_project.get_file_version(), attr_values[0].to_int()));
+            return false;
+        }
+
+        num_clipfiles_complete = 0;
+        return true;
+    }
+
+    public override bool commit_library(string[] attr_names, string[] attr_values) {
+        // We return true since framerate is an optional parameter
+        if (attr_names.length != 1)
+            return true;
+
+        if (attr_names[0] != "framerate") {
+            load_error("Missing framerate tag");
+            return false;
+        }
+
+        string[] arr = attr_values[0].split("/");
+        if (arr.length != 2) {
+            load_error("Invalid framerate attribute");
+            return false;
+        }
+
+        the_project.set_default_framerate(Fraction(arr[0].to_int(), arr[1].to_int()));
+        return true;
+    }
+
+    public override bool commit_track(string[] attr_names, string[] attr_values) {
+        assert(current_track == null);
+
+        int number_of_attributes = attr_names.length;
+        string? name = null;
+        string? type = null;
+        for (int i = 0; i < number_of_attributes; ++i) {
+            switch(attr_names[i]) {
+                case "type":
+                    type = attr_values[i];
+                    break;
+                case "name":
+                    name = attr_values[i];
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        if (name == null) {
+            load_error("Missing track name");
+            return false;
+        }
+
+        if (type == null) {
+            load_error("Missing track type");
+            return false;
+        }
+
+        if (type == "audio") {
+            AudioTrack audio_track = new AudioTrack(the_project, name);
+            current_track = audio_track;
+            the_project.add_track(current_track);
+
+            for (int i = 0; i < number_of_attributes; ++i) {
+                switch(attr_names[i]) {
+                    case "panorama":
+                        audio_track._set_pan(attr_values[i].to_double());
+                        break;
+                    case "volume":
+                        audio_track._set_volume(attr_values[i].to_double());
+                        break;
+                    case "channels":
+                        audio_track.set_default_num_channels(attr_values[i].to_int());
+                        break;
+                    default:
+                        break;
+                }
+            }
+            return true;
+        } else if (type == "video") {
+            current_track = new VideoTrack(the_project);
+            the_project.add_track(current_track);
+        }
+
+        return base.commit_track(attr_names, attr_values);
+    }
+
+    public override void leave_track() {
+        assert(current_track != null);
+        current_track = null;
+    }
+
+    public override bool commit_clip(string[] attr_names, string[] attr_values) {
+        assert(current_track != null);
+
+        int number_of_attributes = attr_names.length;
+        int id = -1;
+        string? clip_name = null;
+        int64 start = -1;
+        int64 media_start = -1;
+        int64 duration = -1;
+        for (int i = 0; i < number_of_attributes; i++) {
+        switch (attr_names[i]) {
+            case "id":
+                id = attr_values[i].to_int();
+                break;
+            case "name":
+                clip_name = attr_values[i];
+                break;
+            case "start":
+                start = attr_values[i].to_int64();
+                break;
+            case "media-start":
+                media_start = attr_values[i].to_int64();
+                break;
+            case "duration":
+                duration = attr_values[i].to_int64();
+                break;
+            default:
+                // TODO: we need a way to deal with orphaned attributes, for now, reject the file
+                load_error("Unknown attribute %s".printf(attr_names[i]));
+                return false;
+            }
+        }
+
+        if (id == -1) {
+            load_error("missing clip id");
+            return false;
+        }
+
+        if (clip_name == null) {
+            load_error("missing clip_name");
+            return false;
+        }
+
+        if (start == -1) {
+            load_error("missing start time");
+            return false;
+        }
+
+        if (media_start == -1) {
+            load_error("missing media_start");
+            return false;
+        }
+
+        if (duration == -1) {
+            load_error("missing duration");
+            return false;
+        }
+
+        if (id >= clipfetchers.size) {
+            load_error("clip file id %s was not loaded".printf(clip_name));
+            return false;
+        }
+
+        Clip clip = new Clip(clipfetchers[id].clipfile, current_track.media_type(), clip_name, 
+            start, media_start, duration, false);
+        current_track.add(clip, start, false);
+        return true;
+    }
+
+    void fetcher_ready(Fetcher f) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "fetcher_ready");
+        if (f.error_string != null) {
+            load_error("Could not load %s.".printf(f.clipfile.filename));
+            warning("Could not load %s: %s", f.clipfile.filename, f.error_string);
+        }
+        the_project.add_clipfile(f.clipfile);
+        num_clipfiles_complete++;
+        if (num_clipfiles_complete == clipfetchers.size) {
+            complete();
+        }
+    }
+
+    public override bool commit_clipfile(string[] attr_names, string[] attr_values) {
+        string filename = null;
+        int id = -1;
+
+        for (int i = 0; i < attr_names.length; i++) {
+            if (attr_names[i] == "filename") {
+                filename = attr_values[i];
+            } else if (attr_names[i] == "id") {
+                id = attr_values[i].to_int();
+            }
+        }
+
+        if (filename == null) {
+            load_error("Invalid clipfile filename");
+            return false;
+        }
+
+        if (id < 0) {
+            load_error("Invalid clipfile id");
+            return false;
+        }
+
+        try {
+            ClipFetcher fetcher = new ClipFetcher(filename);
+            fetcher.ready.connect(fetcher_ready);
+            clipfetchers.insert(id, fetcher);
+        } catch (Error e) {
+            load_error(e.message);
+            return false;
+        }
+        return true;
+    }
+
+    public override bool commit_tempo_entry(string[] attr_names, string[] attr_values) {
+        if (attr_names[0] != "tempo") {
+            load_error("Invalid attribute on tempo entry");
+            return false;
+        }
+
+        the_project._set_bpm(attr_values[0].to_int());
+        return true;
+    }
+
+    public override bool commit_time_signature_entry(string[] attr_names, string[] attr_values) {
+        if (attr_names[0] != "signature") {
+            load_error("Invalid attribute on time signature");
+            return false;
+        }
+
+        the_project._set_time_signature(Fraction.from_string(attr_values[0]));
+        return true;
+    }
+
+    public override bool commit_click(string[] attr_names, string[] attr_values) {
+        for (int i = 0; i < attr_names.length; ++i) {
+            switch (attr_names[i]) {
+                case "on_play":
+                    the_project.click_during_play = attr_values[i] == "true";
+                break;
+                case "on_record":
+                    the_project.click_during_record = attr_values[i] == "true";
+                break;
+                case "volume":
+                    the_project.click_volume = attr_values[i].to_double();
+                break;
+                default:
+                    load_error("unknown attribute for click '%s'".printf(attr_names[i]));
+                    return false;
+            }
+        }
+        return true;
+    }
+
+    public override bool commit_library_preference(string[] attr_names, string[] attr_values) {
+        for (int i = 0; i < attr_names.length; ++i) {
+            switch (attr_names[i]) {
+                case "width":
+                    the_project.library_width = attr_values[i].to_int();
+                break;
+                case "visible":
+                    the_project.library_visible = attr_values[i] == "true";
+                break;
+                default:
+                    load_error("unknown attribute for library '%s'".printf(attr_names[i]));
+                    return false;
+            }
+        }
+        return true;
+    }
+
+    public override void leave_library() {
+        if (clipfetchers.size == 0)
+            complete();
+    }
+}
+
+public abstract class Project : TempoInformation, Object {
+    public const string FILLMORE_FILE_EXTENSION = "fill";
+    public const string FILLMORE_FILE_FILTER = "*." + FILLMORE_FILE_EXTENSION;   
+    public const string LOMBARD_FILE_EXTENSION = "lom";
+    public const string LOMBARD_FILE_FILTER = "*." + LOMBARD_FILE_EXTENSION;
+
+    const string license = """
+%s is free software; you can redistribute it and/or modify it under the 
+terms of the GNU Lesser General Public License as published by the Free 
+Software Foundation; either version 2.1 of the License, or (at your option) 
+any later version.
+
+%s 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 Lesser General Public License for 
+more details.
+
+You should have received a copy of the GNU Lesser General Public License 
+along with %s; if not, write to the Free Software Foundation, Inc., 
+51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
+""";
+
+    public const string[] authors = { 
+        "Robert Powell <rob@yorba.org>",
+        "Adam Dingle <adam@yorba.org>",
+        "Andrew O'Mahony <andrew.omahony@att.net>",
+        "Dru Moore <usr@dru-id.co.uk>",
+        null
+    };
+
+    public Gee.ArrayList<Track> tracks = new Gee.ArrayList<Track>();
+    public Gee.ArrayList<Track> inactive_tracks = new Gee.ArrayList<Track>();
+    Gee.HashSet<ClipFetcher> pending = new Gee.HashSet<ClipFetcher>();
+    Gee.ArrayList<ThumbnailFetcher> pending_thumbs = new Gee.ArrayList<ThumbnailFetcher>();
+    protected Gee.ArrayList<ClipFile> clipfiles = new Gee.ArrayList<ClipFile>();
+    // TODO: media_engine is a member of project only temporarily.  It will be
+    // less work to move it to fillmore/lombard once we have a transport class.
+    public View.MediaEngine media_engine;
+
+    protected string project_file;  // may be null if project has not yet been saved
+    public ProjectLoader loader;
+
+    FetcherCompletion fetcher_completion;
+    public UndoManager undo_manager;
+    public LibraryImporter importer;
+
+    public Fraction default_framerate;
+    int tempo = 120;
+    Fraction time_signature = Fraction(4, 4);
+    public bool click_during_play = false;
+    public bool click_during_record = true;
+    public double click_volume = 0.8;
+    public bool library_visible = true;
+    public int library_width = 600;
+    public bool snap_to_clip;
+
+    /* TODO:
+     * This can't be const since the Vala compiler (0.7.7) crashes if we try to make it a const.
+     * I've filed a bug with the Vala bugzilla for this:
+     * https://bugzilla.gnome.org/show_bug.cgi?id=598204
+     */    
+    public static Fraction INVALID_FRAME_RATE = Fraction(-1, 1);
+
+    public signal void playstate_changed(PlayState playstate);
+
+    public signal void name_changed(string? project_file);
+    public signal void load_error(string error);
+    public virtual signal void load_complete() {
+    }
+
+    public signal void closed();
+
+    public signal void track_added(Track track);
+    public signal void track_removed(Track track);
+    public signal void error_occurred(string major_message, string? minor_message);
+
+    public signal void clipfile_added(ClipFile c);
+    public signal void clipfile_removed(ClipFile clip_file);
+    public signal void cleared();
+
+    public abstract TimeCode get_clip_time(ClipFile f);
+
+    public Project(string? filename, bool include_video) throws Error {
+        undo_manager = new UndoManager();
+        project_file = filename;
+
+        media_engine = new View.MediaEngine(this, include_video);
+        track_added.connect(media_engine.on_track_added);
+        media_engine.playstate_changed.connect(on_playstate_changed);
+        media_engine.error_occurred.connect(on_error_occurred);
+
+        set_default_framerate(INVALID_FRAME_RATE);
+    }
+
+    public void on_playstate_changed() {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_playstate_changed");
+        switch (media_engine.get_play_state()) {
+            case PlayState.STOPPED:
+                ClearTrackMeters();
+                break;
+            case PlayState.CLOSED:
+                closed();
+                break;
+        }
+        playstate_changed(media_engine.get_play_state());
+    }
+
+    public virtual string? get_project_file() {
+        return project_file;
+    }
+
+    public ClipFile? get_clipfile(int index) {
+        if (index < 0 ||
+            index >= clipfiles.size)
+            return null;
+        return clipfiles[index];
+    }
+
+    public int get_clipfile_index(ClipFile find) {
+        int i = 0;
+        foreach (ClipFile f in clipfiles) {
+            if (f == find)
+                return i;
+            i++;
+        }
+        return -1;
+    }
+
+    public Track? track_from_clip(Clip clip) {
+        foreach (Track track in tracks) {
+            foreach (Clip match in track.clips) {
+                if (match == clip) {
+                    return track;
+                }
+            }
+        }
+        return null;
+    }
+
+    public void print_graph(Gst.Bin bin, string file_name) {
+        Gst.debug_bin_to_dot_file_with_ts(bin, Gst.DebugGraphDetails.ALL, file_name);
+    }
+
+    public int64 get_length() {
+        int64 max = 0;
+        foreach (Track track in tracks) {
+            max = int64.max(max, track.get_length());
+        }
+        return max;
+    }
+
+    public int64 snap_clip(Clip c, int64 span) {
+        foreach (Track track in tracks) {
+            int64 new_start = track.snap_clip(c, span);
+            if (new_start != c.start) {
+                return new_start;
+            }
+        }
+        return c.start;
+    }
+
+    public void snap_coord(out int64 coord, int64 span) {
+        foreach (Track track in tracks) {
+            if (track.snap_coord(out coord, span)) {
+                break;
+            }
+        }
+    }
+
+    Gap get_gap_intersection(Gap gap) {
+        Gap intersection = gap;
+
+        foreach (Track track in tracks) {
+            intersection = intersection.intersect(track.find_first_gap(intersection.start));
+        }
+
+        return intersection;
+    }
+
+    public bool can_delete_gap(Gap gap) {
+        Gap intersection = get_gap_intersection(gap);
+        return !intersection.is_empty();
+    }
+
+    public void delete_gap(Gap gap) {
+        Gap intersection = get_gap_intersection(gap);
+        assert(!intersection.is_empty());
+
+        foreach (Track track in tracks) {
+            track.delete_gap(intersection);
+        }
+    }
+
+    protected virtual void do_append(Track track, ClipFile clipfile, string name, 
+        int64 insert_time) {
+        switch(track.media_type()) {
+            case MediaType.AUDIO:
+                if (clipfile.audio_caps == null) {
+                    return;
+                }
+                break;
+            case MediaType.VIDEO:
+                if (clipfile.video_caps == null) {
+                    return;
+                }
+            break;
+        }
+
+        Clip clip = new Clip(clipfile, track.media_type(), name, 0, 0, clipfile.length, false);
+        track.append_at_time(clip, insert_time, true);
+    }
+
+    public void append(Track track, ClipFile clipfile) {
+        string name = isolate_filename(clipfile.filename);
+        int64 insert_time = 0;
+
+        foreach (Track temp_track in tracks) {
+            insert_time = int64.max(insert_time, temp_track.get_length());
+        }
+        do_append(track, clipfile, name, insert_time);
+    }
+
+    public void add(Track track, ClipFile clipfile, int64 time) {
+        string name = isolate_filename(clipfile.filename);
+        do_append(track, clipfile, name, time);
+    }
+
+    public void on_clip_removed(Track t, Clip clip) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_clip_removed");
+        reseek();
+    }
+
+    public void split_at_playhead() {
+        string description = "Split At Playhead";
+        undo_manager.start_transaction(description);
+        foreach (Track track in tracks) {
+            if (track.get_clip_by_position(transport_get_position()) != null) {
+                track.split_at(transport_get_position());
+            }
+        }
+        undo_manager.end_transaction(description);
+    }
+
+    public bool can_trim(out bool left) {
+        Clip first_clip = null;
+
+        // When trimming multiple clips, we allow trimming left only if both clips already start
+        // at the same position, and trimming right only if both clips already end at the same
+        // position.
+
+        int64 start = 0;
+        int64 end = 0;
+        bool start_same = true;
+        bool end_same = true;
+        foreach (Track track in tracks) {
+            Clip clip = track.get_clip_by_position(transport_get_position());
+            if (first_clip != null && clip != null) {
+                start_same = start_same && start == clip.start;
+                end_same = end_same && end == clip.end;
+            } else if (clip != null) {
+                first_clip = clip;
+                start = first_clip.start;
+                end = first_clip.end;
+            }
+        }
+
+        if (first_clip == null) {
+            return false;
+        }
+
+        if (start_same && !end_same) {
+            left = true;
+            return true;
+        }
+
+        if (!start_same && end_same) {
+            left = false;
+            return true;
+        }
+
+        // which half of the clip are we closer to?
+        left = (transport_get_position() - first_clip.start < first_clip.duration / 2);
+        return true;
+    }
+
+    public void trim_to_playhead() {
+        bool left;
+        if (!can_trim(out left)) {
+            return;
+        }
+        string description = "Trim To Playhead";
+        Clip first_clip = null;
+        undo_manager.start_transaction(description);
+        foreach (Track track in tracks) {
+            Clip clip = track.get_clip_by_position(transport_get_position());
+            if (clip != null) {
+                int64 delta;
+                if (left) {
+                    delta = transport_get_position() - clip.start;
+                } else {
+                    delta = transport_get_position() - clip.end;
+                }
+                track.trim(clip, delta, left ? Gdk.WindowEdge.WEST : Gdk.WindowEdge.EAST);
+            }
+        }
+        undo_manager.end_transaction(description);
+
+        if (left && first_clip != null) {
+            transport_go(first_clip.start);
+        }
+    }
+
+    public void transport_go(int64 position) {
+        media_engine.go(position);
+    }
+
+    public bool transport_is_playing() {
+        return media_engine.playing;
+    }
+
+    public bool transport_is_recording() {
+        return media_engine.get_play_state() == PlayState.PRE_RECORD ||
+               media_engine.get_play_state() == PlayState.RECORDING;
+    }
+
+    public bool playhead_on_clip() {
+        foreach (Track track in tracks) {
+            if (track.get_clip_by_position(transport_get_position()) != null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public bool playhead_on_contiguous_clip() {
+        foreach (Track track in tracks) {
+            if (track.are_contiguous_clips(transport_get_position())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public bool is_duplicate_track_name(Track? track, string new_name) {
+        assert(new_name != "");
+        foreach (Track this_track in tracks) {
+            if (track != this_track) {
+                if (this_track.get_display_name() == new_name) {
+                    return true;
+                }
+            }
+        }
+        
+        foreach (Track this_track in inactive_tracks) {
+            if (track != this_track) {
+                if (this_track.get_display_name() == new_name) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    public virtual void add_track(Track track) {
+        track.clip_removed.connect(on_clip_removed);
+        track.error_occurred.connect(on_error_occurred);
+        tracks.add(track);
+        track_added(track);
+    }
+    public virtual void insert_track(int index, Track track) {
+        if (0 > index) index = 0;
+        if (tracks.size <= index) {
+          add_track(track);
+        }
+        else {
+          track.clip_removed.connect(on_clip_removed);
+          track.error_occurred.connect(on_error_occurred);
+          tracks.insert(index, track);
+          track_added(track);
+        }
+    }
+
+    public void add_inactive_track(Track track) {
+        track.hide();
+        inactive_tracks.add(track);
+    }
+
+    public void remove_track(Track track) {
+        media_engine.pipeline.set_state(Gst.State.NULL);
+        track.track_removed(track);
+        tracks.remove(track);
+        track_removed(track);
+    }
+    
+    public void remove_track_at(int index) {
+        remove_track(tracks.get(index));
+    }
+
+    public void add_clipfile(ClipFile clipfile) {
+        Model.Command command = new Model.AddClipCommand(this, clipfile);
+        do_command(command);
+    }
+
+    public void _add_clipfile(ClipFile clipfile) throws Error {
+        clipfiles.add(clipfile);
+        if (clipfile.is_online() && clipfile.is_of_type(MediaType.VIDEO)) {
+            ThumbnailFetcher fetcher = new ThumbnailFetcher(clipfile, 0);
+            fetcher.ready.connect(on_thumbnail_ready);
+            pending_thumbs.add(fetcher);
+        } else {
+            clipfile_added(clipfile);
+        }
+    }
+
+    void on_thumbnail_ready(Fetcher f) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_thumbnail_ready");
+        clipfile_added(f.clipfile);
+        pending_thumbs.remove(f as ThumbnailFetcher);
+    }
+
+    public bool clipfile_on_track(string filename) {
+        ClipFile cf = find_clipfile(filename);
+
+        foreach (Track t in tracks) {
+            foreach (Clip c in t.clips) {
+                if (c.clipfile == cf)
+                    return true;
+            }
+        }
+
+        foreach (Track t in inactive_tracks) {
+            foreach (Clip c in t.clips) {
+                if (c.clipfile == cf)
+                    return true;
+            }
+        }
+
+        return false;
+    }
+
+    void delete_clipfile_from_tracks(ClipFile cf) {
+        foreach (Track t in tracks) {
+            for (int i = 0; i < t.clips.size; i++) {
+                if (t.clips[i].clipfile == cf) {
+                    t.delete_clip(t.clips[i]);
+                    i --;
+                }
+            }
+        }
+
+        foreach (Track t in inactive_tracks) {
+            for (int i = 0; i < t.clips.size; i++) {
+                if (t.clips[i].clipfile == cf) {
+                    t.delete_clip(t.clips[i]);
+                    i --;
+                }
+            }
+        }
+    }
+
+    public void _remove_clipfile(ClipFile cf) {
+        clipfiles.remove(cf);
+        clipfile_removed(cf);
+    }
+
+    public void remove_clipfile(string filename) {
+        ClipFile cf = find_clipfile(filename);
+        if (cf != null) {
+            string description = "Delete From Library";
+            undo_manager.start_transaction(description);
+
+            delete_clipfile_from_tracks(cf);
+
+            Command clipfile_delete = new ClipFileDeleteCommand(this, cf);
+            do_command(clipfile_delete);
+
+            undo_manager.end_transaction(description);
+        }
+    }
+    
+    public ClipFile? find_clipfile(string filename) {
+        foreach (ClipFile cf in clipfiles)
+            if (cf.filename == filename)
+                return cf;
+        return null;
+    }
+
+    public void reseek() { transport_go(transport_get_position()); }
+
+    public void go_start() { transport_go(0); }
+
+    public void go_end() { transport_go(get_length()); }
+
+    public void go_previous() {
+        int64 start_pos = transport_get_position();
+
+        // If we're currently playing, we jump to the previous clip if we're within the first
+        // second of the current clip.
+        if (transport_is_playing())
+            start_pos -= 1 * Gst.SECOND;
+
+        int64 new_position = 0;
+        foreach (Track track in tracks) {
+            new_position = int64.max(new_position, track.previous_edit(start_pos));
+        }
+        transport_go(new_position);
+    }
+
+    // Move to the next clip boundary after the current transport position.
+    public void go_next() {
+        int64 start_pos = transport_get_position();
+        int64 new_position = get_length();
+        foreach (Track track in tracks) {
+            if (track.get_length() > start_pos) {
+                new_position = int64.min(new_position, track.next_edit(start_pos));
+            }
+        }
+        transport_go(new_position);
+    }
+
+    public int64 transport_get_position() {
+        return media_engine.position;
+    }
+
+    public void set_name(string? filename) {
+        if (filename != null) {
+            this.project_file = filename;
+        }
+        name_changed(filename);
+    }
+
+    public void set_default_framerate(Fraction rate) {
+        default_framerate = rate;
+    }
+
+    public string get_file_display_name() {
+        string filename = get_project_file();
+        if (filename == null) {
+            return "Unsaved Project - %s".printf(get_app_name());
+        }
+        else {
+            string dir = Path.get_dirname(filename);
+            string name = Path.get_basename(filename);
+            string home_path = GLib.Environment.get_home_dir();
+
+            if (dir == ".")
+                dir = GLib.Environment.get_current_dir();
+
+            if (dir.has_prefix(home_path))
+                dir = "~" + dir.substring(home_path.length);
+            return "%s (%s) - %s".printf(name, dir, get_app_name());
+        }
+    }
+
+    public void clear() {
+        media_engine.set_gst_state(Gst.State.NULL);
+
+        foreach (Track track in tracks) {
+            track.delete_all_clips();
+            track.track_removed(track);
+            track_removed(track);
+        }
+
+        tracks.clear();
+        
+        clipfiles.clear();
+        set_name(null);
+        cleared();
+    }
+
+    public bool can_export() {
+        if (media_engine.get_play_state() != PlayState.STOPPED) {
+            return false;
+        }
+        foreach (Track track in tracks) {
+            if (track.get_length() > 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public void on_load_started(string filename) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_load_started");
+        project_file = filename;
+    }
+
+    void on_load_error(string error) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_load_error");
+        load_error(error);
+    }
+
+    void on_load_complete() {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_load_complete");
+        undo_manager.reset();
+        set_name(project_file);
+        load_complete(); 
+    }
+
+    // Load a project file.  The load is asynchronous: it may continue after this method returns.
+    // Any load error will be reported via the load_error signal, which may run either while this
+    // method executes or afterward.
+    public virtual void load(string? fname) {
+        emit(this, Facility.LOADING, Level.INFO, "loading project");
+        clear();
+        set_name(null);
+        if (fname == null) {
+            return;
+        }
+
+        loader = new ProjectLoader(new MediaLoaderHandler(this), fname);
+
+        loader.load_started.connect(on_load_started);
+        loader.load_error.connect(on_load_error);
+        loader.load_complete.connect(on_load_complete);
+        loader.load_complete.connect(media_engine.on_load_complete);
+        media_engine.set_play_state(PlayState.LOADING);
+        media_engine.pipeline.set_state(Gst.State.NULL);
+        loader.load();
+    }
+
+    public void on_error_occurred(string major_error, string? minor_error) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_error_occurred");
+        error_occurred(major_error, minor_error);
+    }
+
+    public int get_file_version() {
+        return 4;
+    }
+
+    public void save_library(FileStream f) {
+        f.printf("  <library");
+
+        Fraction r = default_framerate;
+
+        foreach (Track t in tracks) {
+            if (t.media_type () == MediaType.VIDEO) {
+                VideoTrack video_track = t as VideoTrack;
+                if (video_track.get_framerate(out r))
+                    break;
+            }
+        }
+        if (!r.equal(INVALID_FRAME_RATE))
+            f.printf(" framerate=\"%d/%d\"", r.numerator, 
+                                             r.denominator);
+        f.printf(">\n");
+
+        for (int i = 0; i < clipfiles.size; i++) {
+            f.printf("    <clipfile filename=\"%s\" id=\"%d\"/>\n", clipfiles[i].filename, i);
+        }
+
+        f.printf("  </library>\n");
+    }
+
+    public virtual void save(string? filename) {
+        if (filename != null) {
+            set_name(filename);
+        }
+
+        FileStream f = FileStream.open(project_file, "w");
+        if (f == null) {
+            error_occurred("Could not save project",
+                "%s: %s".printf(project_file, GLib.strerror(GLib.errno)));
+            return;
+        }
+        f.printf("<marina version=\"%d\">\n", get_file_version());
+
+        save_library(f);
+
+        f.printf("  <tracks>\n");
+        foreach (Track track in tracks) {
+            track.save(f);
+        }
+        
+        foreach (Track track in inactive_tracks) {
+            track.save(f);
+        }
+        f.printf("  </tracks>\n");
+        f.printf("  <preferences>\n");
+        f.printf("    <click on_play=\"%s\" on_record=\"%s\" volume=\"%lf\"/>\n", 
+            click_during_play ? "true" : "false",
+            click_during_record ? "true" : "false",
+            click_volume);
+        f.printf("    <library width=\"%d\" visible=\"%s\" />\n",
+            library_width, library_visible ? "true" : "false");
+        f.printf("  </preferences>\n");
+        f.printf("  <maps>\n");
+        f.printf("    <tempo>\n");
+        f.printf("      <entry tempo=\"%d\" />\n", tempo);
+        f.printf("    </tempo>\n");
+        f.printf("    <time_signature>\n");
+        f.printf("      <entry signature=\"%s\" />\n", time_signature.to_string());
+        f.printf("    </time_signature>\n");
+        f.printf("  </maps>\n");
+
+        f.printf("</marina>\n");
+        f.flush();
+
+        // TODO: clean up responsibility between dirty and undo
+        undo_manager.mark_clean();
+    }
+
+    public void close() {
+        media_engine.close();
+    }
+
+    public void on_importer_clip_complete(ClipFetcher fetcher) {
+        if (fetcher.error_string != null) {
+            error_occurred("Error importing clip", fetcher.error_string);
+        } else {
+            fetcher_completion.complete(fetcher);
+        }
+    }
+
+    public void create_clip_fetcher(FetcherCompletion fetcher_completion, string filename) 
+            throws Error {
+        ClipFetcher fetcher = new ClipFetcher(filename);
+        this.fetcher_completion = fetcher_completion;
+        fetcher.ready.connect(on_fetcher_ready);
+        pending.add(fetcher);
+    }
+
+    // TODO: We should be using Library importer rather than this mechanism for fillmore
+    void on_fetcher_ready(Fetcher fetcher) {
+        emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_fetcher_ready");
+        pending.remove(fetcher as ClipFetcher);
+        if (fetcher.error_string != null) {
+            emit(this, Facility.DEVELOPER_WARNINGS, Level.INFO, fetcher.error_string);
+            error_occurred("Error retrieving clip", fetcher.error_string);
+        } else {
+            if (get_clipfile_index(fetcher.clipfile) == -1) {
+                add_clipfile(fetcher.clipfile);
+            }
+            fetcher_completion.complete(fetcher);
+        }
+    }
+
+    public bool is_project_extension(string filename) {
+        string extension = get_file_extension(filename);
+        return extension == LOMBARD_FILE_EXTENSION || extension == FILLMORE_FILE_EXTENSION;
+    }
+
+    public void do_command(Command the_command) {
+        undo_manager.do_command(the_command);
+    }
+
+    public void undo() {
+        undo_manager.undo();
+    }
+
+    void ClearTrackMeters() {
+        foreach (Track track in tracks) {
+            AudioTrack audio_track = track as AudioTrack;
+            if (audio_track != null) {
+                audio_track.level_changed(-100, -100);
+            }
+        }
+    }
+
+    public void create_clip_importer(Model.Track? track, bool timeline_add, 
+            int64 time_to_add, bool both_tracks, Gtk.Window? progress_window_parent, int number) {
+        if (timeline_add) {
+            assert(track != null);
+            importer = new Model.TimelineImporter(track, this, time_to_add, both_tracks);
+        } else {
+            importer = new Model.LibraryImporter(this);
+        }
+        if (progress_window_parent != null) {
+            new MultiFileProgress(progress_window_parent, number, "Import", 
+                importer.importer);
+        }
+
+    }
+
+    public string get_version() {
+        return _VERSION;
+    }
+
+    public abstract string get_app_name();
+
+    public string get_license() {
+        return license.printf(get_app_name(), get_app_name(), get_app_name());
+    }
+
+    public void set_time_signature(Fraction time_signature) {
+        TimeSignatureCommand command = new TimeSignatureCommand(this, time_signature);
+        undo_manager.do_command(command);
+    }
+
+    public void _set_time_signature(Fraction time_signature) {
+        this.time_signature = time_signature;
+        time_signature_changed(time_signature);
+    }
+
+    public Fraction get_time_signature() {
+        return time_signature;
+    }
+
+    public void set_bpm(int bpm) {
+        BpmCommand command = new BpmCommand(this, bpm);
+        undo_manager.do_command(command);
+    }
+
+    public void _set_bpm(int bpm) {
+        this.tempo = bpm;
+        bpm_changed(bpm);
+    }
+
+    public int get_bpm() {
+        return tempo;
+    }
+
+    public string get_audio_path() {
+        string path = get_path();
+        return path == null ? null : Path.build_filename(path, "audio files");
+    }
+
+    string get_path() {
+        return project_file == null ? null : Path.get_dirname(project_file);
+    }
+
+    public VideoTrack? find_video_track() {
+        foreach (Track track in tracks) {
+            if (track is VideoTrack) {
+                return track as VideoTrack;
+            }
+        }
+        return null;
+    }
+
+    public AudioTrack? find_audio_track() {
+        foreach (Track track in tracks) {
+            if (track is AudioTrack) {
+                return track as AudioTrack;
+            }
+        }
+        return null;
+    }
+}
+}
+