I significantly improved this to be more like VLC.

It has weird issues with the scrubber.

I like the features, but some of the features and some of the issues kind of are making it not as good as it could be.

give full final bash

#!/usr/bin/env bash
set -euo pipefail

# ----------------------------------------------------------
# CONFIG
# ----------------------------------------------------------
BASE_APP_NAME="CropPlayer-V9"        # bumped version
APP_NAME="${BASE_APP_NAME}-$(date +%s)"
VENV_NAME="venv_player"
DESKTOP_PATH="$HOME/Desktop"
WORK_DIR="$DESKTOP_PATH/${APP_NAME}_Build"

# ----------------------------------------------------------
# 1.  Prepare workspace
# ----------------------------------------------------------
echo "=== CropPlayer Build Script ==="
echo "Working in: $WORK_DIR"
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"

# ----------------------------------------------------------
# 2.  Python environment
# ----------------------------------------------------------
python3 -m venv "$VENV_NAME"
source "$VENV_NAME/bin/activate"
pip install --upgrade pip
pip install PyQt5==5.15.10 pyinstaller==5.13.2

# ----------------------------------------------------------
# 3.  Generate new player.py
# ----------------------------------------------------------
cat > player.py <<'PYEOF'
import sys, os, sqlite3
from PyQt5.QtWidgets import *
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtMultimediaWidgets import QVideoWidget
from PyQt5.QtCore import Qt, QUrl, QTimer, QEvent
from PyQt5.QtGui import QIcon

DEFAULT_TOP_CROP = 90

# ------------- Settings helper -------------
class SettingsManager:
    def __init__(self, app_name):
        base = os.path.expanduser("~/Library/Application Support")
        path = os.path.join(base, app_name)
        os.makedirs(path, exist_ok=True)
        self.db = os.path.join(path, "settings.db")
        self.init_db()
    def init_db(self):
        with sqlite3.connect(self.db) as c:
            c.execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT)")
    def get(self, k, default=None):
        with sqlite3.connect(self.db) as c:
            r = c.execute("SELECT value FROM settings WHERE key=?", (k,)).fetchone()
            return r[0] if r else default
    def set(self, k, v):
        with sqlite3.connect(self.db) as c:
            c.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (k, str(v)))

# ------------- Crop settings dialog -------------
class CropSettingsDialog(QDialog):
    def __init__(self, sm, parent=None):
        super().__init__(parent); self.sm = sm
        self.setWindowTitle("Crop Settings")
        lay = QFormLayout(self)
        for side in ("top","bottom","left","right"):
            setattr(self, side, QSpinBox())
            getattr(self, side).setRange(0,8000)
            lay.addRow(f"{side.capitalize()} crop:", getattr(self, side))
        tip = QLabel("Tip: Shift+Arrows (1 px), Shift+Ctrl+Arrows (10 px)\nwhile video is playing.")
        tip.setStyleSheet("font-size:11px;color:#555"); tip.setWordWrap(True)
        lay.addRow(tip)
        self.load()
        btn = QPushButton("Save & Close"); btn.clicked.connect(self.accept)
        h = QHBoxLayout(); h.addStretch(); h.addWidget(btn)
        lay.addRow(h)
    def load(self):
        self.top.setValue(int(self.sm.get("crop_top", DEFAULT_TOP_CROP)))
        self.bottom.setValue(int(self.sm.get("crop_bottom", 0)))
        self.left.setValue(int(self.sm.get("crop_left", 0)))
        self.right.setValue(int(self.sm.get("crop_right", 0)))
    def accept(self):
        for side in ("top","bottom","left","right"):
            self.sm.set(f"crop_{side}", getattr(self, side).value())
        super().accept()

