JLindvig

TV Player

Jan 12th, 2026
577
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
YAML 14.39 KB | None | 0 0
  1. # ==============================================================================
  2. # DEL 1: CONFIGURATION.YAML (Eller packages/helpers)
  3. # ==============================================================================
  4.  
  5. # 1. Input Select - Holder styr på hvilken kanal der er valgt
  6. # (Kan også oprettes via GUI under Indstillinger -> Enheder -> Hjælpere)
  7. input_select:
  8.   tv_stream_source:
  9.     name: TV Stream Source
  10.     icon: mdi:television
  11.     options:
  12.      - DR 1
  13.       - DR 2
  14.       - TVA
  15.       - Ramasjang
  16.       - Storebælt
  17.  
  18. # 2. Template Sensor - "Hjernen" der holder URL'er og logik
  19. template:
  20.   - trigger:
  21.       - trigger: state
  22.         entity_id: input_select.tv_stream_source
  23.       - trigger: homeassistant
  24.         event: start
  25.       - trigger: event
  26.         event_type: event_template_reloaded
  27.  
  28.     sensor:
  29.       - name: "TV Stream Context"
  30.         unique_id: tv_stream_context
  31.         state: "{{ states('input_select.tv_stream_source') }}"
  32.         attributes:
  33.          # Databasen over alle kanaler (DRY: Defineres kun ét sted)
  34.           sources: >
  35.            {% set base_url = "https://prod95-static.dr-massive.com/api/shain/v1/dataservice/ResizeImage/$value?Format=%27png%27&Quality=90&Width=320&Height=320&ImageId=" %}
  36.             {% set map = {
  37.               "DR 1": {
  38.                 "image": base_url ~ 2344192,
  39.                 "url": "https://drlivedr1hls.akamaized.net/hls/live/2113625/drlivedr1/master.m3u8"
  40.               },
  41.               "DR 2": {
  42.                 "image": base_url ~ 46461911,
  43.                 "url": "https://drlivedr2hls.akamaized.net/hls/live/2113623/drlivedr2/master.m3u8"
  44.               },
  45.               "TVA": {
  46.                 "image": base_url ~ 16100144,
  47.                 "url": "https://drlivedrtvahls.akamaized.net/hls/live/2113613/drlivedrtva/master.m3u8"
  48.               },
  49.               "Ramasjang": {
  50.                 "image": base_url ~ 2399380,
  51.                 "url": "https://drlivedrrhls.akamaized.net/hls/live/2113621/drlivedrr/master.m3u8"
  52.               },
  53.               "Storebælt": {
  54.                 "image": "/local/images/icons/storebælt_tv.jpg",
  55.                 "url": "https://stream.sob.m-dn.net/live/sb1/index.m3u8"
  56.               }
  57.             } %}
  58.             {{ map }}
  59.  
  60.           # Den URL playeren skal afspille LIGE NU
  61.           stream_url: >-
  62.             {{ this.attributes.sources.get(states('input_select.tv_stream_source'), {'url': ''}).url }}
  63.  
  64.  
  65. # ==============================================================================
  66. # DEL 2: HTML PLAYER
  67. # Opret en fil i din config mappe: /www/tv_player.html
  68. # ==============================================================================
  69.  
  70. <!DOCTYPE html>
  71. <html>
  72. <head>
  73.  <meta charset="utf-8">
  74.  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  75.  <title>HA TV Player</title>
  76.    <style>
  77.         body { margin: 0; background: #000; overflow: hidden; font-family: sans-serif; }
  78.         video { width: 100vw; height: 100vh; object-fit: contain; }
  79.  
  80.         /* --- UI Overlay Container --- */
  81.         #ui-layer {
  82.             position: absolute;
  83.             bottom: 0;
  84.             left: 0;
  85.             width: 100%;
  86.             height: 80px;
  87.             background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
  88.             display: flex;
  89.             align-items: center;
  90.             padding: 0 20px;
  91.             box-sizing: border-box;
  92.             opacity: 0;
  93.             transition: opacity 0.5s ease;
  94.         }
  95.         #ui-layer.visible { opacity: 1; }
  96.  
  97.         /* --- Volume Slider --- */
  98.         input[type=range] {
  99.             -webkit-appearance: none;
  100.             width: 200px;
  101.             height: 6px;
  102.             background: rgba(255,255,255,0.3);
  103.             border-radius: 3px;
  104.             outline: none;
  105.             margin-left: 15px;
  106.         }
  107.         input[type=range]::-webkit-slider-thumb {
  108.             -webkit-appearance: none;
  109.             appearance: none;
  110.             width: 20px;
  111.             height: 20px;
  112.             border-radius: 50%;
  113.             background: #a83232;
  114.             cursor: pointer;
  115.             box-shadow: 0 0 10px rgba(0,0,0,0.5);
  116.         }
  117.  
  118.         /* --- Knapper (Mute + Fullscreen) --- */
  119.         .icon-btn {
  120.             background: none;
  121.             border: none;
  122.             cursor: pointer;
  123.             padding: 0;
  124.             width: 32px;
  125.             height: 32px;
  126.             fill: white;
  127.             display: flex;
  128.             align-items: center;
  129.             justify-content: center;
  130.         }
  131.         #fs-btn { margin-left: auto; }
  132.     </style>
  133.  <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
  134. </head>
  135. <body>
  136.    <video id="video" autoplay playsinline ondblclick="toggleFullscreen()"></video>
  137.     <div id="ui-layer">
  138.        <button id="mute-btn" class="icon-btn" onclick="toggleMute()">
  139.            <svg viewBox="0 0 24 24" width="28" height="28"><path id="mute-icon-path" d="M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.48,8.71 14,7.97V16.02C15.48,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z" /></svg>
  140.        </button>
  141.        <input type="range" id="vol-control" min="0" max="1" step="0.05" oninput="setVolume(this.value)" onchange="setVolume(this.value)">
  142.        <button id="fs-btn" class="icon-btn" onclick="toggleFullscreen()">
  143.            <svg viewBox="0 0 24 24" width="28" height="28"><path id="fs-icon-path" d="M7,14H5V19H10V17H7V14M5,10H7V7H10V5H5V10M19,14H17V17H14V19H19V14M14,5V7H17V10H19V5H14Z" /></svg>
  144.        </button>
  145.    </div>
  146.     <script>
  147.        const urlParams = new URLSearchParams(window.location.search);
  148.         const HA_URL = window.location.origin;
  149.         const PLAYER_ENTITY = urlParams.get('entity') || "sensor.tv_stream_context";
  150.  
  151.         function getAuthToken() {
  152.             if (urlParams.get('token')) return urlParams.get('token');
  153.             try {
  154.                 const rawTokens = window.parent.localStorage.getItem('hassTokens');
  155.                 if (rawTokens) {
  156.                     const tokens = JSON.parse(rawTokens);
  157.                     return tokens.access_token;
  158.                 }
  159.             } catch (e) {
  160.                 console.warn("Kunne ikke hente token fra parent vindue. Er du på samme domæne?", e);
  161.             }
  162.             return null;
  163.         }
  164.         const HA_TOKEN = getAuthToken();
  165.  
  166.         if (!HA_TOKEN) {
  167.             const msg = "Fejl: Kunne ikke finde Auth Token.<br>Er du logget ind i HA?";
  168.            document.body.innerHTML = `<h1 style='color:white;padding:20px;font-family:sans-serif'>${msg}</h1>`;
  169.            throw new Error("No Token Found");
  170.        }
  171.  
  172.        const video = document.getElementById('video');
  173.        const uiLayer = document.getElementById('ui-layer');
  174.        const volSlider = document.getElementById('vol-control');
  175.        const muteIconPath = document.getElementById('mute-icon-path');
  176.  
  177.        let hls = null;
  178.        let currentUrl = "";
  179.        let uiTimeout;
  180.  
  181.        // Gendan volumen fra sidst
  182.        const savedVol = localStorage.getItem('tv_volume');
  183.        if (savedVol) {
  184.            video.volume = parseFloat(savedVol);
  185.            volSlider.value = savedVol;
  186.        }
  187.        updateMuteIcon();
  188.  
  189.        async function syncLoop() {
  190.            try {
  191.                const response = await fetch(`${HA_URL}/api/states/${PLAYER_ENTITY}`, {
  192.                    headers: {
  193.                        "Authorization": `Bearer ${HA_TOKEN}`,
  194.                        "Content-Type": "application/json"
  195.                    }
  196.                });
  197.  
  198.                if (!response.ok) return;
  199.                const data = await response.json();
  200.                const attrs = data.attributes;
  201.                const stream_url = attrs.stream_url;
  202.                
  203.                if (stream_url && stream_url !== currentUrl) {
  204.                    playStream(stream_url);
  205.                }
  206.            } catch (e) { console.error("Sync Error:", e); }
  207.        }
  208.  
  209.        function playStream(url) {
  210.            stopStream();
  211.            currentUrl = url;
  212.  
  213.            if (Hls.isSupported()) {
  214.                hls = new Hls({ enableWorker: true, lowLatencyMode: true, backBufferLength: 90 });
  215.                hls.loadSource(url);
  216.                hls.attachMedia(video);
  217.            } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
  218.                video.src = url;
  219.            }
  220.        }
  221.  
  222.        function stopStream() {
  223.            if (hls) { hls.destroy(); hls = null; }
  224.            video.pause();
  225.            video.removeAttribute('src');
  226.            video.load();
  227.            currentUrl = "";
  228.        }
  229.  
  230.        function setVolume(val) {
  231.            video.volume = val; video.muted = false;
  232.            updateMuteIcon(); localStorage.setItem('tv_volume', val);
  233.        }
  234.  
  235.        function toggleMute() { video.muted = !video.muted; updateMuteIcon(); }
  236.  
  237.        function updateMuteIcon() {
  238.            if (video.muted || video.volume === 0) {
  239.                muteIconPath.setAttribute('d', 'M12,4L9.91,6.09L12,8.18M4.27,3L3,4.27L7.73,9H3V15H7L12,20V13.27L16.25,17.53C15.58,18.04 14.83,18.46 14,18.7V20.77C15.38,20.45 16.63,19.82 17.68,18.96L19.73,21L21,19.73L12,10.73M19,12C19,12.94 18.8,13.82 18.46,14.64L19.97,16.15C20.62,14.91 21,13.5 21,12C21,7.72 18,4.14 14,3.23V5.29C16.89,6.15 19,8.83 19,12M16.5,12C16.5,10.23 15.48,8.71 14,7.97V10.18L16.45,12.63C16.5,12.43 16.5,12.21 16.5,12Z');
  240.            } else {
  241.                muteIconPath.setAttribute('d', 'M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.48,8.71 14,7.97V16.02C15.48,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z');
  242.            }
  243.        }
  244.  
  245.        function toggleFullscreen() {
  246.            if (!document.fullscreenElement) {
  247.                document.documentElement.requestFullscreen().catch(e => console.log(e));
  248.            } else { if (document.exitFullscreen) document.exitFullscreen(); }
  249.        }
  250.        
  251.        document.addEventListener("fullscreenchange", () => {
  252.            const path = document.getElementById('fs-icon-path');
  253.            path.setAttribute('d', document.fullscreenElement ? 'M5,16H8V19H10V14H5V16M8,8H5V10H10V5H8V8M14,19H16V16H19V14H14V19M16,8V5H14V10H19V8H16Z' : 'M7,14H5V19H10V17H7V14M5,10H7V7H10V5H5V10M19,14H17V17H14V19H19V14M14,5V7H17V10H19V5H14Z');
  254.        });
  255.  
  256.        function showUI() {
  257.            uiLayer.classList.add('visible');
  258.            clearTimeout(uiTimeout);
  259.            uiTimeout = setTimeout(() => { uiLayer.classList.remove('visible'); }, 3000);
  260.        }
  261.        ['mousemove','click','touchstart'].forEach(e => document.addEventListener(e, showUI));
  262.        showUI();
  263.  
  264.        let observer = new IntersectionObserver((entries) => {
  265.            entries.forEach(entry => {
  266.                if (entry.isIntersecting) {
  267.                    video.play().catch(e => console.log("Auto-resume blocked", e));
  268.                } else { video.pause(); }
  269.            });
  270.        }, { threshold: 0.1 });
  271.        observer.observe(document.body);
  272.  
  273.        setInterval(syncLoop, 1500);
  274.        syncLoop();
  275.    </script>
  276. </body>
  277. </html>
  278.  
  279.  
  280. # ==============================================================================
  281. # DEL 3: LOVELACE DASHBOARD YAML
  282. # Kræver: custom:layout-card, custom:auto-entities, card-mod
  283. # ==============================================================================
  284.  
  285. type: panel
  286. path: tv
  287. title: TV
  288. icon: mdi:television
  289. cards:
  290.  - type: custom:layout-card
  291.    layout_type: custom:grid-layout
  292.    layout:
  293.      grid-template-columns: 200px 1fr
  294.      grid-template-rows: 1fr auto
  295.      grid-gap: 0px
  296.      margin: 0
  297.      padding: 0
  298.      grid-template-areas: |
  299.        "sidebar video"
  300.    cards:
  301.      # --- SIDEBAR MED KANALER ---
  302.      - type: custom:auto-entities
  303.        view_layout:
  304.          grid_area: sidebar
  305.        card:
  306.          type: grid
  307.          columns: 1
  308.          square: true
  309.        card_param: cards
  310.        filter:
  311.          template: >-
  312.            {% set sources = state_attr('sensor.tv_stream_context', 'sources') %}
  313.            {% set options_entity = "input_select.tv_stream_source" %}
  314.  
  315.            {% set ns = namespace(cards = []) %}
  316.            {% for option in state_attr(options_entity, 'options') %}
  317.              {% if sources is not none and sources[option] is defined %}
  318.  
  319.              {# --- STYLING: Glow effekt på valgt kanal --- #}
  320.              {% if is_state(options_entity, option) %}
  321.                {% set style = "ha-card {
  322.                     border: 3px solid white !important;
  323.                     box-shadow: 0 0 15px rgba(255, 255, 255, 0.4);
  324.                     opacity: 1;
  325.                     transform: scale(1.02);
  326.                     transition: all 0.3s ease-in-out;
  327.                     z-index: 2;
  328.                   }" %}
  329.              {% else %}
  330.                {% set style = "ha-card {
  331.                     border: 3px solid transparent;
  332.                     opacity: 0.5;
  333.                     transform: scale(0.95);
  334.                     filter: grayscale(20%);
  335.                     transition: all 0.3s ease-in-out;
  336.                   }" %}
  337.              {% endif %}
  338.              
  339.                {% set card = {
  340.                  "type": "picture",
  341.                  "image": sources[option].image,
  342.                  "card_mod": {
  343.                    "style": style
  344.                  },
  345.                  "tap_action": {
  346.                    "action": "perform-action",
  347.                    "perform_action": "input_select.select_option",
  348.                    "target": {
  349.                      "entity_id": options_entity
  350.                    },
  351.                    "data": {
  352.                      "option": option
  353.                    }
  354.                  }
  355.                } %}
  356.                {% set ns.cards = ns.cards + [card] %}
  357.              {% endif %}
  358.            {% endfor %}
  359.  
  360.            {{ ns.cards }}
  361.  
  362.      # --- VIDEO PLAYER ---
  363.      - type: iframe
  364.        url: /local/tv_player.html?v=11
  365.        aspect_ratio: 16:9
  366.        hide_background: true
  367.        view_layout:
  368.          grid_area: video
  369.    card_mod:
  370.      style: |
  371.        ha-card {
  372.          height: 100vh !important;
  373.          padding: 0 !important;
  374.          margin: 0 !important;
  375.          background: black;
  376.        }
Advertisement
Add Comment
Please, Sign In to add comment