# ------------- Main window -------------
class VideoPlayer(QMainWindow):
    def __init__(self):
        super().__init__()
        self.sm = SettingsManager("CropPlayer-V9")
        self.setWindowTitle("CropPlayer-V9")
        self.setGeometry(150,150,900,700)
        self.setWindowIcon(QIcon.fromTheme("multimedia-video-player"))
        self.player = QMediaPlayer(None, QMediaPlayer.VideoSurface)
        self.playlist = []; self.idx = -1
        self.playback_rates = [1.0,1.2,1.5,1.7,2.0]; self.rate_idx = 0

        # ---------- UI ----------
        self.video_container = QWidget(); self.video_container.setStyleSheet("background:black")
        self.video_widget = QVideoWidget(); self.video_widget.setStyleSheet("background:black")
        self.video_widget.setAspectRatioMode(Qt.KeepAspectRatio)
        self.player.setVideoOutput(self.video_widget)

        self.play_btn = QPushButton("▶"); self.play_btn.setFixedWidth(50)
        self.play_btn.setStyleSheet("font-size:20px;padding-bottom:2px")
        self.play_btn.setEnabled(False)

        self.slider = QSlider(Qt.Horizontal); self.slider.setRange(0,0)
        self.time_lbl = QLabel("00:00 / 00:00")
        self.vol_slider = QSlider(Qt.Horizontal); self.vol_slider.setRange(0,100)
        self.speed_btn = QPushButton("1.0x"); self.speed_btn.setFixedWidth(60)

        # NEW jump buttons
        self.btn_minus5 = QPushButton("-5s"); self.btn_minus3 = QPushButton("-3s")
        self.btn_plus3  = QPushButton("+3s"); self.btn_plus5  = QPushButton("+5s")
        for b in (self.btn_minus5,self.btn_minus3,self.btn_plus3,self.btn_plus5):
            b.setFixedWidth(50)

        h = QHBoxLayout()
        h.addWidget(self.play_btn)
        h.addWidget(self.btn_minus5); h.addWidget(self.btn_minus3)
        h.addWidget(self.slider,1)
        h.addWidget(self.btn_plus3); h.addWidget(self.btn_plus5)
        h.addWidget(self.time_lbl)
        h.addWidget(self.vol_slider)
        h.addWidget(self.speed_btn)

        self.controls = QWidget(); self.controls.setLayout(h)
        v = QVBoxLayout(); v.setContentsMargins(0,0,0,0)
        v.addWidget(self.video_container,1)
        v.addWidget(self.controls,0)
        central = QWidget(); central.setLayout(v); central.setStyleSheet("background:black")
        self.setCentralWidget(central)

        self.playlist_dock = QDockWidget("Playlist"); self.playlist_dock.hide()
        self.playlist_list = QListWidget()
        self.playlist_dock.setWidget(self.playlist_list)
        self.addDockWidget(Qt.RightDockWidgetArea, self.playlist_dock)

        # ---------- timers ----------
        self.hide_timer = QTimer(); self.hide_timer.setSingleShot(True)
        self.hide_timer.timeout.connect(self.hide_controls)
        self.feedback_timer = QTimer(); self.feedback_timer.setSingleShot(True)
        self.feedback_timer.timeout.connect(self.hide_feedback)
        self.crop_lbl = QLabel(self.video_container)
        self.crop_lbl.setStyleSheet("background:rgba(0,0,0,0.7);color:white;font-size:18px;padding:10px;border-radius:8px")
        self.crop_lbl.hide()

        self.create_menus()
        self.connect_signals()
        self.load_settings()
        self.apply_crop()
        self.centralWidget().setMouseTracking(True)
        QApplication.instance().installEventFilter(self)
        QTimer.singleShot(100, self.open_last)

    # ---------- menu ----------
    def create_menus(self):
        mb = self.menuBar()
        f = mb.addMenu("&File"); s = mb.addMenu("&Settings"); v = mb.addMenu("&View")
        f.addAction("Open File...", self.open_file)
        f.addAction("Open Folder...", self.open_folder)
        f.addSeparator(); f.addAction("Exit", self.close)
        s.addAction("Crop Video...", self.open_crop)
        s.addAction("Playback Speed...", self.set_custom_speed)
        v.addAction(self.playlist_dock.toggleViewAction())

    # ---------- connections ----------
    def connect_signals(self):
        self.play_btn.clicked.connect(self.toggle_play)
        self.slider.sliderMoved.connect(self.player.setPosition)
        self.vol_slider.valueChanged.connect(self.player.setVolume)
        self.speed_btn.clicked.connect(self.cycle_speed)
        self.player.stateChanged.connect(self.play_state_changed)
        self.player.positionChanged.connect(self.pos_changed)
        self.player.durationChanged.connect(self.dur_changed)
        self.player.mediaStatusChanged.connect(self.media_done)
        self.player.error.connect(self.handle_error)
        self.playlist_list.itemDoubleClicked.connect(self.play_selected)

        # jump buttons
        self.btn_minus5.clicked.connect(lambda: self.jump(-5000))
        self.btn_minus3.clicked.connect(lambda: self.jump(-3000))
        self.btn_plus3.clicked.connect(lambda: self.jump(3000))
        self.btn_plus5.clicked.connect(lambda: self.jump(5000))

    # ---------- helpers ----------
    def jump(self, ms):
        self.player.setPosition(max(0, min(self.player.duration(), self.player.position()+ms)))

    def open_file(self):
        path = os.path.dirname(self.sm.get("last", os.path.expanduser("~/Desktop")))
        f, _ = QFileDialog.getOpenFileName(self, "Open", path, "Videos (*.mp4 *.avi *.mkv *.mov)")
        if f: self.load_video(f)

    def open_folder(self):
        path = QFileDialog.getExistingDirectory(self, "Open Folder", os.path.dirname(self.sm.get("last","")))
        if not path: return
        files = sorted([os.path.join(path,f) for f in os.listdir(path) if f.lower().endswith((".mp4",".avi",".mkv",".mov"))])
        if files:
            self.playlist = files
            self.playlist_list.clear()
            for f in files: self.playlist_list.addItem(os.path.basename(f))
            self.playlist_dock.show()
            self.load_video(files[0]); self.idx = 0; self.playlist_list.setCurrentRow(0)

    def load_video(self, path):
        self.player.setMedia(QMediaContent(QUrl.fromLocalFile(path)))
        self.play_btn.setEnabled(True); self.player.play()
        self.sm.set("last", path)
        if path in self.playlist:
            self.idx = self.playlist.index(path)
            self.playlist_list.setCurrentRow(self.idx)

    def open_last(self):
        f = self.sm.get("last")
        if f and os.path.exists(f): self.load_video(f)

    # ---------- crop ----------
    def apply_crop(self):
        t,b,l,r = [int(self.sm.get(f"crop_{x}",0)) for x in ("top","bottom","left","right")]
        g = self.video_container.geometry()
        w = g.width()+l+r; h = g.height()+t+b
        if w>0 and h>0:
            self.video_widget.setGeometry(-l,-t,w,h)

    def resizeEvent(self, e):
        super().resizeEvent(e)
        QTimer.singleShot(0, self.apply_crop)
        self.pos_crop_lbl()

    def pos_crop_lbl(self):
        g = self.video_container.geometry(); lg = self.crop_lbl.geometry()
        self.crop_lbl.move((g.width()-lg.width())//2, (g.height()-lg.height())//2)

    # ---------- fullscreen / hide controls ----------
    def changeEvent(self, e):
        if e.type()==QEvent.WindowStateChange:
            if self.isFullScreen():
                self.restart_hide_timer()
            else:
                self.show_controls(); self.hide_timer.stop()
        super().changeEvent(e)

    def mouseMoveEvent(self, e):
        if self.isFullScreen():
            self.show_controls(); self.restart_hide_timer()
        super().mouseMoveEvent(e)

    def restart_hide_timer(self):
        self.hide_timer.start(3000)

    def hide_controls(self):
        if self.isFullScreen():
            self.controls.hide(); self.setCursor(Qt.BlankCursor)

    def show_controls(self):
        self.controls.show(); self.unsetCursor()

    # ---------- key / event ----------
    def eventFilter(self, src, e):
        if e.type()==QEvent.KeyPress and e.key()==Qt.Key_Space:
            if not e.isAutoRepeat():
                self.toggle_play(); return True
        return super().eventFilter(src, e)

    def keyPressEvent(self, e):
        mods = QApplication.keyboardModifiers()
        amt = 10 if (mods & Qt.ControlModifier) else 1
        handled = False
        if mods & Qt.ShiftModifier:
            if e.key()==Qt.Key_Up:   self.nudge("top",-amt); handled=True
            elif e.key()==Qt.Key_Down: self.nudge("top",amt); handled=True
            elif e.key()==Qt.Key_Left: self.nudge("left",-amt); handled=True
            elif e.key()==Qt.Key_Right: self.nudge("left",amt); handled=True
        if handled: e.accept(); return
        super().keyPressEvent(e)

    def mouseDoubleClickEvent(self, e):
        if self.video_container.geometry().contains(e.pos()):
            self.setWindowState(self.windowState() ^ Qt.WindowFullScreen)
            e.accept()

    # ---------- misc ----------
    def open_crop(self):
        d = CropSettingsDialog(self.sm, self)
        if d.exec_(): self.apply_crop()

    def set_custom_speed(self):
        txt, ok = QInputDialog.getText(self, "Speed", "Enter speed:", text=f"{self.player.playbackRate():.2f}")
        if ok:
            try:
                r = float(txt)
                if r>0:
                    self.player.setPlaybackRate(r)
                    self.speed_btn.setText(f"{r:.2f}x")
                    self.sm.set("playback_rate", r)
            except ValueError: QMessageBox.warning(self, "Invalid", "Enter a number.")

    def cycle_speed(self):
        self.rate_idx = (self.rate_idx+1)%len(self.playback_rates)
        r = self.playback_rates[self.rate_idx]
        self.player.setPlaybackRate(r)
        self.speed_btn.setText(f"{r:.1f}x")
        self.sm.set("playback_rate", r)

    def toggle_play(self):
        self.player.play() if self.player.state()==QMediaPlayer.StoppedState or self.player.state()==QMediaPlayer.PausedState else self.player.pause()

    def play_state_changed(self, s):
        self.play_btn.setText("❚❚" if s==QMediaPlayer.PlayingState else "▶")

    def pos_changed(self, p):
        self.slider.setValue(p)
        self.update_lbl(p, self.player.duration())

    def dur_changed(self, d):
        self.slider.setRange(0,d)
        self.update_lbl(self.player.position(), d)

    def update_lbl(self, pos, dur):
        if dur==0: return
        p,s = pos//1000, dur//1000
        self.time_lbl.setText(f"{p//60:02}:{p%60:02} / {s//60:02}:{s%60:02}")

    def media_done(self, s):
        if s==QMediaPlayer.EndOfMedia and self.idx>=0 and self.idx<len(self.playlist)-1:
            self.idx+=1; self.load_video(self.playlist[self.idx]); self.playlist_list.setCurrentRow(self.idx)

    def play_selected(self, item):
        self.load_video(self.playlist[self.playlist_list.row(item)])

    def nudge(self, side, amt):
        v = max(0, int(self.sm.get(f"crop_{side}",0))+amt)
        self.sm.set(f"crop_{side}", v)
        self.apply_crop()
        self.crop_lbl.setText(f"{side.capitalize()} crop: {v}px")
        self.crop_lbl.adjustSize(); self.pos_crop_lbl(); self.crop_lbl.show()
        self.feedback_timer.start(1500)

    def hide_feedback(self): self.crop_lbl.hide()

    def load_settings(self):
        self.vol_slider.setValue(int(self.sm.get("volume",80)))
        r = float(self.sm.get("playback_rate",1.0))
        self.rate_idx = self.playback_rates.index(r) if r in self.playback_rates else 0
        self.player.setPlaybackRate(r); self.speed_btn.setText(f"{r:.1f}x")

    def closeEvent(self, e):
        self.sm.set("volume", self.vol_slider.value())
        if self.player.mediaStatus()==QMediaPlayer.LoadedMedia:
            self.sm.set("last", self.player.currentMedia().canonicalUrl().toLocalFile())
        super().closeEvent(e)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = VideoPlayer()
    w.show()
    sys.exit(app.exec_())
PYEOF

# ----------------------------------------------------------
# 4.  Build .app bundle
# ----------------------------------------------------------
cat > build.spec <<'SPECF'
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(['player.py'],
             pathex=['.'],
             binaries=[],
             datas=[],
             hiddenimports=['PyQt5.sip', 'PyQt5.QtMultimedia', 'PyQt5.QtMultimediaWidgets'],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=None)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(pyz,
          a.scripts,
          [],
          exclude_binaries=True,
          name='CropPlayer-V9',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          console=False,
          icon=None)
coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=True,
               upx_exclude=[],
               name='CropPlayer-V9')
app = BUNDLE(coll,
             name='CropPlayer-V9.app',
             bundle_identifier='com.user.cropplayer.v9',
             info_plist={'NSHighResolutionCapable': 'True'})
SPECF

echo "Running PyInstaller..."
pyinstaller --noconfirm build.spec

APP_PATH="$PWD/dist/CropPlayer-V9.app"
if [[ -d "$APP_PATH" ]]; then
    echo "--------------------------------------------------------"
    echo "✅  Build complete!"
    echo "   $APP_PATH"
    echo "--------------------------------------------------------"
    open "$PWD/dist"
else
    echo "❌  Build failed – check log above"
    exit 1
fi